add 1 another directory
This commit is contained in:
parent
5fc3884101
commit
6d029d1d01
26
delibird-master/LICENSE
Executable file
26
delibird-master/LICENSE
Executable file
|
@ -0,0 +1,26 @@
|
|||
Copyright (c) The Regents of the University of California.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
3. Neither the name of the University nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
|
||||
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
|
||||
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
|
||||
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGE.
|
21
delibird-master/README.md
Executable file
21
delibird-master/README.md
Executable file
|
@ -0,0 +1,21 @@
|
|||
# Delibird / Birb-bot
|
||||
|
||||
Delibird / Birb-bot is a Mastodon bot flying from user to user!
|
||||
|
||||
It can be sent by an user to another by mentioning the bird with a message like
|
||||
“Go see user@domain” or with similar keywords. It can only be at one place at a
|
||||
time, though, meaning that when it is flying away to someone or it is visiting
|
||||
someone else than you, you cannot send it flying to another user!
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Python 3
|
||||
- Mastodon.py: https://github.com/halcy/Mastodon.py/
|
||||
|
||||
## Writing your own birbs
|
||||
|
||||
To avoid spoilers around the “rewards” posted by my own birb-bot, the repo does
|
||||
not include the full “data.py” I use.
|
||||
|
||||
Instead, you can find a “data.py.example” which is pretty close but omits some
|
||||
rewards and media definitions.
|
394
delibird-master/data.py.example
Executable file
394
delibird-master/data.py.example
Executable file
|
@ -0,0 +1,394 @@
|
|||
MEDIA = {
|
||||
'FLYING_AWAY': {
|
||||
'file': 'bye.mp4',
|
||||
'source': 'https://www.youtube.com/watch?v=6Qp4wafJ8_I',
|
||||
},
|
||||
'FLYING_IN': {
|
||||
'file': 'hello.mp4',
|
||||
'source': 'https://www.youtube.com/watch?v=Gu8EudiPLZw',
|
||||
},
|
||||
'STAIRS': {
|
||||
'file': 'stairs.mp4',
|
||||
'source': 'https://www.youtube.com/watch?v=coDxZZjTt4w',
|
||||
},
|
||||
'BELLY_RUB': {
|
||||
'file': 'rewards/belly-rub.mp4',
|
||||
'source': 'https://www.youtube.com/watch?v=-bTaj37E79k',
|
||||
},
|
||||
'DJBIRB': {
|
||||
'file': 'rewards/dj.mp4',
|
||||
'source': 'https://www.youtube.com/watch?v=1v2SN06DnPo',
|
||||
},
|
||||
'SHOPPING': {
|
||||
'file': 'rewards/shopping.mp4',
|
||||
'source': 'https://www.youtube.com/watch?v=BfhlPEkNm7o',
|
||||
},
|
||||
'DANCINGQUEEN': {
|
||||
'file': 'rewards/queen.mp4',
|
||||
'source': 'https://www.youtube.com/watch?v=9DaGgzJm6is',
|
||||
},
|
||||
'MICROWAVE': {
|
||||
'file': 'rewards/microwave.mp4',
|
||||
'source': 'https://www.youtube.com/watch?v=NVY0qNe3BUE',
|
||||
},
|
||||
'JUMPING': {
|
||||
'file': 'rewards/jumping.mp4',
|
||||
'source': 'https://www.youtube.com/watch?v=_62o7u-BYRA',
|
||||
},
|
||||
'RIDING': {
|
||||
'file': 'rewards/riding.mp4',
|
||||
'source': 'https://www.youtube.com/watch?v=dVngVDTYbOQ',
|
||||
},
|
||||
'RIVERDANCE': {
|
||||
'file': 'rewards/riverdance.webm',
|
||||
'source': 'https://www.youtube.com/watch?v=KF7gk10jgEw',
|
||||
},
|
||||
'PAPERTOWEL': {
|
||||
'file': 'rewards/papertowel.mp4',
|
||||
'source': 'https://www.youtube.com/watch?v=6_-4KArFR08',
|
||||
},
|
||||
'BIRDVSPHONE': {
|
||||
'file': 'rewards/phonecover.mp4',
|
||||
'source': 'https://www.youtube.com/watch?v=ktzfC5ZcOtE',
|
||||
},
|
||||
'KILLERBIRD': {
|
||||
'file': 'rewards/killerbird.mp4',
|
||||
'source': 'https://twitter.com/colloritz/status/798470429308424192',
|
||||
},
|
||||
'WITHFRIENDS': {
|
||||
'file': 'rewards/friends.mp4',
|
||||
'source': 'https://twitter.com/s_hatachan/status/879946102878883840',
|
||||
},
|
||||
}
|
||||
|
||||
MSGS = {
|
||||
'ERROR_NOBOT': {
|
||||
'text': '''@{sender_acct} Pwii! Pwi.
|
||||
|
||||
[FR] Désolé, je n'irai pas voir {receiver_acct}, car cette personne ne semble pas vouloir de la visite de bots.
|
||||
|
||||
[EN] Sorry, I won't visit {receiver_acct} as they do not seem to like bots very much.''',
|
||||
'privacy': 'direct',
|
||||
},
|
||||
'ERROR_UNDELIVERABLE': {
|
||||
'text': '''@{sender_acct} Pwiii…
|
||||
|
||||
[FR] Désolé, je n'ai pas réussi à joindre {receiver_acct}… essaie avec quelqu'un d'autre ?
|
||||
|
||||
[EN] Sorry, I can't reach {receiver_acct}… maybe try with someone else?''',
|
||||
'privacy': 'direct',
|
||||
},
|
||||
'ERROR_OWNED': {
|
||||
'text': '''@{sender_acct} Pwi pwi pwi!!!
|
||||
|
||||
[FR] Je suis chez quelqu'un d'autre !
|
||||
Je serai de nouveau disponible si cette personne ne m'envoie pas me promener d'ici {hours}h{minutes:02d} !
|
||||
Tu peux me demander de te dire quand je serai disponible en me disant « notifie moi » !
|
||||
|
||||
[EN] I'm currently visiting someone else!
|
||||
I'll be available in {hours}h{minutes:02d} if that person doesn't send me around in the meantime!
|
||||
You can request that I notify you when I'm available by telling me “notify me”!''',
|
||||
'privacy': 'direct',
|
||||
},
|
||||
'ERROR_OWNED2': {
|
||||
'text': '''@{sender_acct} Pwi pwi pwi!!!
|
||||
|
||||
[FR] Je suis chez quelqu'un d'autre !
|
||||
Je serai de nouveau disponible si cette personne ne m'envoie pas me promener d'ici {hours}h{minutes:02d} !
|
||||
Je te dirai la prochaine fois que je serai disponible !
|
||||
|
||||
[EN] I'm currently visiting someone else!
|
||||
I'll be available in {hours}h{minutes:02d} if that person doesn't send me around in the meantime!
|
||||
I'll tell you next time I'm available!''',
|
||||
'privacy': 'direct',
|
||||
},
|
||||
'ERROR_DELIVERY': {
|
||||
'text': '''@{sender_acct} Pwiii pwii pwii pwiii!
|
||||
|
||||
[FR] Je suis déjà en train de m'envoler pour voir quelqu'un d'autre !
|
||||
Tu peux me demander de te dire quand je serai disponible en me disant « notifie moi » !
|
||||
|
||||
[EN] I'm already flying away to someone else!
|
||||
You can request that I notify you when I'm available by telling me “notify me”!''',
|
||||
'privacy': 'direct',
|
||||
},
|
||||
'ERROR_DELIVERY2': {
|
||||
'text': '''@{sender_acct} Pwiii pwii pwii pwiii!
|
||||
|
||||
[FR] Tu m'as déjà envoyé voir quelqu'un d'autre ! Tu peux encore revenir sur ce choix en me disant « reviens » !
|
||||
|
||||
[EN] You already sent me to someone else! If that was a mistake, you can tell me to “come back”!''',
|
||||
'privacy': 'direct',
|
||||
},
|
||||
'ERROR_DELIVERY3': {
|
||||
'text': '''@{sender_acct} Pwiii pwii pwii pwiii!
|
||||
|
||||
[FR] Je suis déjà en train de m'envoler pour voir quelqu'un d'autre !
|
||||
Je te dirai la prochaine fois que je serai disponible !
|
||||
|
||||
[EN] I'm already flying away to someone else!
|
||||
I'll tell you next time I'm available!''',
|
||||
'privacy': 'direct',
|
||||
},
|
||||
'ERROR_INVALID_FORMAT': {
|
||||
'text': '''@{sender_acct} Pwii? Pwiii!
|
||||
|
||||
[FR] Je n'ai pas compris vers qui je dois m'envoler ? Il faut me dire « va voir utilisateur@domaine » !
|
||||
|
||||
[EN] I don't understand who I'm supposed to be flying to? You have to tell me “go see user@domain”!''',
|
||||
'privacy': 'direct',
|
||||
},
|
||||
'ERROR_UNKNOWN_ACCOUNT': {
|
||||
'text': '''@{sender_acct} Pwiii! Pwiii?
|
||||
|
||||
[FR] Je n'ai pas trouvé {acct} ! Vers qui d'autre dois-je aller ?
|
||||
|
||||
[EN] I can't find {acct}! Who else should I visit?''',
|
||||
'privacy': 'direct',
|
||||
},
|
||||
'ERROR_UNKNOWN_ACCOUNT2': {
|
||||
'text': '''@{sender_acct} Pwiii! Pwiii?
|
||||
|
||||
[FR] Je n'ai pas trouvé {acct} ! Vers qui d'autre dois-je aller ? Peut-être voulais-tu dire {suggested_acct} ?
|
||||
|
||||
[EN] I can't find {acct}! Who else should I visit? Maybe you meant {suggested_acct}?''',
|
||||
'privacy': 'direct',
|
||||
},
|
||||
'ERROR_INTERNAL': {
|
||||
'text': '''@{sender_acct} Pwiiii…
|
||||
|
||||
[FR] Je suis désolé, je n'arrive pas à contacter {acct}… il va falloir essayer avec quelqu'un d'autre.
|
||||
|
||||
[EN] I'm sorry I cannot reach {acct}… who should I be flying to?''',
|
||||
'privacy': 'direct',
|
||||
},
|
||||
'ERROR_SAME_ACCOUNT': {
|
||||
'text': '''@{sender_acct} Pwiii!
|
||||
|
||||
[FR] Hé, ce n'est pas du jeu de vouloir me garder comme ça ! Vers qui d'autre dois-je aller ?
|
||||
|
||||
[EN] Hey, it's not fair to try to keep me like this! Who else should I visit?''',
|
||||
'privacy': 'direct',
|
||||
},
|
||||
'ERROR_OWN_ACCOUNT': {
|
||||
'text': '''@{sender_acct} Pwii. Pwii?
|
||||
|
||||
[FR] Je ne peux pas aller me voir moi-même ?
|
||||
|
||||
[EN] I cannot fly to see myself?''',
|
||||
'privacy': 'direct',
|
||||
},
|
||||
'DELIVERY_START': {
|
||||
'text': '''@{sender_acct} Pwiipwii pwiipwiii!
|
||||
|
||||
[FR] En route vers chez {acct} ! Ça peut me prendre un peu de temps !
|
||||
Si je me suis trompé de personne, tu peux me dire de revenir en me disant « reviens » !
|
||||
|
||||
[EN] On my way to {acct}! This may take me a while!
|
||||
If I'm not flying to the correct person, you can still tell me to “come back”!''',
|
||||
'privacy': 'direct',
|
||||
'media': ['FLYING_AWAY'],
|
||||
},
|
||||
'DELIVERY_CANCELLED': {
|
||||
'text': '''@{sender_acct} Pwiii…
|
||||
|
||||
[FR] Tu ne veux plus que j'aille voir {acct} ? D'accord… qui dois-je aller voir ?
|
||||
|
||||
[EN] You don't want me to go visit {acct} anymore? Ok… but who should I fly to?''',
|
||||
'privacy': 'direct',
|
||||
},
|
||||
'DELIVERED': {
|
||||
'text': '''@{receiver_acct} :caique: Pwii pwiii! :caique:
|
||||
|
||||
[FR] Bonjour de la part de {sender_acct} ! Je ne suis qu'à toi pour les {nb_hours} prochaines heures ! Envoie-moi voir qui tu veux en répondant « va voir utilisateur@domaine » !
|
||||
Tu peux aussi me dire « va jouer ailleurs » !
|
||||
|
||||
[EN] Hello from {sender_acct_short}! Send me to anyone by replying “go see user@domain”! Until then, I'll be yours for the next {nb_hours} hours!
|
||||
You can also tell me to “go play somewhere else”!''',
|
||||
'privacy': 'direct',
|
||||
'media': {('FLYING_IN',): 3, ('STAIRS',): 1},
|
||||
},
|
||||
'IDLE': {
|
||||
'text': ''':caique: Pwiii… pwii pwii! :caique:
|
||||
|
||||
[FR] Personne ne m'a envoyé me promener depuis un moment… du coup, n'importe qui peut le faire en me répondant « va voir utilisateur@domaine » !
|
||||
|
||||
[EN] Noone sent me flying away… but now, anybody can! Reply me with “go see user@domain”, and I'll fly to them!''',
|
||||
'privacy': 'public',
|
||||
},
|
||||
'IDLE2': {
|
||||
'text': ''':caique: Pwiii… pwii pwii! :caique:
|
||||
|
||||
[FR] La personne chez qui j'ai été envoyé préfère que j'aille jouer ailleurs ! Du coup, envoyez-moi chez qui vous voulez en me répondant « va voir utilisateur@domaine » !
|
||||
|
||||
[EN] The person I was sent to would like me to play somewhere else! Reply me with “go see user@domain”, and I'll fly to them!''',
|
||||
'privacy': 'public',
|
||||
},
|
||||
'NOTIFY_REQUEST': {
|
||||
'text': '''@{acct} :caique: Pwii! :caique:
|
||||
|
||||
[FR] Entendu ! Je te dirai la prochaine fois que je serai disponible !
|
||||
|
||||
[EN] Got it! I'll tell you next time I'm available!''',
|
||||
'privacy': 'direct',
|
||||
},
|
||||
'NOTIFY_IDLE': {
|
||||
'text': '''@{acct} :caique: Pwii pwii! :caique:
|
||||
|
||||
[FR] Tu m'avais demandé de te notifier quand je deviendrai disponible ! Et bien c'est chose faite !
|
||||
|
||||
[EN] You requested that I notify you when I become available! I'm available now!''',
|
||||
'privacy': 'direct',
|
||||
},
|
||||
'BELLY_RUB': {
|
||||
'text': ''':caique: Pwiiiiii <3 :caique:
|
||||
|
||||
[FR] J'ai rencontré {nb_users} personnes et j'ai apporté du bonheur {nb_likes} fois ! J'ai bien mérité un petit gratouilli !
|
||||
|
||||
[EN] I've met {nb_users} people and I've made them happy {nb_likes} times! I've earned myself a belly rub!''',
|
||||
'privacy': 'public',
|
||||
'media': ['BELLY_RUB'],
|
||||
},
|
||||
'PAPERTOWEL': {
|
||||
'text': ''':caique: Pwiii… pwii! :caique:
|
||||
|
||||
[FR] Des fois, je comprends vraiment pas mon cousin à tête noire… mais bon, il a l'air de bien s'amuser !
|
||||
|
||||
[EN] Sometimes, I really don't understand my black-headed cousin… but he seems to be having a lot of fun!''',
|
||||
'privacy': 'public',
|
||||
'media': ['PAPERTOWEL'],
|
||||
},
|
||||
'JUMPING': {
|
||||
'text': ''':caique: Pwii? Pwiii! :caique:
|
||||
|
||||
[FR] Mon cousin à tête noir commence à sauter sur place… sinon, j'ai rencontré {nb_users} d'entre vous et je leur ai fait plaisir {nb_likes} fois !
|
||||
|
||||
[EN] My black-headed cousin is starting to jump in-place… by the way, I've met {nb_users} of you and I've made them happy {nb_likes} times!''',
|
||||
'privacy': 'public',
|
||||
'media': ['JUMPING'],
|
||||
},
|
||||
'DANCINGQUEEN': {
|
||||
'text': ''':caique: Pwii~ pwiii~ pwiii~ 🎶 :caique:
|
||||
|
||||
[FR] Je vais bientôt rencontrer tout le fediverse ! {nb_users} personnes ! Pfiou ! Sinon, j'aime bien Queen !
|
||||
|
||||
[EN] I've met almost all of the fediverse! {nb_users} of you! Wow! Also, I like Queen!''',
|
||||
'privacy': 'public',
|
||||
'media': ['DANCINGQUEEN'],
|
||||
},
|
||||
'SHOPPING': {
|
||||
'text': ''':caique: Pwi pwi pwiiii! :caique:
|
||||
|
||||
[FR] J'ai rencontré pas moins de {nb_users} mastonautes ! Et ils ont aimé mes visites {nb_likes} fois ! Mon cousin est parti m'acheter à manger !
|
||||
|
||||
[EN] I've met {nb_users} mastonauts! And they liked my visits {nb_likes} times! My cousin went shopping for dinner!''',
|
||||
'privacy': 'public',
|
||||
'media': ['SHOPPING'],
|
||||
},
|
||||
'MICROWAVE': {
|
||||
'text': ''':caique: Pwi! Pwi! Pwi! Pwi! Pwi! Pwi! :caique:
|
||||
|
||||
[FR] J'ai rencontré {nb_users} personnes formidables ! Ils ont aimé mes visites {nb_likes} fois ! Je vais fêter ça en dinant !
|
||||
|
||||
[EN] I've met {nb_users} amazing people! And they liked me {nb_likes} times! I'll eat dinner to celebrate!''',
|
||||
'privacy': 'public',
|
||||
'media': ['MICROWAVE'],
|
||||
},
|
||||
'DJBIRB': {
|
||||
'text': ''':caique: Pwiii~ pwiii~ pwiii~ pwiii~ :caique:
|
||||
|
||||
[FR] Pwiiiii~ j'ai rencontré {nb_users} personnes qui m'ont aimé {nb_likes} fois, je m'amuse comme un petit fou !
|
||||
|
||||
[EN] Pwiiiii~ I've met {nb_users} who liked me {nb_likes} times, I'm having a lot of fun!''',
|
||||
'privacy': 'public',
|
||||
'media': ['DJBIRB'],
|
||||
},
|
||||
'RIDING': {
|
||||
'text': ''':caique: Pwii pwii pwii~ :caique:
|
||||
|
||||
[FR] Il n'y a pas que mon cousin qui peut s'amuser comme ça ! Regardez-moi ! ~
|
||||
J'ai rencontré {nb_users} personnes et ils m'ont apprécié {nb_likes} fois !
|
||||
|
||||
[EN] My cousin is not the only one that can have fun! Look at me! ~
|
||||
I've met {nb_users} people so far and they liked me {nb_likes} times!''',
|
||||
'privacy': 'public',
|
||||
'media': ['RIDING'],
|
||||
},
|
||||
'RIVERDANCE': {
|
||||
'text': ''':caique: Pwii 🎶 :caique:
|
||||
|
||||
[FR] Regardez mon cousin danser !
|
||||
J'ai rencontré {nb_users} personnes et ils m'ont apprécié {nb_likes} fois !
|
||||
|
||||
[EN] Watch my cousin dance!
|
||||
I've met {nb_users} people so far and they liked me {nb_likes} times!''',
|
||||
'privacy': 'public',
|
||||
'media': ['RIVERDANCE'],
|
||||
},
|
||||
'BIRBVSPHONE': {
|
||||
'text': ''':caique: Pwii! :caique:
|
||||
|
||||
[FR] Ne plaisante pas avec mon cousin, ok ? Ou sinon, gare à ton téléphone !
|
||||
|
||||
[EN] Don't mess with my cousin ok? He'll wreak your phone! Or at least its cover!''',
|
||||
'privacy': 'public',
|
||||
'media': ['BIRDVSPHONE'],
|
||||
},
|
||||
'KILLERBIRD': {
|
||||
'text': ''':caique: … :caique:
|
||||
|
||||
[FR] Mon cousin me fait peur parfois… j'espère qu'il ne fera jamais rien de tel à aucune des {nb_users} personnes que j'ai rencontré !
|
||||
|
||||
[EN] I'm afraid of my cousin sometimes… I hope he won't do anything like this to any of the {nb_users} people I've met so far!''',
|
||||
'privacy': 'public',
|
||||
'media': ['KILLERBIRD'],
|
||||
},
|
||||
'WITHFRIENDS': {
|
||||
'text': ''':caique: Pwiii pwii pwii~ :caique:
|
||||
|
||||
[FR] En train de passer du temps avec mes amis ! Je leur raconte mes rencontres avec vous tous et les {nb_likes} fois où vous avez apprécié mes visites !
|
||||
|
||||
[EN] Hanging out with my friends, telling them about all {nb_users} of you and how you liked my visits {nb_likes} times!''',
|
||||
'privacy': 'public',
|
||||
'media': ['WITHFRIENDS'],
|
||||
}
|
||||
}
|
||||
|
||||
REWARDS = [
|
||||
{'required_likes': 2,
|
||||
'required_users': 3,
|
||||
'msg_id': 'BELLY_RUB'},
|
||||
{'required_likes': 5,
|
||||
'required_users': 5,
|
||||
'msg_id': 'JUMPING'},
|
||||
{'required_likes': 10,
|
||||
'required_users': 10,
|
||||
'msg_id': 'DANCINGQUEEN'},
|
||||
{'required_likes': 20,
|
||||
'required_users': 12,
|
||||
'msg_id': 'SHOPPING'},
|
||||
{'required_likes': 30,
|
||||
'required_users': 15,
|
||||
'msg_id': 'MICROWAVE'},
|
||||
{'required_likes': 40,
|
||||
'required_users': 20,
|
||||
'msg_id': 'DJBIRB'},
|
||||
{'required_likes': 45,
|
||||
'required_users': 30,
|
||||
'msg_id': 'PAPERTOWEL'},
|
||||
{'required_likes': 46,
|
||||
'required_users': 35,
|
||||
'msg_id': 'RIDING'},
|
||||
{'required_likes': 50,
|
||||
'required_users': 40,
|
||||
'msg_id': 'RIVERDANCE'},
|
||||
{'required_likes': 60,
|
||||
'required_users': 50,
|
||||
'msg_id': 'BIRBVSPHONE'},
|
||||
{'required_likes': 70,
|
||||
'required_users': 60,
|
||||
'msg_id': 'KILLERBIRD'},
|
||||
{'required_likes': 80,
|
||||
'required_users': 70,
|
||||
'msg_id': 'WITHFRIENDS'},
|
||||
]
|
485
delibird-master/main.py
Executable file
485
delibird-master/main.py
Executable file
|
@ -0,0 +1,485 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import datetime
|
||||
import random
|
||||
import json
|
||||
import itertools
|
||||
from getpass import getpass
|
||||
from mastodon import Mastodon, StreamListener
|
||||
from mastodon.Mastodon import MastodonAPIError, MastodonNotFoundError
|
||||
from data import MEDIA, MSGS, REWARDS
|
||||
|
||||
API_BASE = 'https://social.sitedethib.com'
|
||||
|
||||
COMMAND_RE = re.compile(r'(va[s]? voir|vole vers|va, vole vers|rends visite à|go see|go visit|fly to)\s*(.+)', re.IGNORECASE)
|
||||
FREE_RE = re.compile(r"(va[s]? (te promener|jouer))|rends-toi disponible|repose-toi|va-t'en|go idle|go away|((go|play) somewhere else)|take a break", re.IGNORECASE)
|
||||
CANCEL_RE = re.compile(r'reviens|arrête|annule|stop|come back|cancel', re.IGNORECASE)
|
||||
MENTION_RE = re.compile(r'([a-z0-9_]+)(@[a-z0-9\.\-]+[a-z0-9]+)?', re.IGNORECASE)
|
||||
NOTIFY_RE = re.compile(r'dis[ -]moi|notifie[ -]moi|rappelle[ -]moi|tell me|remind me|notify me', re.IGNORECASE)
|
||||
LINK_RE = re.compile(r'<a href="([^"]+)"')
|
||||
|
||||
STATE_OWNED, STATE_IDLE, STATE_DELIVERY = range(3)
|
||||
MAX_OWNED = datetime.timedelta(hours=3)
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
"""Base error class for Delibird-generated errors"""
|
||||
pass
|
||||
|
||||
class InternalError(Error):
|
||||
"""Generic internal server error"""
|
||||
def __init__(self, acct):
|
||||
Error.__init__(self)
|
||||
self.acct = acct
|
||||
|
||||
class InvalidFormatError(Error):
|
||||
"""Error thrown when a command does not contain a properly-formatted
|
||||
account"""
|
||||
pass
|
||||
|
||||
class AccountNotFoundError(Error):
|
||||
"""Error thrown when the requested account cannot be resolved"""
|
||||
def __init__(self, acct):
|
||||
Error.__init__(self)
|
||||
self.acct = acct
|
||||
|
||||
|
||||
class Delibird(StreamListener):
|
||||
"""Main class for the Delibird bot."""
|
||||
|
||||
def __init__(self, mastodon):
|
||||
StreamListener.__init__(self)
|
||||
self.mastodon = mastodon
|
||||
self.state = STATE_IDLE
|
||||
self.owner = None
|
||||
self.target = None
|
||||
self.last_owned = datetime.datetime.now()
|
||||
self.like_count = 0
|
||||
self.visited_users = set()
|
||||
self.to_be_notified = set()
|
||||
self.reward_level = -1
|
||||
self.last_idle_toot = None
|
||||
self.additional_idle_toots = []
|
||||
self.last_read_notification = None
|
||||
self.own_acct_id = None
|
||||
self.last_request_id = None
|
||||
self.visit_to_request_map = {}
|
||||
print('Delibird started!')
|
||||
self.load()
|
||||
self.resume()
|
||||
|
||||
|
||||
def resume(self):
|
||||
"""Process missed notifications."""
|
||||
# Process all missed notifications
|
||||
last_notification = self.last_read_notification
|
||||
if last_notification is not None:
|
||||
for notification in reversed(self.mastodon.notifications(since_id=last_notification)):
|
||||
self.on_notification(notification)
|
||||
|
||||
|
||||
def save(self, path='state.json'):
|
||||
"""Save state to a JSON file."""
|
||||
state = {'like_count': self.like_count,
|
||||
'visited_users': list(self.visited_users),
|
||||
'to_be_notified': list(self.to_be_notified),
|
||||
'reward_level': self.reward_level,
|
||||
'state': self.state,
|
||||
'last_owned': self.last_owned.isoformat(),
|
||||
'additional_idle_toots': self.additional_idle_toots,
|
||||
'visit_to_request_map': self.visit_to_request_map}
|
||||
if self.last_request_id is not None:
|
||||
state['last_request_id'] = self.last_request_id
|
||||
if self.last_read_notification is not None:
|
||||
state['last_read_notification'] = self.last_read_notification
|
||||
if self.last_idle_toot is not None:
|
||||
state['last_idle_toot'] = self.last_idle_toot.id
|
||||
if self.owner is not None:
|
||||
state['owner'] = self.owner.id
|
||||
if self.target is not None:
|
||||
state['target'] = self.target.id
|
||||
if self.own_acct_id is not None:
|
||||
state['own_acct_id'] = self.own_acct_id
|
||||
with open(path, 'w') as file:
|
||||
json.dump(state, file)
|
||||
|
||||
|
||||
def load(self, path='state.json'):
|
||||
"""Load state from a JSON file.
|
||||
May perform API requests to retrieve status or account information."""
|
||||
try:
|
||||
with open(path, 'r') as file:
|
||||
state = json.load(file)
|
||||
self.like_count = state['like_count']
|
||||
self.visited_users = set(state['visited_users'])
|
||||
self.to_be_notified = set(state.get('to_be_notified', []))
|
||||
self.reward_level = state['reward_level']
|
||||
self.state = state.get('state', STATE_IDLE)
|
||||
self.last_read_notification = state.get('last_read_notification', None)
|
||||
self.own_acct_id = state.get('own_acct_id', None)
|
||||
self.additional_idle_toots = state.get('additional_idle_toots', [])
|
||||
self.visit_to_request_map = state.get('visit_to_request_map', {})
|
||||
self.last_request_id = state.get('last_request_id', None)
|
||||
last_idle_toot = state.get('last_idle_toot', None)
|
||||
owner = state.get('owner', None)
|
||||
target = state.get('target', None)
|
||||
last_owned = state.get('last_owned', None)
|
||||
if last_owned:
|
||||
self.last_owned = datetime.datetime.strptime(last_owned, '%Y-%m-%dT%H:%M:%S.%f')
|
||||
self.last_idle_toot = None if last_idle_toot is None else self.mastodon.status(last_idle_toot)
|
||||
self.owner = None if owner is None else self.mastodon.account(owner)
|
||||
self.target = None if target is None else self.mastodon.account(target)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def handle_rewards(self):
|
||||
"""Check rewards conditions and post appropriate toot if conditions for a
|
||||
new reward are met."""
|
||||
level = -1
|
||||
for i, reward in enumerate(REWARDS):
|
||||
if (self.like_count >= reward['required_likes']
|
||||
and len(self.visited_users) >= reward['required_users']):
|
||||
level = i
|
||||
if level > self.reward_level:
|
||||
self.reward_level = level
|
||||
self.send_toot(REWARDS[level]['msg_id'],
|
||||
nb_likes=self.like_count, nb_users=len(self.visited_users))
|
||||
self.save()
|
||||
|
||||
|
||||
def upload_media(self, name):
|
||||
"""Look up a media file description and upload it."""
|
||||
desc = MEDIA[name]
|
||||
media = self.mastodon.media_post(desc['file'],
|
||||
description='Source: %s' % desc['source'])
|
||||
print('Uploaded %s!' % name)
|
||||
return media
|
||||
|
||||
|
||||
def send_toot(self, msg_id, in_reply_to_id=None, **kwargs):
|
||||
"""Look up a toot's description by message id and sends it."""
|
||||
print('Sending a toot… id: %s' % msg_id)
|
||||
msg = MSGS[msg_id]
|
||||
if 'media' in msg:
|
||||
if isinstance(msg['media'], dict):
|
||||
grouped_choices = ([key] * count for key, count in msg['media'].items())
|
||||
choices = list(itertools.chain.from_iterable(grouped_choices))
|
||||
media_desc = random.choice(choices)
|
||||
else:
|
||||
media_desc = msg['media']
|
||||
media = [self.upload_media(name) for name in media_desc]
|
||||
else:
|
||||
media = None
|
||||
try:
|
||||
status = self.mastodon.status_post(msg['text'].format(**kwargs),
|
||||
media_ids=media,
|
||||
in_reply_to_id=in_reply_to_id,
|
||||
visibility=msg.get('privacy', ''))
|
||||
except MastodonNotFoundError:
|
||||
# Original status deleted
|
||||
status = self.mastodon.status_post(msg['text'].format(**kwargs),
|
||||
media_ids=media,
|
||||
visibility=msg.get('privacy', ''))
|
||||
self.own_acct_id = status.account.id
|
||||
self.save()
|
||||
return status
|
||||
|
||||
|
||||
def resolve_account(self, text_with_user, status):
|
||||
"""Process command text to resolve an account from account name, mention
|
||||
or profile URL"""
|
||||
receiver_acct = None
|
||||
# First, see if we have a handle we can resolve, without the leading '@'
|
||||
match = MENTION_RE.match(text_with_user)
|
||||
if match:
|
||||
if match.group(2):
|
||||
# If it's a full handle, use it
|
||||
receiver_acct = match.group(0)
|
||||
else:
|
||||
# If it's *not* a full handle, append the user's domain
|
||||
receiver_acct = '@'.join([match.group(1)] + status.account.acct.split('@')[1:])
|
||||
try:
|
||||
matches = self.mastodon.account_search(receiver_acct)
|
||||
except MastodonAPIError:
|
||||
raise InternalError(receiver_acct)
|
||||
if not matches:
|
||||
raise AccountNotFoundError(receiver_acct)
|
||||
return matches[0]
|
||||
|
||||
# Maybe it's a link to their profile, or a mention. Switch to link handling.
|
||||
match = LINK_RE.search(text_with_user)
|
||||
if match:
|
||||
url = match.group(1)
|
||||
try:
|
||||
matches = self.mastodon.search(url, resolve=True).accounts
|
||||
except MastodonAPIError:
|
||||
raise InternalError(url)
|
||||
if matches:
|
||||
return matches[0]
|
||||
|
||||
raise InvalidFormatError
|
||||
|
||||
|
||||
def handle_unknown_account(self, status, acct):
|
||||
"""Handle unknown accounts, potentially suggesting other accounts."""
|
||||
receiver_acct = acct.split('@')
|
||||
suggested_account = None
|
||||
if len(receiver_acct) > 1:
|
||||
username, domain = receiver_acct
|
||||
try:
|
||||
matches = self.mastodon.account_search(username, limit=40)
|
||||
except MastodonAPIError:
|
||||
pass
|
||||
else:
|
||||
for match in matches:
|
||||
if match.username != username:
|
||||
continue
|
||||
if domain in match.acct:
|
||||
suggested_account = match
|
||||
break
|
||||
if suggested_account is None:
|
||||
self.send_toot('ERROR_UNKNOWN_ACCOUNT', status,
|
||||
sender_acct=status.account.acct, acct=acct)
|
||||
else:
|
||||
self.send_toot('ERROR_UNKNOWN_ACCOUNT2', status,
|
||||
sender_acct=status.account.acct, acct=acct,
|
||||
suggested_acct=suggested_account.acct)
|
||||
|
||||
|
||||
def handle_cmd_free(self, status, match=None):
|
||||
"""Handle the command that allows the bird to go idle prematurely"""
|
||||
if not self.owner or self.owner.id != status.account.id:
|
||||
return
|
||||
if self.state != STATE_OWNED:
|
||||
return
|
||||
self.go_idle('IDLE2')
|
||||
self.save()
|
||||
|
||||
|
||||
def handle_cmd_go_see(self, status, match):
|
||||
"""Handle the “go see” command requesting the bot to visit a given user"""
|
||||
text_with_user = match.group(2)
|
||||
if self.state == STATE_DELIVERY:
|
||||
if self.owner.id == status.account.id:
|
||||
self.send_toot('ERROR_DELIVERY2', status, sender_acct=status.account.acct)
|
||||
elif status.account.acct in self.to_be_notified:
|
||||
self.send_toot('ERROR_DELIVERY3', status, sender_acct=status.account.acct)
|
||||
else:
|
||||
self.send_toot('ERROR_DELIVERY', status, sender_acct=status.account.acct)
|
||||
return
|
||||
if self.state == STATE_OWNED and self.owner and self.owner.id != status.account.id:
|
||||
delta = self.last_owned + MAX_OWNED - datetime.datetime.now()
|
||||
minutes = delta.seconds // 60
|
||||
if status.account.acct in self.to_be_notified:
|
||||
self.send_toot('ERROR_OWNED2', status, sender_acct=status.account.acct,
|
||||
hours=(minutes // 60), minutes=(minutes % 60))
|
||||
else:
|
||||
self.send_toot('ERROR_OWNED', status, sender_acct=status.account.acct,
|
||||
hours=(minutes // 60), minutes=(minutes % 60))
|
||||
return
|
||||
|
||||
try:
|
||||
target = self.resolve_account(text_with_user, status)
|
||||
except AccountNotFoundError as err:
|
||||
self.handle_unknown_account(status, err.acct)
|
||||
return
|
||||
except InvalidFormatError:
|
||||
self.send_toot('ERROR_INVALID_FORMAT', status,
|
||||
sender_acct=status.account.acct)
|
||||
return
|
||||
except InternalError as err:
|
||||
self.send_toot('ERROR_INTERNAL', status,
|
||||
sender_acct=status.account.acct, acct=err.acct)
|
||||
return
|
||||
|
||||
if target.id == self.own_acct_id:
|
||||
self.send_toot('ERROR_OWN_ACCOUNT', status,
|
||||
sender_acct=status.account.acct)
|
||||
return
|
||||
|
||||
if target.id == status.account.id:
|
||||
self.send_toot('ERROR_SAME_ACCOUNT', status,
|
||||
sender_acct=status.account.acct)
|
||||
return
|
||||
|
||||
if '#nobot' in target.note:
|
||||
self.send_toot('ERROR_NOBOT', status,
|
||||
sender_acct=status.account.acct,
|
||||
receiver_acct=target.acct)
|
||||
return
|
||||
|
||||
self.state = STATE_DELIVERY
|
||||
self.last_request_id = status.id
|
||||
self.owner = status.account
|
||||
self.last_owned = datetime.datetime.now()
|
||||
self.target = target
|
||||
|
||||
if self.last_idle_toot is not None:
|
||||
try:
|
||||
self.mastodon.status_delete(self.last_idle_toot)
|
||||
except MastodonAPIError:
|
||||
pass
|
||||
self.last_idle_toot = None
|
||||
for toot_id in self.additional_idle_toots:
|
||||
try:
|
||||
self.mastodon.status_delete(toot_id)
|
||||
except MastodonAPIError:
|
||||
pass
|
||||
self.additional_idle_toots = []
|
||||
|
||||
self.send_toot('DELIVERY_START', status, sender_acct=status.account.acct, acct=self.target.acct)
|
||||
|
||||
|
||||
def handle_cmd_cancel(self, status, match=None):
|
||||
"""Handle the “cancel” command that cancels the last ordered delivery if the
|
||||
user issuing it is the current owner and the delivery hasn't finished yet."""
|
||||
if not self.owner or self.owner.id != status.account.id:
|
||||
return
|
||||
if self.state != STATE_DELIVERY:
|
||||
return
|
||||
self.state = STATE_OWNED
|
||||
self.send_toot('DELIVERY_CANCELLED', status, sender_acct=status.account.acct,
|
||||
acct=self.target.acct)
|
||||
|
||||
|
||||
def handle_cmd_notify(self, status, match=None):
|
||||
"""Handle request to be notified when the Birb goes idle."""
|
||||
if self.state == STATE_IDLE:
|
||||
return
|
||||
self.to_be_notified.add(status.account.acct)
|
||||
self.send_toot('NOTIFY_REQUEST', status, acct=status.account.acct)
|
||||
|
||||
|
||||
def handle_mention(self, status):
|
||||
"""Handle toots mentioning Delibird, which may contain commands"""
|
||||
print('Got a mention!')
|
||||
# Do not reply if multiple people are mentionned
|
||||
if len(status.mentions) > 2:
|
||||
return
|
||||
# Process commands, in order of priority
|
||||
cmds = [(COMMAND_RE, self.handle_cmd_go_see),
|
||||
(CANCEL_RE, self.handle_cmd_cancel),
|
||||
(FREE_RE, self.handle_cmd_free),
|
||||
(NOTIFY_RE, self.handle_cmd_notify)]
|
||||
for regexp, handler in cmds:
|
||||
match = regexp.search(status.content)
|
||||
if match:
|
||||
handler(status, match)
|
||||
return
|
||||
|
||||
|
||||
def deliver(self):
|
||||
"""Deliver a message to the target, updating ownership and state in the
|
||||
process"""
|
||||
try:
|
||||
status = self.send_toot('DELIVERED',
|
||||
sender_acct=self.owner.acct,
|
||||
sender_acct_short=self.owner.acct,
|
||||
receiver_acct=self.target.acct,
|
||||
nb_hours=(MAX_OWNED.seconds // 3600))
|
||||
except MastodonAPIError:
|
||||
try:
|
||||
status = self.send_toot('DELIVERED',
|
||||
sender_acct=self.owner.acct,
|
||||
sender_acct_short=self.owner.username,
|
||||
receiver_acct=self.target.acct,
|
||||
nb_hours=(MAX_OWNED.seconds // 3600))
|
||||
except MastodonAPIError:
|
||||
status = self.send_toot('DELIVERED',
|
||||
sender_acct=self.owner.username,
|
||||
sender_acct_short=self.owner.username,
|
||||
receiver_acct=self.target.acct,
|
||||
nb_hours=(MAX_OWNED.seconds // 3600))
|
||||
if status.mentions:
|
||||
self.visit_to_request_map[status.id] = self.last_request_id
|
||||
self.owner = self.target
|
||||
self.last_owned = datetime.datetime.now()
|
||||
self.state = STATE_OWNED
|
||||
self.visited_users.add(self.owner.id)
|
||||
self.to_be_notified.discard(self.owner.acct)
|
||||
self.save()
|
||||
else:
|
||||
# Could not deliver, maybe OStatus-only?
|
||||
self.state = STATE_OWNED
|
||||
self.send_toot('ERROR_UNDELIVERABLE',
|
||||
sender_acct=self.owner.acct,
|
||||
receiver_acct=self.target.acct)
|
||||
|
||||
|
||||
def go_idle(self, msg_id='IDLE'):
|
||||
"""Turn idle and announce it with a public toot"""
|
||||
self.state = STATE_IDLE
|
||||
self.last_idle_toot = self.send_toot(msg_id)
|
||||
self.save()
|
||||
self.additional_idle_toots = [self.send_toot('NOTIFY_IDLE', self.last_idle_toot,
|
||||
acct=acct).id
|
||||
for acct in self.to_be_notified]
|
||||
self.to_be_notified = set()
|
||||
self.save()
|
||||
|
||||
|
||||
def handle_heartbeat(self):
|
||||
if self.state == STATE_DELIVERY and self.target is not None and random.random() >= 0.94:
|
||||
self.deliver()
|
||||
elif self.state == STATE_DELIVERY:
|
||||
self.handle_rewards()
|
||||
elif self.state == STATE_OWNED and datetime.datetime.now() - self.last_owned > MAX_OWNED:
|
||||
self.go_idle()
|
||||
|
||||
|
||||
def on_notification(self, notification):
|
||||
self.last_read_notification = notification.id
|
||||
if notification.type == 'mention':
|
||||
self.handle_mention(notification.status)
|
||||
if notification.type == 'favourite' and notification.status.visibility == 'direct':
|
||||
self.like_count += 1
|
||||
if notification.status.id in self.visit_to_request_map:
|
||||
try:
|
||||
self.mastodon.status_favourite(self.visit_to_request_map[notification.status.id])
|
||||
except MastodonNotFoundError:
|
||||
del self.visit_to_request_map[notification.status.id]
|
||||
except MastodonAPIError:
|
||||
pass
|
||||
self.save()
|
||||
|
||||
|
||||
|
||||
def register(args):
|
||||
"""Register app on the server"""
|
||||
Mastodon.create_app('Delibird', api_base_url=args.api_base, to_file='secrets/clientcred.secret')
|
||||
|
||||
|
||||
def login(args):
|
||||
"""Log in as the given user, generating OAuth credentials"""
|
||||
mastodon = Mastodon(client_id='secrets/clientcred.secret', api_base_url=args.api_base)
|
||||
mastodon.log_in(args.user_mail, getpass(), to_file='secrets/usercred.secret')
|
||||
|
||||
|
||||
def run(args):
|
||||
"""Run the bot"""
|
||||
mastodon = Mastodon(client_id='secrets/clientcred.secret',
|
||||
access_token='secrets/usercred.secret',
|
||||
api_base_url=args.api_base)
|
||||
delibird = Delibird(mastodon)
|
||||
print('Starting streaming!')
|
||||
mastodon.stream_user(delibird)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('command', type=str, choices=['register', 'login', 'run'])
|
||||
parser.add_argument('-a', '--api-base', type=str, default=API_BASE)
|
||||
parser.add_argument('-u', '--user-mail', type=str)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == 'register':
|
||||
register(args)
|
||||
elif args.command == 'login':
|
||||
login(args)
|
||||
elif args.command == 'run':
|
||||
run(args)
|
||||
|
||||
main()
|
Loading…
Reference in New Issue
Block a user