Skip to content

Commit a574900

Browse files
committed
Initial commit
0 parents  commit a574900

File tree

8 files changed

+469
-0
lines changed

8 files changed

+469
-0
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
__pycache__
2+
data/
3+
.python-version
4+
*.log
5+
todo.md

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
## ;;
2+
3+
> v0.1.0
4+
5+
This is the repository for the *new* version of `;;`, a nice Discord bot with currently no features.
6+
[Old version here.](http://github.com/Zeroji/semicold)
7+
8+
If you want to add features, feel free to [write a cog](https://github.com/Zeroji/semicolon/blob/master/docs/cogs.md)!
9+
10+
> *A side note about the `data` folder which is ignored*
11+
`data/secret/token` contains the bot's token, without newline
12+
`data/admins` and `data/banned` contain newline-separated IDs
13+
`data/master` contains the owner's ID, without newline

cogs/__init__.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Importing all cogs."""
2+
COGS = {}
3+
4+
def load(name):
5+
import importlib
6+
import logging
7+
mod = None
8+
try:
9+
mod = importlib.import_module(__name__ + '.' + name)
10+
except Exception as exc: # Yes I know it's too general. Just wanna catch 'em all.
11+
logging.critical("Error while loading '%s': %s", name, exc)
12+
else:
13+
logging.info("Loaded module '%s'.", name)
14+
COGS[name] = mod
15+
16+
def cog(name):
17+
"""Returns a Cog object given its name."""
18+
if not name in COGS:
19+
return None
20+
return COGS[name].cog
21+
22+
def command(cmd):
23+
"""Finds a command given its name."""
24+
matches = []
25+
for name, _cog in COGS.items():
26+
if _cog.cog.has(cmd):
27+
matches.append((name, _cog.cog.get(cmd)))
28+
if not matches:
29+
return None
30+
if len(matches) == 1:
31+
# Return the Command object
32+
return matches[0][1]
33+
else:
34+
# Return the names of the cogs containing that command
35+
return [name for name, _ in matches]

cogs/example.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Example cog."""
2+
import gearbox
3+
cog = gearbox.Cog('example')
4+
5+
@cog.command
6+
@cog.alias('hi')
7+
def hello(author):
8+
"""Say hello."""
9+
return 'Hello, %s!' % author.name

core.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
#!/usr/bin/env python
2+
"""Bot core."""
3+
import asyncio
4+
import importlib
5+
import logging
6+
import os.path
7+
import time
8+
import discord
9+
import cogs
10+
import gearbox
11+
12+
13+
class Bot(discord.Client):
14+
"""Client wrapper."""
15+
16+
def __init__(self, master='', admins=(), banned=()):
17+
"""Magic method docstring."""
18+
super(Bot, self).__init__()
19+
self.master = master
20+
self.admins = admins
21+
self.banned = banned
22+
self.cogs = {}
23+
self.last_update = time.time()
24+
25+
def run(self, *args):
26+
"""Start client."""
27+
super(Bot, self).run(*args)
28+
29+
async def on_message(self, message):
30+
"""Handle messages."""
31+
# Avoid non-dev servers [TEMP] (Imgur ARGs & Nightcore Reality)
32+
if message.channel.is_private or message.server.id in \
33+
('133648084671528961', '91460936186990592', '211982476745113601'):
34+
return
35+
# Avoid replying to self [TEMP]
36+
if message.author == self.user:
37+
return
38+
39+
# Detecting and stripping prefixes
40+
prefixes = [';']
41+
prefixes.append(self.user.mention)
42+
breaker = '|'
43+
text = message.content
44+
if not message.channel.is_private:
45+
text, is_command = gearbox.strip_prefix(text, prefixes)
46+
if is_command:
47+
commands = (text,)
48+
else:
49+
if breaker * 2 in text:
50+
text = text[text.find(breaker * 2) + 2:].lstrip()
51+
text, is_command = gearbox.strip_prefix(text, prefixes)
52+
if is_command:
53+
commands = (text,)
54+
elif breaker in text:
55+
parts = [part.strip() for part in text.split(breaker)]
56+
commands = []
57+
for part in parts:
58+
part, is_command = gearbox.strip_prefix(part, prefixes)
59+
if is_command:
60+
commands.append(part)
61+
is_command = len(parts) > 0
62+
if not is_command:
63+
return
64+
else:
65+
commands = (gearbox.strip_prefix(text, prefixes)[0],)
66+
67+
for text in commands:
68+
# Getting command arguments (or not)
69+
if ' ' in text:
70+
command, arguments = text.split(' ', 1)
71+
else:
72+
command, arguments = text, ''
73+
74+
if '.' in command:
75+
# Getting command from cog when using cog.command
76+
cog, cmd = command.split('.')
77+
cog = cogs.cog(cog)
78+
if not cog:
79+
return
80+
func = cog.get(cmd)
81+
else:
82+
# Checking for command existence / possible duplicates
83+
func = cogs.command(command)
84+
if isinstance(func, list):
85+
output = ("The command `%s` was found in multiple cogs: %s. Use <cog>.%s to specify." %
86+
(command, gearbox.pretty(func, '`%s`'), command))
87+
await self.send_message(message.channel, output)
88+
if isinstance(func, gearbox.Command):
89+
await func.call(self, message, arguments)
90+
91+
async def wheel(self): # They see me loading
92+
logging.info('Wheel rolling.')
93+
while True:
94+
for name, cog in cogs.COGS.items():
95+
if os.path.getmtime(cog.__file__) > self.last_update:
96+
try:
97+
importlib.reload(cog)
98+
except Exception as exc:
99+
logging.error("Error while reloading '%s': %s", name, exc)
100+
else:
101+
logging.info("Reloaded '%s'.", name)
102+
self.last_update = time.time()
103+
for name in [f[:-3] for f in os.listdir('cogs') if f.endswith('.py')]:
104+
if name not in cogs.COGS and gearbox.is_valid(name):
105+
cogs.load(name) # They're addin'
106+
await asyncio.sleep(2)
107+
108+
109+
async def on_ready(self):
110+
"""Initialization."""
111+
self.loop.create_task(self.wheel())
112+
await super(Bot, self).change_status(idle=True)
113+
logging.info('Client started.')
114+
115+
116+
def main():
117+
"""Load authentication data and run the bot."""
118+
logging.basicConfig(filename='run.log', level=logging.DEBUG)
119+
logging.info('Starting...')
120+
token = open('data/secret/token', 'r').read().strip()
121+
122+
master = open('data/master', 'r').read().strip()
123+
admins = open('data/admins', 'r').read().splitlines()
124+
banned = open('data/banned', 'r').read().splitlines()
125+
126+
bot = Bot(master, admins, banned)
127+
bot.run(token)
128+
129+
if __name__ == '__main__':
130+
main()

docs/cogs.md

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
## How to write your cog
2+
3+
`;;` draws its main features from modules, named "cogs".
4+
Writing one is rather straightforward, but a couple rules have to be respected.
5+
6+
### Match `[a-z][a-z_0-9]*\.py`
7+
8+
Don't run away ~~yet~~! This simply means that the name of your file must be **full lowercase** and it has to start by a letter (any file starting with `_` or a digit will be ignored). Once you have that file, just drop it in the `cogs` folder and that's all.
9+
10+
### Don't forget your tools
11+
12+
Every cog must contain a `cog` variable, which has to be an instance of `gearbox.Cog`. Here's what a standard cog header looks like:
13+
```python
14+
import gearbox
15+
cog = gearbox.Cog()
16+
```
17+
> By default, your cog's name will be the file name, minus the `.py` part.
18+
To change this, simply pass a new name as an argument to `gearbox.Cog()`.
19+
20+
### Creating a command
21+
22+
#### The basics
23+
24+
If you're familiar with `discord.py`, then you probably know about `async`, `await` and all that stuff. If not, don't worry! You don't need that to write stuff.
25+
26+
Every command must be "decorated" by placing `@cog.command` before its definition. After that step, it'll be recognized by `;;` as a command - as long as it has a valid name (see above). Here's a really simple command:
27+
28+
```python
29+
@cog.command
30+
def hello():
31+
return 'Hello, world!'
32+
```
33+
34+
Straightforward, right? Your command just have to return something, and `;;` will send it in the good channel. If you return nothing... Well, nothing happens.
35+
But what if you want it to greet someone specifically?
36+
37+
#### Special arguments
38+
39+
Greeting a user can be done very simply:
40+
41+
```python
42+
@cog.command
43+
def hello(author):
44+
return 'Hello, %s!' % author.name
45+
```
46+
47+
> If you really aren't familiar with `discord.py`, have a look at [their documentation](http://discordpy.readthedocs.io/en/latest/). For very simple usage, you can get a user/channel/server name with `.name`.
48+
49+
> Wondering what this `%` thing does? Basically, it's the same as `'Hello, ' + author.name + '!'` but shorter and fancier.
50+
[Learn more here](https://docs.python.org/3.5/library/stdtypes.html#printf-style-string-formatting)
51+
52+
As you can see, simply putting `author` in the function definition will give you the corresponding object. Why? Because `;;` is made in such a way that it'll look at what you want, and attempt to provide it to you so you don't need to write extra pieces of code. Here's a list of those "special" arguments: *(as of v0.1.0)*
53+
54+
|Argument | Description
55+
|-
56+
|`client` | The application's `discord.Client()`
57+
|`message` | The Message object which was sent - don't use like a string!
58+
|`author` | Shortcut for `message.author`
59+
|`channel` | Shortcut for `message.channel`
60+
|`server` | Shortcut for `message.server`
61+
62+
*Remember that using those will give you special values, which might not correspond to your expectations.*
63+
64+
#### Normal arguments
65+
66+
Now maybe you simply want to write a `repeat` command, but you don't know how to get the text? Just ask for it!
67+
68+
```python
69+
@cog.command
70+
def repeat(what_they_said):
71+
return what_they_said
72+
```
73+
74+
When sending arguments to commands, `;;` will take care of special arguments, then send the rest of the message to the other arguments. If you need multiple arguments, just define them!
75+
76+
```python
77+
@cog.command
78+
def add(number_a, number_b):
79+
return str(int(number_a) + int(number_b))
80+
```
81+
82+
> *May change in future versions*
83+
If the user doesn't provide the arguments you need, for example if they type `add 4`, `;;` will send an empty string (`''`) for each missing arguments.
84+
85+
> If the user sends too many arguments, for example by typing `add 1 2 3`, then your last argument will receive the extra information. Here, `number_a` would contain `'1'` but `number_b` would contain `'2 3'`. You can discard unwanted information by adding a third argument which will take it:
86+
```python
87+
def add(number_a, number_b, trash):
88+
```
89+
90+
#### More arguments!
91+
92+
Let's say you want to have a command that takes a string, then a series of strings, and inserts that first string between all the others, i.e. `, 1 2 3` would give `1,2,3` - wow, that's just like `str.join()`!
93+
You'll want to have the first argument, then "all the rest". Of course, you could get away with using `def join(my_string, all_the_rest):` and then use `.split()`, but ;; can do that for you! Simply add `*` before your last argument, and it'll receive a nice little list of whatever was sent:
94+
95+
```python
96+
@cog.command
97+
def join(my_string, *all_the_rest):
98+
return my_string.join(all_the_rest)
99+
```
100+
101+
#### About `async` and `await`
102+
103+
What if you're an advanced user and you know all that async stuff already and just want to add your tasks to the event loop while awaiting coroutines?
104+
105+
```python
106+
async def command(client):
107+
```
108+
109+
It's that simple. If your command is a coroutine, then `;;` will simply `await` it (if you want to send a message, do it yourself!); and the `client` argument will give you access to the main client. Hint, the loop is at `client.loop`
110+
111+
### Decorating your command
112+
113+
No, this isn't about adding a cute little ribbon onto it. *(as of v0.1.0)*
114+
115+
You've already used the decorator `@cog.command` to indicate that your function was a `;;` command and not a random function.
116+
You can do a bit more, here, have a list:
117+
118+
##### `@cog.rename(name)`
119+
This will change the name of your command. It's useful, for example, if you want your command to be called `str` but you can't because of Python's `str()` function. Just call your function `asdf` and put `@cog.rename('str')` before it.
120+
121+
##### `@cog.alias(alias, ...)`
122+
This creates aliases for your command. Let's say you find `encrypt` is a quite long name, just add `@cog.alias('en')` and you'll be able to call your command with `encrypt` *and* `en`.
123+
124+
##### `@cog.init`
125+
This doesn't apply to a command, but to a regular function - it marks it, so it will be called when the cog is loaded. You can only have one init function.
126+
> *Not yet implemented as of v0.1.0*
127+
128+
##### `@cog.exit`
129+
This doesn't apply to a command, but to a regular function - it marks it, so it will be called when the cog is unloaded. You can only have one exit function.
130+
> *Not yet implemented as of v0.1.0*
131+
132+
### Using your cog
133+
134+
As written above, you just need to drop it in the `cogs` folder!
135+
If `;;` is running, it'll automatically load it within a couple of seconds, and reload it when you edit it. Don't worry, if you break stuff, it'll keep running the old code until the new one is working.
136+
If you have name conflicts with another module, call your commands with `cog_name.command_name` to avoid collisions.

0 commit comments

Comments
 (0)