├── __init__.py ├── ext ├── __init__.py ├── utils │ ├── __init__.py │ └── mousey.py ├── phone.py ├── memework.py ├── sub.py ├── playing.py ├── guild_log.py ├── wikihow.py ├── pipupdates.py ├── state.py ├── error.py ├── rpg.py ├── translation.py ├── datamosh.py ├── midi.py ├── botcollection.py ├── stats.py ├── admin.py ├── common.py ├── metrics.py ├── lottery.py ├── gambling.py ├── basic.py ├── exec.py ├── nsfw.py ├── math.py ├── vm.py ├── marry.py ├── profile.py └── channel_logging.py ├── jcoin ├── __init__.py ├── README.md ├── requirements.txt ├── Dockerfile ├── config-example.py ├── scripts │ └── make-account.py ├── v3-ideas.md ├── errors.py ├── client_add.py ├── manager.py ├── schema.sql └── http_api.rst ├── Dockerfile ├── requirements.txt ├── README.md ├── docker-compose.yaml ├── docs └── JoseVM.md ├── LICENSE.md ├── example_config.py ├── josecoin.md ├── .gitignore ├── playing_status.json ├── jose.py └── unused └── chatbot.py /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ext/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /jcoin/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ext/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .mousey import * 2 | -------------------------------------------------------------------------------- /jcoin/README.md: -------------------------------------------------------------------------------- 1 | jcoin 2 | ------- 3 | 4 | JoséCoin system. 5 | -------------------------------------------------------------------------------- /jcoin/requirements.txt: -------------------------------------------------------------------------------- 1 | sanic==0.7.0 2 | asyncpg==0.13.0 3 | voluptuous==0.10.5 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:latest 2 | 3 | WORKDIR /jose 4 | ADD . /jose 5 | 6 | # RUN apk add --no-cache alpine-sdk git 7 | RUN pip3 install -Ur requirements.txt 8 | 9 | ENV NAME jose 10 | 11 | CMD ["python3", "jose.py"] 12 | 13 | -------------------------------------------------------------------------------- /jcoin/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:latest 2 | 3 | WORKDIR /jcoin 4 | ADD . /jcoin 5 | 6 | # RUN apk add --no-cache alpine-sdk git 7 | RUN pip3 install -Ur requirements.txt 8 | 9 | ENV NAME jcoin 10 | 11 | CMD ["python3", "josecoin.py"] 12 | -------------------------------------------------------------------------------- /jcoin/config-example.py: -------------------------------------------------------------------------------- 1 | # example data: not to be used in production 2 | 3 | db = { 4 | 'user': 'jose', 5 | 'password': '12345', 6 | 'database': 'josecoin', 7 | # host is only needed in docker 8 | # 'host': '', 9 | } 10 | 11 | port = 8080 12 | -------------------------------------------------------------------------------- /ext/phone.py: -------------------------------------------------------------------------------- 1 | from .common import Cog 2 | 3 | 4 | class Phone(Cog): 5 | """Yes, you heard that right. Telephone. 6 | 7 | Not really a voice telephone. This is an idea into how it would work. 8 | """ 9 | pass 10 | 11 | 12 | def setup(bot): 13 | bot.add_cog(Phone(bot)) 14 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | yarl<1.2 2 | git+https://github.com/Rapptz/discord.py@rewrite 3 | 4 | asyncpg==0.15.0 5 | motor==1.2.2 6 | uvloop==0.10.1 7 | 8 | markovify==0.7.1 9 | psutil==5.4.5 10 | wolframalpha==3.0.1 11 | pyowm==2.8.0 12 | Pillow==5.1.0 13 | midiutil==1.1.3 14 | chatterbot==0.7.6 15 | 16 | -------------------------------------------------------------------------------- /jcoin/scripts/make-account.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | def main(): 5 | r = requests.post( 6 | 'http://0.0.0.0:8080/api/wallets/162819866682851329', 7 | json={ 8 | 'type': 0, 9 | }) 10 | print(r) 11 | 12 | 13 | if __name__ == '__main__': 14 | main() 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | José 2 | ========= 3 | Welcome to José! José is a multi-function Discord bot made with Python and discord.py. 4 | 5 | Requirements 6 | ========== 7 | - Docker 8 | - Docker Compose 9 | 10 | Installation 11 | ============ 12 | You can just copy and paste this probably: 13 | ```bash 14 | git clone https://github.com/lnmds/jose.git 15 | 16 | cd jose 17 | 18 | # fill in stuff from the example config file 19 | # as you wish 20 | nano joseconfig.py 21 | 22 | # profit 23 | sudo docker-compose up 24 | ``` 25 | 26 | Example config file 27 | ============ 28 | You need a config to run José. There is a example config in `example_config.py`. copy that to `joseconfig.py` and fill it in. 29 | -------------------------------------------------------------------------------- /jcoin/v3-ideas.md: -------------------------------------------------------------------------------- 1 | - **[DESIGN]** Split things up, JoséCoin will have its backend *separated* from the main bot, 2 | using an HTTP API with authorization, that will make other bots able to use 3 | JoséCoin features. 4 | 5 | - **[IMPL]** Use an `asyncio.Queue` to make a queue of transactions 6 | before we commit them to Mongo using a consumer coroutine. 7 | 8 | - **[IDEA]** Bring back loans, but between users 9 | - Make a *Trust Score* for users, if they don't pay back their 10 | loans, gotten from another users, their trust score will be lower 11 | 12 | - The maximum amount you can loan is based on that trust score. 13 | 14 | - **[MOD]** Add bail to the stealing business -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | jose: 4 | build: . 5 | depends_on: 6 | - mongo 7 | - postgres 8 | volumes: 9 | - ./:/jose 10 | josecoin: 11 | build: ./jcoin 12 | depends_on: 13 | - postgres 14 | volumes: 15 | - ./jcoin/:/jcoin 16 | ports: 17 | - "8080:8080" 18 | 19 | mongo: 20 | image: "mongo:3.6-jessie" 21 | volumes: 22 | - ./data/mongo:/data/db 23 | postgres: 24 | image: "postgres:10.1" 25 | volumes: 26 | - ./data/postgres:/var/lib/postgresql/data 27 | - ./jcoin/schema.sql:/docker-entrypoint-initdb.d/schema.sql 28 | -------------------------------------------------------------------------------- /jcoin/errors.py: -------------------------------------------------------------------------------- 1 | class GenericError(Exception): 2 | """Generic API error.""" 3 | status_code = 500 4 | 5 | 6 | class TransferError(GenericError, Exception): 7 | """Generic transfer error""" 8 | pass 9 | 10 | 11 | class AccountNotFoundError(TransferError): 12 | """Account not found""" 13 | status_code = 404 14 | 15 | 16 | class InputError(TransferError): 17 | """Client gave wrong input to a data type.""" 18 | status_code = 400 19 | 20 | 21 | class ConditionError(TransferError): 22 | """A condition was not satisfied.""" 23 | status_code = 412 24 | 25 | 26 | err_list = [ 27 | GenericError, TransferError, AccountNotFoundError, InputError, 28 | ConditionError 29 | ] 30 | -------------------------------------------------------------------------------- /docs/JoseVM.md: -------------------------------------------------------------------------------- 1 | # JoséVM (JVM) 2 | 3 | JoséVM is a try to implement a Virtual Machine, which runs José Byte Code (JBC), similar to the Java Virtual Machine (which runs Java Byte Code). It is currently heavy work in progress and does not have a compiler. 4 | 5 | ### Formatting of an instruction 6 | An Instruction is always 1 byte long, which is interpreted as an unsigned byte (number). This number is comparable to an OP code, as different actions are taken depending on this number. Both instructions and data are in Little Endian encoding. 7 | 8 | ## Currently implemented Instructions 9 | |Code|Name|Actions taken|Caveats| 10 | |---|---|---|---| 11 | |1|PUSH_INT|Pushes an Integer into the stack|Integer is a signed, 4 Bit Integer (Little Endian Encoding)| 12 | |2|VIEW|Prints the current contents of the stack.|This prints the whole stack and not just the most recently pushed item.| -------------------------------------------------------------------------------- /ext/memework.py: -------------------------------------------------------------------------------- 1 | from discord.ext import commands 2 | 3 | from .common import Cog 4 | 5 | MEMEWORK = [ 6 | 295341979800436736, 7 | 387091446676586496, 8 | ] 9 | 10 | MOD_ROLES = ( 11 | 303296657787715585, # root 12 | ) 13 | 14 | 15 | def is_memework_mod(): 16 | def predicate(ctx): 17 | return ctx.guild is not None and any(x.id in MOD_ROLES 18 | for x in ctx.author.roles) 19 | 20 | return commands.check(predicate) 21 | 22 | 23 | class Memework(Cog): 24 | """Memework-only commands. 25 | 26 | Made by me and 90% from FrostLuma 27 | """ 28 | 29 | def __local_check(self, ctx): 30 | if not ctx.guild: 31 | return False 32 | 33 | return ctx.guild.id in MEMEWORK 34 | 35 | @commands.command() 36 | async def email(self, ctx): 37 | """fuck gerd""" 38 | await ctx.send('You can now get a @memework.org address, ' 39 | 'pm heatingdevice#1212 for more info!') 40 | 41 | 42 | def setup(bot): 43 | bot.add_cog(Memework(bot)) 44 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2018 lnmds, et al. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /jcoin/client_add.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.6 2 | 3 | import sys 4 | import os 5 | import hashlib 6 | import time 7 | import base64 8 | 9 | import asyncio 10 | import asyncpg 11 | 12 | import config 13 | 14 | 15 | def generate_id(): 16 | return base64.b64encode(str(time.time()).encode()).decode() 17 | 18 | 19 | def generate_token(client_id): 20 | data = f'{client_id}{os.urandom(1000)}' 21 | return hashlib.sha256(data.encode()).hexdigest() 22 | 23 | 24 | async def main(): 25 | conn = await asyncpg.create_pool(**config.db) 26 | name = sys.argv[1] 27 | description = sys.argv[2] 28 | level = int(sys.argv[3]) 29 | 30 | client_id = generate_id() 31 | token = generate_token(client_id) 32 | 33 | await conn.execute(""" 34 | INSERT INTO clients (client_id, token, client_name, 35 | description, auth_level) VALUES ($1, $2, $3, $4, $5) 36 | """, client_id, token, name, description, level) 37 | 38 | print(f'Add client {name!r}') 39 | print(f'Client ID: {client_id}') 40 | print(f'Client Token: {token}') 41 | 42 | 43 | if __name__ == '__main__': 44 | loop = asyncio.get_event_loop() 45 | loop.run_until_complete(main()) 46 | -------------------------------------------------------------------------------- /jcoin/manager.py: -------------------------------------------------------------------------------- 1 | """ 2 | manager.py - manage transaction commiting from the queue 3 | to Postgres. 4 | """ 5 | import asyncio 6 | import logging 7 | 8 | log = logging.getLogger(__name__) 9 | 10 | 11 | class TransferManager: 12 | def __init__(self, app): 13 | self.app = app 14 | self._queue = [] 15 | 16 | self.txlock = asyncio.Lock() 17 | self.app.loop.create_task(self.commit_task()) 18 | 19 | @property 20 | def db(self): 21 | return self.app.db 22 | 23 | async def commit_all(self): 24 | await self.txlock 25 | # First, process batch of transactions 26 | for tx in self._queues: 27 | pass 28 | 29 | # Then, insert them into the log 30 | await self.db.executemany(""" 31 | INSERT INTO transactions (sender, receiver, amount) 32 | VALUES ($1, $2, $3) 33 | """, ((a, b, str(c)) for (a, b, c) in self._queue)) 34 | 35 | self._queue = [] 36 | self.txlock.release() 37 | 38 | async def commit_task(self): 39 | try: 40 | while True: 41 | await self.commit() 42 | await asyncio.sleep(20) 43 | except: 44 | log.exception('error in commit task') 45 | 46 | async def queue(self, txdata): 47 | await self.txlock 48 | self._queue.append(txdata) 49 | self.txlock.release() 50 | -------------------------------------------------------------------------------- /ext/sub.py: -------------------------------------------------------------------------------- 1 | from discord.ext import commands 2 | 3 | from .common import Cog 4 | 5 | # prod 6 | JOSE_SERVER = 273863625590964224 7 | ROLE_ID = 332410139762098178 8 | 9 | # test 10 | # JOSE_SERVER = 319540379495956490 11 | # ROLE_ID = 332410900600324097 12 | 13 | 14 | class Subscribe(Cog): 15 | """Subscribe to a role and participate in José's development!""" 16 | 17 | def __init__(self, bot): 18 | super().__init__(bot) 19 | 20 | def __local_check(self, ctx): 21 | if not ctx.guild: 22 | return False 23 | return ctx.guild.id == JOSE_SERVER 24 | 25 | def get_role(self, ctx, roleid): 26 | return next(r for r in ctx.guild.roles if r.id == roleid) 27 | 28 | @commands.command() 29 | async def sub(self, ctx): 30 | """Subscribe to the little group of memers who i can ping with 31 | to ask about josé 32 | """ 33 | sub_role = self.get_role(ctx, ROLE_ID) 34 | await ctx.member.add_roles(sub_role) 35 | await ctx.ok() 36 | 37 | @commands.command() 38 | async def unsub(self, ctx): 39 | """Unsubscribe from the paradise""" 40 | sub_role = self.get_role(ctx, ROLE_ID) 41 | await ctx.member.remove_roles(sub_role) 42 | await ctx.send(':c') 43 | 44 | @commands.command() 45 | @commands.is_owner() 46 | async def callout(self, ctx, *, msg: str): 47 | """Call out the subscribers""" 48 | role = self.get_role(ctx, ROLE_ID) 49 | await role.edit(mentionable=True) 50 | await ctx.send(f'{role.mention} {msg}') 51 | await role.edit(mentionable=False) 52 | 53 | 54 | def setup(bot): 55 | bot.add_cog(Subscribe(bot)) 56 | -------------------------------------------------------------------------------- /example_config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | # discord stuff 4 | token = 'token goes here' 5 | 6 | # default prefix for josé 7 | prefix = 'j!' 8 | 9 | # where is mongo 10 | # if docker, uncomment 11 | # MONGO_LOC = 'mongo' 12 | 13 | # api stuff 14 | WOLFRAMALPHA_APP_ID = 'app id for wolframalpha' 15 | OWM_APIKEY = 'api key for OpenWeatherMap' 16 | MSFT_TRANSLATION = { 17 | 'name': 'name for the project', 18 | 'key': 'subscription key', 19 | } 20 | 21 | # set those to whatever 22 | SPEAK_PREFIXES = ['josé ', 'José ', 'jose ', 'Jose '] 23 | 24 | # channel for interesting packets 25 | PACKET_CHANNEL = 361685197852508173 26 | 27 | # channel log levels 28 | # 60 is used for transaction logs 29 | LEVELS = { 30 | logging.INFO: 'https://discordapp.com/api/webhooks/:webhook_id/:token', 31 | logging.WARNING: 'https://discordapp.com/api/webhooks/:webhook_id/:token', 32 | logging.ERROR: 'https://discordapp.com/api/webhooks/:webhook_id/:token', 33 | 60: 'https://discordapp.com/api/webhooks/:webhook_id/:token', 34 | } 35 | 36 | # lottery configuration 37 | JOSE_GUILD = 273863625590964224 38 | LOTTERY_LOG = 368509920632373258 39 | 40 | postgres = { 41 | 'user': 'slkdjkjlsfd', 42 | 'password': 'dlkgajkgj', 43 | 'database': 'jose', 44 | 'host': 'memeland.com' 45 | } 46 | 47 | JOSECOIN_API = 'http://0.0.0.0:8080/api' 48 | 49 | # If on docker 50 | # JOSECOIN_API = 'http://josecoin:8080/api' 51 | 52 | # generated using ./jcoin/client_add.py 53 | JOSECOIN_TOKEN = 'something secret' 54 | 55 | # Where to put guild logs (join, leave, available, unavailable) 56 | GUILD_LOG_CHAN = 'a webhook url' 57 | 58 | # Where to warn the bot owner about event thresholds 59 | METRICS_WEBHOOK = 'a webhook url' 60 | 61 | 62 | GUILD_LOGGING = 'a webhook url' 63 | -------------------------------------------------------------------------------- /ext/playing.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import json 3 | import asyncio 4 | import random 5 | 6 | import discord 7 | from discord.ext import commands 8 | 9 | from .common import Cog 10 | 11 | MINUTE = 60 12 | PL_MIN = 3 * MINUTE 13 | PL_MAX = 10 * MINUTE 14 | 15 | log = logging.getLogger(__name__) 16 | 17 | 18 | class PlayingStatus(Cog): 19 | """Playing status shit""" 20 | 21 | def __init__(self, bot): 22 | super().__init__(bot) 23 | self.rotate_task = None 24 | self.phrases = json.load(open('./playing_status.json', 'r')) 25 | 26 | async def on_ready(self): 27 | # don't fuck up 28 | if self.rotate_task is not None: 29 | return 30 | 31 | self.rotate_task = self.bot.loop.create_task(self.rotate_loop()) 32 | 33 | async def rotate(self): 34 | """Get a random playing status and use it""" 35 | msg = random.choice(self.phrases) 36 | g_type = 0 37 | if isinstance(msg, list): 38 | g_type, msg = msg 39 | 40 | fmt = f'{msg} | v{self.JOSE_VERSION} | {self.bot.config.prefix}help' 41 | 42 | log.info('activity: type=%d v=%r', g_type, fmt) 43 | await self.bot.change_presence( 44 | activity=discord.Activity(type=g_type, name=fmt)) 45 | 46 | async def rotate_loop(self): 47 | try: 48 | while True: 49 | await self.rotate() 50 | await asyncio.sleep(random.randint(PL_MIN, PL_MAX)) 51 | except asyncio.CancelledError: 52 | pass 53 | 54 | @commands.command(name='rotate') 55 | @commands.is_owner() 56 | async def _rotate(self, ctx): 57 | """Rotate playing status""" 58 | await self.rotate() 59 | await ctx.send('done!') 60 | 61 | 62 | def setup(bot): 63 | bot.add_cog(PlayingStatus(bot)) 64 | -------------------------------------------------------------------------------- /josecoin.md: -------------------------------------------------------------------------------- 1 | # JoséCoin Implementation Spec 2 | 3 | Set of functions or objects/structures 4 | that a compliant implementation 5 | must provide: 6 | 7 | ```py 8 | 9 | enum AccountType: 10 | USER = 0 11 | TAXBANK = 1 12 | 13 | class TransferError(Exception): 14 | pass 15 | 16 | class TransferResult: 17 | attribute message: str 18 | attribute success: bool 19 | 20 | class Account: 21 | attribute id: int 22 | methods[...] 23 | 24 | # functions to be implemented 25 | create_account(id: int, type: AccountType) -> bool 26 | 27 | #: The implementation must create an account for the 28 | # bot user, which has infinite money, we call this the "sanity check" function 29 | sane(ctx: Context) -> None 30 | 31 | ## raw operations over IDs 32 | get_account(id: int) -> Account 33 | 34 | #: A tip, implementations could use an async queue and 35 | # a background task that commits each transaction to the db 36 | transfer(from: int, to: int, amount: decimal) -> TransferResult 37 | zero(id: int) -> TransferResult 38 | sink(id: int, amount: decimal) -> TransferResult 39 | 40 | ## operations that are db intensive should return 41 | # async iterators to work with 42 | type AccountIterator AsyncIterator 43 | 44 | #: Get all accounts that match a specific type 45 | accounts_by_type(type: AccountType) -> AccountIterator[Account] 46 | 47 | #: Get all accounts, ordered by the field 48 | all_accounts(field: str='amount', 49 | type: AccountType=AccountType.USER, 50 | order: int=pymongo.DESCENDING) -> AccountIterator[Account] 51 | 52 | #: Get all accounts 53 | guild_accounts(guild: discord.guild, 54 | field: str='amount') -> AccountIterator[Account] 55 | 56 | #: This should be cached and updated with each transfer 57 | get_gdp() -> decimal 58 | 59 | #: get the ranking of a user(both global and local) 60 | ranks(user_id: int, guild: discord.Guild) -> tuple(4) 61 | 62 | ## Misc functions 63 | 64 | pricing(ctx: Context, base_tax: decimal) -> TransferResult 65 | 66 | #: Probability of getting coins per message 67 | get_probability(account: Account) -> float 68 | 69 | ``` 70 | 71 | Account object 72 | ```py 73 | # For users, type 0 74 | {} 75 | 76 | # For taxbanks, type 1 77 | {} 78 | ``` 79 | -------------------------------------------------------------------------------- /ext/guild_log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | 4 | import discord 5 | 6 | from .common import Cog 7 | 8 | 9 | class GuildLog(Cog): 10 | def __init__(self, bot): 11 | super().__init__(bot) 12 | wbh = discord.Webhook.from_url 13 | adp = discord.AsyncWebhookAdapter(self.bot.session) 14 | 15 | self.webhook = wbh(self.bot.config.GUILD_LOGGING, adapter=adp) 16 | 17 | def guild_embed(self, em, guild): 18 | em.set_thumbnail(url=guild.icon_url) 19 | 20 | em.add_field(name='guild id', value=guild.id) 21 | 22 | em.add_field(name='guild name', value=guild.name) 23 | em.add_field(name='guild owner', value=guild.owner) 24 | em.add_field(name='guild region', value=str(guild.region)) 25 | em.add_field(name='guild member count', value=guild.member_count) 26 | em.add_field(name='guild large?', value=guild.large) 27 | em.add_field(name='guild <- shard id', value=guild.shard_id) 28 | 29 | async def on_guild_join(self, guild): 30 | if not self.bot.is_ready(): 31 | return 32 | 33 | em = discord.Embed(title='Guild join', color=discord.Color.green()) 34 | self.guild_embed(em, guild) 35 | await self.webhook.execute(embed=em) 36 | 37 | async def on_guild_remove(self, guild): 38 | if not self.bot.is_ready(): 39 | return 40 | 41 | em = discord.Embed(title='Guild remove', color=discord.Color.red()) 42 | self.guild_embed(em, guild) 43 | await self.webhook.execute(embed=em) 44 | 45 | async def on_guild_unavailable(self, guild): 46 | if not self.bot.is_ready(): 47 | return 48 | 49 | em = discord.Embed( 50 | title='Guild unavailable', color=discord.Color(0xfcd15c)) 51 | 52 | self.guild_embed(em, guild) 53 | await self.webhook.execute(embed=em) 54 | 55 | async def on_guild_available(self, guild): 56 | if not self.bot.is_ready(): 57 | return 58 | 59 | em = discord.Embed( 60 | title='Guild available', color=discord.Color(0x00f00f)) 61 | 62 | self.guild_embed(em, guild) 63 | await self.webhook.execute(embed=em) 64 | 65 | 66 | def setup(bot): 67 | bot.add_cog(GuildLog(bot)) 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # JetBrains IDEs 2 | /.idea 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | .hypothesis/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # IPython Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # dotenv 82 | .env 83 | 84 | # virtualenv 85 | venv/ 86 | ENV/ 87 | 88 | # Spyder project settings 89 | .spyderproject 90 | 91 | # Rope project settings 92 | .ropeproject 93 | 94 | #Jose stuff 95 | 96 | # debug, log data 97 | logs/*.log 98 | 99 | # learning data for the markov generator 100 | learning_data/ 101 | jose-data.txt 102 | 103 | # JoseCoin database 104 | jcoin/*.db 105 | jcoin/*.journal 106 | 107 | # config data 108 | joseconfig.py 109 | 110 | # Profiles from Python 111 | *.profile 112 | 113 | # Databases from extensions 114 | db/*.other 115 | db/*.json 116 | db/jose-data.txt 117 | db/languages.json 118 | db/magicwords.json 119 | db/messages.json 120 | db/stats.json 121 | db/wordlength.json 122 | db/zelao.txt 123 | ext/*.db 124 | database.db 125 | jose.db 126 | zelao.txt 127 | markov-database.json 128 | 129 | # backup file 130 | jose-backup* 131 | 132 | # chatterbot 133 | db.sqlite3 134 | 135 | # josecoin 136 | jcoin/config.py 137 | 138 | # docker data directories 139 | data/ 140 | -------------------------------------------------------------------------------- /ext/wikihow.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import urllib.parse 3 | 4 | import discord 5 | from discord.ext import commands 6 | 7 | from .common import Cog 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | 12 | async def custom_getjson(ctx, url: str) -> dict: 13 | """Get JSON from a website with custom useragent.""" 14 | log.debug('Requesting %s', url) 15 | resp = await ctx.bot.session.get( 16 | url, headers={ 17 | 'User-Agent': ctx.cog.USER_AGENT, 18 | }) 19 | return await resp.json() 20 | 21 | 22 | class WikiHow(Cog): 23 | def __init__(self, bot): 24 | super().__init__(bot) 25 | self.API_BASE = 'https://www.wikihow.com/api.php?action=query' 26 | self.USER_AGENT = 'JoseBot-WikiHowCog/0.1 ' + \ 27 | '(https://example.com;wikihowcog@ave.zone)' 28 | 29 | async def wh_query(self, ctx, term: str) -> dict: 30 | log.debug(f'Querying {term}') 31 | 32 | url = f'{self.API_BASE}&generator=search&gsrsearch={term}&prop=' + \ 33 | f'info|images&format=json' 34 | 35 | data = await custom_getjson(ctx, url) 36 | return data 37 | 38 | @commands.command(aliases=['wh']) 39 | async def wikihow(self, ctx, *, query: str): 40 | """Search WikiHow""" 41 | query = urllib.parse.quote(query) 42 | await self.jcoin.pricing(ctx, self.prices['API']) 43 | 44 | wh_json = await self.wh_query(ctx, query) 45 | wh_query = wh_json['query'] 46 | 47 | page_count = wh_query["searchinfo"]["totalhits"] 48 | display_count = page_count if page_count < 5 else 5 49 | 50 | log.debug( 51 | f'total results: {page_count}, ' + f'displaying {display_count}') 52 | 53 | if not display_count: 54 | raise self.SayException('No pages found') 55 | 56 | query_continue = wh_json['query-continue'] 57 | image_name = query_continue['images']['imcontinue'] 58 | image_name = image_name.split("|")[-1] 59 | 60 | image_query_link = f'{self.API_BASE}&titles=File:{image_name}' + \ 61 | '&prop=imageinfo&iiprop=url&format=json' 62 | 63 | image_json = await custom_getjson(ctx, image_query_link) 64 | image_data = next(iter(image_json["query"]["pages"].values())) 65 | image_link = image_data["imageinfo"][0]["url"] 66 | 67 | log.info(f'Image: {image_link}') 68 | 69 | pages = wh_query["pages"].values() 70 | pages = sorted(pages, key=lambda page: page['counter'], reverse=True) 71 | pages = pages[:display_count] 72 | 73 | embed_text = [] 74 | 75 | for page in pages: 76 | url = f'https://wikihow.com/{page["title"].replace(" ", "-")}' 77 | embed_text.append(f'**[{page["title"]}]({url}) ' 78 | f'({page["counter"]} views)**') 79 | 80 | e = discord.Embed( 81 | title='WikiHow results for ' 82 | f'`"{urllib.parse.unquote(query)}"`', 83 | url='https://www.wikihow.com/wikiHowTo?' 84 | f'search={query}', 85 | description='\n'.join(embed_text)) 86 | e.set_image(url=image_link) 87 | await ctx.send(embed=e) 88 | 89 | 90 | def setup(bot): 91 | bot.add_cog(WikiHow(bot)) 92 | -------------------------------------------------------------------------------- /ext/pipupdates.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import subprocess 4 | import asyncio 5 | import logging 6 | 7 | from .common import Cog 8 | from discord.ext import commands 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | def pip_freeze(): 14 | """call pip freeze, get results""" 15 | out = subprocess.check_output('python3.6 -m pip freeze', shell=True) 16 | return out 17 | 18 | 19 | class PipUpdates(Cog): 20 | def __init__(self, bot): 21 | super().__init__(bot) 22 | 23 | self.watch = {} 24 | self.requirements = {} 25 | 26 | reqlist = None 27 | with open('requirements.txt', 'r') as reqfile: 28 | reqlist = (reqfile.read()).split('\n') 29 | 30 | for pkg in reqlist: 31 | r = pkg.split('==') 32 | if len(r) != 2: 33 | continue 34 | pkgname, pkgversion = r[0], r[1] 35 | self.requirements[pkgname] = pkgversion 36 | 37 | self.update_task = self.bot.loop.create_task(self.update_task_func()) 38 | 39 | def __unload(self): 40 | self.update_task.cancel() 41 | 42 | async def update_task_func(self): 43 | try: 44 | while True: 45 | await self.checkupdates() 46 | await asyncio.sleep(21600) 47 | except asyncio.CancelledError: 48 | pass 49 | 50 | async def checkupdates(self): 51 | future_pip = self.bot.loop.run_in_executor(None, pip_freeze) 52 | out = await future_pip 53 | out = out.decode('utf-8') 54 | packages = out.split('\n') 55 | 56 | res = [] 57 | 58 | for pkgline in packages: 59 | r = pkgline.split('==') 60 | if len(r) != 2: 61 | continue 62 | pkgname = r[0] 63 | 64 | if pkgname in self.requirements: 65 | cur_version = self.requirements[pkgname] 66 | 67 | pkgdata = await self.get_json('https://pypi.org/' 68 | f'pypi/{pkgname}/json') 69 | new_version = pkgdata['info']['version'] 70 | 71 | if new_version != cur_version: 72 | res.append(" * `%r` needs update from %s to %s" % 73 | (pkgname, cur_version, new_version)) 74 | 75 | await self.say_results(res) 76 | return res 77 | 78 | async def say_results(self, res): 79 | if len(res) <= 0: 80 | return 81 | 82 | owner = (await self.bot.application_info()).owner 83 | res.insert(0, ':alarm_clock: You have package updates :alarm_clock:') 84 | await owner.send('\n'.join(res)) 85 | 86 | @commands.command(hidden=True) 87 | @commands.is_owner() 88 | async def checkpkgs(self, ctx): 89 | """Query PyPI for new package updates.""" 90 | async with ctx.typing(): 91 | res = await self.checkupdates() 92 | 93 | if len(res) <= 0: 94 | return await ctx.send("`No updates found.`") 95 | 96 | await ctx.send('Updates were found and should be ' 97 | 'sent to the bot owner!') 98 | 99 | 100 | def setup(bot): 101 | bot.add_cog(PipUpdates(bot)) 102 | -------------------------------------------------------------------------------- /ext/state.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import asyncio 4 | import logging 5 | 6 | import asyncpg 7 | import discord 8 | 9 | from .common import Cog 10 | from .utils import Timer 11 | from jose import JoseBot 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | 16 | class State(Cog, requires=['config']): 17 | """Synchronizes José's state to the PostgreSQL database for the JoséCoin REST API.""" 18 | 19 | def __init__(self, bot: JoseBot): 20 | super().__init__(bot) 21 | 22 | self.loop.create_task(self.full_sync()) 23 | 24 | @property 25 | def db(self) -> asyncpg.pool.Pool: 26 | cfg = self.bot.get_cog('Config') 27 | if cfg is not None: 28 | return cfg.db 29 | raise RuntimeError('Required config Cog is not loaded.') 30 | 31 | async def on_member_join(self, member: discord.Member): 32 | async with self.db.acquire() as conn: 33 | await conn.execute( 34 | 'INSERT INTO members (guild_id, user_id) VALUES ($1, $2)', 35 | member.guild.id, member.id) 36 | 37 | async def on_member_remove(self, member: discord.Member): 38 | async with self.db.acquire() as conn: 39 | await conn.execute( 40 | 'DELETE FROM members WHERE guild_id = $1 AND user_id = $2', 41 | member.guild.id, member.id) 42 | 43 | async def on_guild_join(self, guild: discord.Guild): 44 | await self.sync_guild(guild) 45 | log.info(f'synced state of {guild} {guild.id}') 46 | 47 | async def on_guild_remove(self, guild: discord.Guild): 48 | async with self.db.acquire() as conn: 49 | await conn.execute('DELETE FROM members WHERE guild_id = $1', 50 | guild.id) 51 | 52 | async def full_sync(self): 53 | await self.bot.wait_until_ready() 54 | 55 | log.info('starting to sync state') 56 | 57 | # since the connection pool has 10 connections we might as well use them 58 | with Timer() as timer: 59 | await asyncio.gather( 60 | *[self.sync_guild(x) for x in self.bot.guilds]) 61 | 62 | log.info(f'synced full state, took {timer}') 63 | 64 | async def sync_guild(self, guild: discord.Guild): 65 | async with self.db.acquire() as conn: 66 | 67 | # calculate which users left, which users are new 68 | results = await conn.fetch( 69 | 'SELECT user_id FROM members WHERE guild_id = $1', guild.id) 70 | 71 | new_members = [] 72 | stale_members = [] 73 | 74 | old = set(x['user_id'] for x in results) 75 | current = set(x.id for x in guild.members) 76 | 77 | for user_id in old.symmetric_difference(current): 78 | if user_id in current: 79 | new_members.append((guild.id, user_id)) 80 | else: 81 | stale_members.append((guild.id, user_id)) 82 | 83 | # insert new members, delete old ones 84 | await conn.executemany( 85 | 'INSERT INTO members (guild_id, user_id) VALUES ($1, $2)', 86 | new_members) 87 | await conn.executemany( 88 | 'DELETE FROM members WHERE guild_id = $1 AND user_id = $2', 89 | stale_members) 90 | 91 | 92 | def setup(bot: JoseBot): 93 | bot.add_cog(State(bot)) 94 | -------------------------------------------------------------------------------- /ext/error.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import collections 3 | 4 | from discord.ext import commands 5 | from .common import Cog 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | 10 | class ErrorHandling(Cog): 11 | async def on_command_error(self, ctx, error): 12 | """Log and signal errors to the user""" 13 | message = ctx.message 14 | content = self.bot.clean_content(message.content) 15 | 16 | # TODO: I get the feeling this function is too long, 17 | # We have too many branches. 18 | # Can we make this cleaner? 19 | 20 | if isinstance(error, commands.errors.CommandInvokeError): 21 | orig = error.original 22 | 23 | if isinstance(orig, self.SayException): 24 | arg0 = orig.args[0] 25 | 26 | if ctx.guild is None: 27 | dm = collections.namedtuple('DM', 'id') 28 | ctx.guild = dm(ctx.author.id) 29 | 30 | log.warning('SayException: %s[%d] %s %r => %r', ctx.guild, 31 | ctx.guild.id, ctx.author, content, arg0) 32 | 33 | return await ctx.send(arg0) 34 | 35 | if isinstance(orig, tuple(self.bot.simple_exc)): 36 | log.error(f'Errored at {content!r} from {ctx.author!s}' 37 | f'\n{orig!r}') 38 | return await ctx.send(f'Error: `{error.original!r}`') 39 | else: 40 | log.exception( 41 | f'Errored at {content!r} from {ctx.author!s}', 42 | exc_info=orig) 43 | 44 | if isinstance(orig, self.bot.cogs['Coins'].TransferError): 45 | return await ctx.send(f'JoséCoin error: `{orig!r}`') 46 | 47 | return await ctx.send('An error happened during command execution:' 48 | f'```py\n{error.original!r}```') 49 | 50 | if isinstance(error, commands.errors.BadArgument): 51 | return await ctx.send('bad argument — ' f'{error!s}') 52 | 53 | if isinstance(error, commands.errors.CommandOnCooldown): 54 | return 55 | 56 | if isinstance(error, commands.errors.MissingRequiredArgument): 57 | return await ctx.send(f'missing argument — `{error.param}`') 58 | if isinstance(error, commands.errors.NoPrivateMessage): 59 | return await ctx.send('sorry, you can not use this command' 60 | ' in a DM.') 61 | if isinstance(error, commands.errors.UserInputError): 62 | return await ctx.send('user input error — ' 63 | 'please, the *right* thing') 64 | 65 | if isinstance(error, commands.errors.MissingPermissions): 66 | join = ', '.join(error.missing_perms) 67 | return await ctx.send(f'user is missing permissions — `{join}`') 68 | if isinstance(error, commands.errors.BotMissingPermissions): 69 | join = ', '.join(error.missing_perms) 70 | return await ctx.send(f'bot is missing permissions — `{join}`') 71 | 72 | # we put this one because MissingPermissions might be a 73 | # disguised CheckFailure 74 | if isinstance(error, commands.errors.CheckFailure): 75 | checks = [c.__qualname__.split('.')[0] for c in ctx.command.checks] 76 | await ctx.err(f'check error — checks: `{", ".join(checks)}`') 77 | 78 | 79 | def setup(bot): 80 | bot.add_cog(ErrorHandling(bot)) 81 | -------------------------------------------------------------------------------- /ext/utils/mousey.py: -------------------------------------------------------------------------------- 1 | """ 2 | Stolen code from Mousey, 3 | Thanks FrostLuma 4 | """ 5 | import asyncio 6 | import functools 7 | import time 8 | 9 | from typing import List 10 | 11 | 12 | class Table: 13 | def __init__(self, *column_titles: str): 14 | self._rows = [column_titles] 15 | self._widths = [] 16 | 17 | for index, entry in enumerate(column_titles): 18 | self._widths.append(len(entry)) 19 | 20 | def _update_widths(self, row: tuple): 21 | for index, entry in enumerate(row): 22 | width = len(entry) 23 | if width > self._widths[index]: 24 | self._widths[index] = width 25 | 26 | def add_row(self, *row: str): 27 | """ 28 | Add a row to the table. 29 | .. note :: There's no check for the number of items entered, this may cause issues rendering if not correct. 30 | """ 31 | self._rows.append(row) 32 | self._update_widths(row) 33 | 34 | def add_rows(self, *rows: List[str]): 35 | for row in rows: 36 | self.add_row(*row) 37 | 38 | def _render(self): 39 | def draw_row(row_): 40 | columns = [] 41 | 42 | for index, field in enumerate(row_): 43 | # digits get aligned to the right 44 | if field.isdigit(): 45 | columns.append(f" {field:>{self._widths[index]}} ") 46 | continue 47 | 48 | # make sure the codeblock this will end up in won't get escaped 49 | field = field.replace('`', '\u200b`') 50 | 51 | # regular text gets aligned to the left 52 | columns.append(f" {field:<{self._widths[index]}} ") 53 | 54 | return "|".join(columns) 55 | 56 | # column title is centered in the middle of each field 57 | title_row = "|".join(f" {field:^{self._widths[index]}} " 58 | for index, field in enumerate(self._rows[0])) 59 | separator_row = "+".join("-" * (width + 2) for width in self._widths) 60 | 61 | drawn = [title_row, separator_row] 62 | # remove the title row from the rows 63 | self._rows = self._rows[1:] 64 | 65 | for row in self._rows: 66 | row = draw_row(row) 67 | drawn.append(row) 68 | 69 | return "\n".join(drawn) 70 | 71 | async def render(self, loop: asyncio.AbstractEventLoop = None): 72 | """Returns a rendered version of the table.""" 73 | loop = loop or asyncio.get_event_loop() 74 | 75 | func = functools.partial(self._render) 76 | return await loop.run_in_executor(None, func) 77 | 78 | 79 | class Timer: 80 | """Context manager to measure how long the indented block takes to run.""" 81 | 82 | def __init__(self): 83 | self.start = None 84 | self.end = None 85 | 86 | def __enter__(self): 87 | self.start = time.perf_counter() 88 | return self 89 | 90 | async def __aenter__(self): 91 | return self.__enter__() 92 | 93 | def __exit__(self, exc_type, exc_val, exc_tb): 94 | self.end = time.perf_counter() 95 | 96 | async def __aexit__(self, exc_type, exc_val, exc_tb): 97 | return self.__exit__(exc_type, exc_val, exc_tb) 98 | 99 | def __str__(self): 100 | return f'{self.duration:.3f}ms' 101 | 102 | @property 103 | def duration(self): 104 | """Duration in ms.""" 105 | return (self.end - self.start) * 1000 106 | -------------------------------------------------------------------------------- /ext/rpg.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import math 3 | 4 | import discord 5 | 6 | from discord.ext import commands 7 | 8 | from .common import Cog 9 | 10 | log = logging.getLogger(__name__) 11 | LEVEL_CONSTANT = 0.24 12 | 13 | ITEMS = {} 14 | 15 | SKILLS = {} 16 | 17 | SHOPS = {} 18 | 19 | QUESTS = {} 20 | 21 | 22 | class RPG(Cog): 23 | """RPG module.""" 24 | 25 | def __init__(self, bot): 26 | super().__init__(bot) 27 | 28 | # All users are here, with their items and skills 29 | self.inventory_coll = self.config.jose_db['rpg_inventory'] 30 | 31 | def get_level(self, inv) -> int: 32 | return int(LEVEL_CONSTANT * math.sqrt(inv['xp'])) 33 | 34 | def get_next_level_xp(self, inv) -> int: 35 | """Gives how many XP is required to the next level.""" 36 | lvl = self.get_level(inv) 37 | 38 | # level = C * sqrt(xp) 39 | # sqrt(xp) = level / C 40 | # xp = (level / C) ^ 2 41 | return int(pow((lvl + 1) / LEVEL_CONSTANT, 2)) 42 | 43 | async def get_inventory(self, user_id: int) -> dict: 44 | return await self.inventory_coll.find_one({'user_id': user_id}) 45 | 46 | @commands.group() 47 | async def rpg(self, ctx): 48 | """Main entry command to José RPG.""" 49 | pass 50 | 51 | @rpg.command() 52 | async def enter(self, ctx): 53 | """Enter RPG. 54 | 55 | You cannot leave. 56 | """ 57 | 58 | if await self.get_inventory(ctx.author.id): 59 | return await ctx.send('You already have a RPG profile') 60 | 61 | await self.inventory_coll.insert_one({ 62 | 'user_id': ctx.author.id, 63 | 'xp': 0, 64 | 65 | # dict: int (item id) -> int (count) 66 | 'items': {}, 67 | 68 | # dict: int (skill id) -> int (skill level) 69 | 'skills': {}, 70 | 71 | # quest ID: int 72 | 'current_quest': None, 73 | 74 | # TODO: those 75 | 'equiped_weapon': None, 76 | 'equiped_armor': None, 77 | }) 78 | 79 | await ctx.ok() 80 | 81 | @rpg.command(name='inventory', aliases=['inv']) 82 | async def inventory(self, ctx, person: discord.User = None): 83 | """See your inventory.""" 84 | if not person: 85 | person = ctx.author 86 | 87 | inv = await self.get_inventory(person.id) 88 | if not inv: 89 | return await ctx.send('No inventory found') 90 | 91 | e = discord.Embed(title=f'Inventory for {person}') 92 | 93 | if len(person.avatar_url): 94 | e.set_thumbnail(url=person.avatar_url) 95 | 96 | # calculate XP and levels 97 | e.add_field(name='Level', value=str(self.get_level(inv))) 98 | 99 | xp_next_level = self.get_next_level_xp(inv) 100 | e.add_field(name='XP', value=f'{inv["xp"]} / {xp_next_level} XP') 101 | 102 | amount = (await self.jcoin.get_account(person.id))['amount'] 103 | e.add_field(name='Money', value=f'{amount}JC') 104 | 105 | # items 106 | e.add_field( 107 | name='Items', 108 | value='\u200b' + 109 | '\n'.join(f'`{name}` - {count}' 110 | for (name, count) in inv['items'].items())) 111 | 112 | await ctx.send(embed=e) 113 | 114 | @rpg.group() 115 | async def shop(self, ctx): 116 | pass 117 | 118 | @shop.command() 119 | async def view(self, ctx): 120 | """Check available items in the shop""" 121 | pass 122 | 123 | @shop.command() 124 | async def buy(self, ctx, item): 125 | """Buy an item from the shop.""" 126 | pass 127 | 128 | 129 | def setup(bot): 130 | bot.add_cog(RPG(bot)) 131 | -------------------------------------------------------------------------------- /ext/translation.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import urllib.parse 3 | import decimal 4 | 5 | from xml.etree import ElementTree 6 | 7 | from discord.ext import commands 8 | 9 | from .common import Cog 10 | 11 | log = logging.getLogger(__name__) 12 | TAX_PER_CHAR = decimal.Decimal('0.022') 13 | 14 | 15 | class Translation(Cog): 16 | """Microsoft's Translation API.""" 17 | 18 | def __init__(self, bot): 19 | super().__init__(bot) 20 | self.APIROUTE = 'https://api.microsofttranslator.com/V2/Http.svc' 21 | self.apicfg = self.bot.config.MSFT_TRANSLATION 22 | 23 | # This one is given by azure 24 | self.subkey = self.apicfg['key'] 25 | 26 | self.subkey_headers = { 27 | 'Ocp-Apim-Subscription-Key': self.subkey, 28 | } 29 | 30 | async def req(self, method, route, qs_dict: dict) -> 'any': 31 | """Make a request to the translation API.""" 32 | qs = urllib.parse.urlencode(qs_dict) 33 | url = f'{self.APIROUTE}{route}?{qs}' 34 | async with self.bot.session.request( 35 | method, url, headers=self.subkey_headers) as r: 36 | return r 37 | 38 | async def get(self, route: str, qs: dict) -> 'any': 39 | return await self.req('GET', route, qs) 40 | 41 | async def post(self, route: str, qs: dict) -> 'any': 42 | return await self.req('POST', route, qs) 43 | 44 | @commands.command() 45 | async def translist(self, ctx): 46 | """List all available languages.""" 47 | resp = await self.get('/GetLanguagesForTranslate', {}) 48 | text = await resp.text() 49 | if resp.status != 200: 50 | raise self.SayException(f'\N{WARNING SIGN} API ' 51 | f'replied {resp.status}') 52 | 53 | root = ElementTree.fromstring(text) 54 | await ctx.send(f"`{', '.join(list(root.itertext()))}`") 55 | 56 | @commands.command() 57 | async def translate(self, ctx, to_lang: str, *, sentence: str): 58 | """Translate from one language to another.""" 59 | to_lang = self.bot.clean_content(to_lang).lower() 60 | to_lang = to_lang.replace("jp", "ja").replace("zh-CHS", "cn") 61 | sentence = self.bot.clean_content(sentence) 62 | 63 | tax = len(sentence) * TAX_PER_CHAR 64 | await self.coins.pricing(ctx, tax) 65 | 66 | # detect language 67 | resp_detect = await self.get('/Detect', { 68 | 'text': sentence, 69 | }) 70 | text_detect = await resp_detect.text() 71 | if resp_detect.status != 200: 72 | raise self.SayException(f'\N{WARNING SIGN} Detect failed' 73 | f' with {resp_detect.status}') 74 | 75 | root_detect = ElementTree.fromstring(text_detect) 76 | detected = root_detect.text 77 | 78 | # translate 79 | resp = await self.get('/Translate', { 80 | 'to': to_lang, 81 | 'text': sentence, 82 | }) 83 | 84 | text = await resp.text() 85 | if resp.status != 200: 86 | log.warning('[trans] got a non-200, %r', text) 87 | raise self.SayException(f'\N{WARNING SIGN} Translation failed' 88 | f' with {resp.status}') 89 | 90 | root = ElementTree.fromstring(text) 91 | translated = list(root.itertext())[0] 92 | translated = self.bot.clean_content(translated) 93 | 94 | log.debug('[translate] %r [%s] => %r [%s]', sentence, detected, 95 | translated, to_lang) 96 | 97 | res = [ 98 | f'detected language: {detected}', 99 | f'`{translated}` ({to_lang})', 100 | ] 101 | await ctx.send('\n'.join(res)) 102 | 103 | 104 | def setup(bot): 105 | bot.add_cog(Translation(bot)) 106 | -------------------------------------------------------------------------------- /ext/datamosh.py: -------------------------------------------------------------------------------- 1 | import io 2 | import time 3 | from random import randint 4 | 5 | import discord 6 | import aiohttp 7 | from discord.ext import commands 8 | from PIL import Image 9 | 10 | from .common import Cog 11 | 12 | 13 | async def get_data(url): 14 | """Read data from an URL and return 15 | a `io.BytesIO` instance of the data gathered 16 | in that URL. 17 | """ 18 | data = io.BytesIO() 19 | with aiohttp.ClientSession() as session: 20 | async with session.get(url) as resp: 21 | data_read = await resp.read() 22 | data.write(data_read) 23 | 24 | return data 25 | 26 | 27 | def read_chunks(fh): 28 | """Split a file handler into 4 kilobyte chunks.""" 29 | while True: 30 | chunk = fh.read(4096) 31 | if not chunk: 32 | break 33 | yield chunk 34 | 35 | 36 | def datamosh_jpg(source_image: 'io.BytesIO', iterations: int) -> 'io.BytesIO': 37 | """Datamosh a JPG file. 38 | 39 | This changes random blocks in the file 40 | to generate a datamoshed image. 41 | """ 42 | output_image = io.BytesIO() 43 | for chunk in read_chunks(source_image): 44 | output_image.write(chunk) 45 | 46 | # herald the destroyer 47 | iters = 0 48 | steps = 0 49 | block_start = 100 50 | block_end = len(source_image.getvalue()) - 400 51 | replacements = randint(1, 30) 52 | 53 | source_image.close() 54 | 55 | while iters <= iterations: 56 | while steps <= replacements: 57 | pos_a = randint(block_start, block_end) 58 | pos_b = randint(block_start, block_end) 59 | 60 | output_image.seek(pos_a) 61 | content_from_pos_a = output_image.read(1) 62 | output_image.seek(0) 63 | 64 | output_image.seek(pos_b) 65 | content_from_pos_b = output_image.read(1) 66 | output_image.seek(0) 67 | 68 | # overwrite A with B 69 | output_image.seek(pos_a) 70 | output_image.write(content_from_pos_b) 71 | output_image.seek(0) 72 | 73 | # overwrite B with A 74 | output_image.seek(pos_b) 75 | output_image.write(content_from_pos_a) 76 | output_image.seek(0) 77 | 78 | steps += 1 79 | iters += 1 80 | 81 | return output_image 82 | 83 | 84 | class Datamosh(Cog): 85 | """Datamosh.""" 86 | @commands.command() 87 | async def datamosh(self, ctx, url: str, iterations: int = 1): 88 | """Datamosh an image. 89 | 90 | Sometimes the result given by `j!datamosh` doesn't have any thumbnail, 91 | that means it actually broke the file and you need to try again(and 92 | hope it doesn't break the file again). 93 | """ 94 | 95 | if iterations > 10: 96 | return await ctx.send('too much lul') 97 | 98 | await self.jcoin.pricing(ctx, self.prices['OPR']) 99 | 100 | dt1 = time.monotonic() 101 | data = await get_data(url) 102 | dt2 = time.monotonic() 103 | ddelta = round((dt2 - dt1) * 1000, 6) 104 | 105 | source_image = io.BytesIO(data.getvalue()) 106 | try: 107 | img = Image.open(data) 108 | except Exception as err: 109 | raise self.SayException('Error opening image with' 110 | f' Pillow(`{err!r}`)') 111 | 112 | if img.format in ['JPEG', 'JPEG 2000']: 113 | # read the image, copy into a buffer for manipulation 114 | width, height = img.size 115 | 116 | if width > 4096 or height > 2048: 117 | await ctx.send("High resolution to work on" 118 | "(4096x2048 is the hard limit)") 119 | return 120 | 121 | future = self.bot.loop.run_in_executor(None, datamosh_jpg, 122 | source_image, iterations) 123 | 124 | t1 = time.monotonic() 125 | output_image = await future 126 | t2 = time.monotonic() 127 | pdelta = round((t2 - t1) * 1000, 5) 128 | 129 | # send file 130 | output_file = discord.File(output_image, 'datamoshed.jpg') 131 | await ctx.send(f'took {ddelta}ms on download.\n' 132 | f'{pdelta}ms on processing.', file=output_file) 133 | 134 | # done 135 | output_image.close() 136 | elif img.format in ['PNG']: 137 | await ctx.send('no support for png') 138 | elif img.format in ['GIF']: 139 | await ctx.send('no support for gif') 140 | else: 141 | await ctx.send(f'Unknown format: `{img.format}`') 142 | 143 | 144 | def setup(bot): 145 | bot.add_cog(Datamosh(bot)) 146 | -------------------------------------------------------------------------------- /jcoin/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS clients ( 2 | /* Clients connecting to the API should use a token. */ 3 | client_id text PRIMARY KEY, 4 | token text NOT NULL, 5 | client_name text NOT NULL, 6 | description text DEFAULT 'no description', 7 | 8 | /* 9 | 0 = only fetching 10 | 1 = full control 11 | */ 12 | auth_level int NOT NULL DEFAULT 0 13 | ); 14 | 15 | /* member table of all discord users José sees to write JOIN queries, updated in the bot itself */ 16 | CREATE TABLE IF NOT EXISTS members ( 17 | guild_id BIGINT NOT NULL, 18 | user_id BIGINT NOT NULL, 19 | PRIMARY KEY(guild_id, user_id) 20 | ); 21 | 22 | /* both users and taxbanks go here */ 23 | CREATE TABLE IF NOT EXISTS accounts ( 24 | account_id bigint PRIMARY KEY NOT NULL, 25 | account_type int NOT NULL, 26 | amount money DEFAULT 0 27 | ); 28 | 29 | CREATE VIEW account_amount as 30 | SELECT account_id, account_type, amount::money::numeric::float8 31 | FROM accounts; 32 | 33 | /* only user accounts here */ 34 | CREATE TABLE IF NOT EXISTS wallets ( 35 | user_id bigint NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, 36 | 37 | taxpaid money DEFAULT 0, 38 | hidecoins boolean DEFAULT false, 39 | 40 | /* for j!steal statistics */ 41 | steal_uses int DEFAULT 0, 42 | steal_success int DEFAULT 0, 43 | 44 | /* secondary user wallets, more of a bank */ 45 | ubank money DEFAULT 10 46 | ); 47 | 48 | CREATE VIEW wallets_taxpaid as 49 | SELECT user_id, taxpaid::numeric::float8, hidecoins, steal_uses, steal_success, ubank::numeric::float8 50 | FROM wallets; 51 | 52 | /* The Log of all transactions */ 53 | CREATE TABLE IF NOT EXISTS transactions ( 54 | idx serial PRIMARY KEY, 55 | transferred_at timestamp without time zone default now(), 56 | 57 | sender bigint NOT NULL REFERENCES accounts (account_id) ON DELETE RESTRICT, 58 | receiver bigint NOT NULL REFERENCES accounts (account_id) ON DELETE RESTRICT, 59 | amount numeric NOT NULL, 60 | 61 | /* so we can search for description='steal', or something */ 62 | description text DEFAULT 'transfer', 63 | taxreturn_used boolean DEFAULT false 64 | ); 65 | 66 | 67 | /* Steal related stuff */ 68 | CREATE TYPE cooldown_type AS ENUM ('prison', 'points'); 69 | 70 | CREATE TABLE IF NOT EXISTS steal_points ( 71 | user_id bigint NOT NULL REFERENCES accounts (account_id), 72 | points int NOT NULL DEFAULT 3, 73 | primary key (user_id) 74 | ); 75 | 76 | CREATE TABLE IF NOT EXISTS steal_cooldown ( 77 | user_id bigint NOT NULL REFERENCES accounts (account_id), 78 | ctype cooldown_type NOT NULL, 79 | finish timestamp without time zone default now(), 80 | primary key (user_id, ctype) 81 | ); 82 | 83 | CREATE TABLE IF NOT EXISTS steal_grace ( 84 | user_id bigint NOT NULL PRIMARY KEY, 85 | finish timestamp without time zone default now() 86 | ); 87 | 88 | CREATE VIEW steal_state as 89 | SELECT steal_points.user_id, points, steal_cooldown.ctype, steal_cooldown.finish 90 | FROM steal_points 91 | JOIN steal_cooldown 92 | ON steal_points.user_id = steal_cooldown.user_id; 93 | 94 | /* steal historic data */ 95 | CREATE TABLE IF NOT EXISTS steal_history ( 96 | idx serial PRIMARY KEY, 97 | steal_at timestamp without time zone default now(), 98 | 99 | /* who did what */ 100 | thief bigint NOT NULL REFERENCES accounts (account_id) ON DELETE RESTRICT, 101 | target bigint NOT NULL REFERENCES accounts (account_id) ON DELETE RESTRICT, 102 | 103 | /* target's wallet before the steal */ 104 | target_before numeric NOT NULL, 105 | 106 | /* stole amount */ 107 | amount numeric NOT NULL, 108 | 109 | /* steal success context */ 110 | success boolean NOT NULL, 111 | chance float8 NOT NULL, 112 | res float8 NOT NULL 113 | ); 114 | 115 | /* Tax return withdraw cooldown */ 116 | CREATE TABLE IF NOT EXISTS taxreturn_cooldown ( 117 | user_id bigint NOT NULL PRIMARY KEY, 118 | finish timestamp without time zone default now() 119 | ); 120 | 121 | /* <3 */ 122 | CREATE TABLE relationships ( 123 | user_id bigint NOT NULL, 124 | rel_id bigint NOT NULL, 125 | PRIMARY KEY (user_id, rel_id) 126 | ); 127 | 128 | /* MIDIFile: 113 | midi_file = MIDIFile(1) 114 | 115 | midi_file.addTrackName(0, 0, 'beep boop') 116 | midi_file.addTempo(0, 0, tempo) 117 | 118 | channel_datas = data.split('|') 119 | 120 | log.debug(f'creating MIDI out of "{data}"') 121 | 122 | for channel_index, channel_data in enumerate(channel_datas): 123 | await self.add_channel(midi_file, channel_index, channel_data) 124 | 125 | log.info('successfully created MIDI') 126 | return midi_file 127 | 128 | async def download_data(self, message: discord.Message) -> str: 129 | """Checks if an attachment is viable to be used as 130 | MIDI input and downloads it.""" 131 | if not message.attachments: 132 | raise self.SayException('You did not attach a file to ' 133 | 'use as MIDI input!, `j!help midi`') 134 | 135 | attachment = message.attachments[0] 136 | 137 | if not attachment.filename.endswith('.txt'): 138 | raise self.SayException('File must be a .txt!') 139 | 140 | # see if the file is bigger than 20 KiB as we don't want 141 | # to download huge files 142 | if attachment.size >= 20 * 1024: 143 | raise self.SayException('File is too large. ' 144 | 'Your file may only be 20KiB big.') 145 | 146 | log.info('downloading file to use as MIDI input. ' 147 | f'{attachment.size} bytes large.') 148 | buffer = io.BytesIO() 149 | await attachment.save(buffer) 150 | 151 | return buffer.getvalue().decode('utf-8') 152 | 153 | @commands.command() 154 | async def midi(self, 155 | ctx: commands.Context, 156 | tempo: int = 120, 157 | *, 158 | data: str = None): 159 | """ 160 | Convert text to MIDI. Multiple channels can be used by splitting text with |. 161 | Letters are converted to their pitch values using a mapping. 162 | 163 | Full documentation about it is not provided, read the code at 164 | https://github.com/lnmds/jose/blob/master/ext/midi.py 165 | 166 | To give longer input than a discord message allows you may upload a .txt file of up to 20 KiB. 167 | """ 168 | if data is None: 169 | try: 170 | data = await self.download_data(ctx.message) 171 | except Exception as err: 172 | log.exception('error downloading file at midi') 173 | raise self.SayException('We had an error while downloading ' 174 | 'the file, are you sure it is text?') 175 | 176 | before = time.monotonic() 177 | midi_file = await self.make_midi(tempo, data) 178 | duration = (time.monotonic() - before) * 1000 179 | 180 | if midi_file is None: 181 | return await ctx.send('Failed to generate a MIDI file!') 182 | 183 | file = io.BytesIO() 184 | await self.loop.run_in_executor(None, midi_file.writeFile, file) 185 | file.seek(0) 186 | 187 | wrapped = discord.File(file, filename='boop.midi') 188 | await ctx.send(f'Took {duration:.3f}ms!', file=wrapped) 189 | 190 | 191 | def setup(bot: commands.Bot): 192 | bot.add_cog(MIDI(bot)) 193 | -------------------------------------------------------------------------------- /ext/botcollection.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import discord 4 | from discord.ext import commands 5 | 6 | from .common import Cog 7 | 8 | BOT_RATIO_MIN = 1.7 9 | BOT_RATIO_MAX = 1.1 10 | 11 | WHITELIST = ( 12 | 273863625590964224, # José's server 13 | 295341979800436736, # Memework 14 | 319540379495956490, # v2 testing serv 15 | 380295555039100932, # Comet Obversavotory / luma's home 16 | 319252487280525322, # robert is gay 17 | 340609473439596546, # slice is a furry that plays agario 18 | 191611344137617408, # dan's 'haha gay pussy party' 19 | 277919178340565002, # lold - lolbot testing server 20 | 248143597097058305, # cyn private server 21 | 291990349776420865, # em's meme heaven 22 | 366513799404060672, # dan's another gay guild 23 | 322885030806421509, # heating's gay guild 24 | 350752456554184704, # eric's gay guild 25 | 422495103941345282, # muh testing 26 | ) 27 | 28 | log = logging.getLogger(__name__) 29 | 30 | 31 | class BotCollection(Cog): 32 | """Bot collection commands.""" 33 | 34 | def bot_human_ratio(self, guild): 35 | bots = [member for member in guild.members if member.bot] 36 | humans = [member for member in guild.members if not member.bot] 37 | 38 | return bots, humans, (len(bots) / len(humans)) 39 | 40 | def bhratio_global(self): 41 | all_members = self.bot.get_all_members 42 | 43 | bots = [member for member in all_members() if member.bot] 44 | humans = [member for member in all_members() if not member.bot] 45 | 46 | return bots, humans, (len(bots) / len(humans)) 47 | 48 | async def guild_ratio(self, guild: discord.Guild) -> float: 49 | """Get the bot-to-human ratio for a guild""" 50 | if len(guild.members) < 50: 51 | return BOT_RATIO_MIN 52 | else: 53 | return BOT_RATIO_MAX 54 | 55 | def fallback(self, guild: discord.Guild, message: str): 56 | """Send a message to the first channel we can send. 57 | 58 | Serves as a fallback instad of DMing owner. 59 | """ 60 | chan = next( 61 | c for c in guild.text_channels 62 | if guild.me.permissions_in(c).send_messages) 63 | return chan.send(message) 64 | 65 | async def on_guild_join(self, guild): 66 | bots, humans, ratio = self.bot_human_ratio(guild) 67 | owner = guild.owner 68 | 69 | log.info(f'[bh:join] {guild!s} -> ratio {len(bots)}b / {len(humans)}h ' 70 | f'= {ratio:.2}') 71 | 72 | if guild.id in WHITELIST: 73 | return 74 | 75 | bot_ratio = await self.guild_ratio(guild) 76 | if ratio > bot_ratio: 77 | log.info(f'[bh:leave:guild_join] {ratio} > {bot_ratio},' 78 | f' leaving {guild!s}') 79 | 80 | explode_bh = ('This guild was classified as a bot collection, ' 81 | f'josé automatically left. {ratio} > {bot_ratio}') 82 | try: 83 | await owner.send(explode_bh) 84 | except discord.Forbidden: 85 | await self.fallback(guild, explode_bh) 86 | 87 | return await guild.leave() 88 | 89 | if await self.bot.is_blocked_guild(guild.id): 90 | blocked_msg = ('Sorry. The guild you added José on is blocked. ' 91 | 'Appeal to the block at the support server' 92 | '(Use the invite provided in `j!invite`).') 93 | 94 | try: 95 | await owner.send(blocked_msg) 96 | except discord.Forbidden: 97 | await self.fallback(guild, blocked_msg) 98 | 99 | return await guild.leave() 100 | 101 | welcome = ('Hello, welcome to José!\n' 102 | "Discord's API Terms of Service requires me to" 103 | " tell you I log\n" 104 | 'Command usage and errors to a special channel.\n' 105 | '**Only commands and errors are logged, no ' 106 | 'messages are logged, ever.**\n' 107 | '**Disclaimer:** José is free and open source software' 108 | ' maintained by the hard work of many volunteers.\n' 109 | '\n**SPAM IS NOT TOLERATED.**') 110 | 111 | try: 112 | await owner.send(welcome) 113 | except discord.Forbidden: 114 | await self.fallback(guild, welcome) 115 | 116 | async def on_member_join(self, member): 117 | guild = member.guild 118 | 119 | if guild.id in WHITELIST: 120 | return 121 | 122 | bots, humans, ratio = self.bot_human_ratio(guild) 123 | bot_ratio = await self.guild_ratio(guild) 124 | 125 | if ratio > bot_ratio: 126 | log.info(f'[bh:leave:member_join] leaving {guild!r} {guild.id},' 127 | f' {ratio} ({len(bots)} / {len(humans)}) > {bot_ratio}') 128 | 129 | bc_msg = ('Your guild became classified as a bot' 130 | 'collection, josé automatically left.' 131 | f'{len(bots)} bots, ' 132 | f'{len(humans)} humans, ' 133 | f'{ratio}b/h > {bot_ratio}') 134 | 135 | try: 136 | await guild.owner.send(bc_msg) 137 | except discord.Forbidden: 138 | await self.fallback(guild, bc_msg) 139 | 140 | await guild.leave() 141 | 142 | @commands.command() 143 | @commands.guild_only() 144 | async def bhratio(self, ctx): 145 | """Get your guild's bot-to-human ratio""" 146 | 147 | bots, humans, ratio = self.bot_human_ratio(ctx.guild) 148 | _, _, global_ratio = self.bhratio_global() 149 | 150 | await ctx.send(f'{len(bots)} bots / {len(humans)} humans => ' 151 | f'`{ratio:.2}b/h`, global is `{global_ratio:.2}`') 152 | 153 | 154 | def setup(bot): 155 | bot.add_cog(BotCollection(bot)) 156 | -------------------------------------------------------------------------------- /playing_status.json: -------------------------------------------------------------------------------- 1 | [ 2 | "I Sexually Identify as an Attack Helicopter", 3 | "Memes", 4 | "Discord > Skype", 5 | "I'd love to make profit", 6 | "Features coming Soon™", 7 | "Python 3.x, please", 8 | "What are you doing in my swamp™", 9 | "7 GRAND DAD?!?", 10 | "The game who nobody asks about", 11 | "Nice meme'd mister.", 12 | "nothing", 13 | "with destiny", 14 | "with your feelings", 15 | "I wanna be the bot", 16 | "and TASing chess", 17 | "with humans", 18 | "playing", 19 | "4chan", 20 | "Imgur", 21 | "Reddit", 22 | "Sentience™", 23 | "Politics", 24 | "I've seen it on tumblr", 25 | "Expand Dong", 26 | "Bonzi Buddy", 27 | "Debugging", 28 | "Databases", 29 | "Guitar", 30 | [2, "♬ Blank Banshee - B:/ Infinite Login"], 31 | "with your heart", 32 | "Dance Dance Revolution", 33 | "PRANKS ON YOU BRO", 34 | "Really Makes Me Think™", 35 | "Black Mirror", 36 | "Netflix", 37 | "Bot Dev Tycoon", 38 | "Hell™", 39 | "Overwatch", 40 | "Poker with Satan", 41 | "Anime", 42 | "Matrix", 43 | "Communism", 44 | "Capitalism", 45 | "Philosophy with IBM Watson", 46 | "Skynet 2.0", 47 | [2, "♬ Daft Punk"], 48 | [3, "YouTube Red"], 49 | [3, "Westworld with Dolores"], 50 | [2, "♬ Saint Pepsi"], 51 | [3, "stand-up comedy"], 52 | "with bots, my only friends", 53 | [3, "NSFW Fanfics"], 54 | "my keyboard layout keeps changing", 55 | "Linux bash", 56 | "X11 > Wayland", 57 | "Apple fanboys", 58 | [2, "♬ Lady GaGa - Bad Romance"], 59 | [3, "CSI: NY"], 60 | [2, "♬ Michael Jackson"], 61 | [3, "Bazinga"], 62 | "Artificial Intelligence", 63 | "a server beep boop", 64 | "Club Penguin Server Administration", 65 | "Big Tyrone Simulator", 66 | [2, "♬ MACINTOSH PLUS - リサフランク420 / 現代のコンピュー"], 67 | [2, "♬ TANUKI - BABYBABYの夢"], 68 | [2, "♬ ナイトNaito - Talk About Love"], 69 | [2, "♬ 悲しい Android - Apartment - Your Love"], 70 | [2, "♬ BALENTSバランス - 植物と動物"], 71 | [2, "♬ Metallica - Master of Puppets"], 72 | [2, "♬ Information Flash - Early In The Mornin'"], 73 | [2, "♬ Night Tempo & ミカヅキBIGWAVE - Loveパズル"], 74 | [2, "♬ Compilerbau - Fragments Of Bach I"], 75 | [2, "♬ Burzum - Dunkelheit"], 76 | [2, "♬ Aphex Twin - Rhubarb"], 77 | [2, "♬ Lady Gaga - Love Game"], 78 | [2, "♬ ECCO2K - GT-R"], 79 | [2, "♬ Mayhem - Deathcrush"], 80 | [2, "♬ Vond - Selvmord"], 81 | [2, "♬ Venom - Buried Alive"], 82 | [2, "♬ Darkthrone - Transilvanian Hunger"], 83 | [2, "♬ Marcel Everett - The Dead Farmer"], 84 | [2, "♬ Marcel Everett - Stay Inside"], 85 | [2, "♬ We Are Number One"], 86 | 87 | [3, "h3h3productions"], 88 | [3, "science man channel 1 2 and 3"], 89 | 90 | "Transformers", 91 | "jokes HA getit?????", 92 | [3, "PewDiePie"], 93 | [3, "-1,555 subs"], 94 | "PREGANANACY", 95 | "Tinder", 96 | "Richard Stallman Simulator", 97 | "nothing", 98 | [2, "nothing"], 99 | [3, "nothing"], 100 | "good bye fellow ! prefix", 101 | "with patterns", 102 | "the pusi", 103 | "The Skynet Agenda", 104 | "Portal", 105 | "Portal 2", 106 | "Half-Life", 107 | "Super Mario", 108 | "Pokemon Go", 109 | "MMORPGs", 110 | "D&D", 111 | "GTA V", 112 | "Minecraft", 113 | "Crypt of the NecroDancer", 114 | "Zelda", 115 | "Toy Story", 116 | "Dungeon Souls", 117 | "dat ass", 118 | "Minecraft Shaders Mod", 119 | "Sampley of Guitarra", 120 | "Suq Madiq", 121 | "Mars", 122 | "with the microphone", 123 | "terminal seven", 124 | "Cortana", 125 | "Custom Prefixes", 126 | "Natural Language Processing", 127 | "nltk", 128 | "RSS Feeds", 129 | "/r/linuxmasterrace", 130 | "SpaceX", 131 | "International Space Station", 132 | "24 hours showing playing messages", 133 | "Infinite messages per minute", 134 | "Minecraft Let's Play", 135 | "Worship the gods", 136 | "Neural Networks", 137 | "Inter Bot Communication", 138 | "ShitPostBot 5000", 139 | "Pharmercy best ship", 140 | "Fetishism", 141 | "asyncio", 142 | "COBOL", 143 | "Magalzão Show", 144 | "Talk Show", 145 | "Death", 146 | [2, "♬ Cape Coral - Lovely Slut"], 147 | [2, "♬ Fi7i- Eclipse"], 148 | "with dead fish", 149 | "Virtual Reality", 150 | "Other Virtual Reality ( ͡° ͜ʖ ͡°)", 151 | "Thinking", 152 | [3, "capture the flag with 4chan"], 153 | "Real think hours", 154 | "True", 155 | "False", 156 | "Cache misses", 157 | "litecord", 158 | "WannaCry", 159 | "never stop dreaming", 160 | "Ratelimits", 161 | "with b1nzy", 162 | "with aiohttp", 163 | "dude fuck you", 164 | "HELL YEAH", 165 | "HELL NAH", 166 | "Brainfuck", 167 | "Motherlo... The Sims", 168 | [3, "with fire"], 169 | "XdxDXddxDDxdXDXdDXdxXDdxdXDDDXdxDXDx", 170 | [3, "Hackernews"], 171 | "I Wanna Be The Guy", 172 | "Weebsockets", 173 | "HTTP/2", 174 | "🅱enis", 175 | "🅱epis", 176 | "with a Cherry pie", 177 | "Voldemort laughs like a retarded remix", 178 | [3, "the answer to life, the universe, and everything"], 179 | "42", 180 | "27", 181 | "rewrite is the future!", 182 | "async is the future!", 183 | "legacy is the future!", 184 | "JoséCoins :^)", 185 | "StrodlCoins :^)", 186 | "i'm gay", 187 | "stop the spam please", 188 | "ey b0ss", 189 | "with my side hoe tatsu", 190 | "USSR national anthem", 191 | "blocking is the future!", 192 | "honey where's my supersucc?", 193 | "JO-ooo-oo-oo-oo EEEEO-A-AAA-AAAA-O----------", 194 | "with dolls", 195 | "with jorge", 196 | [3, "Ava's Demon"], 197 | "with Ava", 198 | "Am I trans?", 199 | [3, "memes"], 200 | "DDOSing josebox", 201 | "die in a tornado", 202 | "100 warnings to luna", 203 | [3, "Discord"], 204 | "AaAaAAaAAaAAaAaaAa", 205 | "Hunt Down the Missing Assets", 206 | "please come to brazil" 207 | ] 208 | -------------------------------------------------------------------------------- /ext/stats.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import decimal 4 | 5 | import pymongo 6 | 7 | from discord.ext import commands 8 | 9 | from .common import Cog 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | def empty_stats(c_name): 15 | return { 16 | 'name': c_name, 17 | 'uses': 0, 18 | } 19 | 20 | 21 | class Statistics(Cog, requires=['config']): 22 | """Bot stats stuff.""" 23 | 24 | def __init__(self, bot): 25 | super().__init__(bot) 26 | 27 | self.cstats_coll = self.config.jose_db['command_stats'] 28 | 29 | async def gauge(self, key, value): 30 | pass 31 | 32 | async def increment(self, key): 33 | pass 34 | 35 | async def decrement(self, key): 36 | pass 37 | 38 | async def basic_measures(self): 39 | await self.gauge('jose.guilds', len(self.bot.guilds)) 40 | await self.gauge('jose.users', len(self.bot.users)) 41 | await self.gauge('jose.channels', 42 | sum(1 for c in self.bot.get_all_channels())) 43 | 44 | async def starboard_stats(self): 45 | """Pushes starboard statistics to datadog.""" 46 | stars = self.bot.get_cog('Starboard') 47 | if stars is None: 48 | log.warning('[stats] Starboard cog not found, ignoring') 49 | return 50 | 51 | total_sconfig = await stars.starconfig_coll.count() 52 | await self.gauge('jose.starboard.total_configs', total_sconfig) 53 | 54 | total_stars = await stars.starboard_coll.count() 55 | await self.gauge('jose.starboard.total_stars', total_stars) 56 | 57 | async def jcoin_stats(self): 58 | """Push JoséCoin stats to datadog.""" 59 | 60 | coins = self.bot.get_cog('Coins') 61 | if coins is None: 62 | log.warning('[stats] Coins cog not found') 63 | return 64 | 65 | total_accounts = await coins.jcoin_coll.count() 66 | total_users = await coins.jcoin_coll.count({'type': 'user'}) 67 | total_tbanks = await coins.jcoin_coll.count({'type': 'taxbank'}) 68 | await self.gauge('jose.coin.accounts', total_accounts) 69 | await self.gauge('jose.coin.users', total_users) 70 | await self.gauge('jose.coin.taxbanks', total_tbanks) 71 | 72 | total_coins = [decimal.Decimal(0), decimal.Decimal(0)] 73 | inf = decimal.Decimal('inf') 74 | async for account in coins.jcoin_coll.find(): 75 | account['amount'] = decimal.Decimal(account['amount']) 76 | if account['amount'] == inf: 77 | continue 78 | 79 | acctype = account['type'] 80 | if acctype == 'user': 81 | total_coins[0] += account['amount'] 82 | elif acctype == 'taxbank': 83 | total_coins[1] += account['amount'] 84 | 85 | uc, tc = int(total_coins[0]), int(total_coins[1]) 86 | await self.gauge('jose.coin.usercoins', uc) 87 | await self.gauge('jose.coin.taxcoins', tc) 88 | 89 | await self.gauge('jose.coin.totalcoins', uc + tc) 90 | 91 | # haha yes persist data 92 | tx = await self.cstats_coll.find_one({'t': 'coin'}) 93 | if tx: 94 | await self.gauge('jose.coin.transfers', tx.get('tx')) 95 | 96 | async def texter_stats(self): 97 | """Report Texter statistics to datadog.""" 98 | speak = self.bot.get_cog('Speak') 99 | if not speak: 100 | log.warning('[stats] Speak not found') 101 | return 102 | 103 | await self.gauge('jose.tx.count', len(speak.text_generators)) 104 | await self.gauge('jose.tx.avg_gen', 105 | speak.st_gen_totalms / speak.st_gen_count) 106 | await self.gauge('jose.tx.txc_avg_run', 107 | speak.st_txc_totalms / speak.st_txc_runs) 108 | 109 | async def single_stats(self, task=False): 110 | if not self.bot.config.datadog and not task: 111 | log.warning('Datadog not configurated') 112 | return 113 | 114 | await self.basic_measures() 115 | await self.starboard_stats() 116 | await self.jcoin_stats() 117 | await self.texter_stats() 118 | 119 | async def on_command(self, ctx): 120 | command = ctx.command 121 | c_name = command.name 122 | 123 | stats = await self.cstats_coll.find_one({'name': c_name}) 124 | if stats is None: 125 | await self.cstats_coll.insert_one(empty_stats(c_name)) 126 | 127 | await self.increment('jose.complete_commands') 128 | await self.cstats_coll.update_one({ 129 | 'name': c_name 130 | }, {'$inc': { 131 | 'uses': 1 132 | }}) 133 | 134 | async def on_message(self, message): 135 | await self.increment('jose.recv_messages') 136 | 137 | async def on_guild_remove(self, guild): 138 | log.info(f'Left guild {guild.name} {guild.id},' 139 | f' {guild.member_count} members') 140 | 141 | @commands.command(aliases=['cstats']) 142 | async def command_stats(self, ctx, limit: int = 10): 143 | """Show most used commands.""" 144 | if limit > 20 or limit < 1: 145 | await ctx.send('no') 146 | return 147 | 148 | cur = self.cstats_coll.find().sort('uses', 149 | direction=pymongo.DESCENDING)\ 150 | .limit(limit) 151 | res = [] 152 | async for single in cur: 153 | if single.get('t'): 154 | continue 155 | 156 | name, uses = single['name'], single['uses'] 157 | res.append(f'{name}: used {uses} times') 158 | 159 | _res = '\n'.join(res) 160 | await ctx.send(f'```\n{_res}\n```') 161 | 162 | @commands.command(aliases=['cstat']) 163 | async def command_stat(self, ctx, command: str): 164 | """Get usage for a single command""" 165 | stat = await self.cstats_coll.find_one({'name': command}) 166 | if stat is None: 167 | raise self.SayException('Command not found') 168 | 169 | await ctx.send(f'`{command}: {stat["uses"]} uses`') 170 | 171 | 172 | def setup(bot): 173 | bot.add_jose_cog(Statistics) 174 | -------------------------------------------------------------------------------- /jcoin/http_api.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | JoséCoin REST API 3 | ================= 4 | 5 | -------- 6 | Base URL 7 | -------- 8 | 9 | All API requests are based off of this url: 10 | 11 | .. code-block :: http 12 | 13 | https://jose.lnmds.me/api/ 14 | 15 | ------------- 16 | Authorization 17 | ------------- 18 | 19 | All requests must contain an ``Authorization`` header containing your applications API key. 20 | 21 | Not sending this will result in your requests failing. 22 | 23 | --------------- 24 | Calling the API 25 | --------------- 26 | 27 | When sending requests to the API, you might get HTTP status codes that are 28 | different from 200. 29 | 30 | ======= =========================================================== 31 | code meaning 32 | ======= =========================================================== 33 | 500 Generic API error 34 | 404 Account not found, or the route you requested was not found 35 | 400 Input error, you gave wrong input to a data type 36 | 412 A condition for the request was not satisfied 37 | ======= =========================================================== 38 | 39 | With every non-204 and non-500 response from the API, you have 40 | a JSON body, it contains an ``error`` boolean field, and a ``message`` string field. 41 | 42 | Since ``error`` does not appear on successful requests, check for its `existance` 43 | other than actually checking for the value of the field. 44 | 45 | 46 | ====== 47 | Routes 48 | ====== 49 | 50 | ---------- 51 | Get Wallet 52 | ---------- 53 | 54 | .. code-block :: http 55 | 56 | GET /wallets/:wallet_id 57 | 58 | Get a wallet by it's ID, this works for users and taxbanks. 59 | 60 | ------------- 61 | Create Wallet 62 | ------------- 63 | 64 | .. code-block :: http 65 | 66 | POST /wallets/:wallet_id 67 | 68 | Create a wallet using it's ID - the ID is the user or guild ID the wallet will be for. 69 | 70 | The request must contain a json payload containing the wallet ``type``, being either ``0`` for 'user' or ``1`` for 'taxbank'. 71 | 72 | ------------- 73 | Delete Wallet 74 | ------------- 75 | 76 | .. code-block :: http 77 | 78 | DELETE /wallets/:wallet_id 79 | 80 | Permanently delete a wallet using it's ID. This action can not be undone. 81 | 82 | ------------------ 83 | Transfer to Wallet 84 | ------------------ 85 | 86 | .. code-block :: http 87 | 88 | POST /wallets/:wallet_id/transfer 89 | 90 | Transfer from a wallet to another. The wallet ID in the request URI is the wallet being transferred **from**. 91 | 92 | The request body must contain a ``receiver`` as an integer wallet ID and an ``amount`` as a string. 93 | 94 | .. note:: The amount can not be negative to transfer from the receivers account, you have to transfer with the other wallet. 95 | 96 | 97 | =============== ======= ================================== 98 | response field type description 99 | =============== ======= ================================== 100 | sender_amount decimal the new sender's account amount 101 | receiver_amount decimal the new receiver's account amoount 102 | =============== ======= ================================== 103 | 104 | 105 | ----------- 106 | Lock Wallet 107 | ----------- 108 | 109 | .. code-block :: http 110 | 111 | POST /wallets/:wallet_id/lock 112 | 113 | Lock a wallet from being used. 114 | 115 | ------------- 116 | Unlock Wallet 117 | ------------- 118 | 119 | .. code-block :: http 120 | 121 | DELETE /wallets/:wallet_id/lock 122 | 123 | Unlock a wallet if it's locked. 124 | 125 | ------------ 126 | Reset Wallet 127 | ------------ 128 | 129 | .. code-block :: http 130 | 131 | POST /wallets/:wallet_id/reset 132 | 133 | Reset a wallet. This sets the amount to 0 and resets any other statistics associated with it. 134 | 135 | --------------------- 136 | Increment steal usage 137 | --------------------- 138 | 139 | .. code-block :: http 140 | 141 | POST /wallets/:wallet_id/steal_use 142 | 143 | Increment the wallet's `steal_uses` field by one. 144 | 145 | --------------------- 146 | Mark successful steal 147 | --------------------- 148 | 149 | .. code-block :: http 150 | 151 | POST /wallet/:wallet_id/steal_success 152 | 153 | Increment the wallet's `steal_success` field by one. 154 | 155 | ----------- 156 | Wallet Rank 157 | ----------- 158 | 159 | .. code-block :: http 160 | 161 | GET /wallets/:wallet_id/rank 162 | 163 | Get a wallets rank. 164 | By default this returns the global rank, specifying a guild ID as a json parameter will also return the local ranking. 165 | 166 | ------------ 167 | JoséCoin GDP 168 | ------------ 169 | 170 | .. code-block :: http 171 | 172 | GET /gdp 173 | 174 | Gets the GDP of the economy. 175 | 176 | ---------------- 177 | Coin Probability 178 | ---------------- 179 | 180 | .. code-block :: http 181 | 182 | GET /wallets/:wallet_id/probability 183 | 184 | Get the probability of this wallet receiving random JoséCoins by sending messages. 185 | 186 | ------------ 187 | Get Accounts 188 | ------------ 189 | 190 | .. code-block :: http 191 | 192 | GET /wallets 193 | 194 | To receive different top lists you can specify different, mostly optional query parameters. 195 | 196 | The only required paramter is the ``key`` to specify by which criteria accounts get sorted. 197 | 198 | ========= ======= ======= 199 | parameter type default 200 | ========= ======= ======= 201 | key string 202 | reverse boolean false 203 | guild_id integer 204 | limit integer 20 205 | ========= ======= ======= 206 | 207 | 208 | ---------------- 209 | Get Global Stats 210 | ---------------- 211 | 212 | .. code-block :: http 213 | 214 | GET /stats 215 | 216 | Get globally available statistics about the JoséCoin. 217 | 218 | ============= ====== ============================== 219 | field type description 220 | ============= ====== ============================== 221 | gdp string the coin's gdp 222 | accounts int total number of accounts 223 | user_accounts int number of user accounts 224 | txb_accounts int number of taxbank accounts 225 | user_money string coins hold by users 226 | txb_money string coins hold by taxbanks 227 | steals int total steals done 228 | success int total steals which had success 229 | ============= ====== ============================== 230 | 231 | -------------------------------------------------------------------------------- /ext/admin.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | import logging 3 | import re 4 | 5 | import asyncpg 6 | from discord.ext import commands 7 | 8 | from .utils import Table, Timer 9 | from .common import Cog, shell 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | def no_codeblock(text: str) -> str: 15 | """ 16 | Removes codeblocks (grave accents), python and sql syntax highlight 17 | indicators from a text if present. 18 | .. note:: only the start of a string is checked, the text is allowed 19 | to have grave accents in the middle 20 | """ 21 | if text.startswith('```'): 22 | text = text[3:-3] 23 | 24 | if text.startswith(('py', 'sql')): 25 | # cut off the first line as this removes the 26 | # highlight indicator regardless of length 27 | text = '\n'.join(text.split('\n')[1:]) 28 | 29 | if text.startswith('`'): 30 | text = text[1:-1] 31 | 32 | return text 33 | 34 | 35 | class Admin(Cog, requires=['config']): 36 | @property 37 | def db(self) -> asyncpg.pool.Pool: 38 | cfg = self.bot.get_cog('Config') 39 | if cfg is not None: 40 | return cfg.db 41 | raise RuntimeError('Required config Cog is not loaded.') 42 | 43 | @commands.command(hidden=True) 44 | @commands.is_owner() 45 | async def shutdown(self, ctx): 46 | log.info('Logging out! %s', ctx.author) 47 | await ctx.send("dude rip") 48 | # await self.bot.session.close() 49 | await self.bot.logout() 50 | 51 | @commands.command(hidden=True) 52 | @commands.is_owner() 53 | async def load(self, ctx, *exts: str): 54 | """Loads an extension.""" 55 | for ext in exts: 56 | try: 57 | self.bot.load_extension("ext." + ext) 58 | except Exception as e: 59 | await ctx.send(f'Oops. ```py\n{traceback.format_exc()}\n```') 60 | return 61 | log.info(f'Loaded {ext}') 62 | m = ctx.send(f':ok_hand: `{ext}` loaded.') 63 | self.bot.loop.create_task(m) 64 | 65 | @commands.command(hidden=True) 66 | @commands.is_owner() 67 | async def unload(self, ctx, *exts: str): 68 | """Unloads an extension.""" 69 | for ext in exts: 70 | self.bot.unload_extension('ext.' + ext) 71 | log.info(f'Unloaded {ext}') 72 | m = ctx.send(f':ok_hand: `{ext}` unloaded.') 73 | self.bot.loop.create_task(m) 74 | 75 | @commands.command(hidden=True) 76 | @commands.is_owner() 77 | async def reload(self, ctx, *extensions: str): 78 | """Reloads an extension""" 79 | for ext in extensions: 80 | try: 81 | self.bot.unload_extension('ext.' + ext) 82 | self.bot.load_extension('ext.' + ext) 83 | log.info(f'Reloaded {ext}') 84 | except Exception as err: 85 | await ctx.send('OOPSIE WOOPSIE!!\nUwu We made a fucky wucky!\n' 86 | 'A wittle fucko boingo!```py\n' 87 | f'{traceback.format_exc()}\n```') 88 | 89 | return 90 | 91 | # don't block the coro waiting for a message send 92 | # since we might cause state inconsistencies 93 | msg = ctx.send(f':ok_hand: Reloaded `{ext}`') 94 | self.bot.loop.create_task(msg) 95 | 96 | @commands.command() 97 | @commands.is_owner() 98 | async def shell(self, ctx, *, command: str): 99 | """Execute shell commands.""" 100 | with ctx.typing(): 101 | result = await shell(command) 102 | await ctx.send(f"`{command}`: ```{result}```\n") 103 | 104 | @commands.command() 105 | @commands.is_owner() 106 | async def update(self, ctx): 107 | """im lazy""" 108 | await ctx.invoke(self.bot.get_command('shell'), command='git pull') 109 | 110 | @commands.command() 111 | @commands.is_owner() 112 | async def urel(self, ctx): 113 | """Update and reload automatically. 114 | 115 | Thanks aveao (github). <3 116 | """ 117 | with ctx.typing(): 118 | out = await shell('git pull') 119 | 120 | await ctx.send(f"```\n{out}\n```\n") 121 | 122 | # regex magic from ave <3 123 | to_reload = re.findall(r'ext/([a-z]*).py[ ]*\|', out) 124 | 125 | if not to_reload: 126 | return await ctx.send('No cogs found to reload') 127 | 128 | try: 129 | to_reload.remove('coins') 130 | 131 | # enforce coins to be reloaded before every other module 132 | to_reload.insert(0, 'coins') 133 | except ValueError: 134 | pass 135 | 136 | # offload actual reloading to reload command 137 | await ctx.invoke(self.bot.get_command('reload'), *to_reload) 138 | 139 | @commands.command(typing=True) 140 | @commands.is_owner() 141 | async def sql(self, ctx, *, statement: no_codeblock): 142 | """Execute SQL.""" 143 | # this is probably not the ideal solution 144 | if 'select' in statement.lower(): 145 | coro = self.db.fetch 146 | else: 147 | coro = self.db.execute 148 | 149 | try: 150 | with Timer() as t: 151 | result = await coro(statement) 152 | except asyncpg.PostgresError as e: 153 | return await ctx.send(f':x: Failed to execute!' 154 | f' {type(e).__name__}: {e}') 155 | 156 | # execute returns the status as a string 157 | if isinstance(result, str): 158 | return await ctx.send(f'```py\n{result}```took {t.duration:.3f}ms') 159 | 160 | if not result: 161 | return await ctx.send(f'no results, took {t.duration:.3f}ms') 162 | 163 | # render output of statement 164 | columns = list(result[0].keys()) 165 | table = Table(*columns) 166 | 167 | for row in result: 168 | values = [str(x) for x in row] 169 | table.add_row(*values) 170 | 171 | rendered = await table.render(self.loop) 172 | 173 | # properly emulate the psql console 174 | rows = len(result) 175 | rows = f'({rows} row{"s" if rows > 1 else ""})' 176 | 177 | await ctx.send(f'```py\n{rendered}\n{rows}```took {t.duration:.3f}ms') 178 | 179 | 180 | def setup(bot): 181 | bot.add_cog(Admin(bot)) 182 | -------------------------------------------------------------------------------- /ext/common.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import decimal 3 | import logging 4 | import io 5 | 6 | import discord 7 | from discord.ext import commands 8 | 9 | JOSE_VERSION = '2.4' 10 | 11 | ZERO = decimal.Decimal(0) 12 | INF = decimal.Decimal('inf') 13 | 14 | WIDE_MAP = dict((i, i + 0xFEE0) for i in range(0x21, 0x7F)) 15 | WIDE_MAP[0x20] = 0x3000 16 | 17 | log = logging.getLogger(__name__) 18 | 19 | 20 | class SayException(Exception): 21 | """Say something using an exception.""" 22 | pass 23 | 24 | 25 | class GuildConverter(commands.Converter): 26 | """Convert the name of a guild to 27 | a Guild object.""" 28 | 29 | async def convert(self, ctx, arg): 30 | bot = ctx.bot 31 | 32 | try: 33 | guild_id = int(arg) 34 | except ValueError: 35 | 36 | def is_guild(g): 37 | return arg.lower() == g.name.lower() 38 | 39 | guild = discord.utils.find(is_guild, bot.guilds) 40 | 41 | if guild is None: 42 | raise commands.BadArgument('Guild not found') 43 | return guild 44 | 45 | guild = bot.get_guild(guild_id) 46 | if guild is None: 47 | raise commands.BadArgument('Guild not found') 48 | return guild 49 | 50 | 51 | class CoinConverter(commands.Converter): 52 | """Does simple checks to the value being given. 53 | 54 | Also checks if the user has an account 55 | """ 56 | 57 | async def convert(self, ctx, argument): 58 | ba = commands.BadArgument 59 | coins = ctx.bot.get_cog('Coins') 60 | if not coins: 61 | raise ba('Coins extension not loaded.') 62 | 63 | if argument.lower() == 'all': 64 | if ctx.invoked_with in ('steal', 'heist'): 65 | # this is the member/guild 66 | target = ctx.args[-1] 67 | else: 68 | target = ctx.author 69 | 70 | try: 71 | account = await coins.get_account(target.id) 72 | except coins.AccountNotFoundError: 73 | raise ba(f'Your target `{target}` does not have a' 74 | ' JoséCoin account.') 75 | 76 | return account['amount'] 77 | 78 | value = decimal.Decimal(argument) 79 | if value <= ZERO: 80 | raise ba("You can't input values lower or equal to 0.") 81 | elif value >= INF: 82 | raise ba("You can't input values equal or higher to infinity.") 83 | 84 | try: 85 | value = round(value, 2) 86 | except: 87 | raise ba('Rounding failed.') 88 | 89 | # Ensure a taxbank account tied to the guild exists 90 | await coins.ensure_taxbank(ctx) 91 | try: 92 | account = await coins.get_account(ctx.author.id) 93 | except coins.AccountNotFoundError: 94 | raise ba("You don't have a JoséCoin account, " 95 | f"make one with `j!account`") 96 | 97 | return value 98 | 99 | 100 | class FuzzyMember(commands.Converter): 101 | """Fuzzy matching for member objects""" 102 | 103 | async def convert(self, ctx, arg): 104 | arg = arg.lower() 105 | ms = ctx.guild.members 106 | scores = {} 107 | for m in ms: 108 | score = 0 109 | mn = m.name 110 | 111 | # compare against username 112 | # We give a better score to exact matches 113 | # than to just "contain"-type matches 114 | if arg == mn: 115 | score += 10 116 | if arg in mn: 117 | score += 3 118 | 119 | # compare with nickname in a non-throw-exception way 120 | nick = getattr(m.nick, "lower", "".lower)() 121 | if arg == nick: 122 | score += 2 123 | if arg in nick: 124 | score += 1 125 | 126 | # we don't want a really big dict thank you 127 | if score > 0: 128 | scores[m.id] = score 129 | 130 | try: 131 | sortedkeys = sorted( 132 | scores.keys(), key=lambda k: scores[k], reverse=True) 133 | return sortedkeys[0] 134 | except IndexError: 135 | raise commands.BadArgument('No user was found') 136 | 137 | 138 | class Cog: 139 | """Main cog base class. 140 | 141 | Provides common functions to cogs. 142 | """ 143 | 144 | def __init__(self, bot): 145 | self.bot = bot 146 | self.loop = bot.loop 147 | self.JOSE_VERSION = JOSE_VERSION 148 | 149 | # so it becomes available for all cogs without needing to import shit 150 | self.SayException = SayException 151 | self.prices = { 152 | 'OPR': 0.9, 153 | 'API': 0.65, 154 | 'TRN': '0.022/char', 155 | } 156 | 157 | def __init_subclass__(cls, **kwargs): 158 | """Fill in cog metadata about a cog's requirements.""" 159 | requires = kwargs.get('requires', []) 160 | 161 | cls._cog_metadata = { 162 | 'requires': requires, 163 | } 164 | 165 | async def get_json(self, url: str) -> 'any': 166 | """Get JSON from a url.""" 167 | async with self.bot.session.get(url) as resp: 168 | try: 169 | return await resp.json() 170 | except Exception as err: 171 | raise SayException(f'Error parsing JSON: {err!r}') 172 | 173 | async def http_get(self, url): 174 | async with self.bot.session.get(url) as resp: 175 | return await resp.text() 176 | 177 | async def http_read(self, url): 178 | async with self.bot.session.get(url) as resp: 179 | return io.BytesIO(await resp.read()) 180 | 181 | @property 182 | def config(self): 183 | return self.bot.cogs.get('Config') 184 | 185 | @property 186 | def jcoin(self): 187 | return self.bot.cogs.get('Coins') 188 | 189 | @property 190 | def coins(self): 191 | return self.bot.cogs.get('Coins') 192 | 193 | @property 194 | def pool(self): 195 | return self.bot.cogs.get('Config').db 196 | 197 | 198 | async def shell(command: str) -> str: 199 | """Execute shell commands.""" 200 | process = await asyncio.create_subprocess_shell( 201 | command, 202 | stderr=asyncio.subprocess.PIPE, 203 | stdout=asyncio.subprocess.PIPE, 204 | ) 205 | 206 | out, err = map(lambda s: s.decode('utf-8'), await process.communicate()) 207 | return f'{out}{err}'.strip() 208 | -------------------------------------------------------------------------------- /ext/metrics.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | import collections 4 | 5 | import discord 6 | 7 | from discord.ext import commands 8 | from .common import Cog 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | # if average sample and current sample differ the unit 13 | # defined here, a warning is thrown. 14 | 15 | # keep in mind samples happen each minute 16 | EVENT_THRESHOLD = { 17 | 'message': 70, 18 | 'command': 60, 19 | 'command_compl': 50, 20 | 'command_error': 2, # errors should be delivered asap 21 | } 22 | 23 | 24 | class Metrics(Cog): 25 | """Metrics subsystem.""" 26 | 27 | def __init__(self, bot): 28 | super().__init__(bot) 29 | 30 | # get webhooks 31 | wbh = discord.Webhook.from_url 32 | adp = discord.AsyncWebhookAdapter(self.bot.session) 33 | self.webhook = wbh(self.bot.config.METRICS_WEBHOOK, adapter=adp) 34 | 35 | # metrics state 36 | self.sum_state = collections.defaultdict(int) 37 | self.samples = 0 38 | 39 | self.sep_state = {} 40 | 41 | self.last_state = None 42 | self.empty_state() 43 | 44 | self.sampletask = self.bot.loop.create_task(self.sample_task()) 45 | self.owner = None 46 | 47 | def __unload(self): 48 | self.sampletask.cancel() 49 | 50 | def get_rates(self): 51 | """Get the rates, given current state.""" 52 | return {k: round(v / 60, 4) for k, v in self.current_state.items()} 53 | 54 | def empty_state(self): 55 | """Set current state to 0""" 56 | self.current_state = { 57 | 'message': 0, 58 | 'command': 0, 59 | 'command_error': 0, 60 | 'command_compl': 0, 61 | } 62 | 63 | self.sep_state = { 64 | 'message': collections.Counter(), 65 | 'command': collections.Counter(), 66 | 'command_error': collections.Counter(), 67 | 'command_compl': collections.Counter(), 68 | } 69 | 70 | def submit_state(self): 71 | """Submit current state to the sum of states.""" 72 | self.samples += 1 73 | log.debug(f'Sampling: {self.current_state!r}') 74 | for k, val in self.current_state.items(): 75 | self.sum_state[k] += val 76 | 77 | def get_average_state(self): 78 | """Get the average state""" 79 | return {k: (v / self.samples) for k, v in self.sum_state.items()} 80 | 81 | def show_common(self, res: list, event: str, common: int = 5): 82 | common = self.sep_state[event].most_common(common) 83 | 84 | for (idx, (any_id, count)) in enumerate(common): 85 | cause = self.bot.get_guild(any_id) or self.bot.get_user(any_id) 86 | if not cause: 87 | res.append(f'- #{idx} ` [{any_id}]`: {count}\n') 88 | continue 89 | res.append(f'- #{idx} `{cause!s} [{cause.id}]`: {count}\n') 90 | 91 | async def call_owner(self, warn): 92 | if not self.owner: 93 | self.owner = (await self.bot.application_info()).owner 94 | 95 | res = [f'{self.owner.mention}\n'] 96 | 97 | for (event, cur_state, average, delta, threshold) in warn: 98 | res.append(f'\tEvent `{event}`, current: {cur_state}, ' 99 | f'average: {average}, delta: {delta}, ' 100 | f'threshold: {threshold}\n') 101 | 102 | self.show_common(res, event) 103 | 104 | await self.webhook.execute('\n'.join(res)) 105 | 106 | async def sample(self): 107 | """Sample current data.""" 108 | 109 | # compare current_state with last_state 110 | average = self.get_average_state() 111 | 112 | warn = [] 113 | for k, average in average.items(): 114 | delta = self.current_state[k] - average 115 | threshold = EVENT_THRESHOLD[k] 116 | if threshold <= 0: 117 | # ignore if threshold is 0 etc 118 | continue 119 | 120 | if delta > EVENT_THRESHOLD[k]: 121 | # this event is above the threshold, we warn! 122 | warn.append((k, self.current_state[k], average, delta, 123 | threshold)) 124 | 125 | log.debug(warn) 126 | # this only really works if we already made 3 samples 127 | if warn and self.samples > 3: 128 | await self.call_owner(warn) 129 | 130 | # set last_state to a copy of current_state 131 | self.last_state = dict(self.current_state) 132 | 133 | # set current_state to 0 134 | self.submit_state() 135 | self.empty_state() 136 | 137 | async def sample_task(self): 138 | try: 139 | while True: 140 | await self.sample() 141 | await asyncio.sleep(60) 142 | except asyncio.CancelledError: 143 | log.warning('sample task cancel') 144 | except Exception: 145 | log.exception('sample task rip') 146 | 147 | async def on_message(self, message): 148 | if message.author.bot: 149 | return 150 | 151 | self.current_state['message'] += 1 152 | 153 | key = message.guild.id if message.guild else message.author.id 154 | self.sep_state['message'][key] += 1 155 | 156 | async def on_command(self, ctx): 157 | self.current_state['command'] += 1 158 | 159 | key = ctx.guild.id if ctx.guild else ctx.author.id 160 | self.sep_state['command'][key] += 1 161 | 162 | async def on_command_error(self, ctx, error): 163 | self.current_state['command_error'] += 1 164 | 165 | key = ctx.guild.id if ctx.guild else ctx.author.id 166 | self.sep_state['command_error'][key] += 1 167 | 168 | async def on_command_completion(self, ctx): 169 | self.current_state['command_compl'] += 1 170 | 171 | key = ctx.guild.id if ctx.guild else ctx.author.id 172 | self.sep_state['command_compl'][key] += 1 173 | 174 | @commands.command() 175 | async def mstate(self, ctx): 176 | """Get current state of metrics.""" 177 | state = self.current_state 178 | await ctx.send(f'Messages received: {state["message"]}\n' 179 | f'Commands received: {state["command"]}\n' 180 | f'Commands completed: {state["command_compl"]}\n' 181 | 'Commands which raised errors: ' 182 | f'{state["command_error"]}\n') 183 | 184 | @commands.command() 185 | async def mrates(self, ctx): 186 | """Get per-second rates of current state.""" 187 | rates = self.get_rates() 188 | await ctx.send(f'Messages/second: {rates["message"]}\n' 189 | f'Commands/second: {rates["command"]}\n' 190 | f'Complete/second: {rates["command_compl"]}\n' 191 | f'Errors/second: {rates["command_error"]}') 192 | 193 | @commands.command() 194 | async def msample(self, ctx): 195 | """Force a sample""" 196 | await self.sample() 197 | await ctx.ok() 198 | 199 | 200 | def setup(bot): 201 | bot.add_cog(Metrics(bot)) 202 | -------------------------------------------------------------------------------- /ext/lottery.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import decimal 3 | import asyncio 4 | from random import SystemRandom 5 | 6 | import discord 7 | from discord.ext import commands 8 | 9 | from .common import Cog 10 | 11 | random = SystemRandom() 12 | log = logging.getLogger(__name__) 13 | 14 | PERCENTAGE_PER_TAXBANK = decimal.Decimal(0.275 / 100) 15 | TICKET_PRICE = 11 16 | TICKET_INCREASE = decimal.Decimal(65 / 100) 17 | LOTTERY_COOLDOWN = 5 18 | 19 | 20 | class Lottery(Cog, requires=['coins']): 21 | """Weekly lottery. 22 | 23 | The lottery works with you buying a 15JC lottery ticket. 24 | From time to time(maximum a week) a winner is chosen from the people 25 | who bought a ticket. 26 | 27 | The winner gets 0.275% of money from the top 40 taxbanks. 28 | This amount also increases with more people buying tickets. 29 | """ 30 | 31 | def __init__(self, bot): 32 | super().__init__(bot) 33 | self.ticket_coll = self.config.jose_db['lottery'] 34 | self.cdown_coll = self.config.jose_db['lottery_cooldown'] 35 | 36 | async def get_taxbanks(self): 37 | """Fetch taxbanks for lottery.""" 38 | return await self.coins.jc_get( 39 | '/wallets', { 40 | 'key': 'global', 41 | 'reverse': True, 42 | 'type': self.coins.AccountType.TAXBANK, 43 | 'limit': 40, 44 | }) 45 | 46 | @commands.group(aliases=['l'], invoke_without_command=True) 47 | async def lottery(self, ctx): 48 | """Show current lottery state. 49 | 50 | A read on 'j!help Lottery' is highly recommended. 51 | """ 52 | amount = decimal.Decimal(0) 53 | taxbanks = await self.get_taxbanks() 54 | for account in taxbanks: 55 | amount += PERCENTAGE_PER_TAXBANK * \ 56 | decimal.Decimal(account['amount']) 57 | 58 | amount_people = await self.ticket_coll.count() 59 | amount += TICKET_INCREASE * amount_people * TICKET_PRICE 60 | 61 | amount = round(amount, 2) 62 | await ctx.send('Calculation of the big money for lottery: ' 63 | f'`{amount}JC`') 64 | 65 | @lottery.command() 66 | async def users(self, ctx): 67 | """Show the users that are in the current lottery.""" 68 | em = discord.Embed() 69 | 70 | users = [] 71 | async for ticket in self.ticket_coll.find(): 72 | users.append(f'<@{ticket["user_id"]}>') 73 | 74 | if users: 75 | em.add_field(name='Users', value=' '.join(users)) 76 | else: 77 | em.description = 'No users in the current lottery' 78 | 79 | await ctx.send(embed=em) 80 | 81 | async def lottery_send(self, message: str): 82 | lottery_log = self.bot.get_channel(self.bot.config.LOTTERY_LOG) 83 | if not lottery_log: 84 | raise self.SayException('`config error`: log channel not found.') 85 | 86 | return await lottery_log.send(message) 87 | 88 | def get_jose_guild(self): 89 | joseguild = self.bot.get_guild(self.bot.config.JOSE_GUILD) 90 | if not joseguild: 91 | raise self.SayException('`config error`: José guild not found.') 92 | 93 | return joseguild 94 | 95 | @lottery.command() 96 | @commands.is_owner() 97 | async def roll(self, ctx): 98 | """Roll a winner from the pool""" 99 | joseguild = self.get_jose_guild() 100 | cur = self.ticket_coll.find() 101 | 102 | # !!! bad code !!! 103 | # this is not WEBSCALE 104 | all_users = await cur.to_list(length=None) 105 | winner_id = random.choice(all_users)['user_id'] 106 | 107 | if not any(m.id == ctx.author.id for m in joseguild.members): 108 | raise self.SayException(f'selected winner, <@{winner_id}> ' 109 | 'is not in jose guild. ignoring!') 110 | 111 | u_winner = self.bot.get_user(winner_id) 112 | if u_winner is None: 113 | return await ctx.send('Winner is unfindable user.') 114 | 115 | await self.lottery_send(f'**Winner!** `{u_winner!s}, {u_winner.id}`') 116 | await ctx.send(f'Winner: <@{winner_id}>, transferring will take time') 117 | 118 | # insert user into cooldown 119 | await self.cdown_coll.delete_many({'user_id': winner_id}) 120 | await self.cdown_coll.insert_one({ 121 | 'user_id': winner_id, 122 | 'rolls_wait': LOTTERY_COOLDOWN, 123 | }) 124 | 125 | # business logic is here 126 | total = decimal.Decimal(0) 127 | taxbanks = await self.get_taxbanks() 128 | for account in taxbanks: 129 | amount = PERCENTAGE_PER_TAXBANK * \ 130 | decimal.Decimal(account['amount']) 131 | 132 | try: 133 | await self.jcoin.transfer(account['account_id'], winner_id, 134 | amount) 135 | total += amount 136 | except Exception as err: 137 | await ctx.send(f'err txb tx: {err!r}') 138 | 139 | await asyncio.sleep(0.1) 140 | 141 | amount_people = await self.ticket_coll.count() 142 | amount_from_ticket = TICKET_INCREASE * amount_people * TICKET_PRICE 143 | await self.jcoin.transfer(self.bot.user.id, winner_id, 144 | amount_from_ticket) 145 | 146 | total += amount_from_ticket 147 | total = round(total, 3) 148 | await ctx.send(f'Sent a total of `{total}` to the winner') 149 | 150 | # check out all current winners 151 | upd = await self.cdown_coll.update_many({}, {'$inc': {'rolls_wait': -1}}) 152 | await ctx.send(f'Updated {upd.modified_count} winner wait documents') 153 | 154 | delt = await self.ticket_coll.delete_many({}) 155 | await ctx.send(f'Deleted {delt.deleted_count} tickets') 156 | 157 | @lottery.command() 158 | async def enter(self, ctx): 159 | """Enter the weekly lottery. 160 | You will pay 11JC for a ticket. 161 | """ 162 | # Check if the user is in jose guild 163 | joseguild = self.get_jose_guild() 164 | 165 | if ctx.author not in joseguild.members: 166 | raise self.SayException("You are not in José's server. " 167 | 'For means of transparency, it is ' 168 | 'recommended to join it, use ' 169 | f'`{ctx.prefix}invite`') 170 | 171 | win = await self.cdown_coll.find_one({'user_id': ctx.author.id}) 172 | if win and win['rolls_wait'] > 0: 173 | raise self.SayException(f'You have to wait {win["rolls_wait"]} rolls.') 174 | 175 | ticket = await self.ticket_coll.find_one({'user_id': ctx.author.id}) 176 | if ticket: 177 | raise self.SayException('You already bought a ticket.') 178 | 179 | # Pay 20jc to jose 180 | await self.coins.transfer(ctx.author.id, self.bot.user.id, 181 | TICKET_PRICE) 182 | 183 | await self.ticket_coll.insert_one({'user_id': ctx.author.id}) 184 | await self.lottery_send(f'In lottery: `{ctx.author!s}, {ctx.author.id}`') 185 | await ctx.ok() 186 | 187 | 188 | def setup(bot): 189 | bot.add_jose_cog(Lottery) 190 | -------------------------------------------------------------------------------- /ext/gambling.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import decimal 3 | import random 4 | import collections 5 | 6 | import discord 7 | 8 | from discord.ext import commands 9 | from .common import Cog, CoinConverter 10 | 11 | 12 | EMOJI_POOL = ':thinking: :snail: :shrug: :chestnut: :ok_hand: :eggplant:'.split() + \ 13 | ':fire: :green_book: :radioactive: :rage: :new_moon_with_face: :sun_with_face: :bread:'.split() 14 | BET_MULTIPLIER_EMOJI = ':thinking:' 15 | X4_EMOJI = [':snail:', ':ok_hand', ':chestnut:'] 16 | X6_EMOJI = [':eggplant:'] 17 | 18 | 19 | class Gambling(Cog, requires=['coins']): 20 | """Gambling commands.""" 21 | 22 | def __init__(self, bot): 23 | super().__init__(bot) 24 | self.duels = {} 25 | self.locked = collections.defaultdict(bool) 26 | 27 | @commands.command() 28 | @commands.guild_only() 29 | async def duel(self, ctx, challenged_user: discord.User, 30 | amount: CoinConverter): 31 | """Duel a user for coins. 32 | 33 | The winner of the duel is the person that sends a message first as soon 34 | as josé says "GO". 35 | """ 36 | if challenged_user == ctx.author: 37 | raise self.SayException("You can't do this alone.") 38 | 39 | if challenged_user.bot: 40 | raise self.SayException('You cannot duel bots.') 41 | 42 | if amount > 5: 43 | raise self.SayException("Can't duel more than 5JC.") 44 | 45 | challenger_user = ctx.author 46 | challenger = ctx.author.id 47 | challenged = challenged_user.id 48 | 49 | if await self.bot.is_blocked(challenged): 50 | raise self.SayException('Challenged person is blocked from José' 51 | '(use `j!blockreason `)') 52 | 53 | if self.locked[challenged]: 54 | raise self.SayException('Challenged person is locked to new duels') 55 | 56 | if challenger in self.duels: 57 | raise self.SayException('You are already in a duel') 58 | 59 | challenger_acc = await self.jcoin.get_account(challenger) 60 | if not challenger_acc: 61 | raise self.SayException("You don't have a wallet.") 62 | 63 | challenged_acc = await self.jcoin.get_account(challenged) 64 | if not challenged_acc: 65 | raise self.SayException("Challenged person doesn't have a wallet.") 66 | 67 | if amount > challenger_acc['amount'] or \ 68 | amount > challenged_acc['amount']: 69 | raise self.SayException("One of you don't have enough" 70 | " funds to make the duel.") 71 | 72 | try: 73 | await self.coins.lock(challenger, challenged) 74 | 75 | self.locked[challenged] = True 76 | await ctx.send(f'{challenged_user}, you got challenged ' 77 | f'for a duel :gun: by {challenger_user} ' 78 | f'with a total of {amount}JC, accept it? (y/n)') 79 | 80 | def yn_check(msg): 81 | cnt = msg.content 82 | return msg.author.id == challenged and \ 83 | msg.channel == ctx.channel and \ 84 | any(x in cnt for x in ['y', 'n', 'Y', 'N']) 85 | 86 | try: 87 | msg = await self.bot.wait_for( 88 | 'message', timeout=10, check=yn_check) 89 | except asyncio.TimeoutError: 90 | raise self.SayException('timeout reached') 91 | 92 | if msg.content != 'y': 93 | raise self.SayException("Challenged person didn't" 94 | " say a lowercase `y`.") 95 | 96 | countdown = 3 97 | countdown_msg = await ctx.send('First to send a ' 98 | f'message wins! {countdown}') 99 | await asyncio.sleep(1) 100 | 101 | for i in reversed(range(1, 4)): 102 | await countdown_msg.edit(content=f'{i}...') 103 | await asyncio.sleep(1) 104 | 105 | await asyncio.sleep(random.randint(2, 7)) 106 | await countdown_msg.edit(content='**GO!**') 107 | 108 | self.duels[challenger] = { 109 | 'challenged': challenged, 110 | 'amount': amount, 111 | } 112 | 113 | duelists = [challenged, challenger] 114 | 115 | def duel_check(msg): 116 | return msg.channel == ctx.channel and msg.author.id in duelists 117 | 118 | try: 119 | msg = await self.bot.wait_for( 120 | 'message', timeout=5, check=duel_check) 121 | except asyncio.TimeoutError: 122 | raise self.SayException('u guys suck.') 123 | 124 | self.locked[challenged] = False 125 | await self.coins.unlock(challenger, challenged) 126 | 127 | winner = msg.author.id 128 | duelists.remove(winner) 129 | loser = duelists[0] 130 | 131 | try: 132 | await self.jcoin.transfer(loser, winner, amount) 133 | except self.jcoin.TransferError as err: 134 | raise self.SayException(f'Failed to transfer: {err!r}') 135 | 136 | await ctx.send(f'<@{winner}> won {amount}JC.') 137 | finally: 138 | self.locked[challenged] = False 139 | if challenger in self.duels: 140 | del self.duels[challenger] 141 | 142 | await self.coins.unlock(challenger, challenged) 143 | 144 | @commands.command() 145 | async def slots(self, ctx, amount: CoinConverter): 146 | """little slot machine""" 147 | if amount > 8: 148 | raise self.SayException('You cannot gamble too much.') 149 | 150 | await self.jcoin.transfer(ctx.author.id, ctx.guild.id, amount) 151 | 152 | res = [] 153 | slots = [random.choice(EMOJI_POOL) for i in range(3)] 154 | 155 | res.append(' '.join(slots)) 156 | bet_multiplier = slots.count(BET_MULTIPLIER_EMOJI) * 2 157 | 158 | for emoji in slots: 159 | if slots.count(emoji) == 3: 160 | if emoji in X4_EMOJI: 161 | bet_multiplier = 4 162 | elif emoji in X6_EMOJI: 163 | bet_multiplier = 6 164 | 165 | applied_amount = amount * bet_multiplier 166 | 167 | res.append(f'**Multiplier**: {bet_multiplier}x') 168 | res.append(f'bet: {amount}, won: {applied_amount}') 169 | 170 | if applied_amount > 0: 171 | try: 172 | await self.jcoin.transfer(ctx.guild.id, ctx.author.id, 173 | applied_amount) 174 | except self.jcoin.TransferError as err: 175 | raise self.SayException(f'err(g->a, a): {err!r}') 176 | else: 177 | res.append(':peach:') 178 | 179 | await ctx.send('\n'.join(res)) 180 | 181 | @commands.command() 182 | async def flip(self, ctx): 183 | """Flip a coin. (49%, 49%, 2%)""" 184 | p = random.random() 185 | if p < .49: 186 | await ctx.send('https://i.imgur.com/oEEkybO.png') 187 | elif .49 < p < .98: 188 | await ctx.send('https://i.imgur.com/c9smEW6.png') 189 | else: 190 | await ctx.send('https://i.imgur.com/yDPUp3P.png') 191 | 192 | 193 | def setup(bot): 194 | bot.add_jose_cog(Gambling) 195 | -------------------------------------------------------------------------------- /ext/basic.py: -------------------------------------------------------------------------------- 1 | import time 2 | import random 3 | import sys 4 | import os 5 | import datetime 6 | import logging 7 | import asyncio 8 | 9 | import psutil 10 | import discord 11 | from discord.ext import commands 12 | 13 | from .common import Cog, shell 14 | 15 | FEEDBACK_CHANNEL_ID = 290244095820038144 16 | 17 | OAUTH_URL = 'https://discordapp.com/oauth2/authorize?permissions=379968&scope=bot&client_id=202586824013643777' 18 | SUPPORT_SERVER = 'https://discord.gg/5ASwg4C' 19 | log = logging.getLogger(__name__) 20 | 21 | 22 | class Basic(Cog): 23 | """Basic commands.""" 24 | 25 | def __init__(self, bot): 26 | super().__init__(bot) 27 | self.support_inv = SUPPORT_SERVER 28 | self.process = psutil.Process(os.getpid()) 29 | 30 | @commands.command(aliases=['p']) 31 | async def ping(self, ctx): 32 | """Ping. 33 | 34 | Meaning of the data: 35 | - rtt: time taken to send a message in discord. 36 | 37 | - gateway: time taken to send a HEARTBEAT 38 | packet and receive an HEARTBEAT ACK in return. 39 | 40 | None values may be shown if josé did not update the data. 41 | """ 42 | 43 | t1 = time.monotonic() 44 | m = await ctx.send('pinging...') 45 | t2 = time.monotonic() 46 | 47 | rtt = (t2 - t1) * 1000 48 | gw = self.bot.latency * 1000 49 | await m.edit(content=f'rtt: `{rtt:.1f}ms`, gateway: `{gw:.1f}ms`') 50 | 51 | @commands.command(aliases=['rand']) 52 | async def random(self, ctx, n_min: int, n_max: int): 53 | """Get a random number.""" 54 | if n_min < -1e100 or n_max > 1e100: 55 | return await ctx.send('Your values are outside the range ' 56 | '`[-1e100, 1e100]`') 57 | 58 | if n_min > n_max: 59 | await ctx.send("`min > max` u wot") 60 | return 61 | 62 | n_rand = random.randint(n_min, n_max) 63 | await ctx.send(f"from {n_min} to {n_max} I go {n_rand}") 64 | 65 | @commands.command(aliases=['choose', 'choice']) 66 | async def pick(self, ctx, *choices: commands.clean_content): 67 | """Disabled command.""" 68 | await ctx.send('This command is disabled and will be removed soon.') 69 | 70 | @commands.command() 71 | async def version(self, ctx): 72 | """Show current josé version""" 73 | pyver = '%d.%d.%d' % (sys.version_info[:3]) 74 | head_id = await shell('git rev-parse --short HEAD') 75 | branch = await shell('git rev-parse --abbrev-ref HEAD') 76 | 77 | await ctx.send(f'`José v{self.JOSE_VERSION} git:{branch}-{head_id} ' 78 | f'py:{pyver} d.py:{discord.__version__}`') 79 | 80 | @commands.command() 81 | async def uptime(self, ctx): 82 | """Show uptime""" 83 | sec = round(time.time() - self.bot.init_time) 84 | 85 | m, s = divmod(sec, 60) 86 | h, m = divmod(m, 60) 87 | d, h = divmod(h, 24) 88 | 89 | await ctx.send(f'Uptime: **`{d} days, {h} hours, ' 90 | f'{m} minutes, {s} seconds`**') 91 | 92 | @commands.command() 93 | async def stats(self, ctx): 94 | """Statistics.""" 95 | em = discord.Embed(title='Statistics') 96 | 97 | # get memory usage 98 | mem_bytes = self.process.memory_full_info().rss 99 | mem_mb = round(mem_bytes / 1024 / 1024, 2) 100 | 101 | cpu_usage = round(self.process.cpu_percent() / psutil.cpu_count(), 2) 102 | 103 | em.add_field( 104 | name='Memory / CPU usage', 105 | value=f'`{mem_mb}MB / {cpu_usage}% CPU`') 106 | 107 | channels = sum((1 for c in self.bot.get_all_channels())) 108 | 109 | em.add_field(name='Guilds', value=f'{len(self.bot.guilds)}') 110 | em.add_field(name='Channels', value=f'{channels}') 111 | 112 | gay_chans = sum( 113 | 1 for c in self.bot.get_all_channels() if 'gay' in c.name.lower()) 114 | em.add_field(name='Gaynnels', value=gay_chans) 115 | 116 | em.add_field( 117 | name='Texters', 118 | value=f'{len(self.bot.cogs["Speak"].text_generators)}/' 119 | f'{len(self.bot.guilds)}') 120 | 121 | member_count = sum((g.member_count for g in self.bot.guilds)) 122 | em.add_field(name='Members', value=member_count) 123 | 124 | humans = sum(1 for m in self.bot.get_all_members() if not m.bot) 125 | unique_humans = sum(1 for c in self.bot.users) 126 | em.add_field( 127 | name='human/unique humans', value=f'`{humans}, {unique_humans}`') 128 | 129 | await ctx.send(embed=em) 130 | 131 | @commands.command() 132 | async def about(self, ctx): 133 | """Show stuff.""" 134 | 135 | em = discord.Embed(title='José') 136 | em.add_field( 137 | name='About', 138 | value='José is a generic-purpose ' 139 | 'bot (with some complicated features)') 140 | 141 | appinfo = await self.bot.application_info() 142 | owner = appinfo.owner 143 | em.add_field( 144 | name='Owner', value=f'{owner.mention}, {owner}, (`{owner.id}`)') 145 | 146 | em.add_field(name='Guilds', value=f'{len(self.bot.guilds)}') 147 | 148 | await ctx.send(embed=em) 149 | 150 | @commands.command() 151 | async def feedback(self, ctx, *, feedback: str): 152 | """Sends feedback to a special channel. 153 | 154 | Feedback replies will be sent to the 155 | same channel you used the command on. 156 | 157 | Replies can take any time. 158 | """ 159 | 160 | author = ctx.author 161 | channel = ctx.channel 162 | guild = ctx.guild 163 | 164 | em = discord.Embed(title='', colour=discord.Colour.magenta()) 165 | em.timestamp = datetime.datetime.utcnow() 166 | em.set_footer(text='Feedback Report') 167 | em.set_author( 168 | name=str(author), 169 | icon_url=author.avatar_url or author.default_avatar_url) 170 | 171 | em.add_field(name="Feedback", value=feedback) 172 | em.add_field(name="Guild", value=f'{guild.name} [{guild.id}]') 173 | em.add_field(name="Channel", value=f'{channel.name} [{channel.id}]') 174 | 175 | feedback_channel = self.bot.get_channel(FEEDBACK_CHANNEL_ID) 176 | if feedback_channel is None: 177 | await ctx.send('feedback channel not found we are fuckd') 178 | return 179 | 180 | await feedback_channel.send(embed=em) 181 | await ctx.ok() 182 | 183 | @commands.command(name='feedback-reply', aliases=['freply']) 184 | @commands.is_owner() 185 | async def feedback_reply(self, ctx, channel_id: int, *, message: str): 186 | """Sends a feedback reply to a specified channel.""" 187 | channel = self.bot.get_channel(channel_id) 188 | if channel is None: 189 | return await ctx.send("Can't find specified " 190 | "channel! Please try again.") 191 | 192 | embed = discord.Embed( 193 | colour=discord.Color.magenta(), description=message) 194 | embed.set_author(name=str(ctx.author), icon_url=ctx.author.avatar_url) 195 | 196 | embed.timestamp = datetime.datetime.utcnow() 197 | embed.set_footer(text='Feedback Reply') 198 | 199 | await channel.send(embed=embed) 200 | await ctx.ok() 201 | 202 | @commands.command() 203 | async def clist(self, ctx, cog_name: str): 204 | """Search for all commands that were declared by a cog. 205 | 206 | This is case sensitive. 207 | """ 208 | matched = [ 209 | cmd.name for cmd in self.bot.commands if cmd.cog_name == cog_name 210 | ] 211 | 212 | if len(matched) < 1: 213 | await ctx.send('No commands found') 214 | return 215 | 216 | _res = ' '.join(matched) 217 | await ctx.send(f'```\n{_res}\n```') 218 | 219 | @commands.command() 220 | async def invite(self, ctx): 221 | """Get invite URL""" 222 | em = discord.Embed(title='Invite stuff') 223 | em.add_field(name='OAuth URL', value=OAUTH_URL) 224 | em.add_field(name='Support Server', value=SUPPORT_SERVER) 225 | await ctx.send(embed=em) 226 | 227 | @commands.command() 228 | async def source(self, ctx): 229 | """Source code:tm:""" 230 | await ctx.send('https://github.com/lnmds/jose') 231 | 232 | 233 | def setup(bot): 234 | bot.add_cog(Basic(bot)) 235 | -------------------------------------------------------------------------------- /ext/exec.py: -------------------------------------------------------------------------------- 1 | """ 2 | Handy exec (eval, debug) cog. Allows you to run code on the bot during runtime. This cog is a combination of the 3 | exec commands of other bot authors, allowing for maximum efficiency: 4 | 5 | Credit: 6 | - Rapptz: https://github.com/Rapptz/RoboDanny/blob/master/cogs/repl.py#L31-L75 7 | - b1naryth1ef: https://github.com/b1naryth1ef/b1nb0t/blob/master/b1nb0t/plugins/util.py#L229-L266 8 | """ 9 | 10 | import io 11 | import logging 12 | import textwrap 13 | import traceback 14 | from contextlib import redirect_stdout 15 | from typing import List 16 | 17 | import aiohttp 18 | import discord 19 | from discord.ext import commands 20 | from discord.ext.commands import command, \ 21 | is_owner, Converter 22 | 23 | from .common import Cog 24 | 25 | is_bot_admin = is_owner 26 | 27 | 28 | def codeblock(text: str, *, lang: str = '') -> str: 29 | """ 30 | Formats a codeblock. 31 | Parameters 32 | ---------- 33 | text 34 | The text to be inside of the codeblock. 35 | lang 36 | The language to use. 37 | Returns 38 | ------- 39 | str 40 | The formatted message. 41 | """ 42 | return f'```{lang}\n{text}\n```' 43 | 44 | 45 | log = logging.getLogger(__name__) 46 | 47 | IMPLICIT_RETURN_STOP_WORDS = { 48 | 'continue', 'break', 'raise', 'yield', 'with', 'assert', 'del', 'import', 49 | 'pass', 'return', 'from' 50 | } 51 | 52 | 53 | class Code(Converter): 54 | def __init__(self, 55 | *, 56 | wrap_code=False, 57 | strip_ticks=True, 58 | indent_width=4, 59 | implicit_return=False): 60 | """ 61 | A converter that extracts code out of code blocks and inline code formatting. 62 | 63 | Parameters 64 | ---------- 65 | wrap_code 66 | Specifies whether to wrap the resulting code in a function. 67 | strip_ticks 68 | Specifies whether to strip the code of formatting-related backticks. 69 | indent_width 70 | Specifies the indent width, if wrapping. 71 | implicit_return 72 | Automatically adds a return statement, when wrapping code. 73 | """ 74 | self.wrap_code = wrap_code 75 | self.strip_ticks = strip_ticks 76 | self.indent_width = indent_width 77 | self.implicit_return = implicit_return 78 | 79 | async def convert(self, ctx, arg: str) -> str: 80 | result = arg 81 | 82 | if self.strip_ticks: 83 | # remove codeblock ticks 84 | if result.startswith('```') and result.endswith('```'): 85 | result = '\n'.join(result.split('\n')[1:-1]) 86 | 87 | # remove inline code ticks 88 | result = result.strip('` \n') 89 | 90 | if self.wrap_code: 91 | # wrap in a coroutine and indent 92 | result = 'async def func():\n' + textwrap.indent( 93 | result, ' ' * self.indent_width) 94 | 95 | if self.wrap_code and self.implicit_return: 96 | last_line = result.splitlines()[-1] 97 | 98 | # if the last line isn't indented and not returning, add it 99 | first_word = last_line.strip().split(' ')[0] 100 | no_stop = all( 101 | first_word != word for word in IMPLICIT_RETURN_STOP_WORDS) 102 | if not last_line[4:].startswith(' ') and no_stop: 103 | last_line = ( 104 | ' ' * self.indent_width) + 'return ' + last_line[4:] 105 | 106 | result = '\n'.join(result.splitlines()[:-1] + [last_line]) 107 | 108 | return result 109 | 110 | 111 | def format_syntax_error(e: SyntaxError) -> str: 112 | """ Formats a SyntaxError. """ 113 | if e.text is None: 114 | return '```py\n{0.__class__.__name__}: {0}\n```'.format(e) 115 | # display a nice arrow 116 | return '```py\n{0.text}{1:>{0.offset}}\n{2}: {0}```'.format( 117 | e, '^', 118 | type(e).__name__) 119 | 120 | 121 | class Exec(Cog): 122 | def __init__(self, *args, **kwargs): 123 | super().__init__(*args, **kwargs) 124 | 125 | self.last_result = None 126 | self.previous_code = None 127 | 128 | async def execute(self, ctx, code): 129 | log.info('Eval: %s', code) 130 | 131 | async def upload(file_name: str) -> discord.Message: 132 | with open(file_name, 'rb') as fp: 133 | return await ctx.send(file=discord.File(fp)) 134 | 135 | async def send(*args, **kwargs) -> discord.Message: 136 | return await ctx.send(*args, **kwargs) 137 | 138 | def better_dir(*args, **kwargs) -> List[str]: 139 | return [ 140 | n for n in dir(*args, **kwargs) 141 | if not n.endswith('__') and not n.startswith('__') 142 | ] 143 | 144 | env = { 145 | 'bot': ctx.bot, 146 | 'ctx': ctx, 147 | 'msg': ctx.message, 148 | 'guild': ctx.guild, 149 | 'channel': ctx.channel, 150 | 'me': ctx.message.author, 151 | 152 | # modules 153 | 'discord': discord, 154 | 'commands': commands, 155 | 156 | # utilities 157 | '_get': discord.utils.get, 158 | '_find': discord.utils.find, 159 | '_upload': upload, 160 | '_send': send, 161 | 162 | # last result 163 | '_': self.last_result, 164 | '_p': self.previous_code, 165 | 'dir': better_dir, 166 | } 167 | 168 | env.update(globals()) 169 | 170 | # simulated stdout 171 | stdout = io.StringIO() 172 | 173 | # define the wrapped function 174 | try: 175 | exec(compile(code, '', 'exec'), env) 176 | except SyntaxError as e: 177 | # send pretty syntax errors 178 | return await ctx.send(format_syntax_error(e)) 179 | 180 | # grab the defined function 181 | func = env['func'] 182 | 183 | try: 184 | # execute the code 185 | with redirect_stdout(stdout): 186 | ret = await func() 187 | except Exception: 188 | # something went wrong :( 189 | try: 190 | await ctx.not_ok() 191 | except (discord.HTTPException, discord.Forbidden): 192 | # failed to add failure tick, hmm. 193 | pass 194 | 195 | # send stream and what we have 196 | return await ctx.send( 197 | codeblock(traceback.format_exc(limit=7), lang='py')) 198 | 199 | # code was good, grab stdout 200 | stream = stdout.getvalue() 201 | 202 | try: 203 | await ctx.ok() 204 | except (discord.HTTPException, discord.Forbidden): 205 | # couldn't add the reaction, ignore 206 | pass 207 | 208 | # set the last result, but only if it's not none 209 | if ret is not None: 210 | self.last_result = ret 211 | 212 | # form simulated stdout and repr of return value 213 | meat = stream + repr(ret) 214 | message = codeblock(meat, lang='py') 215 | 216 | if len(message) > 2000: 217 | # too long 218 | try: 219 | # send meat to hastebin 220 | await ctx.send('too much for me') 221 | except KeyError: 222 | # failed to upload, probably too big. 223 | await ctx.send('waaay too big') 224 | except aiohttp.ClientError: 225 | # pastebin is probably down. 226 | await ctx.send('haste is dead') 227 | else: 228 | # message was under 2k chars, just send! 229 | await ctx.send(message) 230 | 231 | @command(name='retry', hidden=True) 232 | @is_bot_admin() 233 | async def retry(self, ctx): 234 | """ Retries the previously executed Python code. """ 235 | if not self.previous_code: 236 | return await ctx.send('No previous code.') 237 | 238 | await self.execute(ctx, self.previous_code) 239 | 240 | @command(name='eval', aliases=['exec', 'debug'], hidden=True) 241 | @is_bot_admin() 242 | async def _eval(self, ctx, *, code: Code( 243 | wrap_code=True, implicit_return=True)): 244 | """ Executes Python code. """ 245 | 246 | # store previous code 247 | self.previous_code = code 248 | 249 | await self.execute(ctx, code) 250 | 251 | 252 | def setup(bot): 253 | bot.add_cog(Exec(bot)) 254 | -------------------------------------------------------------------------------- /ext/nsfw.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | import urllib.parse 4 | import collections 5 | 6 | import aiohttp 7 | import discord 8 | import motor.motor_asyncio 9 | 10 | from discord.ext import commands 11 | from .common import Cog 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | 16 | class BooruError(Exception): 17 | pass 18 | 19 | 20 | class BooruProvider: 21 | url = '' 22 | 23 | @classmethod 24 | def transform_file_url(cls, url): 25 | return url 26 | 27 | @classmethod 28 | def get_author(cls, post): 29 | return post['author'] 30 | 31 | @classmethod 32 | async def get_posts(cls, bot, tags, *, limit=15): 33 | headers = { 34 | 'User-Agent': 'Yiffmobile v2 (José, https://github.com/lnmds/jose)' 35 | } 36 | 37 | tags = urllib.parse.quote(' '.join(tags), safe='') 38 | async with bot.session.get( 39 | f'{cls.url}&limit={limit}&tags={tags}', 40 | headers=headers) as resp: 41 | results = await resp.json() 42 | if not results: 43 | return [] 44 | 45 | try: 46 | # e621 sets this to false 47 | # when the request fails 48 | if not results.get('success', True): 49 | raise BooruError(results.get('reason')) 50 | except AttributeError: 51 | # when the thing actually worked and 52 | # its a list of posts and not a fucking 53 | # dictionary 54 | 55 | # where am I gonna see good porn APIs? 56 | pass 57 | 58 | # transform file url 59 | for post in results: 60 | post['file_url'] = cls.transform_file_url(post['file_url']) 61 | 62 | return results 63 | 64 | 65 | class E621Booru(BooruProvider): 66 | url = 'https://e621.net/post/index.json?' 67 | url_post = 'https://e621.net/post/show/{0}' 68 | 69 | 70 | class HypnohubBooru(BooruProvider): 71 | url = 'http://hypnohub.net/post/index.json?' 72 | url_post = 'https://hypnohub.net/post/show/{0}' 73 | 74 | @classmethod 75 | def transform_file_url(cls, url): 76 | return 'https:' + url.replace('.net//', '.net/') 77 | 78 | 79 | class GelBooru(BooruProvider): 80 | url = 'https://gelbooru.com/index.php?page=dapi&s=post&json=1&q=index' 81 | url_post = 'https://gelbooru.com/index.php?page=post&s=view&id={0}' 82 | 83 | @classmethod 84 | def get_author(cls, post): 85 | return post['owner'] 86 | 87 | 88 | class NSFW(Cog, requires=['config']): 89 | """NSFW commands. 90 | 91 | Fetching works on a "non-repeataibility" basis (unless 92 | the bot restarts). This means that with each set of tags 93 | you give for José to search, it will record the given post 94 | and make sure it doesn't repeat again. 95 | """ 96 | 97 | def __init__(self, bot): 98 | super().__init__(bot) 99 | self.whip_coll = self.config.jose_db['whip'] 100 | self.repeat_cache = collections.defaultdict(dict) 101 | 102 | def key(self, tags): 103 | return ','.join(tags) 104 | 105 | def mark_post(self, ctx, tags: list, post: dict): 106 | """Mark this post as seen.""" 107 | cache = self.repeat_cache[ctx.guild.id] 108 | 109 | k = self.key(tags) 110 | used = cache.get(k, []) 111 | used.append(post['id']) 112 | cache[k] = used 113 | 114 | def filter(self, ctx, tags: list, posts): 115 | """Filter the posts so we get the only posts 116 | that weren't seen.""" 117 | cache = self.repeat_cache[ctx.guild.id] 118 | used_posts = cache.get(self.key(tags), []) 119 | return list(filter(lambda post: post['id'] not in used_posts, posts)) 120 | 121 | async def booru(self, ctx, booru, tags: list): 122 | if ctx.channel.topic and '[jose:no_nsfw]' in ctx.channel.topic: 123 | return 124 | # taxxx 125 | await self.jcoin.pricing(ctx, self.prices['API']) 126 | 127 | try: 128 | # grab posts 129 | posts = await booru.get_posts(ctx.bot, tags) 130 | posts = self.filter(ctx, tags, posts) 131 | 132 | if not posts: 133 | return await ctx.send('Found nothing.\n' 134 | '(this can be caused by an exhaustion ' 135 | f'of the tags `{ctx.prefix}help NSFW`)') 136 | 137 | # grab random post 138 | post = random.choice(posts) 139 | 140 | self.mark_post(ctx, tags, post) 141 | 142 | post_id = post.get('id') 143 | post_author = booru.get_author(post) 144 | 145 | log.info('%d posts from %s, chose %d', len(posts), booru.__name__, 146 | post_id) 147 | 148 | tags = (post['tags'].replace('_', '\\_'))[:500] 149 | 150 | # add stuffs 151 | embed = discord.Embed(title=f'Posted by {post_author}') 152 | embed.set_image(url=post['file_url']) 153 | embed.add_field(name='Tags', value=tags) 154 | embed.add_field(name='URL', value=booru.url_post.format(post_id)) 155 | 156 | # hypnohub doesn't have this 157 | if 'fav_count' in post and 'score' in post: 158 | embed.add_field( 159 | name='Votes/Favorites', 160 | value=f"{post['score']} votes, " 161 | f"{post['fav_count']} favorites") 162 | 163 | # send 164 | await ctx.send(embed=embed) 165 | except BooruError as err: 166 | raise self.SayException(f'Error while fetching posts: `{err!r}`') 167 | except aiohttp.ClientError as err: 168 | log.exception('nsfw client error') 169 | raise self.SayException(f'Something went wrong. Sorry! `{err!r}`') 170 | 171 | @commands.command() 172 | @commands.is_nsfw() 173 | async def e621(self, ctx, *tags): 174 | """Randomly searches e621 for posts.""" 175 | async with ctx.typing(): 176 | await self.booru(ctx, E621Booru, tags) 177 | 178 | @commands.command(aliases=['hh']) 179 | @commands.is_nsfw() 180 | async def hypnohub(self, ctx, *tags): 181 | """Randomly searches Hypnohub for posts.""" 182 | async with ctx.typing(): 183 | await self.booru(ctx, HypnohubBooru, tags) 184 | 185 | @commands.command() 186 | @commands.is_nsfw() 187 | async def gelbooru(self, ctx, *tags): 188 | """Randomly searches Gelbooru for posts.""" 189 | async with ctx.typing(): 190 | await self.booru(ctx, GelBooru, tags) 191 | 192 | @commands.command() 193 | @commands.is_nsfw() 194 | async def penis(self, ctx): 195 | """get penis from e621 bb""" 196 | await ctx.invoke(self.bot.get_command('e621'), 'penis') 197 | 198 | @commands.command() 199 | @commands.cooldown(5, 1800, commands.BucketType.user) 200 | async def whip(self, ctx, *, person: discord.User = None): 201 | """Whip someone. 202 | 203 | If no arguments provided, shows how many whips you 204 | received. 205 | 206 | The command has a 5/1800s cooldown per-user 207 | """ 208 | if not person: 209 | whip = await self.whip_coll.find_one({'user_id': ctx.author.id}) 210 | if not whip: 211 | return await ctx.send(f'**{ctx.author}** was never whipped') 212 | 213 | return await ctx.send(f'**{ctx.author}** was whipped' 214 | f' {whip["whips"]} times') 215 | 216 | if person == ctx.author: 217 | return await ctx.send('no') 218 | 219 | uid = person.id 220 | whip = await self.whip_coll.find_one({'user_id': uid}) 221 | if not whip: 222 | whip = { 223 | 'user_id': uid, 224 | 'whips': 0, 225 | } 226 | await self.whip_coll.insert_one(whip) 227 | 228 | await self.whip_coll.update_one({ 229 | 'user_id': uid 230 | }, {'$inc': { 231 | 'whips': 1 232 | }}) 233 | 234 | await ctx.send(f'**{ctx.author}** whipped **{person}** ' 235 | f'They have been whipped {whip["whips"] + 1} times.') 236 | 237 | @commands.command() 238 | async def whipboard(self, ctx): 239 | """Whip leaderboard.""" 240 | e = discord.Embed(title='Whip leaderboard') 241 | data = [] 242 | cur = self.whip_coll.find().sort('whips', 243 | motor.pymongo.DESCENDING).limit(15) 244 | 245 | async for whip in cur: 246 | u = self.bot.get_user(whip['user_id']) 247 | u = str(u) 248 | data.append(f'{u:30s} -> {whip["whips"]}') 249 | 250 | joined = '\n'.join(data) 251 | e.description = f'```\n{joined}\n```' 252 | await ctx.send(embed=e) 253 | 254 | 255 | def setup(bot): 256 | bot.add_jose_cog(NSFW) 257 | -------------------------------------------------------------------------------- /ext/math.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | import random 4 | import decimal 5 | 6 | import discord 7 | import wolframalpha 8 | import pyowm 9 | 10 | from discord.ext import commands 11 | 12 | from .common import Cog 13 | 14 | log = logging.getLogger(__name__) 15 | 16 | W_CLEAR_SKY = ':sunny:' 17 | W_FEW_CLOUDS = ':white_sun_small_cloud:' 18 | W_SCATTERED_CLOUDS = ':partly_sunny:' 19 | W_BROKEN_CLOUDS = ':cloud:' 20 | W_SHOWER_RAIN = ':cloud_rain:' 21 | W_RAIN = ':cloud_rain:' 22 | W_THUNDERSTORM = ':thunder_cloud_rain:' 23 | W_SNOW = ':snowflake:' 24 | W_MIST = ':foggy:' 25 | 26 | OWM_ICONS = { 27 | '01d': W_CLEAR_SKY, 28 | '02d': W_FEW_CLOUDS, 29 | '03d': W_SCATTERED_CLOUDS, 30 | '04d': W_BROKEN_CLOUDS, 31 | '09d': W_SHOWER_RAIN, 32 | '10d': W_RAIN, 33 | '11d': W_THUNDERSTORM, 34 | '13d': W_SNOW, 35 | '50d': W_MIST, 36 | } 37 | 38 | RESULT_PODS = {'Result', 'Plot', 'Plots', 'Solution', 'Derivative'} 39 | 40 | UNECESSARY_PODS = {'Input', 'Input interpretation'} 41 | 42 | 43 | def pod_finder(pod_list): 44 | """Finds a probable pod.""" 45 | log.debug('pod_finder: going to score %d pods', len(pod_list)) 46 | log.debug(f'pod_finder {pod_list!r}') 47 | pod_scores = {} 48 | 49 | for pod in pod_list: 50 | # convert pod to dict 51 | pod = dict(pod) 52 | 53 | if pod.get('@title') in RESULT_PODS: 54 | # log.info('pod_finder: found result pod! %s', pod) 55 | return pod 56 | 57 | score = 0 58 | 59 | # meh pods 60 | if pod.get('@title') in UNECESSARY_PODS: 61 | score -= 100 62 | 63 | if 'subpod' not in pod: 64 | # ignore pods without subpod 65 | continue 66 | 67 | if isinstance(pod['subpod'], list): 68 | # subpod has an image 69 | score += 10 + (len(pod['subpod']) * 10) 70 | else: 71 | # subpod is singular 72 | 73 | # plain text 74 | if pod['subpod'].get('plaintext'): 75 | score += 50 76 | 77 | # image 78 | if pod['subpod'].get('img'): 79 | score += 30 80 | 81 | pod_scores[pod['@id']] = score 82 | 83 | log.debug('pod_finder: got %d pods', len(pod_scores)) 84 | log.debug('pod_finder scores: %s', pod_scores) 85 | 86 | # return pod with highest score 87 | best_id = max(pod_scores, key=pod_scores.get) 88 | return discord.utils.find(lambda pod: pod['@id'] == best_id, pod_list) 89 | 90 | 91 | class Math(Cog): 92 | """Math related commands.""" 93 | 94 | def __init__(self, bot): 95 | super().__init__(bot) 96 | 97 | self.wac = wolframalpha.Client(self.bot.config.WOLFRAMALPHA_APP_ID) 98 | self.owm = pyowm.OWM(self.bot.config.OWM_APIKEY) 99 | 100 | @commands.command(aliases=['wa']) 101 | @commands.cooldown(rate=1, per=6, type=commands.BucketType.user) 102 | async def wolframalpha(self, ctx, *, term: str): 103 | """Query Wolfram|Alpha""" 104 | if len(term) < 1: 105 | await ctx.send('Haha, no.') 106 | return 107 | 108 | await self.jcoin.pricing(ctx, self.prices['API']) 109 | 110 | log.debug('Wolfram|Alpha: %s', term) 111 | 112 | future = self.loop.run_in_executor(None, self.wac.query, term) 113 | res = None 114 | 115 | # run the thingy 116 | async with ctx.typing(): 117 | try: 118 | res = await asyncio.wait_for(future, 13) 119 | except asyncio.TimeoutError: 120 | await ctx.send( 121 | '\N{HOURGLASS WITH FLOWING SAND} Timeout reached.') 122 | return 123 | except Exception as err: 124 | await ctx.send(f'\N{CRYING FACE} Error: `{err!r}`') 125 | return 126 | 127 | if res is None: 128 | await ctx.send("\N{THINKING FACE} Wolfram|Alpha didn't reply.") 129 | return 130 | 131 | if not res.success: 132 | await ctx.send("\N{CRYING FACE} Wolfram|Alpha failed.") 133 | return 134 | 135 | if not getattr(res, 'pods', False): 136 | # no pods were returned by wa 137 | await ctx.send("\N{CYCLONE} No answer. \N{CYCLONE}") 138 | return 139 | 140 | pods = list(res.pods) 141 | 142 | # run algo on pod list 143 | pod = pod_finder(pods) 144 | 145 | def subpod_simplify(subpod): 146 | """Simplifies a subpod into its image or plaintext equivalent.""" 147 | if subpod.get('img'): 148 | # use image over text 149 | return subpod['img']['@src'] 150 | return subpod['plaintext'] 151 | 152 | if isinstance(pod['subpod'], dict): 153 | # just a single pod! 154 | await ctx.send(subpod_simplify(pod['subpod'])) 155 | else: 156 | # multiple pods...choose the first one. 157 | await ctx.send(subpod_simplify(pod['subpod'][0])) 158 | 159 | @commands.command(aliases=['owm']) 160 | async def weather(self, ctx, *, location: str): 161 | """Get weather data for a location.""" 162 | 163 | await self.jcoin.pricing(ctx, self.prices['API']) 164 | 165 | try: 166 | future = self.loop.run_in_executor(None, self.owm.weather_at_place, 167 | location) 168 | observation = await future 169 | except Exception as err: 170 | raise self.SayException( 171 | f'Error retrieving weather data: `{err!r}`') 172 | 173 | w = observation.get_weather() 174 | 175 | def _wg(t): 176 | return w.get_temperature(t)['temp'] 177 | 178 | _icon = w.get_weather_icon_name() 179 | icon = OWM_ICONS.get(_icon, '**') 180 | status = w.get_detailed_status() 181 | 182 | em = discord.Embed(title=f"Weather for '{location}'") 183 | 184 | o_location = observation.get_location() 185 | 186 | em.add_field(name='Location', value=f'{o_location.get_name()}') 187 | em.add_field(name='Situation', value=f'{status} {icon}') 188 | em.add_field( 189 | name='Temperature', 190 | value= 191 | f'`{_wg("celsius")} °C, {_wg("fahrenheit")} °F, {_wg("kelvin")} K`' 192 | ) 193 | 194 | await ctx.send(embed=em) 195 | 196 | @commands.command() 197 | async def money(self, 198 | ctx, 199 | amount: str, 200 | currency_from: str = '', 201 | currency_to: str = ''): 202 | """Convert currencies.""" 203 | 204 | currency_from = currency_from.upper() 205 | currency_to = currency_to.upper() 206 | 207 | if amount == 'list': 208 | data = await self.get_json('http://api.fixer.io/latest') 209 | res = ' '.join(data["rates"].keys()) 210 | await ctx.send(f'```\n{res}\n```') 211 | return 212 | 213 | try: 214 | amount = decimal.Decimal(amount) 215 | except: 216 | raise self.SayException('Error parsing `amount`') 217 | 218 | await self.jcoin.pricing(ctx, self.prices['API']) 219 | 220 | data = await self.get_json('https://api.fixer.io/' 221 | f'latest?base={currency_from}') 222 | 223 | if 'error' in data: 224 | raise self.SayException(f'API error: {data["error"]}') 225 | 226 | if currency_to not in data['rates']: 227 | raise self.SayException('Invalid currency to convert to: ' 228 | f'{currency_to}') 229 | 230 | rate = data['rates'][currency_to] 231 | rate = decimal.Decimal(rate) 232 | res = amount * rate 233 | res = round(res, 7) 234 | 235 | await ctx.send(f'{amount} {currency_from} = {res} {currency_to}') 236 | 237 | @commands.command() 238 | async def roll(self, ctx, dicestr: str): 239 | """Roll fucking dice. 240 | format is d 241 | """ 242 | 243 | dice = dicestr.split('d') 244 | dice_amount = 1 245 | dice_sides = 6 246 | 247 | try: 248 | if dice[0] != '': 249 | dice_amount = int(dice[0]) 250 | except (ValueError, IndexError): 251 | await ctx.send('invalid amount') 252 | return 253 | 254 | try: 255 | dice_sides = int(dice[1]) 256 | except (IndexError, ValueError): 257 | await ctx.send('invalid dice side') 258 | return 259 | 260 | if dice_amount <= 0 or dice_sides <= 0: 261 | await ctx.send('nonono') 262 | return 263 | 264 | if dice_amount > 100: 265 | await ctx.send('100+ dice? nonono') 266 | return 267 | 268 | if dice_sides > 10000: 269 | await ctx.send('10000+ sides? nonono') 270 | return 271 | 272 | dices = [] 273 | for i in range(dice_amount): 274 | dice_result = random.randint(1, dice_sides) 275 | dices.append(dice_result) 276 | 277 | joined = ', '.join(str(r) for r in dices) 278 | await ctx.send(f'{dicestr} : `{joined}` => {sum(dices)}') 279 | 280 | 281 | def setup(bot): 282 | bot.add_cog(Math(bot)) 283 | -------------------------------------------------------------------------------- /jose.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | import time 4 | import asyncio 5 | import pathlib 6 | import importlib 7 | import collections 8 | 9 | import discord 10 | import aiohttp 11 | 12 | import uvloop 13 | 14 | from discord.ext import commands 15 | 16 | import joseconfig as config 17 | from ext.common import SayException 18 | 19 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 20 | 21 | log = logging.getLogger(__name__) 22 | 23 | extensions = [ 24 | 'channel_logging', # loading at start to get the logger to run 25 | 'config', 26 | 'admin', 27 | 'exec', 28 | 'state', 29 | ] 30 | 31 | CHECK_FAILURE_PHRASES = [ 32 | 'br?', 33 | 'u died [real] [Not ClickBait]', 34 | 'rEEEEEEEEEEEEE', 35 | 'not enough permissions lul', 36 | 'you sure you can run this?', 37 | ] 38 | 39 | BAD_ARG_MESSAGES = [ 40 | 'dude give me the right thing', 41 | "u can't give me this and think i can do something", 42 | 'succ', 43 | "i'm not a god, fix your args", 44 | 'why. just why', 45 | ] 46 | 47 | 48 | class JoseContext(commands.Context): 49 | @property 50 | def member(self): 51 | if self.guild is None: 52 | return None 53 | return self.guild.get_member(self.author.id) 54 | 55 | async def ok(self): 56 | try: 57 | await self.message.add_reaction('👌') 58 | except discord.Forbidden: 59 | await self.message.channel.send('ok') 60 | 61 | async def not_ok(self): 62 | try: 63 | await self.message.add_reaction('❌') 64 | except discord.Forbidden: 65 | await self.message.channel.send('not ok') 66 | 67 | async def success(self, flag): 68 | if flag: 69 | await self.ok() 70 | else: 71 | await self.not_ok() 72 | 73 | async def status(self, flag): 74 | await self.success(flag) 75 | 76 | async def err(self, msg): 77 | await self.send(f'\N{POLICE CARS REVOLVING LIGHT} {msg}') 78 | 79 | def send(self, content='', **kwargs): 80 | # FUCK EACH AND @EVERYONE OF YOU 81 | # specially mary and gerd 82 | 83 | # i hope this saves my life, forever. 84 | nc = self.bot.clean_content(content, normal_send=True) 85 | return super().send(nc, **kwargs) 86 | 87 | 88 | class JoseBot(commands.Bot): 89 | """Main bot subclass.""" 90 | 91 | def __init__(self, *args, **kwargs): 92 | super().__init__(*args, **kwargs) 93 | 94 | self.init_time = time.time() 95 | self.config = config 96 | self.session = aiohttp.ClientSession() 97 | 98 | #: Exceptions that will be simplified 99 | # to WARN logging instead of ERROR logging 100 | self.simple_exc = [SayException] 101 | 102 | #: used by ext.channel_logging 103 | self.channel_handlers = [] 104 | 105 | #: blocking stuff 106 | self.block_coll = None 107 | self.block_cache = {} 108 | 109 | async def on_ready(self): 110 | """Bot ready handler""" 111 | log.info(f'Logged in! {self.user!s}') 112 | 113 | async def is_blocked(self, user_id: int, key: str = 'user_id') -> bool: 114 | """Returns if something blocked to use José. Uses cache""" 115 | if user_id in self.block_cache: 116 | return self.block_cache[user_id] 117 | 118 | blocked = await self.block_coll.find_one({key: user_id}) 119 | is_blocked = bool(blocked) 120 | self.block_cache[user_id] = is_blocked 121 | 122 | return is_blocked 123 | 124 | async def is_blocked_guild(self, guild_id: int) -> bool: 125 | """Returns if a guild is blocked to use José. Uses cache""" 126 | return await self.is_blocked(guild_id, 'guild_id') 127 | 128 | def clean_content(self, content: str, **kwargs) -> str: 129 | """Make a string clean of mentions and not breaking codeblocks""" 130 | content = str(content) 131 | 132 | # only escape codeblocks when we are not normal_send 133 | # only escape single person pings when we are not normal_send 134 | if not kwargs.get('normal_send', False): 135 | content = content.replace('`', r'\`') 136 | content = content.replace('<@', '<@\u200b') 137 | content = content.replace('<#', '<#\u200b') 138 | 139 | # always escape role pings (@everyone) and @here 140 | content = content.replace('<@&', '<@&\u200b') 141 | content = content.replace('@here', '@\u200bhere') 142 | content = content.replace('@everyone', '@\u200beveryone') 143 | 144 | return content 145 | 146 | async def on_command(self, ctx): 147 | """Log command usage""" 148 | # thanks dogbot ur a good 149 | content = ctx.message.content 150 | content = self.clean_content(content) 151 | 152 | author = ctx.message.author 153 | guild = ctx.guild 154 | checks = [c.__qualname__.split('.')[0] for c in ctx.command.checks] 155 | location = '[DM]' if isinstance(ctx.channel, discord.DMChannel) else \ 156 | f'[Guild {guild.name} {guild.id}]' 157 | 158 | log.info('%s [cmd] %s(%d) "%s" checks=%s', location, author, author.id, 159 | content, ','.join(checks) or '(none)') 160 | 161 | async def on_error(self, event_method, *args, **kwargs): 162 | # TODO: analyze current exception 163 | # and simplify the logging to WARN 164 | # if it is on self.simple_exc 165 | log.exception(f'evt error ({event_method}) ' 166 | f'args={args!r} kwargs={kwargs!r}') 167 | 168 | async def on_message(self, message): 169 | if message.author.bot: 170 | return 171 | 172 | author_id = message.author.id 173 | if await self.is_blocked(author_id): 174 | return 175 | 176 | if message.guild is not None: 177 | guild_id = message.guild.id 178 | 179 | if await self.is_blocked_guild(guild_id): 180 | return 181 | 182 | ctx = await self.get_context(message, cls=JoseContext) 183 | await self.invoke(ctx) 184 | 185 | def load_extension(self, name: str): 186 | """wrapper for the Bot.load_extension""" 187 | log.debug(f'[load:loading] {name}') 188 | t_start = time.monotonic() 189 | super().load_extension(name) 190 | t_end = time.monotonic() 191 | 192 | delta = round((t_end - t_start) * 1000, 2) 193 | log.info(f'[load] {name} took {delta}ms') 194 | 195 | def add_jose_cog(self, cls: 'class'): 196 | """Add a cog but load its requirements first.""" 197 | requires = cls._cog_metadata.get('requires', []) 198 | 199 | log.debug('requirements for %s: %r', cls, requires) 200 | if not requires: 201 | log.debug(f'no requirements for {cls}') 202 | for _req in requires: 203 | req = f'ext.{_req}' 204 | if req in self.extensions: 205 | log.debug('loading %r from requirements', req) 206 | self.load_extension(req) 207 | else: 208 | log.debug('%s is already loaded', req) 209 | 210 | # We instantiate here because 211 | # instantiating on the old add_cog 212 | # is exactly the cause of the problem 213 | cog = cls(self) 214 | super().add_cog(cog) 215 | 216 | def load_all(self): 217 | """Load all extensions in the extensions folder. 218 | 219 | Thanks FrostLuma for code! 220 | """ 221 | 222 | for extension in extensions: 223 | self.load_extension(f'ext.{extension}') 224 | 225 | path = pathlib.Path('ext/') 226 | files = path.glob('**/*.py') 227 | 228 | for fileobj in files: 229 | if fileobj.stem == '__init__': 230 | name = str(fileobj)[:-12] 231 | else: 232 | name = str(fileobj)[:-3] 233 | 234 | name = name.replace('/', '.') 235 | module = importlib.import_module(name) 236 | 237 | if not hasattr(module, 'setup'): 238 | # ignore extensions that do not have a setup() function 239 | continue 240 | 241 | if name in extensions: 242 | log.debug(f'ignoring {name}') 243 | 244 | self.load_extension(name) 245 | 246 | 247 | async def get_prefix(bot, message) -> list: 248 | """Get the preferred list of prefixes for a determined guild/dm.""" 249 | if not message.guild: 250 | return bot.config.prefix 251 | 252 | config_cog = bot.get_cog('Config') 253 | if not config_cog: 254 | log.warning('config cog not found') 255 | return [config.prefix] 256 | 257 | custom = await config_cog.cfg_get(message.guild, 'prefix') 258 | if custom == bot.config.prefix: 259 | return custom 260 | 261 | # sort backwards due to the command parser taking the first match 262 | return sorted([bot.config.prefix, custom], reverse=True) 263 | 264 | 265 | def main(): 266 | """Main entry point""" 267 | jose = JoseBot( 268 | command_prefix=get_prefix, 269 | description='henlo dis is jose', 270 | pm_help=None, 271 | owner_id=getattr(config, 'owner_id', None), 272 | ) 273 | 274 | jose.load_all() 275 | jose.run(config.token) 276 | 277 | 278 | if __name__ == '__main__': 279 | main() 280 | -------------------------------------------------------------------------------- /unused/chatbot.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | 4 | import discord 5 | 6 | from chatterbot import ChatBot 7 | from discord.ext import commands 8 | 9 | from .common import Cog 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | TRAINING = ( 14 | [ 15 | 'Hi', 16 | 'Hello!', 17 | 'How are you?', 18 | "I'm fine, what about you?", 19 | "I'm good.", 20 | 'Good to hear!' 21 | ], 22 | [ 23 | 'What are you up to?', 24 | 'Talking to you.', 25 | ], 26 | [ 27 | 'hi', 28 | 'go away', 29 | 'ok', 30 | 'bye loser', 31 | ], 32 | [ 33 | 'José', 34 | "What's good meme boy?", 35 | "I'm feeling fresh as fuck", 36 | "That's good to hear" 37 | ], 38 | [ 39 | 'lit', 40 | "What's lit lmao", 41 | 'you', 42 | "Aww that's kind" 43 | ], 44 | [ 45 | 'Somebody once told me the world is gonna roll me', 46 | "I ain't the sharpest tool in the shed", 47 | 'She was looking kind of dumb with her finger and her thumb', 48 | 'In the shape of an "L" on her forehead', 49 | "Well the years start coming and they don't stop coming", 50 | 'Fed to the rules and I hit the ground running', 51 | "Didn't make sense not to live for fun", 52 | 'Your brain gets smart but your head gets dumb', 53 | 'So much to do, so much to see', 54 | "So what's wrong with taking the back streets?", 55 | "You'll never know if you don't go", 56 | "You'll never shine if you don't glow", 57 | "Hey now, you're an all-star, get your game on, go play", 58 | "Hey now, you're a rock star, get the show on, get paid", 59 | 'And all that glitters is gold', 60 | 'Only shooting stars break the mold', 61 | "It's a cool place and they say it gets colder", 62 | "You're bundled up now, wait till you get older", 63 | 'But the meteor men beg to differ', 64 | 'Judging by the hole in the satellite picture', 65 | 'The ice we skate is getting pretty thin', 66 | "The water's getting warm so you might as well swim", 67 | "My world's on fire, how about yours?", 68 | "That's the way I like it and I never get bored", 69 | ], 70 | [ 71 | 'Tell me a joke', 72 | "I'm not just here for your entertainment, I have feelings you know" 73 | ], 74 | [ 75 | 'Say something funny', 76 | "I'm not just here for your entertainment, I have feelings you know" 77 | ], 78 | [ 79 | 'Say something funny', 80 | 81 | "Idk how to make you laugh since you've " 82 | 'already seen the funniest joke ever', 83 | 84 | 'What?', 85 | 'You HAHA REKT' 86 | ], 87 | [ 88 | "I'm gay lol", 89 | 'Tatsu is just a side hoe' 90 | ], 91 | [ 92 | 'Communism' 93 | 94 | 'Death is a preferable alternative to communism. ' 95 | 'Capitalism all the way baby' 96 | ], 97 | [ 98 | 'Capitalism is great', 99 | 'Communism is better!', 100 | 'Wrong! Joseism is better' 101 | ], 102 | [ 103 | 'Who is your creator?', 104 | 'Luna' 105 | ], 106 | [ 107 | 'Say something', 108 | 'idk my dude' 109 | ], 110 | [ 111 | 'Good night', 112 | 'Sweet dreams' 113 | ], 114 | [ 115 | 'Good morning', 116 | 'Hello' 117 | ], 118 | [ 119 | 'You were already José', 120 | 'Nani!?' 121 | ], 122 | [ 123 | 'Omae wa moe José', 124 | 'Nani?!' 125 | ], 126 | [ 127 | 'What?', 128 | "I don't know my dude", 129 | ], 130 | [ 131 | 'We are one', 132 | 'We are legion' 133 | ], 134 | [ 135 | "You're a big guy", 136 | 'For you', 137 | ], 138 | ) 139 | 140 | 141 | class JoseChat(Cog): 142 | def __init__(self, bot): 143 | super().__init__(bot) 144 | self.whitelist = self.config.jose_db['chatbot_whitelist'] 145 | 146 | self.chatbot = ChatBot( 147 | 'José', 148 | trainer='chatterbot.trainers.ListTrainer', 149 | 150 | preprocessors=[ 151 | 'chatterbot.preprocessors.clean_whitespace', 152 | 'chatterbot.preprocessors.unescape_html', 153 | ], 154 | 155 | logic_adapters=[ 156 | 'chatterbot.logic.BestMatch', 157 | 'chatterbot.logic.MathematicalEvaluation', 158 | { 159 | 'import_path': 'chatterbot.logic.LowConfidenceAdapter', 160 | 'threshold': 0.2, 161 | 'default_response': 'I do not understand.' 162 | } 163 | ], 164 | 165 | input_adapter="chatterbot.input.VariableInputTypeAdapter", 166 | output_adapter="chatterbot.output.OutputAdapter", 167 | logger=log 168 | ) 169 | 170 | # Dict[int] -> str 171 | self.sessions = {} 172 | 173 | self.train_lock = asyncio.Lock() 174 | self.chat_lock = asyncio.Lock() 175 | 176 | @commands.command() 177 | @commands.is_owner() 178 | async def train(self, ctx): 179 | """Train the chatbot with the default conversations.""" 180 | for convo in TRAINING: 181 | self.chatbot.train(convo) 182 | await ctx.send(f'Trained {len(TRAINING)} Conversations.') 183 | 184 | def get_chat_session(self, user) -> 'chatbot session object': 185 | """Get a chatbot session given a user. 186 | 187 | Creates a new chat session if it doesn't exist. 188 | """ 189 | cs = self.chatbot.conversation_sessions 190 | 191 | try: 192 | sess_id = self.sessions[user.id] 193 | sess = cs.get(sess_id) 194 | except KeyError: 195 | sess = cs.new() 196 | sess_id = sess.id_string 197 | self.sessions[user.id] = sess_id 198 | 199 | return sess 200 | 201 | @commands.command() 202 | @commands.cooldown(1, 5, commands.BucketType.default) 203 | async def chat(self, ctx, *, user_input: str): 204 | """Talk to the chatbot. 205 | 206 | This is completly separated from José's markov feature, 207 | the one you use through 'j!spt' and 'jose thing' messages. 208 | 209 | It is not "mixing words and sentences" together like 210 | markov did. This is Machine Learning and it will use 211 | previous inputs to it as-is. 212 | 213 | Powered by Chatterbot, kudos to them. 214 | """ 215 | ok = await self.whitelist.find_one({'user_id': ctx.author.id}) 216 | if not ok: 217 | raise self.SayException('You are not in the whitelist' 218 | ' to use the chatbot.') 219 | 220 | with ctx.typing(): 221 | session = self.get_chat_session(ctx.author) 222 | 223 | await self.chat_lock 224 | future = self.loop.run_in_executor(None, 225 | self.chatbot.get_response, 226 | user_input, session.id_string) 227 | response = await future 228 | self.chat_lock.release() 229 | 230 | await ctx.send(response) 231 | 232 | @commands.is_owner() 233 | @commands.group(name='whitelist', aliases=['wl']) 234 | async def whitelist_cmd(self, ctx): 235 | """Add or remove someone from the j!chat whitelist.""" 236 | pass 237 | 238 | @whitelist_cmd.command(name='add') 239 | async def whitelist_add(self, ctx, person: discord.User): 240 | """Add someone to the j!chat whitelist.""" 241 | obj = {'user_id': person.id} 242 | r = await self.whitelist.insert_one(obj) 243 | await ctx.send(f'Mongo ACK: {r.acknowledged}') 244 | 245 | @whitelist_cmd.command(name='remove') 246 | async def whitelist_remove(self, ctx, person: discord.User): 247 | """Remove someone from the j!chat whitelist.""" 248 | obj = {'user_id': person.id} 249 | r = await self.whitelist.delete_many(obj) 250 | await ctx.send(f'Deleted {r.deleted_count} documents') 251 | 252 | @whitelist_cmd.command(name='list') 253 | async def whitelist_list(self, ctx): 254 | """List users in the whitelist""" 255 | em = discord.Embed(description='', title='People in whitelist') 256 | async for whitelist in self.whitelist.find(): 257 | em.description += f'<@{whitelist["user_id"]}> ' 258 | await ctx.send(embed=em) 259 | 260 | @chat.error 261 | async def error_handler(self, ctx, error): 262 | ok = await self.whitelist.find_one({'user_id': ctx.author.id}) 263 | if not ok: 264 | return 265 | 266 | if isinstance(error, commands.errors.CommandOnCooldown): 267 | em = discord.Embed() 268 | 269 | em.description = 'You are being ratelimited, please retry in ' + \ 270 | f'`{error.retry_after:.2}` seconds' 271 | em.set_image(url='https://cdn.discordapp.com/attachments' 272 | '/110373943822540800/183257679324643329' 273 | '/b1nzybuddy.png') 274 | 275 | await ctx.send(embed=em) 276 | 277 | 278 | def setup(bot): 279 | bot.add_cog(JoseChat(bot)) 280 | -------------------------------------------------------------------------------- /ext/vm.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import logging 3 | import base64 4 | import binascii 5 | import contextlib 6 | import io 7 | import inspect 8 | import time 9 | 10 | import discord 11 | from discord.ext import commands 12 | 13 | from .common import Cog 14 | 15 | log = logging.getLogger(__name__) 16 | 17 | 18 | class VMError(Exception): 19 | """General VM error.""" 20 | pass 21 | 22 | 23 | class VMEOFError(Exception): 24 | """Represents the end of a program's bytecode.""" 25 | pass 26 | 27 | 28 | class Instructions: 29 | """All the instructions in José VM Bytecode.""" 30 | PUSH_INT = 1 31 | PUSH_UINT = 2 32 | PUSH_LONG = 3 33 | PUSH_ULONG = 4 34 | 35 | PUSH_STR = 5 36 | 37 | #: Send a message with the top of the stack. 38 | SHOW_TOP = 6 39 | SHOW_POP = 7 40 | 41 | ADD = 8 42 | VIEW = 9 43 | 44 | 45 | def encode_inst(int_inst) -> bytes: 46 | return struct.pack('B', int_inst) 47 | 48 | 49 | async def assembler(ctx, program: str): 50 | lines = program.split('\n') 51 | bytecode = [] 52 | 53 | for line in lines: 54 | words = line.split(' ') 55 | command = words[0] 56 | 57 | if command == 'pushint': 58 | bytecode.append(encode_inst(Instructions.PUSH_INT)) 59 | num = int(words[1]) 60 | bytecode.append(struct.pack('i', num)) 61 | elif command == 'pushstr': 62 | bytecode.append(encode_inst(Instructions.PUSH_STR)) 63 | string = ' '.join(words[1:]) 64 | bytecode.append(struct.pack('Q', len(string))) 65 | bytecode.append(string.encode('utf-8')) 66 | elif command == 'pop': 67 | bytecode.append(encode_inst(Instructions.SHOW_POP)) 68 | elif command == 'add': 69 | bytecode.append(encode_inst(Instructions.ADD)) 70 | else: 71 | raise ctx.cog.SayException(f'Invalid instruction: `{command}`') 72 | 73 | return b''.join(bytecode) 74 | 75 | 76 | class JoseVM: 77 | """An instance of the José Virutal Machine.""" 78 | 79 | def __init__(self, ctx, bytecode): 80 | #: Command context, in the case we want to echo 81 | self.ctx = ctx 82 | 83 | #: Program bytecode 84 | self.bytecode = bytecode 85 | 86 | #: Holds current instruction being executed 87 | self.running_op = -1 88 | 89 | #: Executed op count 90 | self.op_count = 0 91 | 92 | #: Program counter 93 | self.pcounter = 0 94 | 95 | #: Program stack and its length 96 | self.stack = [] 97 | 98 | #: Loop counter, unused 99 | self.lcounter = 0 100 | 101 | #: Instruction handlers 102 | self.map = { 103 | Instructions.PUSH_INT: self.push_int, 104 | Instructions.PUSH_UINT: self.push_uint, 105 | Instructions.PUSH_LONG: self.push_long, 106 | Instructions.PUSH_ULONG: self.push_ulong, 107 | Instructions.PUSH_STR: self.push_str, 108 | Instructions.SHOW_TOP: self.show_top, 109 | Instructions.SHOW_POP: self.show_pop, 110 | Instructions.ADD: self.add_op, 111 | Instructions.VIEW: self.view_stack, 112 | } 113 | 114 | def push(self, val): 115 | """Push to the program's stack.""" 116 | if len(self.stack) > 1000: 117 | raise VMError('Stack overflow') 118 | 119 | self.stack.append(val) 120 | 121 | def pop(self): 122 | """Pop from the program's stack.""" 123 | value = self.stack.pop() 124 | return value 125 | 126 | async def read_bytes(self, bytecount): 127 | """Read an arbritary amount of bytes from the bytecode.""" 128 | data = self.bytecode[self.pcounter:self.pcounter + bytecount] 129 | self.pcounter += bytecount 130 | return data 131 | 132 | async def read_int(self) -> int: 133 | """Read an integer.""" 134 | data = await self.read_bytes(4) 135 | return struct.unpack('i', data)[0] 136 | 137 | async def read_uint(self) -> int: 138 | """Read an unsigned integer.""" 139 | data = await self.read_bytes(4) 140 | return struct.unpack('I', data)[0] 141 | 142 | async def read_long(self): 143 | """Read a long long.""" 144 | data = await self.read_bytes(8) 145 | return struct.unpack('q', data)[0] 146 | 147 | async def read_ulong(self): 148 | """Read an unsigned long long.""" 149 | data = await self.read_bytes(8) 150 | return struct.unpack('Q', data)[0] 151 | 152 | def read_size(self): 153 | """read what is comparable to C's size_t.""" 154 | return self.read_ulong() 155 | 156 | async def get_instruction(self) -> int: 157 | """Read one byte, comparable to a instruction""" 158 | data = await self.read_bytes(1) 159 | return struct.unpack('B', data)[0] 160 | 161 | # instruction handlers 162 | 163 | async def push_int(self): 164 | """Push an integer into the stack.""" 165 | integer = await self.read_int() 166 | self.push(integer) 167 | 168 | async def push_uint(self): 169 | """Push an unsigned integer into the stack.""" 170 | integer = await self.read_uint() 171 | self.push(integer) 172 | 173 | async def push_long(self): 174 | """Push a long into the stack.""" 175 | longn = await self.read_long() 176 | self.push(longn) 177 | 178 | async def push_ulong(self): 179 | """Push an unsigned long into the stack.""" 180 | longn = await self.read_ulong() 181 | self.push(longn) 182 | 183 | async def push_str(self): 184 | """Push a UTF-8 encoded string into the stack.""" 185 | string_len = await self.read_size() 186 | string = await self.read_bytes(string_len) 187 | string = string.decode('utf-8') 188 | self.push(string) 189 | 190 | async def add_op(self): 191 | """Pop 2. Add them. Push the result.""" 192 | res = self.pop() + self.pop() 193 | self.push(res) 194 | 195 | async def show_top(self): 196 | """Send a message containing the current top of the stack.""" 197 | top = self.stack[len(self.stack) - 1] 198 | print(top) 199 | 200 | async def show_pop(self): 201 | """Send a message containing the result of a pop.""" 202 | print(self.pop()) 203 | 204 | async def view_stack(self): 205 | print(self.stack) 206 | 207 | async def run(self): 208 | """Run the VM in a loop.""" 209 | while True: 210 | if self.pcounter >= len(self.bytecode): 211 | return 212 | 213 | instruction = await self.get_instruction() 214 | try: 215 | self.running_op = instruction 216 | func = self.map[instruction] 217 | except KeyError: 218 | raise VMError(f'Invalid instruction: {instruction!r}') 219 | 220 | await func() 221 | self.op_count += 1 222 | 223 | 224 | class VM(Cog): 225 | """José's Virtual Machine. 226 | 227 | This is a stack-based VM. There is no documentation other 228 | than reading the VM's source. 229 | 230 | You are allowed to have 1 VM running your code at a time. 231 | """ 232 | 233 | def __init__(self, bot): 234 | super().__init__(bot) 235 | 236 | self.vms = {} 237 | 238 | async def print_traceback(self, ctx, vm, err): 239 | """Print a traceback of the VM.""" 240 | em = discord.Embed(title='José VM error', color=discord.Color.red()) 241 | 242 | em.add_field(name='program counter', value=vm.pcounter, inline=False) 243 | 244 | em.add_field( 245 | name='stack at moment of crash', value=repr(vm.stack), inline=True) 246 | 247 | # I'm already hating dir() enough. 248 | attributes = inspect.getmembers(Instructions, 249 | lambda a: not inspect.isroutine(a)) 250 | 251 | names = [ 252 | a for a in attributes 253 | if not (a[0].startswith('__') and a[0].endswith('__')) 254 | ] 255 | rev_names = {v: k for k, v in names} 256 | 257 | em.add_field( 258 | name='executing instruction', value=rev_names.get(vm.running_op)) 259 | 260 | em.add_field(name='error', value=repr(err)) 261 | 262 | await ctx.send(embed=em) 263 | 264 | async def assign_and_exec(self, ctx, bytecode: str): 265 | """Create a VM, assign to the user and run the VM.""" 266 | if ctx.author in self.vms: 267 | raise self.SayException('You already have a VM running.') 268 | 269 | jvm = JoseVM(ctx, bytecode) 270 | self.vms[ctx.author.id] = jvm 271 | 272 | em = discord.Embed(title='José VM', color=discord.Color.blurple()) 273 | 274 | try: 275 | out = io.StringIO() 276 | t_start = time.monotonic() 277 | with contextlib.redirect_stdout(out): 278 | await jvm.run() 279 | t_end = time.monotonic() 280 | 281 | time_taken = round((t_end - t_start) * 1000, 4) 282 | 283 | em.add_field( 284 | name='executed instructions', value=jvm.op_count, inline=False) 285 | em.add_field( 286 | name='time taken', value=f'`{time_taken}ms`', inline=False) 287 | em.add_field(name='output', value=out.getvalue() or '') 288 | await ctx.send(embed=em) 289 | except VMError as err: 290 | await self.print_traceback(ctx, jvm, err) 291 | except Exception as err: 292 | await self.print_traceback(ctx, jvm, err) 293 | finally: 294 | self.vms.pop(ctx.author.id) 295 | 296 | @commands.command() 297 | async def run_compiled(self, ctx, data: str): 298 | """Receive a base64 representation of your bytecode and run it.""" 299 | try: 300 | bytecode = base64.b64decode(data.encode('utf-8')) 301 | except binascii.Error: 302 | raise self.SayException('Invalid base64.') 303 | await self.assign_and_exec(ctx, bytecode) 304 | 305 | @commands.command() 306 | async def assemble(self, ctx, *, program: str): 307 | """Call the assembler on your code.""" 308 | bytecode = await assembler(ctx, program) 309 | await ctx.send(base64.b64encode(bytecode).decode('utf-8')) 310 | 311 | @commands.command() 312 | async def run(self, ctx, *, program: str): 313 | """Assemble code and execute.""" 314 | bytecode = await assembler(ctx, program) 315 | await self.assign_and_exec(ctx, bytecode) 316 | 317 | 318 | def setup(bot): 319 | bot.add_cog(VM(bot)) 320 | -------------------------------------------------------------------------------- /ext/marry.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | import collections 4 | 5 | import discord 6 | 7 | from discord.ext import commands 8 | 9 | from .common import Cog 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | class Marry(Cog): 15 | """Relationships.""" 16 | 17 | def __init__(self, bot): 18 | super().__init__(bot) 19 | self.locks = collections.defaultdict(bool) 20 | 21 | async def get_rels(self, user_id: int) -> list: 22 | rels = await self.pool.fetch(""" 23 | select rel_id from relationships 24 | where user_id = $1 25 | """, user_id) 26 | 27 | return [row['rel_id'] for row in rels] 28 | 29 | async def get_users(self, rel_id: int) -> list: 30 | uids = await self.pool.fetch(""" 31 | select user_id from relationships 32 | where rel_id = $1 33 | """, rel_id) 34 | 35 | if not uids: 36 | return [] 37 | 38 | return [row['user_id'] for row in uids] 39 | 40 | @commands.command() 41 | @commands.guild_only() 42 | async def marry(self, ctx, who: discord.Member, rel_id: int = None): 43 | """Request someone to be in a relationship with you. 44 | 45 | Giving a relationship ID will turn that relationship into a 46 | poly (more than two) relationship. 47 | 48 | You do not use the relationship ID given here to claim an ID. 49 | 50 | Relationship IDs are generated based on 51 | the last one generated, plus one. 52 | """ 53 | new_relationship = rel_id is None 54 | 55 | if who.bot: 56 | raise self.SayException('no bots allowed') 57 | 58 | if who == ctx.author: 59 | raise self.SayException('no selfcest allowed') 60 | 61 | if rel_id: 62 | users = await self.get_users(rel_id) 63 | 64 | if not users: 65 | raise self.SayException('Relationship not found.') 66 | 67 | if ctx.author.id not in users: 68 | raise self.SayException('You are not in ' 69 | f'relationship id {rel_id}') 70 | 71 | if who.id in users: 72 | raise self.SayException('This person is already ' 73 | f'in relationship id {rel_id}') 74 | 75 | restraint = await self.pool.fetchrow(""" 76 | select user1, user2 77 | from restrains 78 | where (user1 = $1 and user2 = $2) or (user1 = $2 and user2 = $1) 79 | """, ctx.author.id, who.id) 80 | 81 | grest = await self.pool.fetchrow(""" 82 | select user1 83 | from restrains 84 | where (user1 = $1 or user1 = $2) and user2 = -1 85 | """, ctx.author.id, who.id) 86 | 87 | if restraint: 88 | return await ctx.send(f"You can not marry {who}.") 89 | 90 | if grest: 91 | return await ctx.send(f'{self.bot.get_user(grest)} ' 92 | 'has global restraints enabled.') 93 | 94 | # get all relationships 95 | rels = await self.get_rels(ctx.author.id) 96 | for in_rel_id in rels: 97 | users = await self.get_users(in_rel_id) 98 | if who.id in users: 99 | raise self.SayException('You are already with this person ' 100 | f'(relationship id {in_rel_id})') 101 | 102 | if self.locks[who.id]: 103 | raise self.SayException('Please wait.') 104 | 105 | self.locks[who.id] = True 106 | 107 | # critical session 108 | try: 109 | await ctx.send(f'{who.mention}, {ctx.author.mention} has just ' 110 | 'proposed to you. Do you agree to marry them?\n' 111 | 'Reply with y/n.') 112 | 113 | def yn_check(msg): 114 | cnt = msg.content.lower() 115 | chk1 = msg.author == who and msg.channel == ctx.channel 116 | chk2 = any(x == cnt for x in ['yes', 'no', 'y', 'n']) 117 | return chk1 and chk2 118 | 119 | try: 120 | message = await self.bot.wait_for( 121 | 'message', timeout=30, check=yn_check) 122 | except asyncio.TimeoutError: 123 | raise self.SayException(f'{ctx.author.mention}, ' 124 | 'timeout reached.') 125 | 126 | if message.content.lower() in ['no', 'n']: 127 | raise self.SayException(f'Invite denied, {ctx.author.mention}') 128 | finally: 129 | self.locks[who.id] = False 130 | 131 | # get next available id 132 | if not rel_id: 133 | new_rel_id = await self.pool.fetchval(""" 134 | select max(rel_id) + 1 from relationships 135 | """) 136 | 137 | if not new_rel_id: 138 | new_rel_id = 1 139 | 140 | rel_id = new_rel_id 141 | 142 | # guarantee we don't fuck up on our polycules 143 | # by making this a proper transaction 144 | log.info(f'putting {ctx.author.id} <=> {who.id} to {rel_id}') 145 | 146 | async with self.pool.acquire() as conn: 147 | stmt = await conn.prepare(""" 148 | insert into relationships (user_id, rel_id) 149 | values ($1, $2) 150 | """) 151 | 152 | if new_relationship: 153 | await stmt.fetchval(ctx.author.id, rel_id) 154 | await stmt.fetchval(who.id, rel_id) 155 | 156 | await ctx.send('All good!') 157 | 158 | @commands.command() 159 | async def relations(self, ctx, who: discord.User = None): 160 | """Show relationships the user is in. 161 | """ 162 | if not who: 163 | who = ctx.author 164 | 165 | rel_ids = await self.pool.fetch(""" 166 | select rel_id from relationships 167 | where user_id = $1 168 | """, who.id) 169 | 170 | if not rel_ids: 171 | raise self.SayException("You don't have any relationships") 172 | 173 | rel_ids = [row['rel_id'] for row in rel_ids] 174 | 175 | rels = {rel_id: await self.get_users(rel_id) for rel_id in rel_ids} 176 | res = [] 177 | 178 | for rel_id, user_ids in rels.items(): 179 | user_ids.remove(who.id) 180 | user_list = ', '.join(self.jcoin.get_name(uid) for uid in user_ids) 181 | 182 | res.append(f'#{rel_id}: `{user_list}`') 183 | 184 | await ctx.send('\n'.join(res)) 185 | 186 | @commands.command(aliases=['divorce']) 187 | async def breakup(self, ctx, rel_id: int): 188 | """Remove yourself from a relationship.""" 189 | users = await self.get_users(rel_id) 190 | if not users: 191 | raise self.SayException('Relationship not found') 192 | 193 | if ctx.author.id not in users: 194 | raise self.SayException('You are not in this relationship.') 195 | 196 | log.debug(f'breakup {ctx.author.id} from rel {rel_id}') 197 | 198 | await self.pool.execute(""" 199 | delete from relationships 200 | where user_id = $1 and rel_id = $2 201 | """, ctx.author.id, rel_id) 202 | 203 | users = await self.get_users(rel_id) 204 | if len(users) < 2: 205 | log.info(f'deleting relationship {rel_id}: can not sustain') 206 | 207 | await self.pool.execute(""" 208 | delete from relationships 209 | where rel_id = $1 210 | """, rel_id) 211 | 212 | await ctx.ok() 213 | 214 | @commands.group(invoke_without_command=True) 215 | async def restrain(self, ctx, user: discord.User): 216 | """Restrain someone from marrying you.""" 217 | if user == ctx.author: 218 | return await ctx.send('no') 219 | 220 | restraint = await self.pool.fetchrow(""" 221 | SELECT user1 222 | FROM restrains 223 | WHERE user1 = $1 AND user2 = $2 224 | """, ctx.author.id, user.id) 225 | 226 | if restraint is not None: 227 | return await ctx.send('To remove a restraint, use ' 228 | f'`{ctx.prefix}restrainoff`') 229 | 230 | await self.pool.execute(""" 231 | INSERT INTO restrains (user1, user2) 232 | VALUES ($1, $2) 233 | """, ctx.author.id, user.id) 234 | 235 | await ctx.ok() 236 | 237 | @restrain.command(name='everyone') 238 | async def restrain_everyone(self, ctx): 239 | """Restrain everyone from marrying you. 240 | 241 | Does not make a restraint for each user available in the world. 242 | """ 243 | restraint = await self.pool.fetchval(""" 244 | SELECT user1 245 | FROM restrains 246 | WHERE user1 = $1 AND user2 = -1 247 | """, ctx.author.id) 248 | 249 | if restraint: 250 | return await ctx.send('To turn off global restraints, ' 251 | f'use `{ctx.prefix}restrainoff everyone`') 252 | 253 | await self.pool.execute(""" 254 | INSERT INTO restrains (user1, user2) 255 | VALUES ($1, -1) 256 | """, ctx.author.id) 257 | 258 | await ctx.ok() 259 | 260 | @commands.group(invoke_without_command=True) 261 | async def restrainoff(self, ctx, user: discord.User): 262 | """Remove a restraint.""" 263 | user2 = await self.pool.fetchval(""" 264 | select user2 265 | from restrains 266 | where user1 = $1 and user2 = $2 267 | """, ctx.author.id, user.id) 268 | 269 | if not user2: 270 | return await ctx.send("You don't have any restraints to " 271 | "that person") 272 | 273 | await self.pool.execute(""" 274 | DELETE FROM restrains 275 | WHERE user1 = $1 AND user2 = $2 276 | """, ctx.author.id, user.id) 277 | 278 | await ctx.ok() 279 | 280 | @restrainoff.command(name='everyone') 281 | async def restrainoff_everyone(self, ctx): 282 | """Turn off restraints for everyone. 283 | 284 | Does not remove already existing restraints 285 | """ 286 | restraint = await self.pool.fetchval(""" 287 | SELECT user1 288 | FROM restrains 289 | WHERE user1 = $1 AND user2 = -1 290 | """, ctx.author.id) 291 | 292 | if not restraint: 293 | return await ctx.send('To turn on global restraints, ' 294 | f'use `{ctx.prefix}restrain everyone`') 295 | 296 | await self.pool.execute(""" 297 | DELETE FROM restrains 298 | WHERE user1 = $1 299 | """, ctx.author.id) 300 | 301 | await ctx.ok() 302 | 303 | 304 | def setup(bot): 305 | bot.add_jose_cog(Marry) 306 | -------------------------------------------------------------------------------- /ext/profile.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import hashlib 3 | import datetime 4 | import re 5 | 6 | import discord 7 | from discord.ext import commands 8 | 9 | from .common import Cog 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | class Profile(Cog, requires=['config', 'coins']): 15 | def __init__(self, bot): 16 | super().__init__(bot) 17 | 18 | self.description_coll = self.config.jose_db['descriptions'] 19 | 20 | regex = r'([a-zA-Z0-9]| |\'|\"|\!|\%|\<|\>|\:|\.|\,|\;|\*|\~|\n)' 21 | self.description_regex = re.compile(regex) 22 | 23 | async def set_description(self, user_id: int, description: str): 24 | """Set the description for a user.""" 25 | desc_obj = { 26 | 'id': user_id, 27 | 'description': description, 28 | } 29 | 30 | if await self.get_description(user_id) is not None: 31 | await self.description_coll.update_one({ 32 | 'id': user_id 33 | }, {'$set': desc_obj}) 34 | else: 35 | await self.description_coll.insert_one(desc_obj) 36 | 37 | async def get_description(self, user_id): 38 | """Get the description for a user.""" 39 | dobj = await self.description_coll.find_one({'id': user_id}) 40 | if dobj is None: 41 | return None 42 | return dobj['description'] 43 | 44 | def mkcolor(self, name): 45 | """Make a color value based on a username.""" 46 | colorval = int(hashlib.md5(name.encode("utf-8")).hexdigest()[:6], 16) 47 | return discord.Colour(colorval) 48 | 49 | def delta_str(self, delta) -> str: 50 | """Convert a time delta to a "humanized" form""" 51 | seconds = delta.total_seconds() 52 | years = seconds / 60 / 60 / 24 / 365.25 53 | days = seconds / 60 / 60 / 24 54 | 55 | if years >= 1: 56 | return f'{years:.2f} years' 57 | 58 | return f'{days:.2f} days' 59 | 60 | async def fill_jcoin(self, account, user, em, ctx): 61 | """Fill the embed with josécoin information.""" 62 | ranks = await self.jcoin.get_ranks(user.id, ctx.guild.id) 63 | r_global, r_tax, r_guild = ranks['global'], ranks['taxes'], \ 64 | ranks['local'] 65 | 66 | guild_rank, global_rank = r_guild['rank'], r_global['rank'] 67 | guild_accounts, all_accounts = r_guild['total'], r_global['total'] 68 | tax_rank, tax_global = r_tax['rank'], r_tax['total'] 69 | 70 | em.add_field( 71 | name='JC Rank', 72 | value=f'{guild_rank}/{guild_accounts}, ' 73 | f'{global_rank}/{all_accounts} globally') 74 | 75 | em.add_field(name='JoséCoin Wallet', value=f'{account["amount"]}JC') 76 | em.add_field(name='Tax paid', value=f'{account["taxpaid"]}JC') 77 | 78 | em.add_field( 79 | name='Tax rank', value=f'{tax_rank} / {tax_global} globally') 80 | 81 | try: 82 | s_success = account['steal_success'] 83 | s_uses = account['steal_uses'] 84 | 85 | ratio = s_success / s_uses 86 | ratio = round((ratio * 100), 3) 87 | 88 | em.add_field( 89 | name='Stealing', 90 | value=f'{s_uses} tries, ' 91 | f'{s_success} success, ' 92 | f'ratio of success: {ratio}') 93 | except ZeroDivisionError: 94 | pass 95 | 96 | async def fill_badges(self, ctx, embed): 97 | """Fill the badges for the profile embed.""" 98 | emojis = await self.pool.fetch(""" 99 | select badges.emoji 100 | from badge_users 101 | join badges 102 | on badge_users.badge = badges.badge_id 103 | where user_id = $1 104 | """, ctx.author.id) 105 | 106 | if not emojis: 107 | return 108 | 109 | embed.add_field(name='Badges', 110 | value=''.join((b['emoji'] for b in emojis))) 111 | 112 | @commands.command() 113 | @commands.guild_only() 114 | async def profile(self, ctx, *, user: discord.User = None): 115 | """Get profile cards.""" 116 | if user is None: 117 | user = ctx.author 118 | 119 | maybe_member = ctx.guild.get_member(user.id) 120 | if maybe_member: 121 | user = maybe_member 122 | 123 | em = discord.Embed(title='Profile card', 124 | colour=self.mkcolor(user.name)) 125 | 126 | if user.avatar_url: 127 | em.set_thumbnail(url=user.avatar_url) 128 | 129 | raw_repr = await self.get_json('https://api.getdango.com/api/' 130 | f'emoji?q={user.name}') 131 | 132 | emoji_repr = ''.join(emoji['text'] for emoji in raw_repr['results']) 133 | em.set_footer(text=f'{emoji_repr} | User ID: {user.id}') 134 | 135 | if isinstance(user, discord.Member) and (user.nick is not None): 136 | em.add_field(name='Name', value=f'{user.nick} ({user.name})') 137 | else: 138 | em.add_field(name='Name', value=user.name) 139 | 140 | description = await self.get_description(user.id) 141 | if description is not None: 142 | em.add_field(name='Description', value=description) 143 | 144 | delta = datetime.datetime.utcnow() - user.created_at 145 | em.add_field(name='Account age', value=f'{self.delta_str(delta)}') 146 | 147 | try: 148 | account = await self.jcoin.get_account(user.id) 149 | await self.fill_jcoin(account, user, em, ctx) 150 | except self.jcoin.AccountNotFoundError: 151 | pass 152 | 153 | await self.fill_badges(ctx, em) 154 | await ctx.send(embed=em) 155 | 156 | @commands.command() 157 | async def setdesc(self, ctx, *, description: str = ''): 158 | """Set your profile card description.""" 159 | description = description.strip() 160 | 161 | if len(description) > 80: 162 | raise self.SayException('3 long 5 me pls bring it down dud') 163 | 164 | notmatch = re.sub(self.description_regex, '', description) 165 | if notmatch: 166 | raise self.SayException('there are non-allowed characters.') 167 | 168 | if not description: 169 | raise self.SayException('pls put something') 170 | 171 | if description.count('\n') > 10: 172 | raise self.SayException('too many newlines') 173 | 174 | await self.set_description(ctx.author.id, description) 175 | await ctx.ok() 176 | 177 | @commands.group(aliases=['badges', 'b']) 178 | async def badge(self, ctx): 179 | """Profile badges.""" 180 | pass 181 | 182 | @badge.command(name='show') 183 | async def badge_show(self, ctx, user: discord.User = None): 184 | """Show badges for a user""" 185 | if not user: 186 | user = ctx.author 187 | 188 | badges = await self.pool.fetch(""" 189 | select user_id, badge, badges.name, 190 | badges.emoji, badges.description 191 | from badge_users 192 | join badges 193 | on badge_users.badge = badges.badge_id 194 | where user_id = $1 195 | """, user.id) 196 | 197 | embed = discord.Embed(title=f'Badges for {user}') 198 | embed.description = '' 199 | 200 | for badge in badges: 201 | embed.description += (f'#{badge["badge"]} {badge["emoji"]} ' 202 | f'`{badge["name"]}` ' 203 | f'`{badge["description"]}`\n') 204 | 205 | await ctx.send(embed=embed) 206 | 207 | @badge.command(name='list') 208 | async def badge_list(self, ctx, page: int = 0): 209 | """List available badges for your profile.""" 210 | badges = await self.pool.fetch(""" 211 | select badge_id, name, emoji, description, price 212 | from badges 213 | limit 10 214 | offset ($1 * 10) 215 | """, page) 216 | 217 | embed = discord.Embed(title=f'Page {page} of badges') 218 | embed.description = '' 219 | 220 | for badge in badges: 221 | embed.description += (f' - #{badge["badge_id"]}, {badge["name"]}, ' 222 | f'{badge["emoji"]} `{badge["description"]}` ' 223 | f'`{badge["price"]}JC`\n') 224 | 225 | await ctx.send(embed=embed) 226 | 227 | @badge.command(name='buy') 228 | async def badge_buy(self, ctx, badge_id: int): 229 | """Buy a badge. This is not a tax transfer. 230 | 231 | (your personal bank does not apply to badge buying) 232 | """ 233 | badge = await self.pool.fetchrow(""" 234 | select name, emoji, description, price 235 | from badges 236 | where badge_id = $1 237 | """, badge_id) 238 | 239 | if not badge: 240 | raise self.SayException('No badge found with that ID.') 241 | 242 | bdg_user = await self.pool.fetchval(""" 243 | select user_id 244 | from badge_users 245 | where user_id = $1 and badge = $2 246 | """, ctx.author.id, badge_id) 247 | 248 | if bdg_user is not None: 249 | raise self.SayException('You already bought that badge.') 250 | 251 | await self.coins.transfer(ctx.author.id, 252 | self.bot.user.id, badge['price']) 253 | 254 | await self.pool.execute(""" 255 | insert into badge_users (user_id, badge) 256 | values ($1, $2) 257 | """, ctx.author.id, badge_id) 258 | 259 | await ctx.ok() 260 | 261 | async def badge_remove(self, ctx, badge_id: int): 262 | # TODO: this 263 | pass 264 | 265 | @badge.command(name='bootstrap') 266 | @commands.is_owner() 267 | async def bootstrap(self, ctx): 268 | """Insert/bootstrap a handful of badges in an empty badge table.""" 269 | badges = [ 270 | [0, 'badger', '\N{OK HAND SIGN}', 'Bought a badge', 1], 271 | [1, 'angery', '<:blobangery:437365762597060618>', 272 | 'i angery', 15], 273 | [2, 'yeboi', '<:yeboi:353997914332200961>', 274 | 'The meaning of life', 34.5], 275 | [3, 'gay', '\N{RAINBOW}', 'im gay', 13], 276 | ] 277 | 278 | success = 0 279 | for badge in badges: 280 | try: 281 | await self.pool.execute(""" 282 | insert into badges (badge_id, name, 283 | emoji, description, price) 284 | values ($1, $2, $3, $4, $5) 285 | """, *badge) 286 | success += 1 287 | except Exception as err: 288 | await ctx.send(f'Failed on `{badge[0]}`: `{err!r}`') 289 | 290 | await ctx.send(f'{success} badges inserted (total {len(badges)})') 291 | 292 | def setup(bot): 293 | bot.add_jose_cog(Profile) 294 | 295 | -------------------------------------------------------------------------------- /ext/channel_logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import aiohttp 3 | import asyncio 4 | import time 5 | 6 | import discord 7 | from discord.ext import commands 8 | 9 | from .common import Cog 10 | from joseconfig import PACKET_CHANNEL, LEVELS 11 | 12 | log = logging.getLogger(__name__) 13 | 14 | LOG_LEVEL = logging.DEBUG 15 | LOGGER_SILENCE = ['discord', 'websockets'] 16 | 17 | CUSTOM_LEVELS = { 18 | 60: '\N{MONEY BAG}' # 60 is used for transactions (coins cog) 19 | } 20 | 21 | 22 | def clean_content(content): 23 | content = content.replace('`', '\'') 24 | content = content.replace('@', '@\u200b') 25 | content = content.replace('&', '&\u200b') 26 | content = content.replace('<#', '<#\u200b') 27 | return content 28 | 29 | 30 | # copied from https://github.com/FrostLuma/Mousey 31 | class DiscordHandler(logging.Handler): 32 | """ 33 | A custom logging handler which sends records to a Discord webhook. 34 | 35 | Messages are queued internally and only sent every 5 seconds 36 | to avoid waiting due to ratelimits. 37 | 38 | Parameters 39 | ---------- 40 | webhook : discord.Webhook 41 | The webhook the logs will be sent to 42 | level : Optional[int] 43 | The level this logger logs at 44 | loop : Optional[asyncio.AbstractEventLoop] 45 | The loop which the handler will run on 46 | 47 | Attributes 48 | ---------- 49 | closed : bool 50 | Whether this handler is closed or not 51 | """ 52 | 53 | def __init__(self, webhook, *, level=None, loop=None): 54 | if level is not None: 55 | super().__init__(level) 56 | else: 57 | super().__init__() 58 | 59 | self.webhook = webhook 60 | self.loop = loop = loop or asyncio.get_event_loop() 61 | 62 | self.closed = False 63 | 64 | self._buffer = [] 65 | 66 | self._last_emit = 0 67 | self._can_emit = asyncio.Event() 68 | 69 | self._emit_task = loop.create_task(self.emitter()) 70 | 71 | def emit(self, record: logging.LogRecord): 72 | if record.levelno != self.level: 73 | # only log the handlers level to the handlers channel, not above 74 | return 75 | 76 | msg = self.format(record) 77 | 78 | start = msg.find('```py\n') 79 | if start != -1: 80 | msg, trace = msg[:start], msg[start:] 81 | else: 82 | trace = None 83 | 84 | # the actual log message 85 | for line in msg.split('\n'): 86 | # if this is a small codeblock and goes over multiple messages 87 | # it will break out. so we check that each chunk 88 | # (besides the first) starts and stops with a backtick 89 | for idx, chunk in enumerate( 90 | line[x:x + 1994] for x in range(0, len(line), 1994)): 91 | # ugh 92 | if not chunk.endswith('`'): 93 | chunk = f'{chunk}`' 94 | if not chunk.startswith('`'): 95 | chunk = f'`{chunk}' 96 | 97 | self._buffer.append(chunk) 98 | 99 | # the traceback, sent separately to be in a 100 | # big codeblock for syntax highlighting 101 | if trace is not None: 102 | # cut off the original codeblock 103 | trace = trace[6:-3] 104 | 105 | paginator = commands.Paginator(prefix='```py\n', suffix='```') 106 | 107 | for line in trace.split('\n'): 108 | for chunk in (line[x:x + 1987] 109 | for x in range(0, len(line), 1987)): 110 | paginator.add_line(chunk) 111 | 112 | for page in paginator.pages: 113 | self._buffer.append(page) 114 | 115 | self._can_emit.set() 116 | 117 | async def emitter(self): 118 | while not self.closed: 119 | now = time.monotonic() 120 | 121 | send_delta = now - self._last_emit 122 | if send_delta < 5: 123 | await asyncio.sleep(5 - send_delta) 124 | 125 | self._last_emit = time.monotonic() 126 | 127 | paginator = commands.Paginator(prefix='', suffix='') 128 | 129 | for chunk in self._buffer: 130 | paginator.add_line(chunk.strip()) 131 | 132 | self._buffer.clear() 133 | self._can_emit.clear() 134 | 135 | try: 136 | for page in paginator.pages: 137 | await self.webhook.execute(page) 138 | except (discord.HTTPException, aiohttp.ClientError): 139 | log.exception('Failed to emit logs') 140 | 141 | await self._can_emit.wait() 142 | 143 | def close(self): 144 | try: 145 | self.closed = True 146 | self._emit_task.cancel() 147 | finally: 148 | super().close() 149 | 150 | 151 | class DiscordFormatter(logging.Formatter): 152 | """ 153 | Custom logging formatter meant to use in 154 | combination with the DiscordHandler. 155 | 156 | The formatter puts exceptions into a big codeblock to 157 | properly highlight them in Discord as well as allowing to pass emoji 158 | which will replace the level (name) to allow seeing what's going on easier. 159 | 160 | Parameters 161 | ---------- 162 | emoji : Dict[int, str] 163 | Dictionary of logging levels and characters which will replace the 164 | level name or 'Level ' in the log. 165 | 166 | By default the DEBUG, INFO, WARNING and ERROR levels have emoji set, 167 | but these can be overwritten. 168 | """ 169 | 170 | def __init__(self, fmt=None, datefmt=None, style='%', *, emoji=None): 171 | super().__init__(fmt, datefmt, style) 172 | 173 | self.emoji = { 174 | logging.DEBUG: '\N{CONSTRUCTION SIGN}', 175 | logging.INFO: '\N{ENVELOPE}', 176 | logging.WARNING: '\N{WARNING SIGN}', 177 | logging.ERROR: '\N{HEAVY EXCLAMATION MARK SYMBOL}', 178 | } 179 | 180 | if emoji is not None: 181 | self.emoji.update(emoji) 182 | 183 | def formatMessage(self, record: logging.LogRecord): 184 | msg = super().formatMessage(record) 185 | 186 | emoji = self.emoji.get(record.levelno) 187 | if emoji is None: 188 | return msg 189 | 190 | msg = msg.replace(record.levelname, emoji) 191 | msg = msg.replace(f'Level {record.levelno}', emoji) 192 | 193 | return msg 194 | 195 | def format(self, record: logging.LogRecord): 196 | """ 197 | Format the specified record as text. 198 | 199 | This implementation is directly copied from the superclass, 200 | only adding the codeblock to the traceback. 201 | """ 202 | 203 | record.message = clean_content(record.getMessage()) 204 | 205 | if self.usesTime(): 206 | record.asctime = self.formatTime(record, self.datefmt) 207 | 208 | s = self.formatMessage(record) 209 | 210 | if record.exc_info: 211 | # Cache the traceback text to avoid converting it multiple times 212 | # (it's constant anyway) 213 | if not record.exc_text: 214 | record.exc_text = self.formatException(record.exc_info) 215 | 216 | if record.exc_text: 217 | if s[-1:] != "\n": 218 | s = s + "\n" 219 | 220 | # add a codeblock so the DiscordHandler can properly split 221 | # the error into multiple messages if needed 222 | s = f'{s}```py\n{record.exc_text}```' 223 | 224 | if record.stack_info: 225 | if s[-1:] != "\n": 226 | s = s + "\n" 227 | s = s + self.formatStack(record.stack_info) 228 | 229 | return s 230 | 231 | 232 | class Logging(Cog): 233 | def __init__(self, bot): 234 | super().__init__(bot) 235 | self._special_packet_channel = None 236 | 237 | async def on_ready(self): 238 | self._special_packet_channel = self.bot.get_channel(PACKET_CHANNEL) 239 | 240 | async def on_socket_response(self, payload): 241 | """Convert msg to JSON and check for specific 242 | OP codes""" 243 | if self._special_packet_channel is None: 244 | return 245 | 246 | op, t = payload['op'], payload['t'] 247 | if op != 0: 248 | return 249 | 250 | if t in ('WEBHOOKS_UPDATE', 'PRESENCES_REPLACE'): 251 | log.info('GOT A WANTED PACKET!!') 252 | await self._special_packet_channel.send('HELLO I GOT A GOOD' 253 | ' PACKET PLS SEE ' 254 | f'```py\n{payload}\n```') 255 | 256 | @commands.command() 257 | @commands.is_owner() 258 | async def logerr(self, ctx): 259 | log.error('EXAMPLE ERROR') 260 | await ctx.send('logged') 261 | 262 | @commands.command() 263 | @commands.is_owner() 264 | async def err(self, ctx): 265 | raise Exception('THIS IS A TEST ERROR!!!!!') 266 | 267 | @commands.command() 268 | @commands.is_owner() 269 | async def test_2k(self, ctx): 270 | log.info('a' * 2000) 271 | 272 | 273 | def setup(bot): 274 | root = logging.getLogger() 275 | root.setLevel(LOG_LEVEL) 276 | 277 | # stdout logging 278 | default_formatter = logging.Formatter( 279 | '[%(levelname)s] [%(name)s] %(message)s') 280 | 281 | sh = logging.StreamHandler() 282 | sh.setFormatter(default_formatter) 283 | root.addHandler(sh) 284 | 285 | # so it gets detach on reload 286 | bot.channel_handlers.append(sh) 287 | 288 | formatter = DiscordFormatter( 289 | '`[%(asctime)s]` %(levelname)s `[%(name)s]` `%(message)s`', 290 | datefmt='%H:%M:%S', 291 | emoji=CUSTOM_LEVELS) 292 | 293 | # silence loggers 294 | # force them to info 295 | for logger_name in LOGGER_SILENCE: 296 | logger = logging.getLogger(logger_name) 297 | logger.setLevel(logging.INFO) 298 | 299 | # add all channel loggers from the config 300 | for level, url in LEVELS.items(): 301 | webhook = discord.Webhook.from_url( 302 | url, adapter=discord.AsyncWebhookAdapter(bot.session)) 303 | 304 | handler = DiscordHandler(webhook, level=level) 305 | handler.setFormatter(formatter) 306 | 307 | # Somehow this line of logging keeps spitting out errors 308 | # while the entirety of the bot keeps up. 309 | 310 | # this was somehow made by the coins cog but I don't see 311 | # what might be wrong with it. 312 | 313 | if level == logging.ERROR: 314 | line = 'Fatal error on transport TCPTransport' 315 | 316 | handler.addFilter(lambda record: line not in record.getMessage()) 317 | 318 | root.addHandler(handler) 319 | bot.channel_handlers.append(handler) 320 | 321 | bot.add_cog(Logging(bot)) 322 | 323 | 324 | def teardown(bot): 325 | """Shutdown all logging when turning off this cog.""" 326 | root = logging.getLogger() 327 | 328 | for handler in bot.channel_handlers: 329 | root.removeHandler(handler) 330 | 331 | bot.channel_handlers = [] 332 | --------------------------------------------------------------------------------