├── .codeclimate.yml ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .snyk ├── LICENSE ├── README.md ├── data ├── config.json ├── radios.json └── voice │ ├── Goodbye1.raw │ ├── hello.raw │ ├── idle1.raw │ └── map.json ├── index.js ├── jeanne ├── lib ├── logger.js ├── main.js ├── model │ ├── speechstream.js │ └── youtube │ │ ├── youtube.api.js │ │ └── youtube.player.js ├── mumble │ ├── audio │ │ └── mixer.js │ ├── extension.js │ ├── extensions │ │ ├── notepad.js │ │ ├── phone.js │ │ ├── radio.js │ │ ├── voice-command │ │ │ ├── speech-commands.js │ │ │ └── voice-command.js │ │ ├── voice.js │ │ └── youtube │ │ │ ├── youtube.info.js │ │ │ ├── youtube.js │ │ │ ├── youtube.playlist.js │ │ │ └── youtube.suscription.js │ └── stumble-instance.js └── utils.js ├── package-lock.json └── package.json /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | engines: 2 | duplication: 3 | enabled: false 4 | config: 5 | languages: 6 | - ruby 7 | - javascript 8 | - python 9 | - php 10 | eslint: 11 | enabled: true 12 | fixme: 13 | enabled: true 14 | ratings: 15 | paths: 16 | - "**.inc" 17 | - "**.js" 18 | - "**.jsx" 19 | - "**.module" 20 | - "**.php" 21 | - "**.py" 22 | - "**.rb" 23 | exclude_paths: [] 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | 7 | [*.yml] 8 | indent_style = space 9 | indent_size = 2 -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*{.,-}min.js 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true, 6 | "node": true 7 | }, 8 | "parserOptions": { 9 | "ecmaVersion": 8, 10 | "sourceType": "module" 11 | }, 12 | "rules": { 13 | "no-const-assign": "error", 14 | "no-this-before-super": "error", 15 | "no-undef": "error", 16 | "no-unreachable": "error", 17 | "no-unused-vars": "off", 18 | "constructor-super": "warn", 19 | "valid-typeof": "warn" 20 | }, 21 | "extends": "eslint:recommended" 22 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | run.sh 40 | keys/ 41 | data/database.db 42 | data/voice 43 | node_modules/ 44 | *.pem 45 | *.p12 46 | -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.12.0 3 | ignore: {} 4 | # patches apply the minimum changes required to fix a vulnerability 5 | patch: 6 | 'npm:hoek:20180212': 7 | - stumble > sqlite3 > node-pre-gyp > request > hawk > sntp > hoek: 8 | patched: '2018-06-25T01:32:25.418Z' 9 | - youtube-node > request > hawk > hoek: 10 | patched: '2018-06-25T01:32:25.418Z' 11 | - youtube-node > request > hawk > sntp > hoek: 12 | patched: '2018-06-25T01:32:25.418Z' 13 | - youtube-node > request > hawk > cryptiles > boom > hoek: 14 | patched: '2018-06-25T01:32:25.418Z' 15 | - stumble > sqlite3 > node-pre-gyp > hawk > hoek: 16 | patched: '2018-06-25T01:32:25.418Z' 17 | - stumble > sqlite3 > node-pre-gyp > hawk > boom > hoek: 18 | patched: '2018-06-25T01:32:25.418Z' 19 | - stumble > sqlite3 > node-pre-gyp > hawk > sntp > hoek: 20 | patched: '2018-06-25T01:32:25.418Z' 21 | - stumble > sqlite3 > node-pre-gyp > hawk > cryptiles > boom > hoek: 22 | patched: '2018-06-25T01:32:25.418Z' 23 | - stumble > sqlite3 > node-pre-gyp > request > hawk > hoek: 24 | patched: '2018-06-25T01:32:25.418Z' 25 | - stumble > sqlite3 > node-pre-gyp > request > hawk > boom > hoek: 26 | patched: '2018-06-25T01:32:25.418Z' 27 | - youtube-node > request > hawk > boom > hoek: 28 | patched: '2018-06-25T01:32:25.418Z' 29 | - stumble > sqlite3 > node-pre-gyp > request > hawk > cryptiles > boom > hoek: 30 | patched: '2018-06-25T01:32:25.418Z' 31 | - sylvia > serialport > node-pre-gyp > hawk > hoek: 32 | patched: '2018-06-25T01:32:25.418Z' 33 | - sylvia > serialport > node-pre-gyp > hawk > boom > hoek: 34 | patched: '2018-06-25T01:32:25.418Z' 35 | - sylvia > serialport > node-pre-gyp > hawk > sntp > hoek: 36 | patched: '2018-06-25T01:32:25.418Z' 37 | - sylvia > serialport > node-pre-gyp > hawk > cryptiles > boom > hoek: 38 | patched: '2018-06-25T01:32:25.418Z' 39 | - sylvia > serialport > node-pre-gyp > request > hawk > hoek: 40 | patched: '2018-06-25T01:32:25.418Z' 41 | - sylvia > serialport > node-pre-gyp > request > hawk > boom > hoek: 42 | patched: '2018-06-25T01:32:25.418Z' 43 | - sylvia > serialport > node-pre-gyp > request > hawk > sntp > hoek: 44 | patched: '2018-06-25T01:32:25.418Z' 45 | - sylvia > serialport > node-pre-gyp > request > hawk > cryptiles > boom > hoek: 46 | patched: '2018-06-25T01:32:25.418Z' 47 | 'npm:tunnel-agent:20170305': 48 | - youtube-node > request > tunnel-agent: 49 | patched: '2018-06-25T18:31:00.557Z' 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2017 TinyMan 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Who is Jeanne ? 2 | [![Code Climate](https://codeclimate.com/github/TinyMan/node-jeanne/badges/gpa.svg)](https://codeclimate.com/github/TinyMan/node-jeanne) 3 | [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/TinyMan/node-jeanne/issues) 4 | 5 | Jeanne is meant to be a powerful Music bot for Mumble, with voice recognition. She can stream youtube video and (web)radio, with features like on-the-fly playlist and auto-playing. 6 | 7 | Table of Contents 8 | ================= 9 | 10 | * [Who is Jeanne ?](#who-is-jeanne-) 11 | * [How does she work ?](#how-does-she-work-) 12 | * [How to use her ?](#how-to-use-her-) 13 | * [Commands](#commands) 14 | * [Radios](#radios) 15 | * [Setup](#setup) 16 | * [Running at startup](#running-at-startup) 17 | * [Todo](#todo) 18 | 19 | 20 | # How does she work ? 21 | Jeanne uses Google Speech API (old version) to get transcripts of your whispers. She then matches them against a set of commands. You can also type those commands to her. 22 | 23 | Currently, the only language supported is french, but it should be simple to add new ones with your pull requests ! 24 | 25 | Jeanne is based on [Stumble](https://github.com/Okahyphen/stumble). She uses: 26 | * [node-mumble](https://github.com/tinyman/node-mumble) 27 | * [google-speech](https://github.com/TinyMan/google-speech) 28 | * [youtube-node](https://github.com/nodenica/youtube-node) 29 | * [node-ytdl-code](https://github.com/fent/node-ytdl-core) 30 | * [node-fluent-ffmpeg](https://github.com/fluent-ffmpeg/node-fluent-ffmpeg) 31 | * ... and couple others 32 | 33 | # How to use her ? 34 | ## Commands 35 | Please see the wiki [commands page](https://github.com/TinyMan/node-jeanne/wiki/Commands). 36 | 37 | ## Radios 38 | The list of radio currently available is: 39 | ``` 40 | Radio Kawa, Bim Team Radio, Nova, franceinfo, France Inter, France Musique, Skyrock, Oui FM, France Musique Classique easy, France Musique Classique plus, France Musique Concerts de Radio France, France Musique Musiques du monde - Ocora, France Musique La Jazz, France Musique La Contemporaine, France Culture, FIP (national), FIP à Nantes, FIP à Bordeaux, FIP à Strasbourg, FIP autour du rock, FIP autour du jazz, FIP autour du groove, FIP autour du monde, Tout nouveau, tout FIP, Mouv’, Mouv’ Xtra, France Bleu Alsace, France Bleu Armorique, France Bleu Auxerre, France Bleu Azur, France Bleu Béarn , France Bleu Belfort-Montbéliard, France Bleu Berry, France Bleu Besançon, France Bleu Bourgogne, France Bleu Breizh Izel, France Bleu Champagne-Ardenne, France Bleu Cotentin, France Bleu Creuse, France Bleu Drôme Ardèche, France Bleu Elsass, France Bleu Gard Lozère, France Bleu Gascogne, France Bleu Gironde, France Bleu Hérault, France Bleu Isère, France Bleu La Rochelle, France Bleu Limousin, France Bleu Loire Océan, France Bleu Lorraine Nord, France Bleu Maine, France Bleu Mayenne, France Bleu Nord, France Bleu Normandie (Calvados – Orne), France Bleu Normandie (Seine-Maritime – Eure), France Bleu Orléans, France Bleu Paris, France Bleu Pays d’Auvergne, France Bleu Pays de Savoie, France Bleu Pays Basque, France Bleu Perigord, France Bleu Picardie, France Bleu Poitou, France Bleu Provence, France Bleu RCFM Frequenza Mora, France Bleu Roussillon, France Bleu Saint-Étienne-Loire, France Bleu Sud Lorraine, France Bleu Toulouse, France Bleu Touraine, France Bleu Vaucluse, RFI Monde, RFI Afrique, BFM, Canal FM, Canal Sud, Chérie FM, Europe 1, Evasion FM, Flor FM, Fréquence3, Gold Fréquence3, Urban Fréquence3, Fréquence Plus, Gold FM, Jazz Radio, Kiss FM Dance, Magic Radio, Nostalgie, NRJ, Oceane FM, 100% Radio, Radio 6, Radio Bonne Nouvelle, Radio Bruaysis, Radio Campus Rennes, Radio Emotion, Radio Galaxie, Radio Menergy, Radio Scoop, Radio Scoop 91.3, Radio Scoop 98.8, Radio Scoop 89.2, Radio Scoop 100% Années 80, Radio Scoop 100% Music Pod, Radio Scoop 100% Powerdance, Radio Scoop 100% Salon du Mariage, RFM, RFM Collector, RFM Night Fever, Rire et Chansons, RMC, Toulouse FM, Virgin Radio, Virgin Radio Classics, Virgin Radio New, Virgin Radio Electro Shock, Virgin Radio Hit, Virgin Radio Rock, Vitamine 41 | ``` 42 | You can easily add radios by editing the file `data/radios.json` 43 | 44 | # Setup 45 | Since Jeanne uses [fluent-ffmpeg](https://github.com/fluent-ffmpeg/node-fluent-ffmpeg), you have to install `ffmpeg`. If you have some troubles please follow the instructions at https://github.com/fluent-ffmpeg/node-fluent-ffmpeg#usage 46 | 47 | Start by cloning the repo and installing the package: 48 | ``` 49 | git clone https://github.com/TinyMan/node-jeanne.git 50 | cd node-jeanne 51 | npm i 52 | ``` 53 | 54 | Then, you need API-keys for youtube and google-speech (https://github.com/gillesdemey/google-speech-v2). They should be placed in `keys/api-keys.json`. The file should look like this: 55 | ```json 56 | { 57 | "youtube": "your-key-here", 58 | "google-speech": "your-key-here" 59 | } 60 | ``` 61 | 62 | After that you need to edit your config file (`data/config.json`). You should only modify the server part and the default channel (extensions>info>movement>home) 63 | 64 | You can test that she runs and connects to your server by running `node index.js` 65 | 66 | ## Running at startup 67 | ### On debian-base linux: 68 | For that you need to install `forever`: 69 | ``` 70 | npm i -g forever 71 | ``` 72 | 73 | Then simply edit the file `jeanne` and replace the paths with yours: 74 | ```bash 75 | ... 76 | pidFile="/jeanne.pid" 77 | logFile="/jeanne.log" 78 | 79 | command="node" 80 | nodeApp="/index.js" 81 | foreverApp="forever" 82 | 83 | ... 84 | ``` 85 | Move the script to `/etc/init.d/jeanne` and run 86 | ``` 87 | update-rc.d jeanne defaults 88 | ``` 89 | To uninstall run 90 | ``` 91 | update-rc.d -f jeanne remove 92 | ``` 93 | # Todo 94 | * Wiki 95 | * Add languages 96 | -------------------------------------------------------------------------------- /data/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "operator": "!", 3 | "mumble": { 4 | "server": "192.168.1.18", 5 | "port": 64738, 6 | "username": "Jeanne", 7 | "password": "", 8 | "key": "/home/pi/share/jeanne/keys/jeanne.key.pem", 9 | "cert": "/home/pi/share/jeanne/keys/jeanne.crt.pem" 10 | }, 11 | "locale": "fr", 12 | "extensions": { 13 | "audio": { 14 | "gain": 0.1, 15 | "directory": "", 16 | "volumestep": 0.05 17 | }, 18 | "database": { 19 | "location": "/home/pi/share/jeanne/data/database.db" 20 | }, 21 | "messenger": { 22 | "textmessagelength": 5000 23 | }, 24 | "movement": { 25 | "home": "Taverne Maggle", 26 | "afk": "Autre" 27 | }, 28 | "youtube": { 29 | "search_max_results": 5, 30 | "subscription_max_videos": 2, 31 | "norepeat_length": 15, 32 | "auto_play": true, 33 | "lang": "fr", 34 | "jump_distance": 20000 35 | }, 36 | "voice": { 37 | "map": "/home/pi/share/jeanne/data/voice/map.json", 38 | "folder": "/home/pi/share/jeanne/data/voice/", 39 | "idle_timer": { 40 | "min": 300, 41 | "max": 1800 42 | }, 43 | "gain": 0.1 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /data/radios.json: -------------------------------------------------------------------------------- 1 | { 2 | "radiokawa": { 3 | "name": "Radio Kawa", 4 | "bitrate": 128000, 5 | "url": "https://api.spreaker.com/v2/episodes/11709034/stream" 6 | }, 7 | "bim team radio": { 8 | "name": "Bim Team Radio", 9 | "bitrate": 128000, 10 | "url": "https://www.radioking.com/play/bim-team-radio" 11 | }, 12 | "nova": { 13 | "name": "Nova", 14 | "bitrate": 128000, 15 | "url": "http://novazz.ice.infomaniak.ch/novazz-128.mp3" 16 | }, 17 | "france info": { 18 | "name": "franceinfo", 19 | "bitrate": "64", 20 | "url": "http://direct.franceinfo.fr/live/franceinfo-midfi.mp3?ID=f9fbk29m84" 21 | }, 22 | "france inter": { 23 | "name": "France Inter", 24 | "bitrate": 128000, 25 | "url": "http://direct.franceinter.fr/live/franceinter-midfi.mp3?ID=f9fbk29m84" 26 | }, 27 | "france musique": { 28 | "name": "France Musique", 29 | "bitrate": 128000, 30 | "url": "http://direct.francemusique.fr/live/francemusique-midfi.mp3?ID=f9fbk29m84" 31 | }, 32 | "skyrock": { 33 | "name": "Skyrock", 34 | "bitrate": 128000, 35 | "format": "mp3", 36 | "url": "http://icecast.skyrock.net/s/natio_mp3_128k?type=.mp3" 37 | }, 38 | "oui fm": { 39 | "name": "Oui FM", 40 | "bitrate": 128000, 41 | "format": "mp3", 42 | "url": "http://stream.ouifm.fr/ouifm-high.mp3" 43 | }, 44 | "france musique classique easy": { 45 | "name": "France Musique Classique easy", 46 | "bitrate": "192", 47 | "url": "http://direct.francemusique.fr/live/francemusiqueeasyclassique-hifi.mp3?ID=f9fbk29m84" 48 | }, 49 | "france musique classique plus": { 50 | "name": "France Musique Classique plus", 51 | "bitrate": "192", 52 | "url": "http://direct.francemusique.fr/live/francemusiqueclassiqueplus-hifi.mp3?ID=f9fbk29m84" 53 | }, 54 | "france musique concerts de radio france": { 55 | "name": "France Musique Concerts de Radio France", 56 | "bitrate": "192", 57 | "url": "http://direct.francemusique.fr/live/francemusiqueconcertsradiofrance-hifi.mp3?ID=f9fbk29m84" 58 | }, 59 | "france musique musiques du monde - ocora": { 60 | "name": "France Musique Musiques du monde - Ocora", 61 | "bitrate": "192", 62 | "url": "http://direct.francemusique.fr/live/francemusiqueocoramonde-hifi.mp3?ID=f9fbk29m84" 63 | }, 64 | "france musique la jazz": { 65 | "name": "France Musique La Jazz", 66 | "bitrate": "192", 67 | "url": "http://direct.francemusique.fr/live/francemusiquelajazz-hifi.mp3?ID=f9fbk29m84" 68 | }, 69 | "france musique la contemporaine": { 70 | "name": "France Musique La Contemporaine", 71 | "bitrate": "192", 72 | "url": "http://direct.francemusique.fr/live/francemusiquelacontemporaine-hifi.mp3?ID=f9fbk29m84" 73 | }, 74 | "france culture": { 75 | "name": "France Culture", 76 | "bitrate": 128000, 77 | "url": "http://direct.franceculture.fr/live/franceculture-midfi.mp3?ID=f9fbk29m84" 78 | }, 79 | "fip (national)": { 80 | "name": "FIP (national)", 81 | "bitrate": 128000, 82 | "url": "http://direct.fipradio.fr/live/fip-midfi.mp3?ID=f9fbk29m84" 83 | }, 84 | "fip à nantes": { 85 | "name": "FIP à Nantes", 86 | "bitrate": 128000, 87 | "url": "http://audio.scdn.arkena.com/11344/fipnantes-midfi128.mp3" 88 | }, 89 | "fip à bordeaux": { 90 | "name": "FIP à Bordeaux", 91 | "bitrate": 128000, 92 | "url": "http://audio.scdn.arkena.com/11343/fipbordeaux-midfi128.mp3" 93 | }, 94 | "fip à strasbourg": { 95 | "name": "FIP à Strasbourg", 96 | "bitrate": 128000, 97 | "url": "http://direct.fipradio.fr/live/fipstrasbourg-midfi.mp3?ID=f9fbk29m84" 98 | }, 99 | "fip autour du rock": { 100 | "name": "FIP autour du rock", 101 | "bitrate": "192", 102 | "url": "http://direct.fipradio.fr/live/fip-webradio1.mp3?ID=f9fbk29m84" 103 | }, 104 | "fip autour du jazz": { 105 | "name": "FIP autour du jazz", 106 | "bitrate": "192", 107 | "url": "http://direct.fipradio.fr/live/fip-webradio2.mp3?ID=f9fbk29m84" 108 | }, 109 | "fip autour du groove": { 110 | "name": "FIP autour du groove", 111 | "bitrate": "192", 112 | "url": "http://direct.fipradio.fr/live/fip-webradio3.mp3?ID=f9fbk29m84" 113 | }, 114 | "fip autour du monde": { 115 | "name": "FIP autour du monde", 116 | "bitrate": "192", 117 | "url": "http://direct.fipradio.fr/live/fip-webradio4.mp3?ID=f9fbk29m84" 118 | }, 119 | "tout nouveau, tout fip": { 120 | "name": "Tout nouveau, tout FIP", 121 | "bitrate": "192", 122 | "url": "http://direct.fipradio.fr/live/fip-webradio5.mp3?ID=f9fbk29m84" 123 | }, 124 | "mouv'": { 125 | "name": "Mouv’", 126 | "bitrate": 128000, 127 | "url": "http://direct.mouv.fr/live/mouv-midfi.mp3?ID=f9fbk29m84" 128 | }, 129 | "mouv' xtra": { 130 | "name": "Mouv’ Xtra", 131 | "bitrate": 128000, 132 | "url": "http://direct.mouv.fr/live/mouvxtra-midfi.mp3?ID=f9fbk29m84" 133 | }, 134 | "france bleu alsace": { 135 | "name": "France Bleu Alsace", 136 | "bitrate": 128000, 137 | "url": "http://direct.francebleu.fr/live/fbalsace-midfi.mp3?ID=f9fbk29m84" 138 | }, 139 | "france bleu armorique": { 140 | "name": "France Bleu Armorique", 141 | "bitrate": 128000, 142 | "url": "http://audio.scdn.arkena.com/11358/fbarmorique-midfi128.mp3" 143 | }, 144 | "france bleu auxerre": { 145 | "name": "France Bleu Auxerre", 146 | "bitrate": 128000, 147 | "url": "http://direct.francebleu.fr/live/fbauxerre-midfi.mp3?ID=f9fbk29m84" 148 | }, 149 | "france bleu azur": { 150 | "name": "France Bleu Azur", 151 | "bitrate": 128000, 152 | "url": "http://audio.scdn.arkena.com/11361/fbazur-midfi128.mp3" 153 | }, 154 | "france bleu béarn ": { 155 | "name": "France Bleu Béarn ", 156 | "bitrate": 128000, 157 | "url": "http://direct.francebleu.fr/live/fbbearn-midfi.mp3?ID=f9fbk29m84" 158 | }, 159 | "france bleu belfort-montbéliard": { 160 | "name": "France Bleu Belfort-Montbéliard", 161 | "bitrate": 128000, 162 | "url": "http://direct.francebleu.fr/live/fbbelfort-midfi.mp3?ID=f9fbk29m84" 163 | }, 164 | "france bleu berry": { 165 | "name": "France Bleu Berry", 166 | "bitrate": 128000, 167 | "url": "http://direct.francebleu.fr/live/fbberry-midfi.mp3?ID=f9fbk29m84" 168 | }, 169 | "france bleu besançon": { 170 | "name": "France Bleu Besançon", 171 | "bitrate": 128000, 172 | "url": "http://direct.francebleu.fr/live/fbbesancon-midfi.mp3?ID=f9fbk29m84" 173 | }, 174 | "france bleu bourgogne": { 175 | "name": "France Bleu Bourgogne", 176 | "bitrate": 128000, 177 | "url": "http://direct.francebleu.fr/live/fbbourgogne-midfi.mp3?ID=f9fbk29m84" 178 | }, 179 | "france bleu breizh izel": { 180 | "name": "France Bleu Breizh Izel", 181 | "bitrate": 128000, 182 | "url": "http://direct.francebleu.fr/live/fbbreizizel-midfi.mp3?ID=f9fbk29m84" 183 | }, 184 | "france bleu champagne-ardenne": { 185 | "name": "France Bleu Champagne-Ardenne", 186 | "bitrate": 128000, 187 | "url": "http://direct.francebleu.fr/live/fbchampagne-midfi.mp3?ID=f9fbk29m84" 188 | }, 189 | "france bleu cotentin": { 190 | "name": "France Bleu Cotentin", 191 | "bitrate": 128000, 192 | "url": "http://direct.francebleu.fr/live/fbcotentin-midfi.mp3?ID=f9fbk29m84" 193 | }, 194 | "france bleu creuse": { 195 | "name": "France Bleu Creuse", 196 | "bitrate": 128000, 197 | "url": "http://direct.francebleu.fr/live/fbcreuse-midfi.mp3?ID=f9fbk29m84" 198 | }, 199 | "france bleu drôme ardèche": { 200 | "name": "France Bleu Drôme Ardèche", 201 | "bitrate": 128000, 202 | "url": "http://direct.francebleu.fr/live/fbdromeardeche-midfi.mp3?ID=f9fbk29m84" 203 | }, 204 | "france bleu elsass": { 205 | "name": "France Bleu Elsass", 206 | "bitrate": 128000, 207 | "url": "http://direct.francebleu.fr/live/Fbelsass-midfi.mp3?ID=f9fbk29m84" 208 | }, 209 | "france bleu gard lozère": { 210 | "name": "France Bleu Gard Lozère", 211 | "bitrate": 128000, 212 | "url": "http://direct.francebleu.fr/live/fbgardlozere-midfi.mp3?ID=f9fbk29m84" 213 | }, 214 | "france bleu gascogne": { 215 | "name": "France Bleu Gascogne", 216 | "bitrate": 128000, 217 | "url": "http://direct.francebleu.fr/live/fbgascogne-midfi.mp3?ID=f9fbk29m84" 218 | }, 219 | "france bleu gironde": { 220 | "name": "France Bleu Gironde", 221 | "bitrate": 128000, 222 | "url": "http://direct.francebleu.fr/live/fbgironde-midfi.mp3?ID=f9fbk29m84" 223 | }, 224 | "france bleu hérault": { 225 | "name": "France Bleu Hérault", 226 | "bitrate": 128000, 227 | "url": "http://direct.francebleu.fr/live/fbherault-midfi.mp3?ID=f9fbk29m84" 228 | }, 229 | "france bleu isère": { 230 | "name": "France Bleu Isère", 231 | "bitrate": 128000, 232 | "url": "http://direct.francebleu.fr/live/fbisere-midfi.mp3?ID=f9fbk29m84" 233 | }, 234 | "france bleu la rochelle": { 235 | "name": "France Bleu La Rochelle", 236 | "bitrate": 128000, 237 | "url": "http://direct.francebleu.fr/live/fblarochelle-midfi.mp3?ID=f9fbk29m84" 238 | }, 239 | "france bleu limousin": { 240 | "name": "France Bleu Limousin", 241 | "bitrate": 128000, 242 | "url": "http://direct.francebleu.fr/live/fblimousin-midfi.mp3?ID=f9fbk29m84" 243 | }, 244 | "france bleu loire océan": { 245 | "name": "France Bleu Loire Océan", 246 | "bitrate": 128000, 247 | "url": "http://direct.francebleu.fr/live/fbloireocean-midfi.mp3?ID=f9fbk29m84" 248 | }, 249 | "france bleu lorraine nord": { 250 | "name": "France Bleu Lorraine Nord", 251 | "bitrate": 128000, 252 | "url": "http://direct.francebleu.fr/live/fblorrainenord-midfi.mp3?ID=f9fbk29m84" 253 | }, 254 | "france bleu maine": { 255 | "name": "France Bleu Maine", 256 | "bitrate": 128000, 257 | "url": "http://direct.francebleu.fr/live/fbmaine-midfi.mp3?ID=f9fbk29m84" 258 | }, 259 | "france bleu mayenne": { 260 | "name": "France Bleu Mayenne", 261 | "bitrate": 128000, 262 | "url": "http://direct.francebleu.fr/live/fbmayenne-midfi.mp3?ID=f9fbk29m84" 263 | }, 264 | "france bleu nord": { 265 | "name": "France Bleu Nord", 266 | "bitrate": 128000, 267 | "url": "http://direct.francebleu.fr/live/fbnord-midfi.mp3?ID=f9fbk29m84" 268 | }, 269 | "france bleu normandie (calvados – orne)": { 270 | "name": "France Bleu Normandie (Calvados – Orne)", 271 | "bitrate": 128000, 272 | "url": "http://direct.francebleu.fr/live/fbbassenormandie-midfi.mp3?ID=f9fbk29m84" 273 | }, 274 | "france bleu normandie (seine-maritime – eure)": { 275 | "name": "France Bleu Normandie (Seine-Maritime – Eure)", 276 | "bitrate": 128000, 277 | "url": "http://direct.francebleu.fr/live/fbhautenormandie-midfi.mp3?ID=f9fbk29m84" 278 | }, 279 | "france bleu orléans": { 280 | "name": "France Bleu Orléans", 281 | "bitrate": 128000, 282 | "url": "http://direct.francebleu.fr/live/fborleans-midfi.mp3?ID=f9fbk29m84" 283 | }, 284 | "france bleu paris": { 285 | "name": "France Bleu Paris", 286 | "bitrate": 128000, 287 | "url": "http://direct.francebleu.fr/live/fb1071-midfi.mp3?ID=f9fbk29m84" 288 | }, 289 | "france bleu pays d’auvergne": { 290 | "name": "France Bleu Pays d’Auvergne", 291 | "bitrate": 128000, 292 | "url": "http://direct.francebleu.fr/live/fbpaysdauvergne-midfi.mp3?ID=f9fbk29m84" 293 | }, 294 | "france bleu pays de savoie": { 295 | "name": "France Bleu Pays de Savoie", 296 | "bitrate": 128000, 297 | "url": "http://direct.francebleu.fr/live/fbpaysdesavoie-midfi.mp3?ID=f9fbk29m84" 298 | }, 299 | "france bleu pays basque": { 300 | "name": "France Bleu Pays Basque", 301 | "bitrate": 128000, 302 | "url": "http://direct.francebleu.fr/live/fbpaysbasque-midfi.mp3?ID=f9fbk29m84" 303 | }, 304 | "france bleu perigord": { 305 | "name": "France Bleu Perigord", 306 | "bitrate": 128000, 307 | "url": "http://direct.francebleu.fr/live/fbperigord-midfi.mp3?ID=f9fbk29m84" 308 | }, 309 | "france bleu picardie": { 310 | "name": "France Bleu Picardie", 311 | "bitrate": 128000, 312 | "url": "http://direct.francebleu.fr/live/fbpicardie-midfi.mp3?ID=f9fbk29m84" 313 | }, 314 | "france bleu poitou": { 315 | "name": "France Bleu Poitou", 316 | "bitrate": 128000, 317 | "url": "http://direct.francebleu.fr/live/fbpoitou-midfi.mp3?ID=f9fbk29m84" 318 | }, 319 | "france bleu provence": { 320 | "name": "France Bleu Provence", 321 | "bitrate": 128000, 322 | "url": "http://direct.francebleu.fr/live/fbprovence-midfi.mp3?ID=f9fbk29m84" 323 | }, 324 | "france bleu rcfm frequenza mora": { 325 | "name": "France Bleu RCFM Frequenza Mora", 326 | "bitrate": 128000, 327 | "url": "http://audio.scdn.arkena.com/11371/fbfrequenzamora-midfi128.mp3" 328 | }, 329 | "france bleu roussillon": { 330 | "name": "France Bleu Roussillon", 331 | "bitrate": 128000, 332 | "url": "http://direct.francebleu.fr/live/fbroussillon-midfi.mp3?ID=f9fbk29m84" 333 | }, 334 | "france bleu saint-étienne-loire": { 335 | "name": "France Bleu Saint-Étienne-Loire", 336 | "bitrate": 128000, 337 | "url": "http://direct.francebleu.fr/live/fbstetienne-midfi.mp3?ID=f9fbk29m84" 338 | }, 339 | "france bleu sud lorraine": { 340 | "name": "France Bleu Sud Lorraine", 341 | "bitrate": 128000, 342 | "url": "http://direct.francebleu.fr/live/fbsudlorraine-midfi.mp3?ID=f9fbk29m84" 343 | }, 344 | "france bleu toulouse": { 345 | "name": "France Bleu Toulouse", 346 | "bitrate": 128000, 347 | "url": "http://direct.francebleu.fr/live/fbtoulouse-midfi.mp3?ID=f9fbk29m84" 348 | }, 349 | "france bleu touraine": { 350 | "name": "France Bleu Touraine", 351 | "bitrate": 128000, 352 | "url": "http://direct.francebleu.fr/live/fbtouraine-midfi.mp3?ID=f9fbk29m84" 353 | }, 354 | "france bleu vaucluse": { 355 | "name": "France Bleu Vaucluse", 356 | "bitrate": 128000, 357 | "url": "http://direct.francebleu.fr/live/fbvaucluse-midfi.mp3?ID=f9fbk29m84" 358 | }, 359 | "rfi monde": { 360 | "name": "RFI Monde", 361 | "bitrate": "64", 362 | "url": "http://live02.rfi.fr/rfimonde-64.mp3" 363 | }, 364 | "rfi afrique": { 365 | "name": "RFI Afrique", 366 | "bitrate": "64", 367 | "url": "http://live02.rfi.fr/rfiafrique-64.mp3" 368 | }, 369 | "bfm": { 370 | "name": "BFM", 371 | "bitrate": "64", 372 | "url": "http://bfmbusiness.scdn.arkena.com/bfmbusiness.mp3" 373 | }, 374 | "canal fm": { 375 | "name": "Canal FM", 376 | "bitrate": 128000, 377 | "url": "http://95.81.146.10/4532/canalfmhd.mp3" 378 | }, 379 | "canal sud": { 380 | "name": "Canal Sud", 381 | "bitrate": 128000, 382 | "url": "http://mp3.live.tv-radio.com/canalsud/all/canalsud.mp3" 383 | }, 384 | "chérie fm": { 385 | "name": "Chérie FM", 386 | "bitrate": 128000, 387 | "url": "http://cdn.nrjaudio.fm/audio1/fr/30201/mp3_128.mp3?origine=listenlive" 388 | }, 389 | "europe 1": { 390 | "name": "Europe 1", 391 | "bitrate": 128000, 392 | "url": "http://mp3lg4.tdf-cdn.com/9240/lag_180945.mp3" 393 | }, 394 | "evasion fm": { 395 | "name": "Evasion FM", 396 | "bitrate": 128000, 397 | "url": "http://stream.evasionfm.com/stream.mp3" 398 | }, 399 | "flor fm": { 400 | "name": "Flor FM", 401 | "bitrate": 128000, 402 | "url": "http://stream.florfm.com:8000/florfm" 403 | }, 404 | "fréquence3": { 405 | "name": "Fréquence3", 406 | "bitrate": 128000, 407 | "url": "http://rod.frequence3.net:80/frequence3-128.mp3" 408 | }, 409 | "gold fréquence3": { 410 | "name": "Gold Fréquence3", 411 | "bitrate": 128000, 412 | "url": "http://rod.frequence3.net:80/frequence3ac-128.mp3" 413 | }, 414 | "urban fréquence3": { 415 | "name": "Urban Fréquence3", 416 | "bitrate": 128000, 417 | "url": "http://rod.frequence3.net:80/frequence3urban-128.mp3" 418 | }, 419 | "fréquence plus": { 420 | "name": "Fréquence Plus", 421 | "bitrate": 128000, 422 | "url": "http://freqplus.ice.infomaniak.ch:80/freqplus-high" 423 | }, 424 | "gold fm": { 425 | "name": "Gold FM", 426 | "bitrate": 128000, 427 | "url": "http://mp3.live.tv-radio.com/goldfm/all/goldfm.mp3" 428 | }, 429 | "jazz radio": { 430 | "name": "Jazz Radio", 431 | "bitrate": 128000, 432 | "url": "http://jazzradio.ice.infomaniak.ch/jazzradio-high.mp3" 433 | }, 434 | "kiss fm dance": { 435 | "name": "Kiss FM Dance", 436 | "bitrate": 128000, 437 | "url": "http://mp3.live.tv-radio.com/kissdance/all/kissdance-128k.mp3" 438 | }, 439 | "magic radio": { 440 | "name": "Magic Radio", 441 | "bitrate": "192" 442 | }, 443 | "nostalgie": { 444 | "name": "Nostalgie", 445 | "bitrate": 128000, 446 | "url": "http://cdn.nrjaudio.fm/audio1/fr/30601/mp3_128.mp3?origine=listenlive" 447 | }, 448 | "nrj": { 449 | "name": "NRJ", 450 | "bitrate": 128000, 451 | "url": "http://cdn.nrjaudio.fm/audio1/fr/30001/mp3_128.mp3?origine=listenlive" 452 | }, 453 | "oceane fm": { 454 | "name": "Oceane FM", 455 | "bitrate": 128000, 456 | "url": "http://mp3.live.tv-radio.com/oceanefm/all/oceanefm.mp3" 457 | }, 458 | "100% radio": { 459 | "name": "100% Radio", 460 | "bitrate": 128000, 461 | "url": "http://mp3.live.tv-radio.com/centpourcent/all/centpourcent-128k.mp3" 462 | }, 463 | "radio 6": { 464 | "name": "Radio 6", 465 | "bitrate": 128000, 466 | "url": "http://176.31.115.208:80/dunkerque" 467 | }, 468 | "radio bonne nouvelle": { 469 | "name": "Radio Bonne Nouvelle", 470 | "bitrate": 128000, 471 | "url": "\nhttp://mp3.live.tv-radio.com/bonnenouvelle/all/bonnenouvelle.mp3" 472 | }, 473 | "radio bruaysis": { 474 | "name": "Radio Bruaysis", 475 | "bitrate": 128000, 476 | "url": "http://radiobruaysis.ice.infomaniak.ch:80/radiobruaysis-128" 477 | }, 478 | "radio campus rennes": { 479 | "name": "Radio Campus Rennes", 480 | "bitrate": "192", 481 | "url": "http://stream.radiocampusrennes.fr:8001/rcr" 482 | }, 483 | "radio emotion": { 484 | "name": "Radio Emotion", 485 | "bitrate": 128000, 486 | "url": "http://95.81.147.10/4837/rad_171419.mp3" 487 | }, 488 | "radio galaxie": { 489 | "name": "Radio Galaxie", 490 | "bitrate": "64", 491 | "url": "http://radiogalaxie.ice.infomaniak.ch:80/radiogalaxie-64.aac" 492 | }, 493 | "radio menergy": { 494 | "name": "Radio Menergy", 495 | "bitrate": "160", 496 | "url": "http://mp3.live.tv-radio.com/menergy/all/menergy.mp3" 497 | }, 498 | "radio scoop": { 499 | "name": "Radio Scoop", 500 | "bitrate": 128000, 501 | "url": "http://mp3.live.tv-radio.com/scooplyon/all/scooplyon-128k.mp3" 502 | }, 503 | "radio scoop 91.3": { 504 | "name": "Radio Scoop 91.3", 505 | "bitrate": 128000, 506 | "url": "http://mp3.live.tv-radio.com/scoop/all/scoopst.mp3" 507 | }, 508 | "radio scoop 98.8": { 509 | "name": "Radio Scoop 98.8", 510 | "bitrate": "64", 511 | "url": "http://mp3.live.tv-radio.com/scoop/all/scoopclermont.mp3" 512 | }, 513 | "radio scoop 89.2": { 514 | "name": "Radio Scoop 89.2", 515 | "bitrate": "64", 516 | "url": "http://mp3.live.tv-radio.com/scoop/all/scoopbeb.mp3" 517 | }, 518 | "radio scoop 100% années 80": { 519 | "name": "Radio Scoop 100% Années 80", 520 | "bitrate": 128000, 521 | "url": "http://stream.radioscoop.com:80/scoop1.mp3" 522 | }, 523 | "radio scoop 100% music pod": { 524 | "name": "Radio Scoop 100% Music Pod", 525 | "bitrate": 128000, 526 | "url": "http://stream.radioscoop.com:80/scoop2.mp3" 527 | }, 528 | "radio scoop 100% powerdance": { 529 | "name": "Radio Scoop 100% Powerdance", 530 | "bitrate": 128000, 531 | "url": "http://stream.radioscoop.com:80/scoop3.mp3" 532 | }, 533 | "radio scoop 100% salon du mariage": { 534 | "name": "Radio Scoop 100% Salon du Mariage", 535 | "bitrate": 128000, 536 | "url": "http://stream.radioscoop.com:80/scoop4.mp3" 537 | }, 538 | "rfm": { 539 | "name": "RFM", 540 | "bitrate": 128000, 541 | "url": "http://rfm-live-mp3-128.scdn.arkena.com/rfm.mp3" 542 | }, 543 | "rfm collector": { 544 | "name": "RFM Collector", 545 | "bitrate": 128000, 546 | "url": "http://rfm-wr1-mp3-128.scdn.arkena.com/rfm.mp3" 547 | }, 548 | "rfm night fever": { 549 | "name": "RFM Night Fever", 550 | "bitrate": 128000, 551 | "url": "http://rfm-wr2-mp3-128.scdn.arkena.com/rfm.mp3" 552 | }, 553 | "rire et chansons": { 554 | "name": "Rire et Chansons", 555 | "bitrate": 128000, 556 | "url": "http://cdn.nrjaudio.fm/audio1/fr/30401/mp3_128.mp3?origine=listenlive" 557 | }, 558 | "rmc": { 559 | "name": "RMC", 560 | "bitrate": "64", 561 | "url": "http://rmc.scdn.arkena.com/rmc.mp3" 562 | }, 563 | "toulouse fm": { 564 | "name": "Toulouse FM", 565 | "bitrate": 128000, 566 | "url": "http://mp3.live.tv-radio.com/toulousefm/all/toulousefm.mp3" 567 | }, 568 | "virgin radio": { 569 | "name": "Virgin Radio", 570 | "bitrate": 128000, 571 | "url": "http://mp3lg4.tdf-cdn.com/9243/lag_164753.mp3" 572 | }, 573 | "virgin radio classics": { 574 | "name": "Virgin Radio Classics", 575 | "bitrate": 128000, 576 | "url": "http://mp3lg3.tdf-cdn.com/9146/lag_103325.mp3" 577 | }, 578 | "virgin radio new": { 579 | "name": "Virgin Radio New", 580 | "bitrate": 128000, 581 | "url": "http://mp3lg3.tdf-cdn.com/9145/lag_103228.mp3" 582 | }, 583 | "virgin radio electro shock": { 584 | "name": "Virgin Radio Electro Shock", 585 | "bitrate": 128000, 586 | "url": "http://mp3lg3.tdf-cdn.com/9148/lag_103401.mp3" 587 | }, 588 | "virgin radio hit": { 589 | "name": "Virgin Radio Hit", 590 | "bitrate": 128000, 591 | "url": "http://mp3lg3.tdf-cdn.com/9150/lag_103440.mp3" 592 | }, 593 | "virgin radio rock": { 594 | "name": "Virgin Radio Rock", 595 | "bitrate": 128000, 596 | "url": "http://mp3lg3.tdf-cdn.com/9151/lag_103523.mp3" 597 | }, 598 | "vitamine": { 599 | "name": "Vitamine", 600 | "bitrate": 128000, 601 | "url": "http://radiovitamine.ice.infomaniak.ch:80/radiovitamine-128" 602 | } 603 | } 604 | -------------------------------------------------------------------------------- /data/voice/Goodbye1.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TinyMan/node-jeanne/7fa5e6910cb760163fb5da14febd8cf6f5ab061c/data/voice/Goodbye1.raw -------------------------------------------------------------------------------- /data/voice/hello.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TinyMan/node-jeanne/7fa5e6910cb760163fb5da14febd8cf6f5ab061c/data/voice/hello.raw -------------------------------------------------------------------------------- /data/voice/idle1.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TinyMan/node-jeanne/7fa5e6910cb760163fb5da14febd8cf6f5ab061c/data/voice/idle1.raw -------------------------------------------------------------------------------- /data/voice/map.json: -------------------------------------------------------------------------------- 1 | { 2 | "idle": [ 3 | "idle1.raw" 4 | ], 5 | "welcome": [ 6 | "hello.raw" 7 | ], 8 | "stop": [], 9 | "failure": [], 10 | "goodbye": [ 11 | "Goodbye1.raw" 12 | ], 13 | "laugh": [], 14 | "special": {} 15 | } -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const paths = require('app-module-path').addPath(__dirname) 3 | 4 | const debug = require('lib/logger') 5 | debug.setNS("jeanne") 6 | debug.enable("jeanne:*") 7 | 8 | const main = require('lib/main') 9 | 10 | const s = require('lib/mumble/stumble-instance'); 11 | 12 | s.on('ready', main); 13 | debug('').log("Started") 14 | -------------------------------------------------------------------------------- /jeanne: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ### BEGIN INIT INFO 3 | # If you wish the Daemon to be lauched at boot / stopped at shutdown : 4 | # 5 | # On Debian-based distributions: 6 | # INSTALL : update-rc.d scriptname defaults 7 | # (UNINSTALL : update-rc.d -f scriptname remove) 8 | # 9 | # On RedHat-based distributions (CentOS, OpenSUSE...): 10 | # INSTALL : chkconfig --level 35 scriptname on 11 | # (UNINSTALL : chkconfig --level 35 scriptname off) 12 | # 13 | # chkconfig: 2345 90 60 14 | # Provides: /home/pi/share/jeanne/index.js 15 | # Required-Start: $remote_fs $syslog 16 | # Required-Stop: $remote_fs $syslog 17 | # Default-Start: 2 3 4 5 18 | # Default-Stop: 0 1 6 19 | # Short-Description: forever running /home/pi/share/jeanne/index.js 20 | # Description: /home/pi/share/jeanne/index.js 21 | ### END INIT INFO 22 | # 23 | # initd a node app 24 | # Based on a script posted by https://gist.github.com/jinze at https://gist.github.com/3748766 25 | # 26 | 27 | if [ -e /lib/lsb/init-functions ]; then 28 | # LSB source function library. 29 | . /lib/lsb/init-functions 30 | fi; 31 | 32 | pidFile="/home/pi/share/jeanne/jeanne.pid" 33 | logFile="/home/pi/share/jeanne/jeanne.log" 34 | 35 | command="node" 36 | nodeApp="/home/pi/share/jeanne/index.js" 37 | foreverApp="forever" 38 | 39 | start() { 40 | echo "Starting $nodeApp" 41 | 42 | # Notice that we change the PATH because on reboot 43 | # the PATH does not include the path to node. 44 | # Launching forever with a full path 45 | # does not work unless we set the PATH. 46 | PATH=/usr/local/bin:$PATH 47 | export NODE_ENV=production 48 | #PORT=80 49 | $foreverApp start --pidFile $pidFile -l $logFile -a -d -c "$command" $nodeApp 50 | RETVAL=$? 51 | } 52 | 53 | restart() { 54 | echo -n "Restarting $nodeApp" 55 | $foreverApp restart $nodeApp 56 | RETVAL=$? 57 | } 58 | 59 | stop() { 60 | echo -n "Shutting down $nodeApp" 61 | $foreverApp stop $nodeApp 62 | RETVAL=$? 63 | } 64 | 65 | status() { 66 | echo -n "Status $nodeApp" 67 | $foreverApp list 68 | RETVAL=$? 69 | } 70 | 71 | case "$1" in 72 | start) 73 | start 74 | ;; 75 | stop) 76 | stop 77 | ;; 78 | status) 79 | status 80 | ;; 81 | restart) 82 | restart 83 | ;; 84 | *) 85 | echo "Usage: {start|stop|status|restart}" 86 | exit 1 87 | ;; 88 | esac 89 | exit $RETVAL 90 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug') 2 | let NS = "" 3 | /* eslint no-console: "off" */ 4 | module.exports = function (module) { 5 | const logger = {} 6 | logger.log = debug(NS + ":" + module) 7 | logger.log.log = console.log.bind(console) 8 | logger.log.color = 6 9 | logger.warn = debug(NS + ":" + module) 10 | logger.warn.log = console.warn.bind(console) 11 | logger.warn.color = 3 12 | logger.info = debug(NS + ":" + module) 13 | logger.info.log = console.info.bind(console) 14 | logger.info.color = 5 15 | logger.error = debug(NS + ":" + module) 16 | logger.error.log = console.error.bind(console) 17 | logger.error.color = 1 18 | return logger 19 | } 20 | module.exports.setNS = (ns) => NS = ns 21 | module.exports.enable = debug.enable 22 | module.exports.disable = debug.disable 23 | module.exports.save = debug.save 24 | module.exports.log = debug.log 25 | module.exports.load = debug.load -------------------------------------------------------------------------------- /lib/main.js: -------------------------------------------------------------------------------- 1 | const s = require('lib/mumble/stumble-instance') 2 | const logger = require('lib/logger')('main') 3 | const minmax = (a, b, c) => b < a ? a : b > c ? c : b 4 | 5 | function main() { 6 | s.client.connection.on('error', e => { 7 | logger.error(e) 8 | logger.log(e.data) 9 | }) 10 | s.client.on('user-disconnect', (user) => { 11 | setImmediate(() => { 12 | logger.log("User " + user.name + " disconnected.") 13 | const u = s.client.users() 14 | if (u.length == 1) { 15 | logger.log("No one left on the server, stoping the music...") 16 | s.invoke('stop') 17 | } 18 | }) 19 | }) 20 | 21 | if (!s.io.input) { 22 | s.io.establish({ 23 | input: true, 24 | inputOptions: { 25 | channels: 1, 26 | sampleRate: 48000, 27 | gain: minmax(0.01, s.config.extensions.audio.gain, 1.0), 28 | } 29 | }) 30 | this.space.set('audio.streaming', true) 31 | } 32 | s.space.get('mixer').pipe(s.io.input) 33 | 34 | } 35 | 36 | module.exports = main 37 | -------------------------------------------------------------------------------- /lib/model/speechstream.js: -------------------------------------------------------------------------------- 1 | const google_speech = require('google-speech') 2 | const path = require('path') 3 | const ffmpeg = require("fluent-ffmpeg") 4 | const Stream = require("stream") 5 | const fs = require('fs') 6 | const keys = require('keys/api-keys.json') 7 | const google_api_key = keys["google-speech"] 8 | const logger = require('lib/logger')('SpeechStream') 9 | 10 | 11 | function SpeechStream(engine = "GOOGLE", lang = "fr-FR") { 12 | return new GoogleStream(google_api_key, lang) 13 | } 14 | 15 | 16 | class GoogleStream extends Stream.Writable { 17 | constructor(api_key, lang) { 18 | super() 19 | this.api_key = api_key 20 | this.lang = lang 21 | this.textStream = new Stream.PassThrough({ 22 | objectMode: true 23 | }) 24 | this.acc = [] 25 | this.timeout = null 26 | this.pending = false 27 | this.input = this.converted = null 28 | } 29 | _write(chunk, encoding, cb) { 30 | clearTimeout(this.timeout) 31 | if (this.pending) { 32 | this.acc.push(chunk) 33 | } else { 34 | if (this.input == null) { 35 | this.input = new Stream.PassThrough 36 | this.converted = new Stream.PassThrough 37 | ffmpeg(this.input) 38 | .withInputFormat('s16le') 39 | .format('s16le') 40 | .withAudioFrequency(16000) 41 | .pipe(this.converted) 42 | 43 | if (this.acc.length > 0) { 44 | this.input.write(Buffer.concat(this.acc)) 45 | this.acc = [] 46 | } 47 | } 48 | this.input.write(chunk) 49 | this.timeout = setTimeout(this.makeRequest.bind(this), 500) 50 | } 51 | cb() 52 | } 53 | makeRequest() { 54 | logger.log("Making request ..\n") 55 | this.pending = true 56 | this.input.end() 57 | google_speech.ASR({ 58 | //debug: true, 59 | developer_key: this.api_key, 60 | stream: this.converted, 61 | lang: this.lang 62 | }, this.requestCallback.bind(this)) 63 | } 64 | requestCallback(err, res) { 65 | this.converted = null 66 | this.input = null 67 | this.pending = false 68 | if (err) return logger.error(err) 69 | this.handleResponse(res) 70 | } 71 | handleResponse(response) { 72 | logger.log(JSON.stringify(response)) 73 | if (response.result && response.result.length > 0) 74 | this.textStream.push(response.result[response.result_index].alternative) 75 | } 76 | } 77 | 78 | module.exports = SpeechStream 79 | -------------------------------------------------------------------------------- /lib/model/youtube/youtube.api.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const YouTube = require('youtube-node') 3 | 4 | const keys = require('keys/api-keys.json') 5 | 6 | module.exports = function ytapi(options) { 7 | options = Object.assign({}, { 8 | type: "video" 9 | }, options) 10 | let ytapi = new YouTube() 11 | ytapi.setKey(keys["youtube"]) 12 | for (var k in options) 13 | ytapi.addParam(k, options[k]) 14 | return ytapi 15 | } -------------------------------------------------------------------------------- /lib/model/youtube/youtube.player.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events') 2 | const fixedQueue = require('fixedqueue').FixedQueue 3 | const cheerio = require('cheerio') 4 | const promisify = require('util').promisify 5 | const ytdl = require('ytdl-core') 6 | const { chooseFormat, fromHumanTime } = require("ytdl-core/lib/util") 7 | 8 | const utils = require('lib/utils') 9 | const logger = require('lib/logger')('youtube:player') 10 | 11 | const ytapi = require('./youtube.api') 12 | 13 | class YoutubePlayer extends EventEmitter { 14 | constructor(options) { 15 | super() 16 | const conf = Object.assign({ 17 | norepeat_length: 15, 18 | lang: 'fr', 19 | auto_play: true, 20 | search_max_results: 5, 21 | jump_distance: 20000 22 | }, options) 23 | 24 | this.conf = conf 25 | this._history = fixedQueue(conf.norepeat_length) 26 | this.queue = [] 27 | this.playing = false 28 | this.current_video = null 29 | this.lang = conf.lang 30 | this.stream = null 31 | this.player = null 32 | this.last_video = null 33 | } 34 | set player(s) { 35 | this._player = s 36 | if (s) { 37 | this._player.on('error', e => logger.error(e)) 38 | this.player.on('naturalEnd', this.next.bind(this)) 39 | this.player.on('seek', time => time > 0 && this.emit('message', "Seeking " + utils.humanTime(time))) 40 | } 41 | } 42 | get player() { return this._player } 43 | playfirst(termsOrLink) { 44 | // check if we have a link to the video 45 | let links = [] 46 | cheerio.load(termsOrLink)('a').each((_, el) => { 47 | const attr = el.attribs['href'] 48 | if (attr) links.push(attr) 49 | }) 50 | // if we have a link 51 | if (links.length > 0) 52 | return this.play(utils.youtube_parser(links[0])) 53 | 54 | // play the first video that match those terms 55 | return this.search(termsOrLink, 1) 56 | .then(res => res.items[0].id.videoId) 57 | .then(id => this.play(id)) 58 | } 59 | play(id) { 60 | if (!this.player) throw new Error("Error: no player available") 61 | logger.log("Playing " + id) 62 | if (typeof id !== "string" || id.length > 11 || !(/[^"&?/ ]{11}/.test(id))) return this.playfirst(id) 63 | this.emit("stop", this.current_video) 64 | if (this.current_video) this.last_video = this.current_video 65 | 66 | // play the video with the id id 67 | const getById = promisify(ytapi().getById) 68 | const link = utils.video_link(id) 69 | return getById(id) 70 | .then(res => { 71 | if (res.items.length < 1) throw "No result for video link " + link 72 | const video = res.items[0] // the first result 73 | 74 | this.current_video = video 75 | this.emit("play", video) 76 | const getInfo = promisify(ytdl.getInfo.bind(ytdl)) 77 | return getInfo(link) 78 | }) 79 | .then(info => { 80 | this._history.push(id) 81 | let format = ytdl.chooseFormat(info.formats, { 82 | quality: 'highest', 83 | filter: "audioonly" 84 | }) 85 | this.player.play(format) 86 | }) 87 | } 88 | playQueue() { 89 | if (this.queue.length > 0) this.play(this.queue.shift()) 90 | } 91 | stop() { 92 | if (this.player) this.player.stop() 93 | this.emit("stop", this.current_video) 94 | this.queue = [] 95 | this.last_video = this.current_video 96 | this.current_video = null 97 | } 98 | next() { 99 | if (!this.current_video) return // return if we're not playing 100 | this.emit("stop", this.current_video) 101 | // stream next video 102 | if (this.queue.length > 0) // if we have demands for next 103 | this.playQueue() 104 | else if (this.conf.auto_play && this.current_video) { // else if auto play is enabled, search for related 105 | this.getRelated(this.current_video.id) 106 | .then(video => this.play(video.id.videoId)) 107 | } else { 108 | this.stop() 109 | this.emit('message', "Empty queue. Enable auto play to play related videos automatically.") 110 | } 111 | } 112 | previous() { 113 | if (this.last_video) this.play(this.last_video.id) 114 | } 115 | add(terms) { 116 | // add a video to the on-the-fly queue 117 | this.queue.push(terms) 118 | this.emit('add', terms) 119 | } 120 | addnext(terms) { 121 | // add a video directly after the current one 122 | this.queue.unshift(terms) 123 | this.emit('addnext', terms) 124 | } 125 | seek(time) { 126 | if (this.current_video) { 127 | if (this.player.seekable) { 128 | time = fromHumanTime(time) / 1000.0 129 | logger.info("Seeking " + time) 130 | this.player.seek(time) 131 | } 132 | else this.emit("message", "This video is not seekable") 133 | } 134 | } 135 | jumpForward(distance = this.conf.jump_distance) { 136 | const current = this.player.currentTime * 1000 137 | this.seek(current + distance) 138 | } 139 | jumpBackward(distance = this.conf.jump_distance) { 140 | const current = this.player.currentTime * 1000 141 | this.seek(current - distance) 142 | } 143 | get related() { 144 | if (!this.current_video) return [] 145 | return (async () => { 146 | const related = promisify(ytapi().related) 147 | const vids = await related(this.current_video.id, 15) 148 | return vids.items.map(e => e.id.videoId) 149 | })() 150 | } 151 | getRelated(id) { 152 | const link = utils.video_link(id) 153 | logger.log("Searching related for " + id + "(" + link + ")") 154 | const related = promisify(ytapi().related) 155 | return related(id, this.conf.norepeat_length + 1) 156 | .then(res => { 157 | if (res.items.length < 1) throw new Error('No related found') 158 | let item = null 159 | let i = 0 160 | let found = false 161 | while (!found && i < res.items.length) { 162 | found = this._history.indexOf(res.items[i++].id.videoId) === -1 163 | } 164 | if (found) return res.items[i - 1] 165 | else throw new Error('No related found') 166 | }) 167 | } 168 | search(terms, max_results = this.conf.search_max_results) { 169 | logger.log("Searching video with terms: " + terms) 170 | const search = promisify(ytapi().search) 171 | return search(terms, max_results) 172 | .then(res => { 173 | if (res.items.length > 0) 174 | return res 175 | else { 176 | this.emit('message', 'No video found: ' + terms + '') 177 | throw new Error('No video found with those terms: ' + terms) 178 | } 179 | }) 180 | } 181 | async mostPopular(regionCode = this.lang, videoCategoryId = 10, maxResults = 25) { 182 | logger.log("Retrieving most popular videos from category " + videoCategoryId + " and country code " + regionCode) 183 | const api = ytapi({ 184 | part: "id", 185 | chart: "mostPopular", 186 | videoCategoryId, 187 | maxResults, 188 | regionCode 189 | }) 190 | 191 | const url = api.getUrl('videos') 192 | const req = promisify(api.request.bind(api)) 193 | const res = await req(url) 194 | return res 195 | } 196 | } 197 | module.exports = YoutubePlayer -------------------------------------------------------------------------------- /lib/mumble/audio/mixer.js: -------------------------------------------------------------------------------- 1 | const Stream = require('stream') 2 | const fs = require('fs') 3 | const logger = require('lib/logger')('mixer') 4 | 5 | function mixSamples(samples) { 6 | const length = samples.length 7 | const ratio = 1 / length 8 | let mixed = 0; 9 | for (let i = 0; i < length; i++) mixed += samples[i] * ratio 10 | return mixed 11 | // return samples.reduce((a, s) => Math.max(Math.min(s / length + a, 32767), -32768), 0) 12 | } 13 | // chunks MUST have the same size 14 | function mixChunks(chunks, sampleByteLength) { 15 | const length = chunks[0].length 16 | const mixed = chunks[0] 17 | const l = chunks.length 18 | const samples = new Array(l) 19 | for (let offset = 0; offset < length; offset += sampleByteLength) { 20 | //chunks.map(chunk => mixed.readInt16LE.call(chunk, offset)) 21 | for (let i = 0; i < l; i++) samples[i] = chunks[i].readInt16LE(offset) 22 | mixed.writeInt16LE(mixSamples(samples), offset) 23 | } 24 | return mixed 25 | } 26 | // inspired by https://github.com/stephen/audio-mixer 27 | class Input extends Stream.Writable { 28 | constructor(options) { 29 | super(options) 30 | options = Object.assign({ 31 | bitDepth: 16, 32 | channels: 1, 33 | volume: 1 34 | }, options) 35 | this.bitDepth = options.bitDepth 36 | this.channels = options.channels 37 | this.volume = options.volume 38 | 39 | this.getMoreData = null 40 | this.buffer = Buffer.alloc(0) 41 | this.finished = false 42 | this.once('finish', () => { 43 | this.finished = true 44 | if (this.buffer.length === 0) this.emit('end') 45 | else this.once('flushed', () => this.emit('end')) 46 | }) 47 | } 48 | read(samples) { 49 | let bytes = samples * this.sampleLength 50 | if (this.buffer.length < bytes) bytes = this.buffer.length 51 | 52 | let r = this.buffer.slice(0, bytes) 53 | this.buffer = this.buffer.slice(bytes) 54 | 55 | if (this.buffer.length <= 131072 && this.getMoreData) { 56 | const getMoreData = this.getMoreData 57 | this.getMoreData = null 58 | process.nextTick(getMoreData) 59 | } 60 | if (this.buffer.length === 0) this.emit('flushed') 61 | return r 62 | } 63 | availSamples(length = this.buffer.length) { 64 | return Math.floor(length / this.sampleLength) 65 | } 66 | _write(chunk, encoding, callback) { 67 | this.buffer = Buffer.concat([this.buffer, chunk]) 68 | this.emit('readable') 69 | if (this.buffer.length > 131072) 70 | this.getMoreData = callback 71 | else 72 | callback() 73 | 74 | } 75 | get sampleLength() { 76 | return this.bitDepth / 8 * this.channels 77 | } 78 | } 79 | class MixerSlow extends Stream.Readable { 80 | constructor(options) { 81 | super(options) 82 | options = Object.assign({ 83 | bitDepth: 16, 84 | channels: 1, 85 | volume: 1 86 | }, options) 87 | this.bitDepth = options.bitDepth 88 | this.channels = options.channels 89 | this.sampleByteLength = this.bitDepth / 8 90 | 91 | this.buffer = Buffer.alloc(0) 92 | this.inputs = {} 93 | this.retry = null 94 | } 95 | 96 | _read() { 97 | this.retry = null 98 | let samples = Number.MAX_VALUE 99 | const keys = Object.keys(this.inputs) 100 | const keyslenth = keys.length 101 | for (let i = 0; i < keyslenth; i++) { 102 | const availSamples = this.inputs[keys[i]].availSamples() 103 | if (availSamples < samples) samples = availSamples 104 | } 105 | if (samples > 0 && samples != Number.MAX_VALUE) { 106 | const chunks = [] 107 | for (let i = 0; i < keyslenth; i++) { 108 | chunks.push(this.inputs[keys[i]].read(samples)) 109 | // Object.keys(this.inputs).map(id => this.inputs[id].read(samples)) 110 | } 111 | const mixedBuffer = mixChunks(chunks, this.sampleByteLength) 112 | this.push(mixedBuffer) 113 | } else { 114 | this.retry = this._read.bind(this) 115 | } 116 | } 117 | plug(stream, options) { 118 | const id = Math.random().toString(36).substr(7) 119 | const input = new Input(options) 120 | input.on('readable', () => this.retry && this.retry()) 121 | input.on('end', () => this.unplug(id)) 122 | stream.pipe(input) 123 | this.inputs[id] = input 124 | if (this.retry) process.nextTick(this.retry.bind(this)) 125 | return id 126 | } 127 | unplug(id) { 128 | const input = this.inputs[id] 129 | input.removeAllListeners() 130 | delete this.inputs[id] 131 | if (this.retry) process.nextTick(this.retry.bind(this)) 132 | this.emit('unplug', id) 133 | } 134 | } 135 | 136 | class Mixer2 extends Stream.Transform { 137 | constructor(options) { 138 | super() 139 | this.retry = null 140 | this.input = null 141 | this.sampleByteLength = 2 142 | this.count = 0 143 | this.lastUpdate = Date.now() 144 | setInterval(() => { 145 | const now = Date.now() 146 | const time = now - this.lastUpdate 147 | this.lastUpdate = now 148 | if (!this.count) return 149 | const rate = this.count / time 150 | this.count = 0 151 | // logger.info("Rate: " + rate.toFixed(2) + " bytes/s") 152 | }, 2000) 153 | this.on('pipe', () => this.piped = true) 154 | this.on('unpipe', () => { this.piped = false }) 155 | } 156 | _transform(chunk, encoding, callback) { 157 | // return callback(null, chunk) 158 | this.retry = null 159 | const sampleAvailable = Math.min(this.input ? this.input.availSamples() : Number.MAX_VALUE, chunk.length / this.sampleByteLength) 160 | if (this.input && sampleAvailable > 0) { 161 | const bytes = sampleAvailable * this.sampleByteLength 162 | const newBuf = mixChunks([this.input.read(sampleAvailable), chunk.slice(0, bytes)], this.sampleByteLength) 163 | this.push(newBuf) 164 | this.count += bytes 165 | chunk = chunk.slice(bytes) 166 | if (chunk.length) { 167 | this.retry = () => this._transform(chunk, encoding, callback) 168 | this.input ? this.input.once('readable', () => this.retry && this.retry()) : this.retry() 169 | } 170 | else callback() 171 | } else { 172 | this.count += chunk.length 173 | if (this.input) logger.log(this.input.availSamples()) 174 | // else logger.log('no input') 175 | callback(null, chunk) 176 | 177 | } 178 | 179 | } 180 | plug(stream) { 181 | if (this.piped) { 182 | this.input = new Input() 183 | stream.pipe(this.input) 184 | this.input.once('end', () => this.input = null && this.retry && this.retry()) 185 | } else { 186 | stream.pipe(this, { end: false }) 187 | stream.on("end", () => stream.unpipe()) 188 | } 189 | } 190 | } 191 | 192 | module.exports = Mixer2 193 | function main() { 194 | let m = new module.exports 195 | let s1 = fs.createReadStream('data/voice/1.wav') 196 | let s2 = fs.createReadStream('data/voice/2.wav') 197 | 198 | const input = new Input() 199 | m.input = input 200 | s2.pipe(input) 201 | s1.pipe(m) 202 | m.pipe(fs.createWriteStream('data/voice/test.wav')) 203 | 204 | // setTimeout(() => { }, 5000) 205 | } 206 | 207 | // main() -------------------------------------------------------------------------------- /lib/mumble/extension.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const ffmpeg = require('fluent-ffmpeg') 3 | const request = require('request') 4 | const Stream = require('stream') 5 | const minmax = require('stumble/lib/gutil').minmax 6 | const fs = require('fs') 7 | const EventEmitter = require('events') 8 | const Jamy = require('jamy') 9 | const { min, max } = require('lib/utils') 10 | const utils = require('lib/utils') 11 | 12 | const radio = require('./extensions/radio') 13 | const youtube = require('./extensions/youtube/youtube') 14 | const Mixer = require('./audio/mixer') 15 | const voice = require('./extensions/voice') 16 | const notepad = require('./extensions/notepad') 17 | const voiceCommand = require('./extensions/voice-command/voice-command') 18 | const phone = require('./extensions/phone') 19 | 20 | const logger = require('lib/logger')('extension') 21 | 22 | const volumedown = { 23 | handle: "volumedown", 24 | exec: function (data) { 25 | let gain = this.io.input ? this.io.input.gain : this.config.extensions.audio.gain 26 | gain = max(0.01, gain - this.config.extensions.audio.volumestep) 27 | if (this.io.input) { 28 | this.io.input.setGain(gain) 29 | } 30 | this.config.extensions.audio.gain = gain 31 | this.execute('info::gain') 32 | } 33 | } 34 | const volumeup = { 35 | handle: "volumeup", 36 | exec: function (data) { 37 | let gain = this.io.input ? this.io.input.gain : this.config.extensions.audio.gain 38 | gain = min(1, gain + this.config.extensions.audio.volumestep) 39 | if (this.io.input) { 40 | this.io.input.setGain(gain) 41 | } 42 | this.config.extensions.audio.gain = gain 43 | this.execute('info::gain') 44 | } 45 | } 46 | const pause = { 47 | handle: "pause", 48 | exec: function (data) { 49 | this.space.get('player').pause() 50 | } 51 | } 52 | const resume = { 53 | handle: "play", 54 | exec: function (data) { 55 | this.space.get('player').resume() 56 | } 57 | } 58 | const info = { 59 | handle: "info", 60 | extensions: [{ 61 | handle: "info::gain", 62 | exec: function (data) { 63 | let gain = this.config.extensions.audio.gain 64 | if (this.io.input) 65 | gain = this.io.input.gain 66 | this.client.user.channel.sendMessage("Current volume: " + (gain * 100).toFixed(2)) 67 | } 68 | }], 69 | commands: [{ 70 | handle: 'help', 71 | exec: function (data) { 72 | const cmds = [...this.commands.keys()].sort((a, b) => { 73 | return a.localeCompare(b); 74 | }).map(cmd => `
  • ${cmd}
  • `); 75 | 76 | data.user.sendMessage('List of available commands:'); 77 | const link = utils.link_with_title('https://github.com/TinyMan/node-jeanne#commands', "find information online") 78 | const message = `You have asked for help. 79 |
      ${cmds.join('')}

    80 | Use info [ COMMAND_NAME ] to gain additional information, or ${link}.` 81 | utils.autoPartsString(message, 50000, '').forEach(m => data.user.sendMessage(m)) 82 | }, 83 | info: () => `Displays each command currently loaded.` 84 | }, { 85 | handle: 'info', 86 | exec: function info(data) { 87 | const name = data.message || 'info'; 88 | const aliased = data.message && this.aliases.get(name); 89 | const target = this.commands.get(aliased || name); 90 | 91 | if (target) { 92 | if (aliased) 93 | data.user.sendMessage(`Note: [ ${name} ] is an alias for [ ${aliased} ].`); 94 | 95 | const reply = target.info ? target.info.call(this, this, data) : 'No info available.'; 96 | data.user.sendMessage(reply || `Command [ ${name} ] not found.`); 97 | } 98 | }, 99 | info: () => `Provides information about a given command` 100 | }] 101 | } 102 | 103 | 104 | const reboot = { 105 | handle: "reboot", 106 | exec: _ => process.exit(0) 107 | } 108 | 109 | module.exports = { 110 | handle: 'streaming', 111 | needs: [], 112 | init: stumble => { 113 | const jamy = new Jamy({ 114 | format: "s16le", 115 | frequency: 48000, 116 | channels: 1 117 | }) 118 | const mixer = new Mixer() 119 | mixer.plug(jamy.stream) 120 | stumble.space.set("player", jamy) 121 | stumble.space.set("mixer", mixer) 122 | stumble.space.set("commands", new EventEmitter()) 123 | if ("voice" in stumble.config.extensions) stumble.use(voice) 124 | 125 | }, 126 | term: stumble => { }, 127 | extensions: [info, youtube, notepad, voiceCommand, phone], 128 | commands: [radio, volumeup, volumedown, pause, resume, reboot] 129 | } 130 | -------------------------------------------------------------------------------- /lib/mumble/extensions/notepad.js: -------------------------------------------------------------------------------- 1 | const util = require('util') 2 | const utils = require("lib/utils") 3 | const logger = require('lib/logger')("notepad") 4 | 5 | const breakTag = "
    " 6 | const notepad = { 7 | handle: "notepad", 8 | init: async stumble => { 9 | const db = stumble.execute('database::use') 10 | const notepad_table = "notepad" 11 | stumble.space.set("notepad::notepad_table", notepad_table) 12 | 13 | const run = util.promisify(db.run.bind(db)) 14 | await run(` 15 | CREATE TABLE IF NOT EXISTS ${notepad_table} ( 16 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL UNIQUE, 17 | content TEXT NOT NULL 18 | );`) 19 | }, 20 | commands: [{ 21 | handle: "note", 22 | exec: async function (data) { 23 | const note = data.message.trim() 24 | if (note.length > 0) { 25 | const db = this.execute('database::use') 26 | const table = this.space.get("notepad::notepad_table") 27 | const run = util.promisify(db.run.bind(db)) 28 | const sql = `INSERT INTO ${table}(content) VALUES($note);` 29 | // logger.info("Adding note: ", sql) 30 | try { 31 | await run(sql, { $note: note }) 32 | this.client.user.channel.sendMessage("Note added.") 33 | } catch (e) { 34 | logger.error(e) 35 | } 36 | } 37 | } 38 | }, { 39 | handle: "dispNote", 40 | exec: async function (data) { 41 | let filter = data.message 42 | const db = this.execute('database::use') 43 | const table = this.space.get("notepad::notepad_table") 44 | const dbAll = util.promisify(db.all.bind(db)) 45 | let sql = `SELECT id, content FROM ${table}` 46 | // logger.info(sql) 47 | try { 48 | let rows = await dbAll(sql) 49 | if (filter.length > 0) { 50 | filter += ":" 51 | const len = filter.length 52 | rows = rows.filter(r => r.content.startsWith(filter)) 53 | .map(r => (r.content = r.content.slice(len).trim(), r)) 54 | } 55 | // logger.info(rows) 56 | let message = "Notes: " + breakTag 57 | rows.forEach(r => { 58 | message += r.id + ". " + r.content + breakTag 59 | }) 60 | utils.autoPartsString(message, 50000, breakTag).forEach(m => this.client.user.channel.sendMessage(m)) 61 | } catch (e) { 62 | logger.error(e) 63 | } 64 | 65 | } 66 | }, { 67 | handle: "rmNote", 68 | exec: async function (data) { 69 | const id = parseInt(data.message) 70 | if (!isNaN(id) && id > 0) { 71 | try { 72 | const db = this.execute('database::use') 73 | const table = this.space.get("notepad::notepad_table") 74 | const run = util.promisify(db.run.bind(db)) 75 | const sql = `DELETE FROM ${table} WHERE id = $id;` 76 | await run(sql, { $id: id }) 77 | this.client.user.channel.sendMessage("Note removed.") 78 | } catch (e) { 79 | logger.error(e) 80 | } 81 | } 82 | } 83 | }] 84 | } 85 | module.exports = notepad -------------------------------------------------------------------------------- /lib/mumble/extensions/phone.js: -------------------------------------------------------------------------------- 1 | const sylvia = require('sylvia') 2 | const htmlToText = require('html-to-text') 3 | 4 | const utils = require('lib/utils') 5 | const logger = require('lib/logger')('phone') 6 | const serialLogger = require('lib/logger')('phone::serial') 7 | const audioLogger = require('lib/logger')('phone::audio') 8 | 9 | module.exports = { 10 | handle: "phone", 11 | init: async stumble => { 12 | return new Error('Not implemented') 13 | try { 14 | const phone = new sylvia() 15 | stumble.space.set('phone', phone) 16 | phone.on('error', e => logger.error(e)) 17 | phone.on('sms', async sms => await handle('phone::handler::sms', sms)) 18 | phone.on('sms-sent', async id => await handle('phone::handler::sms-sent', id)) 19 | phone.on('ring', async () => await handle('phone::handler::ring')) 20 | phone.on('clip', async clip => await handle('phone::handler::ring', clip)) 21 | phone.on('hangup', async () => await handle('phone::handler::hangup')) 22 | phone.on('call-connected', async () => await handle('phone::connect-call')) 23 | phone.on('serial-msg', msg => serialLogger.info(msg)) 24 | phone.audioIn.on('error', e => audioLogger.error(e)) 25 | phone.audioOut.on('error', e => audioLogger.error(e)) 26 | 27 | stumble.on('ready', async () => await handle('phone::start')) 28 | 29 | stumble.space.set('phone.connected', false) 30 | 31 | } catch (e) { 32 | logger.error(e) 33 | } 34 | async function handle(method, ...args) { 35 | try { 36 | logger.log("Handling method " + method) 37 | return await stumble.execute(method, ...args) 38 | } catch (e) { 39 | logger.error(e) 40 | } 41 | } 42 | }, 43 | extensions: [{ 44 | handle: "phone::handler::sms-sent", 45 | exec: async function (id) { 46 | // update database 47 | this.client.user.channel.sendMessage('Sms sent with id ' + id + '') 48 | } 49 | }, 50 | { 51 | handle: "phone::handler::sms", 52 | exec: async function (sms) { 53 | // update database 54 | this.execute('phone::display::sms', sms) 55 | } 56 | }, { 57 | handle: "phone::handler::ring", 58 | exec: async function (clip) { 59 | let message = 'RING' 60 | if (clip) { 61 | message = "Incoming call from " + clip 62 | } 63 | this.client.user.channel.sendMessage(message) 64 | } 65 | }, { 66 | handle: "phone::handler::hangup", 67 | exec: async function (clip) { 68 | this.client.user.channel.sendMessage('Hangup') 69 | await this.execute('phone::disconnect-call') 70 | } 71 | }, { 72 | handle: "phone::disconnect-call", 73 | exec: async function () { 74 | if (!this.space.get('phone.connected')) return 75 | 76 | const mixer = this.space.get('mixer') 77 | const player = this.space.get('player') 78 | const phone = this.space.get('phone') 79 | 80 | this.io.nullify({ output: true }) 81 | 82 | phone.audioOut.stream.unpipe() 83 | mixer.pipe(this.io.input) 84 | 85 | this.execute('voice-command::start') 86 | this.space.set('phone.connected', false) 87 | } 88 | }, { 89 | handle: "phone::connect-call", 90 | exec: async function (data) { 91 | const mixer = this.space.get('mixer') 92 | const player = this.space.get('player') 93 | const phone = this.space.get('phone') 94 | 95 | // stop what we're doing 96 | mixer.unpipe() 97 | this.execute('voice-command::stop') 98 | 99 | // mumble -> phone 100 | this.io.output.pipe(phone.audioIn.stream) 101 | 102 | // phone -> mumble 103 | phone.audioOut.stream.pipe(this.io.input) 104 | this.space.set('phone.connected', true) 105 | } 106 | }, { 107 | handle: "phone::display::sms", 108 | exec: async function (sms) { 109 | const message = sms.time.toLocaleString(this.config.locale || 'fr') + ", " + sms.sender + ":
    " + sms.text.replace(/(\r\n?|\r?\n)/, '
    ') 110 | utils.autoPartsString(message, 50000, utils.breakTag).forEach(m => this.client.user.channel.sendMessage(m)) 111 | } 112 | }, { 113 | handle: "phone::display::status-report", 114 | exec: async function (data) { 115 | 116 | } 117 | }, { 118 | handle: "phone::start", 119 | exec: async function () { 120 | const phone = this.space.get('phone') 121 | return await phone.start() 122 | } 123 | }, { 124 | handle: "phone::stop", 125 | exec: async function () { 126 | const phone = this.space.get('phone') 127 | return await phone.stop() 128 | } 129 | }], 130 | commands: [{ 131 | handle: "sms", 132 | exec: async function (data) { 133 | try { 134 | const matches = /^ *([^ ]+) (.+)$/.exec(data.message) 135 | const recipient = matches[1] 136 | const msg = htmlToText.fromString(matches[2], { 137 | hideLinkHrefIfSameAsText: true, 138 | ignoreImage: true 139 | }) 140 | const phone = this.space.get('phone') 141 | phone.sendSms(msg, recipient) 142 | } catch (e) { 143 | logger.error(e) 144 | } 145 | } 146 | }, { 147 | handle: "dial", 148 | exec: async function (data) { 149 | const phone = this.space.get('phone') 150 | try { 151 | const m = data.message.match(/\+?\d+/) 152 | if (m) { 153 | logger.log("Dialing " + m[0]) 154 | await phone.dial(m[0]) 155 | } else { 156 | data.user.sendMessage('Invalid number') 157 | } 158 | } catch (e) { 159 | logger.error(e) 160 | } 161 | } 162 | }, { 163 | handle: "hangup", 164 | exec: async function (data) { 165 | await this.space.get('phone').hangup() 166 | } 167 | }, { 168 | handle: "answer", 169 | exec: async function (data) { 170 | await this.space.get('phone').answer() 171 | } 172 | }] 173 | } -------------------------------------------------------------------------------- /lib/mumble/extensions/radio.js: -------------------------------------------------------------------------------- 1 | const request = require('request') 2 | const path = require('path') 3 | const Stream = require('stream') 4 | const radios = require("data/radios.json") 5 | const logger = require('lib/logger')('radio') 6 | 7 | const utils = require('lib/utils') 8 | 9 | 10 | const radio = { 11 | handle: 'radio', 12 | exec: function (data) { 13 | const stumble = this 14 | const links = utils.getLinks(data.message) 15 | let link = "" 16 | if (links.length < 1) { 17 | const r = {} 18 | try { 19 | const name = data.message.toLowerCase() 20 | //logger.log(name, radios[name]) 21 | Object.assign(r, radios[name]) 22 | link = r.url 23 | if (!link || typeof link == "undefined") 24 | throw new Error 25 | } catch (e) { 26 | this.client.user.channel.sendMessage("Error: no link provided") 27 | let radioList = "" 28 | for (let e in radios) { 29 | radioList += "" + radios[e].name + ", " 30 | } 31 | radioList += "Total: " + Object.keys(radios).length 32 | this.client.user.channel.sendMessage(radioList) 33 | return 34 | } 35 | this.client.user.channel.sendMessage("Streaming " + r.name) 36 | } else { 37 | link = links[0] 38 | this.client.user.channel.sendMessage("Streaming " + link) 39 | } 40 | stumble.invoke('stop') 41 | stumble.space.get('player').play({ url: link }) 42 | } 43 | } 44 | module.exports = radio 45 | -------------------------------------------------------------------------------- /lib/mumble/extensions/voice-command/speech-commands.js: -------------------------------------------------------------------------------- 1 | const s = require('lib/mumble/stumble-instance.js') 2 | const logger = require('lib/logger.js')('speech-commands') 3 | const between = (a, b, c) => c < a ? a : (c > b ? b : c) 4 | const { BreakException } = require('lib/utils.js') 5 | const commands = { 6 | volume: { 7 | detector: /(?:.* )?volume +(\d+)/i, 8 | func: function (matches, tr) { 9 | let vol = parseFloat(matches[1], 10) / 100.0 10 | vol = between(0, 1, vol) 11 | this.config.extensions.audio.gain = vol 12 | if (this.io.input) 13 | this.io.input.setGain(vol) 14 | 15 | this.execute("info::gain") 16 | } 17 | }, 18 | stop: { 19 | detector: /(.* )?(?:(ferme (la)?)?ta gueule?)|(?:stop)/i, 20 | func: function (matches, tr) { 21 | this.invoke('stop') 22 | } 23 | }, 24 | next: { 25 | detector: /(.* )?(?:(?:met?)|(?:mai)[a-z]{0,3})? ?(la)? ?(suivante?s?)|(next?)/i, 26 | func: function (matches, tr) { 27 | this.invoke('next') 28 | } 29 | }, 30 | play: { 31 | detector: /je (?:(?:met?s?)|(?:mai)[a-z]{0,3}) (.+)/i, 32 | func: function (matches, tr) { 33 | let terms = matches.slice(1).join(' ') 34 | logger.log("Setting video/song: " + terms) 35 | this.invoke('yt', { 36 | message: terms 37 | }) 38 | } 39 | }, 40 | reboot: { 41 | detector: /(.* )?(reboot)|(redémarrer?)/i, 42 | func: function (matches, tr) { 43 | this.invoke('reboot') 44 | } 45 | }, 46 | volumedown: { 47 | detector: /(baisse (le son))|(moins fort)/i, 48 | func: function (matches, tr) { 49 | this.invoke("volumedown") 50 | } 51 | }, 52 | volumeup: { 53 | detector: /(monte (le son))|(plus fort)/i, 54 | func: function (matches, tr) { 55 | this.invoke("volumeup") 56 | } 57 | }, 58 | addnext: { 59 | detector: /pre|épare? (.+)/i, 60 | func: function (matches, tr) { 61 | this.invoke("addnext", { 62 | message: matches.slice(1).join(' ') 63 | }) 64 | } 65 | }, 66 | add: { 67 | detector: /ajoute? (.+)/i, 68 | func: function (matches, tr) { 69 | this.invoke("add", { 70 | message: matches.slice(1).join(' ') 71 | }) 72 | } 73 | }, 74 | mute: { 75 | detector: /(mute)|(muett?e?)/i, 76 | func: function (matches, tr) { 77 | this.invoke("mute") 78 | } 79 | }, 80 | radio: { 81 | detector: /radio (.+)/i, 82 | func: function (matches, tr) { 83 | this.invoke("radio", { 84 | message: matches.slice(1).join(' ') 85 | }) 86 | } 87 | }, 88 | playlist: { 89 | detector: /play ?list (.+)/i, 90 | func: function (matches, tr) { 91 | const terms = matches[1] 92 | logger.log("Playing playlist: " + terms) 93 | this.invoke('playList', { 94 | message: terms 95 | }) 96 | } 97 | }, 98 | playlists: { 99 | detector: /affiche les play ?lists?/i, 100 | func: function (matches, tr) { 101 | this.invoke('playlists') 102 | } 103 | }, 104 | pause: { 105 | detector: /(pause)|(pose)/i, 106 | func: function (matches, tr) { 107 | this.invoke("pause") 108 | } 109 | }, 110 | resume: { 111 | detector: /(play)|(lecture)/i, 112 | func: function (matches, tr) { 113 | this.invoke("play") 114 | } 115 | }, 116 | search: { 117 | detector: /(?:re)?(?:cherche) (.+)/i, 118 | func: function (matches, tr) { 119 | let terms = matches.slice(1).join(' ') 120 | this.invoke("search", { 121 | message: terms 122 | }) 123 | } 124 | }, 125 | description: { 126 | detector: /description/i, 127 | func: function (matches, tr) { 128 | this.invoke("description") 129 | } 130 | }, 131 | move_forward: { 132 | detector: /avance/i, 133 | func: function (matches, tr) { 134 | const player = this.space.get('youtube-player') 135 | player.jumpForward() 136 | } 137 | }, 138 | move_backward: { 139 | detector: /recule/i, 140 | func: function (matches, tr) { 141 | const player = this.space.get('youtube-player') 142 | player.jumpBackward() 143 | } 144 | }, 145 | history: { 146 | detector: /historique/i, 147 | func: function (matches, tr) { 148 | this.invoke("history") 149 | } 150 | }, 151 | hits: { 152 | detector: /(vidéos?)? ?(les )?plus écoutée?s?/i, 153 | func: function (matches, tr) { 154 | this.invoke("hits") 155 | } 156 | }, 157 | trending: { 158 | detector: /(trending)|(tendances?)/i, 159 | func: function (matches, tr) { 160 | this.invoke("playList", { message: 'trending' }) 161 | } 162 | }, 163 | previous: { 164 | detector: /précédente?/, 165 | func: function (matches, tr) { 166 | this.invoke('previous') 167 | } 168 | }, 169 | related: { 170 | detector: /propositions?/, 171 | func: function (matches, tr) { 172 | this.invoke('related') 173 | } 174 | }, 175 | news: { 176 | detector: /(news?)|(nouveautés?)/i, 177 | func: function (matches, tr) { 178 | this.invoke("playList", { message: 'news' }) 179 | } 180 | } 181 | } 182 | 183 | module.exports = commands 184 | -------------------------------------------------------------------------------- /lib/mumble/extensions/voice-command/voice-command.js: -------------------------------------------------------------------------------- 1 | const commands = require('./speech-commands') 2 | const SpeechStream = require('lib/model/speechstream') 3 | const BreakException = require('lib/utils').BreakException 4 | const logger = require('lib/logger')('voice-command') 5 | 6 | module.exports = { 7 | handle: 'voice-command', 8 | init: async stumble => { 9 | const speechstream = SpeechStream() 10 | speechstream.textStream.on('data', transcripts => stumble.execute('voice-command::detect', transcripts)) 11 | stumble.space.set('speechstream', speechstream) 12 | stumble.on('ready', () => stumble.execute('voice-command::start')) 13 | }, 14 | extensions: [{ 15 | handle: "voice-command::start", 16 | exec: async function () { 17 | this.client.connection.ignoreLastAudioFrame = true 18 | this.client.connection.ignoreNormalTalking = true 19 | this.io.establish({ 20 | output: true, 21 | outputFrom: true 22 | }) 23 | this.io.output.pipe(this.space.get('speechstream')) 24 | } 25 | }, { 26 | handle: "voice-command::stop", 27 | exec: async function () { 28 | this.client.connection.ignoreNormalTalking = false 29 | this.client.connection.ignoreLastAudioFrame = false 30 | 31 | this.io.output.unpipe() 32 | 33 | } 34 | }, { 35 | handle: "voice-command::detect", 36 | exec: async function (transcripts) { 37 | try { 38 | for (let name in commands) { 39 | let command = commands[name] 40 | if (!command.func) return 41 | if (typeof command.detector === "object" && command.detector instanceof RegExp) { 42 | transcripts.forEach((tr) => { 43 | let matches = command.detector.exec(tr.transcript) 44 | if (matches) { 45 | command.func.call(this, matches, tr) 46 | throw new BreakException(name, tr.transcript) 47 | } 48 | }) 49 | } else if (typeof command.detector === "function") { 50 | command.detector.call(this, transcripts, command.func) 51 | } else { 52 | logger.error('Voice command detector not implemented for ' + name) 53 | } 54 | } 55 | } catch (e) { 56 | if (!(e instanceof BreakException)) throw e 57 | logger.log("Found match: " + e.command, e.transcript) 58 | } 59 | 60 | } 61 | }] 62 | } -------------------------------------------------------------------------------- /lib/mumble/extensions/voice.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | const logger = require('lib/logger')('voice') 4 | 5 | function getStream(file) { 6 | const st = fs.createReadStream(file).on('error', e => { 7 | st.unpipe() 8 | logger.error(e) 9 | }) 10 | return st 11 | } 12 | const playvoice = { 13 | handle: "voice::play", 14 | exec: function ({ filename, user }) { 15 | const file = path.resolve(this.config.extensions.voice.folder, filename) 16 | 17 | const player = this.space.get('player') 18 | if (!player.playing) { 19 | logger.log("Playing file " + file) 20 | if (!user) { 21 | // getStream(file).pipe(mixer, { end: false }) 22 | if (this.client.user.channel.users.length > 1) 23 | player.play({ 24 | filepath: path.resolve(this.config.extensions.voice.folder, file), 25 | playbackOptions: { 26 | inputOptions: ["-f s16le"] 27 | } 28 | }) 29 | } else { 30 | const stream = this.client.inputStreamForUser(user.session, { gain: this.config.extensions.voice.gain }) 31 | getStream(file).pipe(stream) 32 | } 33 | } 34 | } 35 | } 36 | 37 | const playRandom = { 38 | handle: "voice::playRandom", 39 | exec: function ({ category, user, id }) { 40 | // logger.log("Playing random voice sample from " + category) 41 | const samples = this.space.get('voice:map')[category] 42 | if (!samples) return 43 | if (!id) id = Math.floor(Math.random() * samples.length) 44 | const filename = samples[id] 45 | if (filename) 46 | this.execute("voice::play", { filename, user }) 47 | } 48 | } 49 | const playIdle = { 50 | handle: "voice::playIdle", 51 | exec: function () { 52 | const range = this.config.extensions.voice.idle_timer.max - this.config.extensions.voice.idle_timer.min 53 | const time = (Math.random() * range + this.config.extensions.voice.idle_timer.min) * 1000 // config is in seconds 54 | // logger.log(time) 55 | setTimeout(() => { 56 | if (this.client.users().length > 0) 57 | this.execute("voice::playRandom", { category: "idle" }) 58 | this.execute("voice::playIdle") 59 | }, time) 60 | } 61 | } 62 | 63 | module.exports = { 64 | handle: 'voice', 65 | needs: [], 66 | init: stumble => { 67 | try { 68 | stumble.space.set('voice:map', require(stumble.config.extensions.voice.map)) 69 | } catch (e) { 70 | return logger.error(e) 71 | } 72 | // setup listeners 73 | stumble.on('ready', () => { 74 | logger.log('Started') 75 | const client = stumble.client 76 | client.on('user-move', (user, fromChannel, toChannel, actor) => { 77 | // logger.log("User " + user.name + " moved from channel " + fromChannel.name + " to " + toChannel.name + " by " + actor.name); 78 | if (toChannel === client.user.channel) // user joined our channel 79 | stumble.execute("voice::playRandom", { category: "welcome" }) 80 | else if (fromChannel === client.user.channel) { // user left our channel 81 | stumble.execute("voice::playRandom", { category: "goodbye", user }) 82 | } 83 | }) 84 | client.on('user-connect', user => { 85 | if (user.channel === client.user.channel) 86 | stumble.execute("voice::playRandom", { category: "welcome" }) 87 | }) 88 | }) 89 | stumble.space.get('commands').on('stop', () => setImmediate(() => stumble.execute("voice::playRandom", { category: "stop" }))) 90 | stumble.space.get('commands').on('search', terms => setImmediate(() => stumble.execute("voice::playRandom", { category: "special", id: "searching" }))) 91 | stumble.on('ready', () => stumble.execute('voice::playIdle')) 92 | }, 93 | term: stumble => { }, 94 | extensions: [playvoice, playRandom, playIdle], 95 | commands: [] 96 | } -------------------------------------------------------------------------------- /lib/mumble/extensions/youtube/youtube.info.js: -------------------------------------------------------------------------------- 1 | const promisify = require('util').promisify 2 | require('autolink-js') 3 | 4 | const logger = require('lib/logger')('youtube:info') 5 | const utils = require('lib/utils') 6 | const ytapi = require('lib/model/youtube/youtube.api') 7 | 8 | module.exports = { 9 | handle: "youtube::info", 10 | needs: ["database"], 11 | init: stumble => { 12 | const player = stumble.space.get('youtube-player') 13 | player.on('play', v => stumble.execute("youtube::info::playing", v)) 14 | player.on('add', v => stumble.execute("youtube::info::added", v)) 15 | player.on('addnext', v => stumble.execute("youtube::info::next", v)) 16 | player.on('message', m => stumble.client.user.channel.sendMessage(m)) 17 | 18 | const db = stumble.execute('database::use') 19 | const history_table = "youtube_history" 20 | stumble.space.set("youtube::database::history_table", history_table) 21 | db.run(` 22 | CREATE TABLE IF NOT EXISTS ${history_table} ( 23 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 24 | videoId VARCHAR(11) NOT NULL, 25 | timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL 26 | ); 27 | `, function (err) { 28 | if (err) return logger.error(err) 29 | }) 30 | player.on('play', v => { 31 | const timeout = setTimeout(registerHistory, 40000) // 40sec 32 | function cancel() { 33 | clearTimeout(timeout); 34 | } 35 | function registerHistory() { 36 | if (v.id !== player.current_video.id) return 37 | player.removeListener("stop", cancel); 38 | db.run(`INSERT INTO ${history_table}(videoId) VALUES($videoId);`, { $videoId: v.id }, function (err) { 39 | if (err) return logger.error(err) 40 | }) 41 | } 42 | player.once("stop", cancel) 43 | }) 44 | }, 45 | extensions: [{ 46 | handle: "youtube::info::playing", 47 | exec: function (video) { 48 | const id = video.id 49 | const link = utils.video_link(id) 50 | let message = "Now playing: " + utils.link_with_title(link, video.snippet.title) + " (" + utils.convertISO8601ToSring(video.contentDetails.duration) + ")" 51 | this.client.user.channel.sendMessage(message) 52 | } 53 | }, 54 | { 55 | handle: "youtube::info::description", 56 | exec: function (video) { 57 | const id = video.id 58 | const link = utils.video_link(id) 59 | let message = "" + utils.link_with_title(link, video.snippet.title) + " (" + utils.convertISO8601ToSring(video.contentDetails.duration) + ")" + utils.breakTag 60 | const date = new Date(video.snippet.publishedAt) 61 | message += "Publish Date: " + `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}` + " - View count: " + video.statistics.viewCount.replace(/\B(?=(\d{3})+(?!\d))/g, " ") + utils.breakTag 62 | message += video.snippet.description.replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)+/g, '$1' + utils.breakTag + '$2').autoLink() 63 | utils.autoPartsString(message, 50000, "
    ").forEach(m => this.client.user.channel.sendMessage(m)) 64 | } 65 | }, { 66 | handle: "youtube::info::next", 67 | exec: function (terms) { 68 | let message = "Suivante: " + terms + "" 69 | this.client.user.channel.sendMessage(message) 70 | } 71 | }, 72 | { 73 | handle: "youtube::info::added", 74 | exec: function (terms) { 75 | let message = "La video " + terms + " a été ajoutée à la file d'attente." 76 | this.client.user.channel.sendMessage(message) 77 | } 78 | }, 79 | { 80 | handle: "youtube::info::displayVideoList", 81 | exec: function (ids) { 82 | const videos = {} 83 | const getById = promisify(ytapi().getById) 84 | const stumble = this 85 | let n = 1; 86 | return getById(ids.join(',')) 87 | .then(res => { 88 | let message = "" 89 | res.items.forEach(v => { 90 | videos[v.id] = v 91 | }) 92 | ids.forEach(id => { 93 | const video = videos[id] 94 | const link = utils.video_link(id) 95 | message += n++ + ". " + utils.link_with_title(link, video.snippet.title) + " (" + utils.convertISO8601ToSring(video.contentDetails.duration) + ")" + utils.breakTag 96 | }) 97 | utils.autoPartsString(message, 50000, utils.breakTag).forEach(m => stumble.client.user.channel.sendMessage(m)) 98 | }) 99 | .catch(err => logger.error(err)) 100 | } 101 | }, 102 | { 103 | handle: "youtube::playlist::dynamic::hits", 104 | exec: async function () { 105 | const history_table = this.space.get('youtube::database::history_table') 106 | const db = this.execute("database::use") 107 | const dbAll = promisify(db.all.bind(db)) 108 | return (await dbAll(` 109 | SELECT videoId, count(videoId) 110 | FROM ${history_table} 111 | GROUP BY videoId 112 | ORDER BY count(videoId) DESC 113 | LIMIT 20; 114 | `)).map(({ videoId }) => videoId) 115 | } 116 | }], 117 | commands: [{ 118 | handle: "history", 119 | exec: function () { 120 | const stumble = this 121 | const history_table = stumble.space.get('youtube::database::history_table') 122 | const db = stumble.execute("database::use") 123 | const dbAll = promisify(db.all.bind(db)) 124 | dbAll(` 125 | SELECT videoId 126 | FROM ${history_table} 127 | ORDER BY id DESC 128 | LIMIT 30; 129 | `) 130 | .then(rows => { 131 | const ids = rows.map(({ videoId }) => videoId) 132 | return this.execute('youtube::info::displayVideoList', ids) 133 | }) 134 | .catch(err => logger.error(err)) 135 | } 136 | }, { 137 | handle: "hits", 138 | exec: async function () { 139 | try { 140 | this.execute('youtube::info::displayVideoList', await this.execute("youtube::playlist::dynamic::hits")) 141 | } 142 | catch (err) { 143 | logger.error(err) 144 | } 145 | } 146 | }] 147 | } -------------------------------------------------------------------------------- /lib/mumble/extensions/youtube/youtube.js: -------------------------------------------------------------------------------- 1 | const utils = require('lib/utils') 2 | const logger = require('lib/logger')('youtube') 3 | 4 | const info = require('./youtube.info') 5 | const playlist = require('./youtube.playlist') 6 | const subscription = require('./youtube.suscription') 7 | 8 | const YoutubePlayer = require('lib/model/youtube/youtube.player') 9 | 10 | const trending = { 11 | handle: "youtube::playlist::dynamic::trending", 12 | exec: async function (data) { 13 | const stumble = this 14 | const player = stumble.space.get('youtube-player') 15 | let regionCode = player.lang 16 | if (data && data.message && data.message.length) 17 | regionCode = /([a-z]{2})/i.exec(data.message)[1] 18 | 19 | const mostPopular = (await player.mostPopular(regionCode)).items.map(({ id }) => id) 20 | return mostPopular 21 | } 22 | } 23 | 24 | const play = { 25 | handle: 'yt', 26 | exec: function (data) { 27 | const player = this.space.get('youtube-player') 28 | player.queue = [] 29 | player.playfirst(data.message).catch(e => { logger.error(e) }) 30 | } 31 | } 32 | const addnext = { 33 | handle: "addnext", 34 | exec: function (data) { 35 | this.space.get('youtube-player').addnext(data.message) 36 | } 37 | } 38 | const add = { 39 | handle: "add", 40 | exec: function (data) { 41 | this.space.get('youtube-player').add(data.message) 42 | } 43 | } 44 | const stop = { 45 | handle: "stop", 46 | exec: function (data) { 47 | this.space.get('commands').emit('stop') 48 | this.space.get('youtube-player').stop() 49 | } 50 | } 51 | const next = { 52 | handle: "next", 53 | exec: function (data) { 54 | this.space.get('youtube-player').next() 55 | } 56 | } 57 | const previous = { 58 | handle: "previous", 59 | exec: function (data) { 60 | this.space.get('youtube-player').previous() 61 | } 62 | } 63 | const description = { 64 | handle: "description", 65 | exec: function (data) { 66 | const video = this.space.get('youtube-player').current_video 67 | if (video) { 68 | this.execute("youtube::info::description", video) 69 | } 70 | } 71 | } 72 | const seek = { 73 | handle: "seek", 74 | exec: function (data) { 75 | this.space.get('youtube-player').seek(data.message) 76 | } 77 | } 78 | const jumpForward = { 79 | handle: "jumpf", 80 | exec: function (data) { 81 | this.space.get('youtube-player').jumpForward() 82 | } 83 | } 84 | const jumpBackward = { 85 | handle: "jumpb", 86 | exec: function (data) { 87 | this.space.get('youtube-player').jumpBackward() 88 | } 89 | } 90 | const related = { 91 | handle: "related", 92 | exec: async function (data) { 93 | const related = await this.space.get('youtube-player').related 94 | return this.execute('youtube::info::displayVideoList', related) 95 | } 96 | } 97 | 98 | const search = { 99 | handle: "search", 100 | exec: function (data) { 101 | if (!data.message) { 102 | return // error 103 | } 104 | const stumble = this 105 | const terms = data.message 106 | this.space.get('commands').emit('search', terms) 107 | this.space.get('youtube-player').search(terms) 108 | .then(res => { 109 | let message = "Search results for " + terms + ":" + utils.breakTag 110 | for (let i in res.items) { 111 | const id = res.items[i].id.videoId 112 | const title = res.items[i].snippet.title 113 | const channelTitle = res.items[i].snippet.channelTitle 114 | // TODO: second request getById on comma separated id list to get duration and other stats 115 | message += i + ". " + utils.link_with_title(utils.video_link(id), title) + ', ' + channelTitle + '' + utils.breakTag 116 | } 117 | utils.autoPartsString(message, 50000, "
    ").forEach(m => stumble.client.user.channel.sendMessage(m)) 118 | }) 119 | .catch(e => logger.error(e)) 120 | } 121 | } 122 | 123 | module.exports = { 124 | handle: 'youtube', 125 | needs: [], 126 | init: stumble => { 127 | // mount script 128 | const player = new YoutubePlayer(stumble.config.extensions.youtube) 129 | player.player = stumble.space.get('player') 130 | stumble.space.set("youtube-player", player) 131 | }, 132 | term: stumble => { 133 | // unmount script 134 | const player = stumble.space.get("youtube-player") 135 | // destroy things 136 | }, 137 | extensions: [info, playlist, subscription, trending], 138 | commands: [play, stop, next, add, addnext, search, description, seek, jumpForward, jumpBackward, previous, related] 139 | } 140 | -------------------------------------------------------------------------------- /lib/mumble/extensions/youtube/youtube.playlist.js: -------------------------------------------------------------------------------- 1 | const promisify = require('util').promisify 2 | 3 | const utils = require('lib/utils') 4 | const logger = require('lib/logger')('youtube:playlist') 5 | 6 | module.exports = { 7 | handle: "youtube::playlist", 8 | needs: ["database", "youtube::info"], 9 | init: stumble => { 10 | const db = stumble.execute('database::use') 11 | 12 | const playlist_table = "youtube_playlist" 13 | const playlist_content_table = "youtube_playlist_content" 14 | stumble.space.set("youtube::database::playlist_table", playlist_table) 15 | stumble.space.set("youtube::database::playlist_content_table", playlist_content_table) 16 | const run = promisify(db.run.bind(db)) 17 | run('PRAGMA foreign_keys = ON;') 18 | .then(a => run(` 19 | CREATE TABLE IF NOT EXISTS ${playlist_table} ( 20 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL UNIQUE, 21 | name TEXT NOT NULL UNIQUE 22 | );`)) 23 | .then(() => run(` 24 | CREATE TABLE IF NOT EXISTS ${playlist_content_table} ( 25 | videoId VARCHAR(11) NOT NULL, 26 | playlistId INTEGER NOT NULL, 27 | PRIMARY KEY (videoId, playlistId), 28 | FOREIGN KEY(playlistId) 29 | REFERENCES ${playlist_table} (id) 30 | ON DELETE CASCADE 31 | ON UPDATE CASCADE 32 | );`)) 33 | .catch(err => logger.error(err)) 34 | }, 35 | commands: [{ 36 | handle: "createList", 37 | exec: function (data) { 38 | const name = data.message 39 | if (typeof name !== "string" || name.length <= 0) return 40 | 41 | const stumble = this 42 | 43 | const playlist_table = stumble.space.get("youtube::database::playlist_table") 44 | const sql = ` 45 | INSERT INTO ${playlist_table} (name) VALUES ("${name}") 46 | ` 47 | const db = stumble.execute('database::use') 48 | db.run(sql, function (err) { 49 | if (err) return logger.error(err) 50 | stumble.client.user.channel.sendMessage("Playlist " + name + " successfully created.") 51 | }) 52 | } 53 | }, { 54 | handle: "addTo", 55 | exec: function (data) { 56 | const name = data.message 57 | if (typeof name !== "string" || name.length <= 0) return 58 | 59 | const stumble = this 60 | const playlist_table = stumble.space.get("youtube::database::playlist_table") 61 | const playlist_content_table = stumble.space.get("youtube::database::playlist_content_table") 62 | const player = stumble.space.get("youtube-player") 63 | const video = player.current_video 64 | // logger.log(video) 65 | const sql = ` 66 | INSERT INTO ${playlist_content_table} (videoId, playlistId) 67 | SELECT "${video.id}", id 68 | FROM ${playlist_table} WHERE name = "${name}" 69 | ` 70 | const db = stumble.execute('database::use') 71 | db.run(sql, function (err) { 72 | if (err) return logger.error(err) 73 | stumble.client.user.channel.sendMessage("" + video.snippet.title + " was successfully added to playlist " + name + ".") 74 | }) 75 | } 76 | }, { 77 | handle: "displayList", 78 | exec: function (data) { 79 | const name = data.message 80 | if (typeof name !== "string" || name.length <= 0) return 81 | 82 | 83 | const stumble = this 84 | const playlist_table = stumble.space.get("youtube::database::playlist_table") 85 | const playlist_content_table = stumble.space.get("youtube::database::playlist_content_table") 86 | const db = stumble.execute('database::use') 87 | const sql = ` 88 | SELECT videoId 89 | FROM ${playlist_content_table}, ${playlist_table} 90 | WHERE ${playlist_table}.id = ${playlist_content_table}.playlistId AND ${playlist_table}.name = "${name}" 91 | ` 92 | const dbAll = promisify(db.all.bind(db)) 93 | dbAll(sql) 94 | .then(rows => { 95 | const ids = rows.map(({ videoId }) => videoId) 96 | return this.execute('youtube::info::displayVideoList', ids) 97 | }).catch(err => logger.error(err)) 98 | 99 | } 100 | }, { 101 | handle: "deleteList", 102 | exec: function (data) { 103 | const name = data.message 104 | if (typeof name !== "string" || name.length <= 0) return 105 | 106 | 107 | const stumble = this 108 | const playlist_table = stumble.space.get("youtube::database::playlist_table") 109 | const playlist_content_table = stumble.space.get("youtube::database::playlist_content_table") 110 | const db = stumble.execute('database::use') 111 | const sql = ` 112 | DELETE FROM ${playlist_table} 113 | WHERE name = "${name}" 114 | ` 115 | db.run(sql, function (err) { 116 | if (err) return logger.error(err) 117 | stumble.client.user.channel.sendMessage("Playlist " + name + " erased.") 118 | }) 119 | } 120 | }, { 121 | handle: "playList", 122 | exec: async function (data) { 123 | try { 124 | const name = data.message 125 | if (typeof name !== "string" || name.length <= 0) return 126 | const player = this.space.get("youtube-player") 127 | let ids = await this.execute('youtube::playlist::dynamic::' + name) // try to fetch ids from dynamic playlist 128 | if (!(ids && ids.length)) { 129 | const playlist_table = this.space.get("youtube::database::playlist_table") 130 | const playlist_content_table = this.space.get("youtube::database::playlist_content_table") 131 | const db = this.execute('database::use') 132 | 133 | const sql = `SELECT videoId 134 | FROM ${playlist_content_table}, ${playlist_table} 135 | WHERE ${playlist_table}.id = ${playlist_content_table}.playlistId AND ${playlist_table}.name = "${name}";` 136 | const dbAll = promisify(db.all.bind(db)) 137 | const rows = await dbAll(sql) 138 | ids = rows.map(({ videoId }) => videoId) 139 | } 140 | utils.shuffle(ids) 141 | this.execute('youtube::info::displayVideoList', ids.slice(0)) 142 | player.queue = ids 143 | player.playQueue() 144 | } catch (err) { 145 | logger.error(err) 146 | } 147 | } 148 | }, { 149 | handle: "removeFrom", 150 | exec: function (data) { 151 | const name = data.message 152 | if (typeof name !== "string" || name.length <= 0) return 153 | 154 | const stumble = this 155 | const playlist_table = stumble.space.get("youtube::database::playlist_table") 156 | const playlist_content_table = stumble.space.get("youtube::database::playlist_content_table") 157 | const db = stumble.execute('database::use') 158 | 159 | const video = stumble.space.get('youtube-player').current_video 160 | const sql = ` 161 | DELETE FROM ${playlist_content_table} 162 | WHERE videoId = "${video.id}" 163 | AND playlistId = (SELECT id 164 | FROM ${playlist_table} 165 | WHERE name = "${name}");` 166 | 167 | const run = promisify(db.run.bind(db)) 168 | run(sql) 169 | .then(() => { 170 | stumble.client.user.channel.sendMessage("Video " + video.snippet.title + " removed from playlist " + name + ".") 171 | }) 172 | .catch(err => logger.error(err)) 173 | } 174 | }, { 175 | handle: "playlists", 176 | exec: function (data) { 177 | const stumble = this 178 | const playlist_table = stumble.space.get("youtube::database::playlist_table") 179 | const playlist_content_table = stumble.space.get("youtube::database::playlist_content_table") 180 | const db = stumble.execute('database::use') 181 | 182 | const sql = ` 183 | SELECT name, COUNT(videoId) AS n 184 | FROM ${playlist_content_table}, ${playlist_table} 185 | WHERE playlistId = id 186 | GROUP BY playlistId;` 187 | const dbAll = promisify(db.all.bind(db)) 188 | dbAll(sql) 189 | .then(rows => { 190 | let message = "Playlists:" + utils.breakTag 191 | let i = 1 192 | rows.forEach(({ name, n }) => message += i++ + ". " + name + " (" + n + ")" + utils.breakTag) 193 | utils.autoPartsString(message, 50000, utils.breakTag).forEach(m => this.client.user.channel.sendMessage(m)) 194 | }) 195 | .catch(err => logger.error(err)) 196 | } 197 | }] 198 | } -------------------------------------------------------------------------------- /lib/mumble/extensions/youtube/youtube.suscription.js: -------------------------------------------------------------------------------- 1 | const promisify = require('util').promisify 2 | 3 | const logger = require('lib/logger')('youtube:subscription') 4 | const utils = require('lib/utils') 5 | const ytapi = require('lib/model/youtube/youtube.api') 6 | const google = require('googleapis'); 7 | const youtube = google.youtube('v3'); 8 | const parse = require('parse-youtube-user') 9 | const cheerio = require('cheerio') 10 | 11 | const API_KEY = require('keys/api-keys.json').youtube; 12 | 13 | const listActivities = promisify(youtube.activities.list) 14 | const listChannels = promisify(youtube.channels.list) 15 | 16 | module.exports = { 17 | handle: 'youtube::subscription', 18 | needs: ['database', 'youtube::info'], 19 | init: async stumble => { 20 | try { 21 | const db = stumble.execute('database::use') 22 | const subscriptions_table = "youtube_subscriptions" 23 | stumble.space.set("youtube::database::subscriptions_table", subscriptions_table) 24 | const run = promisify(db.run.bind(db)) 25 | await run('PRAGMA foreign_keys = ON;') 26 | await run(`CREATE TABLE IF NOT EXISTS ${subscriptions_table} ( 27 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL UNIQUE, 28 | channelId TEXT NOT NULL UNIQUE, 29 | title TEXT NOT NULL);`) 30 | } 31 | catch (err) { 32 | logger.log(err); 33 | } 34 | 35 | }, 36 | commands: 37 | [{ 38 | handle: 'subscribe', 39 | exec: async function (data) { 40 | try { 41 | const links = utils.getLinks(data.message) 42 | const givenId = parse(links.length > 0 ? links[0] : data.message) 43 | const isId = /^UC[a-zA-Z0-9_-]{1,}$/.test(givenId) 44 | 45 | if (givenId.length === 0) { 46 | this.client.user.channel.sendMessage('You must supply a channel link or id') 47 | throw new Error("Invalid argument") 48 | } 49 | const params = { 50 | key: API_KEY, 51 | part: 'id,snippet' 52 | } 53 | if (isId) { 54 | params.id = givenId 55 | } else { 56 | params.forUsername = givenId 57 | } 58 | const result = (await listChannels(params)).items[0] 59 | if (result) { 60 | const id = result.id 61 | const title = result.snippet.title 62 | 63 | const table = this.space.get('youtube::database::subscriptions_table') 64 | const db = this.execute('database::use') 65 | const run = promisify(db.run.bind(db)) 66 | await run(`INSERT INTO ${table}(channelId, title) VALUES('${id}', '${title}')`) 67 | this.client.user.channel.sendMessage('Subscription added: ' + title) 68 | } 69 | 70 | } catch (e) { 71 | logger.error(e); 72 | } 73 | }, 74 | info: () => `Subscribe to the given youtube channel` 75 | }, { 76 | handle: 'subscriptions', 77 | exec: async function (data) { 78 | try { 79 | const table = this.space.get('youtube::database::subscriptions_table') 80 | const db = this.execute('database::use') 81 | const all = promisify(db.all.bind(db)) 82 | const result = await all(`SELECT channelId,title FROM ${table}`) 83 | 84 | let message = '
      ' 85 | result.forEach(r => { 86 | const link = 'https://www.youtube.com/channel/' + r.channelId 87 | message += '
    • ' + utils.link_with_title(link, r.title) + '
    • ' 88 | }) 89 | message += '
    ' 90 | 91 | utils.autoPartsString(message, 50000, utils.breakTag).forEach(m => this.client.user.channel.sendMessage(m)) 92 | } catch (e) { 93 | logger.error(e) 94 | } 95 | }, 96 | info: () => `Display the list of subscriptions` 97 | }, { 98 | handle: 'unsubscribe', 99 | exec: async function (data) { 100 | try { 101 | const table = this.space.get('youtube::database::subscriptions_table') 102 | const db = this.execute('database::use') 103 | const run = promisify(db.run.bind(db)) 104 | if (!(data.message && data.message.length > 0)) { 105 | this.client.user.channel.sendMessage('You must supply a channel link or id') 106 | throw new Error("Invalid argument") 107 | } 108 | await run(`DELETE FROM ${table} WHERE title = '${data.message}';`) 109 | this.client.user.channel.sendMessage('Subscription to ' + data.message + ' successfully removed') 110 | } catch (e) { 111 | logger.error(e) 112 | } 113 | }, 114 | info: () => `Remove the subscription to the given channel` 115 | }], 116 | extensions: [{ 117 | handle: 'youtube::playlist::dynamic::news', 118 | exec: async function (data) { 119 | try { 120 | const table = this.space.get('youtube::database::subscriptions_table') 121 | const db = this.execute('database::use') 122 | const all = promisify(db.all.bind(db)) 123 | const ids = (await all(`SELECT channelId FROM ${table};`)).map(e => e.channelId) 124 | const videos = [] 125 | for (let i = 0; i < ids.length; i++) { 126 | const channelId = ids[i]; 127 | const activities = (await listActivities({ 128 | key: API_KEY, 129 | part: 'contentDetails', 130 | maxResults: 50, 131 | channelId 132 | })).items.filter(i => 'upload' in i.contentDetails).map(e => e.contentDetails.upload.videoId).slice(0, this.config.extensions.youtube.subscription_max_videos) 133 | videos.push(...activities); 134 | } 135 | return videos 136 | } catch (e) { 137 | logger.error(e) 138 | } 139 | }, 140 | info: () => `Show new videos of subscribed channels` 141 | }] 142 | } -------------------------------------------------------------------------------- /lib/mumble/stumble-instance.js: -------------------------------------------------------------------------------- 1 | const Stumble = require('stumble') 2 | 3 | const s = new Stumble(require('data/config.json')) 4 | s.use(require('./extension')) 5 | 6 | s.connect() 7 | module.exports = s 8 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | const cheerio = require('cheerio') 2 | 3 | class BreakException { 4 | constructor(command, transcript) { 5 | this.command = command 6 | this.transcript = transcript 7 | } 8 | } 9 | 10 | function min(a, b) { 11 | return a < b ? a : b 12 | } 13 | 14 | function max(a, b) { 15 | return a > b ? a : b 16 | } 17 | 18 | function youtube_parser(url) { 19 | var ID = '' 20 | url = url.replace(/(>|<)/gi, '').split(/(vi\/|v=|\/v\/|youtu\.be\/|\/embed\/)/) 21 | if (url[2] !== undefined) { 22 | ID = url[2].split(/[^0-9a-z_-]/i) 23 | ID = ID[0] 24 | } else { 25 | ID = url 26 | } 27 | return ID 28 | } 29 | 30 | function convertISO8601ToSring(input) { 31 | const dur = convertISO8601ToObject(input) 32 | let str = "" 33 | if (dur.hours > 0) { 34 | if (str.length > 0 && dur.hours < 10) str += "0" 35 | str += dur.hours + ":" 36 | } 37 | if (dur.minutes < 10) str += "0" 38 | str += dur.minutes + ":" 39 | 40 | if (dur.seconds < 10) str += "0" 41 | str += dur.seconds 42 | return str 43 | } 44 | 45 | function convertISO8601ToSeconds(input) { 46 | return convertISO8601ToObject(input).totalseconds 47 | } 48 | 49 | function convertISO8601ToObject(input) { 50 | var reptms = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/ 51 | var hours = 0, 52 | minutes = 0, 53 | seconds = 0, 54 | totalseconds 55 | 56 | if (reptms.test(input)) { 57 | var matches = reptms.exec(input) 58 | if (matches[1]) hours = Number(matches[1]) 59 | if (matches[2]) minutes = Number(matches[2]) 60 | if (matches[3]) seconds = Number(matches[3]) 61 | totalseconds = hours * 3600 + minutes * 60 + seconds 62 | } 63 | 64 | return { 65 | hours, 66 | minutes, 67 | seconds, 68 | totalseconds 69 | } 70 | } 71 | 72 | /** 73 | * Parts a string into substrings of max length maxLength using separator sep 74 | * 75 | * @param {string} string 76 | * @param {number} maxLength 77 | * @param {string} sep 78 | * @returns {array} the strings of max length maxLength 79 | */ 80 | function autoPartsString(string, maxLength, sep) { 81 | const sepLength = sep.length 82 | const strings = string.split(sep); 83 | const lengths = strings.map(e => e.length) 84 | const result = [] 85 | lengths.reduce((acc, val, i, arr) => { 86 | if (val + acc + sepLength <= maxLength) 87 | arr[i] += acc + sepLength 88 | else { 89 | let str = "" 90 | for (let y = 0; y < i; y++) 91 | str += strings.shift() + sep 92 | result.push(str) 93 | } 94 | return arr[i] 95 | }) 96 | result.push(strings.join(sep)) 97 | return result 98 | } 99 | 100 | /** 101 | * Shuffles array in place. ES6 version 102 | * @param {Array} a items The array containing the items. 103 | */ 104 | function shuffle(a) { 105 | for (let i = a.length; i; i--) { 106 | let j = Math.floor(Math.random() * i); 107 | [a[i - 1], a[j]] = [a[j], a[i - 1]]; 108 | } 109 | } 110 | 111 | /** 112 | * Translates seconds into human readable format of seconds, minutes, hours, days, and years 113 | * https://stackoverflow.com/questions/8211744/convert-time-interval-given-in-seconds-into-more-human-readable-form 114 | * 115 | * @param {number} seconds The number of seconds to be processed 116 | * @return {string} The phrase describing the the amount of time 117 | */ 118 | function humanTime(seconds) { 119 | var levels = [ 120 | [Math.floor(seconds / 31536000), 'years'], 121 | [Math.floor((seconds % 31536000) / 86400), 'days'], 122 | [Math.floor(((seconds % 31536000) % 86400) / 3600), 'hours'], 123 | [Math.floor((((seconds % 31536000) % 86400) % 3600) / 60), 'minutes'], 124 | [(((seconds % 31536000) % 86400) % 3600) % 60, 'seconds'], 125 | ]; 126 | var returntext = ''; 127 | 128 | for (var i = 0, max = levels.length; i < max; i++) { 129 | if (levels[i][0] === 0) continue; 130 | returntext += ' ' + levels[i][0].toFixed(0) + ' ' + (levels[i][0] === 1 ? levels[i][1].substr(0, levels[i][1].length - 1) : levels[i][1]); 131 | } 132 | return returntext.trim(); 133 | } 134 | 135 | 136 | 137 | module.exports = { 138 | BreakException, 139 | min, 140 | max, 141 | youtube_parser, 142 | convertISO8601ToSeconds, 143 | convertISO8601ToObject, 144 | convertISO8601ToSring, 145 | autoPartsString, 146 | shuffle, 147 | humanTime 148 | } 149 | module.exports.link_with_title = (link, title = link) => "" + title + "" 150 | module.exports.video_link = (id) => "https://youtu.be/" + id 151 | module.exports.breakTag = "
    " 152 | module.exports.getLinks = function (txt) { 153 | let links = [] 154 | cheerio.load(txt)('a').each((_, el) => { 155 | const attr = el.attribs['href'] 156 | if (attr) links.push(attr) 157 | }) 158 | return links 159 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jeanne", 3 | "version": "0.6.0", 4 | "description": "Jeanne is meant to be a powerful Music bot for Mumble, with voice recognition", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "snyk-protect": "snyk protect", 9 | "prepare": "npm run snyk-protect" 10 | }, 11 | "author": "TinyMan ", 12 | "license": "MIT", 13 | "dependencies": { 14 | "app-module-path": "^2.2.0", 15 | "autolink-js": "^1.0.2", 16 | "cheerio": "^0.22.0", 17 | "debug": "^2.6.8", 18 | "es6-promisify": "^5.0.0", 19 | "file-type": "^3.8.0", 20 | "fixedqueue": "^0.0.1", 21 | "fluent-ffmpeg": "^2.1.0", 22 | "google-speech": "git+https://github.com/TinyMan/google-speech.git", 23 | "googleapis": "^20.1.0", 24 | "html-to-text": "^3.3.0", 25 | "jamy": "^0.2.1", 26 | "mumble": "git+https://github.com/TinyMan/node-mumble.git", 27 | "parse-youtube-user": "0.0.2", 28 | "request": "^2.81.0", 29 | "sqlite3": "^5.0.3", 30 | "stumble": "^1.1.1", 31 | "sylvia": "^0.1.0", 32 | "timed-stream": "^1.1.0", 33 | "youtube-node": "^1.3.3", 34 | "ytdl-core": "^0.20.1", 35 | "snyk": "^1.84.0" 36 | }, 37 | "devDependencies": {}, 38 | "repository": { 39 | "type": "git", 40 | "url": "git+https://github.com/TinyMan/node-jeanne.git" 41 | }, 42 | "keywords": [ 43 | "mumble", 44 | "bot", 45 | "voice", 46 | "music" 47 | ], 48 | "bugs": { 49 | "url": "https://github.com/TinyMan/node-jeanne/issues" 50 | }, 51 | "homepage": "https://github.com/TinyMan/node-jeanne#readme", 52 | "snyk": true 53 | } 54 | --------------------------------------------------------------------------------