├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── lyrebot ├── __init__.py ├── discord_bot.py ├── lyrebird.py └── main.py ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .tokens.yaml 3 | .idea 4 | venv 5 | **/__pycache__ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.1.1] - 2019-07-28 10 | ### Changed 11 | - Changed helptext to clarify issue #6. 12 | 13 | ## [0.1.0] - 2019-06-10 14 | ### Added 15 | - Initial release 16 | 17 | [Unreleased]: https://github.com/MartinHowarth/lyrebot/compare/0.1.1...HEAD 18 | [0.1.1]: https://github.com/MartinHowarth/lyrebot/compare/0.1.0...0.1.1 19 | [0.1.0]: https://github.com/MartinHowarth/lyrebot/releases/tag/0.1.0 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Martin Howarth 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lyrebot 2 | 3 | A discord bot that uses the Lyrebird API to allow users to use text-to-speech in their own voice. 4 | 5 | ## Usage 6 | The main command of this bot is text to speech: 7 | 8 | "speak Welcome to lyrebot. 9 | 10 | Lyrebot will join the current voice channel of the user who sent that message, and say it in their voice. 11 | 12 | ## User setup 13 | Users of your bot must perform some setup: 14 | * Create a lyrebird account and create a voice model here: https://beta.myvoice.lyrebird.ai/ 15 | * Provide the bot with OAuth2 authentication to use their voice by running these commands and following the instructions. 16 | * `generate_token_uri` 17 | * `generate_token` 18 | 19 | It is highly recommended that users carry this out in a PM to the bot so others cannot impersonate their voice by getting access to their token. 20 | 21 | Authentication details are currently forgotten over bot restart. In this instance, returning users can use the `set_token` command to use a previously-generated token. 22 | 23 | Tokens currently expire after 1 year (controlled by Lyrebird). 24 | 25 | ## Bot Installation and Setup 26 | ### Discord application 27 | You need a discord application with a bot configured to use this package. 28 | A good tutorial is here: https://www.devdungeon.com/content/make-discord-bot-python 29 | 30 | ### Lyrebird application 31 | You need to create a lyrebird application so that users can authenticate your bot to use their voices. 32 | You can do that here: https://beta.myvoice.lyrebird.ai/developer 33 | 34 | The homepage and redirect uri just needs to be valid url as a web server is not actually required. Users will be redirected to it and instructed to simply copy-paste the url (now with their auth token) and give it to the bot. 35 | 36 | ### FFmpeg 37 | FFmpeg is required - you can install it from https://www.ffmpeg.org/download.html 38 | 39 | ### Python package (the bot itself) 40 | Install using: 41 | 42 | python setup.py install 43 | 44 | Set the following variables in your environment: 45 | 46 | DISCORD_BOT_TOKEN=YOUR_BOT_TOKEN 47 | LYRE_CLIENT_ID=YOUR_LYREBIRD_CLIENT_ID 48 | LYRE_CLIENT_SECRET=YOUR_LYREBIRD_CLIENT_SECRET 49 | LYRE_REDIRECT_URI=YOUR_LYREBIRD_REDIRECT_URI 50 | 51 | Then simply run: 52 | 53 | lyrebot 54 | 55 | or 56 | 57 | python lyrebot/main.py 58 | -------------------------------------------------------------------------------- /lyrebot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MartinHowarth/lyrebot/def73956025da08d3f83aca61ed36856d2a8b3a8/lyrebot/__init__.py -------------------------------------------------------------------------------- /lyrebot/discord_bot.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import logging 3 | import os 4 | import yaml 5 | import sys 6 | 7 | from collections import defaultdict 8 | from discord import FFmpegPCMAudio, PCMVolumeTransformer 9 | from discord.ext import commands 10 | from textwrap import dedent 11 | 12 | from lyrebot.lyrebird import generate_voice_for_text, generate_oauth2_url, generate_oauth2_token 13 | 14 | log = logging.getLogger(__name__) 15 | 16 | OK_HAND = "\U0001F44C" 17 | ARROW_DOWN = "\U0001F53D" 18 | THUMBS_UP = "\U0001F44D" 19 | THUMBS_DOWN = u"\U0001F44E" 20 | CLOCK = "\U0001F550" 21 | 22 | 23 | if not discord.opus.is_loaded(): 24 | # the 'opus' library here is opus.dll on windows 25 | # or libopus.so on linux in the current directory 26 | # you should replace this with the location the 27 | # opus library is located in and with the proper filename. 28 | # note that on windows this DLL is automatically provided for you 29 | discord.opus.load_opus('opus') 30 | 31 | 32 | class LyreBot(commands.Cog): 33 | """Voice related commands. 34 | 35 | Works in multiple guilds at once. 36 | """ 37 | def __init__(self, bot, lyre_client_id, lyre_client_secret, lyre_redirect_uri): 38 | self.bot = bot 39 | self.voice_channels = {} 40 | self.lyrebird_tokens = {} # Map from player to lyrebird auth tokens 41 | self._lyre_auth_state_cache = {} 42 | self.lyre_client_id = lyre_client_id 43 | self.lyre_client_secret = lyre_client_secret 44 | self.lyre_redirect_uri = lyre_redirect_uri 45 | self.volume = 1 46 | self.always_speak_users_by_channel = defaultdict(list) 47 | 48 | async def get_voice_client(self, channel): 49 | if channel.guild.id in self.voice_channels: 50 | log.debug("Already in a voice channel in this guild.") 51 | vc = self.voice_channels[channel.guild.id] 52 | 53 | if vc.channel != channel: 54 | log.debug("Moving voice channel from %s to %s", vc.channel, channel) 55 | await vc.move_to(channel) 56 | else: 57 | log.debug("Connecting to voice channel %s", channel) 58 | vc = await channel.connect() 59 | self.voice_channels[channel.guild.id] = vc 60 | 61 | log.debug("Got voice client for voice channel %s", channel) 62 | return vc 63 | 64 | async def summon(self, message): 65 | log.debug("Being summoned...") 66 | if message.author.voice is None or message.author.voice.channel is None: 67 | await message.channel.send('You are not in a voice channel.') 68 | return None 69 | 70 | log.debug("Being summoned to channel %s", message.author.voice.channel) 71 | vc = await self.get_voice_client(message.author.voice.channel) 72 | return vc 73 | 74 | @commands.command(no_pm=True) 75 | async def volume(self, ctx, value: int): 76 | """Sets the volume of this bot.""" 77 | log.debug("Setting volume...") 78 | 79 | self.volume = value / 100 80 | await ctx.message.channel.send('Set the volume to {:.0%}'.format(self.volume)) 81 | 82 | @commands.command() 83 | async def set_token(self, ctx, token: str): 84 | """Sets the Lyrebird API token. The token should look like `oauth_[random characters]`""" 85 | log.debug("Setting lyre token for user: %s", ctx.author) 86 | self.lyrebird_tokens[ctx.author.id] = token 87 | await ctx.message.add_reaction(THUMBS_UP) 88 | 89 | @commands.command() 90 | async def generate_token_uri(self, ctx): 91 | """Step 1 to generate your token. Call this command and follow the instructions.""" 92 | user = ctx.author 93 | log.debug("Getting lyre oauth uri for user: %s", user) 94 | auth_url, state = generate_oauth2_url(self.lyre_client_id, self.lyre_redirect_uri) 95 | self._lyre_auth_state_cache[user.id] = state 96 | await ctx.channel.send( 97 | "Please go to this url, authenticate the app, then paste the URL you are " 98 | "redirected to into the 'generate_token' command") 99 | await ctx.channel.send(auth_url) 100 | 101 | @commands.command() 102 | async def generate_token(self, ctx, callback_uri): 103 | """Step 2 to generate your token. Provide the url from the 'generate_token_uri' step.""" 104 | log.debug("Getting lyre oauth token for user: %s", ctx.author) 105 | token = generate_oauth2_token( 106 | self.lyre_client_id, 107 | self.lyre_client_secret, 108 | self._lyre_auth_state_cache[ctx.author.id], 109 | callback_uri 110 | ) 111 | self.lyrebird_tokens[ctx.author.id] = token 112 | await ctx.channel.send( 113 | "Your token is '%s'. Please retain it in case I forget myself!" % token) 114 | await ctx.channel.send( 115 | "You can set it again using the 'set_token' command.") 116 | 117 | async def speak_aloud(self, message, *words: str): 118 | ident = message.author.id 119 | if ident not in self.lyrebird_tokens: 120 | await message.channel.send( 121 | "I do not have a lyrebird token for you. Call set_token or generate_token_uri (in a PM)") 122 | return 123 | 124 | sentence = ' '.join(words) 125 | log.debug("Echoing '%s' as speech...", sentence) 126 | await message.add_reaction(CLOCK) 127 | 128 | # Join the channel of the person who requested the say 129 | voice_client = await self.summon(message) 130 | if voice_client is None: 131 | return 132 | 133 | log.debug("Getting voice from lyrebird...") 134 | voice_bytes = await generate_voice_for_text( 135 | sentence, self.lyrebird_tokens[ident]) 136 | log.debug("Got voice from lyrebird...") 137 | user_filename = "~/{}.wav".format(message.author.id) 138 | user_filename = os.path.expanduser(user_filename) 139 | with open(user_filename, 'wb') as fi: 140 | fi.write(voice_bytes) 141 | 142 | try: 143 | log.debug("Creating audio source.") 144 | audio_source = FFmpegPCMAudio(user_filename) 145 | audio_source = PCMVolumeTransformer(audio_source, volume=self.volume) 146 | log.debug("Created audio source.") 147 | except Exception as e: 148 | fmt = 'An error occurred while processing this request: ```py\n{}: {}\n```' 149 | await message.channel.send(fmt.format(type(e).__name__, e)) 150 | else: 151 | def after(err): 152 | if err: 153 | log.error("Error playing media: %s", err) 154 | os.remove(user_filename) 155 | 156 | voice_client.play(audio_source, after=after) 157 | await message.remove_reaction(CLOCK, self.bot.user) 158 | 159 | @commands.command(no_pm=True) 160 | async def speak(self, ctx, *words: str): 161 | """Echoes the following text as speech.""" 162 | await self.speak_aloud(ctx.message, *words) 163 | 164 | @commands.command() 165 | async def always_speak(self, ctx, word): 166 | """Enter "y" or "yes" to enable speaking of everything. Any other entry disables.""" 167 | log.debug("always_speak called with: {}".format(word)) 168 | if word.lower() in ['y', 'ye', 'yes', 'on']: 169 | self.always_speak_users_by_channel[ctx.channel.id].append(ctx.author.id) 170 | await ctx.message.add_reaction(OK_HAND) 171 | else: 172 | if ctx.author.id in self.always_speak_users_by_channel[ctx.channel.id]: 173 | self.always_speak_users_by_channel[ctx.channel.id].remove(ctx.author.id) 174 | await ctx.message.add_reaction(ARROW_DOWN) 175 | log.debug("Always speak users are: {}".format(self.always_speak_users_by_channel)) 176 | 177 | @commands.command() 178 | async def restart(self, ctx): 179 | """Force quit the bot (expecting something else to restart it).""" 180 | log.error("Force quitting...") 181 | sys.exit(1) 182 | 183 | @commands.Cog.listener() 184 | async def on_message(self, message): 185 | log.debug("message from {0!r} in channel {1!r}.".format(message.author, message.channel)) 186 | a, b, c, d = ( 187 | not message.content.startswith('"'), 188 | message.author.id in self.always_speak_users_by_channel[message.channel.id], 189 | message.author.voice is not None and message.author.voice.channel is not None, 190 | message.author != self.bot.user, 191 | ) 192 | log.debug("always speak bools are: {} {} {} {}".format(a, b, c, d)) 193 | if a and b and c and d: 194 | log.debug("Always speaking for {}".format(message.author)) 195 | await self.speak_aloud(message, message.content) 196 | 197 | async def cog_command_error(self, ctx, error): 198 | log.error("command_error: %s; %s", ctx, error) 199 | 200 | 201 | def create_bot(lyre_client_id, lyre_client_secret, lyre_redirect_uri): 202 | bot = commands.Bot( 203 | command_prefix=commands.when_mentioned_or('"'), 204 | description=dedent( 205 | """ 206 | This bot echoes what you type into your current voice channel. 207 | 208 | Usage: "speak 209 | 210 | First time: 211 | Set yourself up with a lyrebird account here: https://beta.myvoice.lyrebird.ai/ 212 | Then run "generate_token_uri (in a PM!) and follow the instructions. 213 | The tokens time out after a year. 214 | 215 | Returning users: 216 | If this bot restarts/dies, it forgets your tokens. 217 | If you still have your token run "set_token 218 | """ 219 | ) 220 | ) 221 | 222 | lyrebot = LyreBot(bot, lyre_client_id, lyre_client_secret, lyre_redirect_uri) 223 | bot.add_cog(lyrebot) 224 | 225 | # Load in some pre-defined tokens for ease of testing. 226 | # Expects a yaml file of: 227 | # : 228 | # token: 229 | # default_channels: 230 | # - 231 | filename = os.environ.get("TOKEN_FILE", os.path.join(os.getcwd(), ".tokens.yaml")) 232 | if os.path.exists(filename): 233 | log.debug("tokens.yaml exists at: %s", filename) 234 | with open(filename) as fi: 235 | token_dict = yaml.safe_load(fi) 236 | for user, details in token_dict.items(): 237 | log.info("loaded token from file for: %s", user) 238 | if 'token' in details: 239 | lyrebot.lyrebird_tokens[user] = details['token'] 240 | for channel in details.get('default_channels', []): 241 | lyrebot.always_speak_users_by_channel[channel].append(user) 242 | 243 | log.info("Initial always speak users: {}".format(lyrebot.always_speak_users_by_channel)) 244 | 245 | @bot.event 246 | async def on_ready(): 247 | log.debug('Logged in as:\n{0} (ID: {0.id})'.format(bot.user)) 248 | 249 | return bot 250 | -------------------------------------------------------------------------------- /lyrebot/lyrebird.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | 4 | from textwrap import dedent 5 | from oauthlib.oauth2 import WebApplicationClient 6 | from requests_oauthlib import OAuth2Session 7 | 8 | log = logging.getLogger(__name__) 9 | 10 | GENERATE_API = 'https://avatar.lyrebird.ai/api/v0/generate' 11 | TOKEN_API = 'https://avatar.lyrebird.ai/api/v0/token' 12 | AUTH_API = 'https://myvoice.lyrebird.ai/authorize' 13 | 14 | 15 | def generate_oauth2_url(oauth2_client_id, redirect_uri): 16 | client = WebApplicationClient( 17 | client_id=oauth2_client_id, 18 | token_type="authorization_code", 19 | ) 20 | oauth = OAuth2Session( 21 | client=client, 22 | scope="voice", 23 | redirect_uri=redirect_uri, 24 | ) 25 | auth_url, state = oauth.authorization_url(AUTH_API) 26 | return auth_url, state 27 | 28 | 29 | def generate_oauth2_token(oauth2_client_id, oauth2_client_secret, expected_state, callback_uri): 30 | code, response_state = callback_uri.split('?')[-1].split('&') 31 | code = code.split('=')[1] 32 | response_state = response_state.split('=')[1].rstrip() 33 | 34 | if expected_state != response_state: 35 | raise AssertionError("MITM attack! (or you failed to copy-paste...)") 36 | 37 | token_json = { 38 | "grant_type": "authorization_code", 39 | "client_id": oauth2_client_id, 40 | "client_secret": oauth2_client_secret, 41 | "code": code, 42 | } 43 | token_response = requests.post(TOKEN_API, json=token_json) 44 | token_response.raise_for_status() 45 | return token_response.json()['access_token'] 46 | 47 | 48 | def get_auth_with_user_input(oauth2_client_id, oauth2_client_secret, redirect_uri): 49 | """Generates an OAuth2 token for the user, by getting them to authorize via a browser, and paste the result back.""" 50 | auth_url, state = generate_oauth2_url(oauth2_client_id, redirect_uri) 51 | user_auth_response = input( 52 | dedent(""" 53 | Please click this link, authorize the request, and paste the full redirected URL here: 54 | %s 55 | 56 | Tip: In pycharm, hit space after pasting, then hit enter. 57 | """ % auth_url) 58 | ) 59 | return generate_oauth2_token(oauth2_client_id, oauth2_client_secret, state, user_auth_response) 60 | 61 | 62 | async def generate_voice_for_text(text: str, access_token: str) -> bytes: 63 | """Generate a bytestring containing the audio of the given text, using the Lyrebird API.""" 64 | headers = {"Authorization": "Bearer {token}".format(token=access_token)} 65 | result = requests.post( 66 | GENERATE_API, 67 | headers=headers, 68 | json={ 69 | 'text': text 70 | } 71 | ) 72 | result.raise_for_status() 73 | log.info("Successfully generated audio for: %s", text) 74 | return result.content 75 | -------------------------------------------------------------------------------- /lyrebot/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import sys 4 | 5 | from lyrebot.discord_bot import create_bot 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | 10 | def configure_logging(): 11 | root = logging.getLogger() 12 | 13 | if 'DEBUG' in os.environ: 14 | root.setLevel(logging.DEBUG) 15 | else: 16 | root.setLevel(logging.INFO) 17 | 18 | ch = logging.StreamHandler(sys.stdout) 19 | ch.setLevel(logging.DEBUG) 20 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 21 | ch.setFormatter(formatter) 22 | root.addHandler(ch) 23 | 24 | 25 | def main(): 26 | configure_logging() 27 | discord_bot_token = os.environ['DISCORD_BOT_TOKEN'] 28 | lyre_redirect_uri = os.environ['LYRE_REDIRECT_URI'] 29 | lyre_client_id = os.environ['LYRE_CLIENT_ID'] 30 | lyre_client_secret = os.environ['LYRE_CLIENT_SECRET'] 31 | 32 | bot = create_bot(lyre_client_id, lyre_client_secret, lyre_redirect_uri) 33 | bot.run(discord_bot_token) 34 | 35 | 36 | if __name__ == "__main__": 37 | main() 38 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test = pytest 3 | 4 | [bdist_wheel] 5 | universal = 1 6 | 7 | [flake8] 8 | max-line-length = 88 9 | 10 | [metadata] 11 | license_file = LICENSE 12 | 13 | [mypy] 14 | ignore_missing_imports = True 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Always prefer setuptools over distutils 2 | from setuptools import setup, find_packages 3 | from os import path 4 | 5 | here = path.abspath(path.dirname(__file__)) 6 | 7 | setup( 8 | name="lyrebot", 9 | version="0.1.1", 10 | description="Lyrebird API bot", 11 | url="https://github.com/MartinHowarth/lyrebot", 12 | author="Martin Howarth", 13 | license="MIT", 14 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 15 | classifiers=[ 16 | "Development Status :: 3 - Alpha", 17 | "License :: OSI Approved :: MIT License", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.6", 20 | ], 21 | keywords="", 22 | packages=find_packages(exclude=["contrib", "docs", "tests*"]), 23 | install_requires=[ 24 | 'discord.py', 25 | 'requests', 26 | 'requests_oauthlib', 27 | 'pynacl', 28 | 'pyyaml', 29 | ], 30 | setup_requires=["pytest-runner"], 31 | tests_require=["pytest"], 32 | entry_points={ 33 | 'console_scripts': ['lyrebot=lyrebot.main:main'], 34 | } 35 | ) 36 | --------------------------------------------------------------------------------