initial commit
This commit is contained in:
commit
70dc4d4dbf
215
.gitignore
vendored
Normal file
215
.gitignore
vendored
Normal file
@ -0,0 +1,215 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
|
||||
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
|
||||
/data/
|
8
.idea/.gitignore
vendored
Normal file
8
.idea/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
8
Dockerfile
Normal file
8
Dockerfile
Normal file
@ -0,0 +1,8 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM python:3.10
|
||||
WORKDIR /v6
|
||||
ENV v6root=/v6data
|
||||
COPY requirements.txt requirements.txt
|
||||
RUN pip install -r requirements.txt
|
||||
COPY v6d3vote v6d3vote
|
||||
CMD ["python3", "-m", "v6d3vote.run-bot"]
|
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@ -0,0 +1,4 @@
|
||||
aiohttp~=3.7.4.post0
|
||||
discord.py
|
||||
git+https://gitea.ongoteam.net/PTV/v6d0auth.git
|
||||
git+https://gitea.ongoteam.net/PTV/v6d1tokens.git
|
0
v6d3vote/__init__.py
Normal file
0
v6d3vote/__init__.py
Normal file
3
v6d3vote/config.py
Normal file
3
v6d3vote/config.py
Normal file
@ -0,0 +1,3 @@
|
||||
import os
|
||||
|
||||
prefix = os.getenv('v6prefix', '??')
|
102
v6d3vote/context.py
Normal file
102
v6d3vote/context.py
Normal file
@ -0,0 +1,102 @@
|
||||
import asyncio
|
||||
import time
|
||||
from io import StringIO
|
||||
from typing import Union, Optional, Callable, Awaitable
|
||||
|
||||
# noinspection PyPackageRequirements
|
||||
import discord
|
||||
|
||||
usertype = Union[discord.abc.User, discord.user.BaseUser, discord.Member, discord.User]
|
||||
|
||||
|
||||
class Context:
|
||||
def __init__(self, message: discord.Message):
|
||||
self.message: discord.Message = message
|
||||
self.channel: discord.abc.Messageable = message.channel
|
||||
self.dm_or_text: Union[discord.DMChannel, discord.TextChannel] = message.channel
|
||||
self.author: usertype = message.author
|
||||
self.content: str = message.content
|
||||
self.member: Optional[discord.Member] = message.author if isinstance(message.author, discord.Member) else None
|
||||
self.guild: Optional[discord.Guild] = None if self.member is None else self.member.guild
|
||||
|
||||
async def reply(self, content=None, **kwargs) -> discord.Message:
|
||||
return await self.message.reply(content, mention_author=False, **kwargs)
|
||||
|
||||
async def long(self, s: str):
|
||||
resio = StringIO(s)
|
||||
res = ''
|
||||
for line in resio:
|
||||
if len(res) + len(line) < 2000:
|
||||
res += line
|
||||
else:
|
||||
await self.reply(res)
|
||||
res = line
|
||||
if res:
|
||||
await self.reply(res)
|
||||
|
||||
|
||||
ESCAPED = '`_*\'"\\'
|
||||
|
||||
|
||||
def escape(s: str):
|
||||
res = StringIO()
|
||||
for c in s:
|
||||
if c in ESCAPED:
|
||||
c = '\\' + c
|
||||
res.write(c)
|
||||
return res.getvalue()
|
||||
|
||||
|
||||
buckets: dict[str, dict[str, Callable[[Context, list[str]], Awaitable[None]]]] = {}
|
||||
|
||||
|
||||
def at(bucket: str, name: str):
|
||||
def wrap(f: Callable[[Context, list[str]], Awaitable[None]]):
|
||||
buckets.setdefault(bucket, {})[name] = f
|
||||
|
||||
return f
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
class Explicit(Exception):
|
||||
def __init__(self, msg: str):
|
||||
self.msg = msg
|
||||
|
||||
|
||||
class Implicit(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def of(bucket: str, name: str) -> Callable[[Context, list[str]], Awaitable[None]]:
|
||||
try:
|
||||
return buckets[bucket][name]
|
||||
except KeyError:
|
||||
raise Implicit
|
||||
|
||||
|
||||
benchmarks: dict[str, dict[str, float]] = {}
|
||||
_t = time.perf_counter()
|
||||
|
||||
|
||||
class Benchmark:
|
||||
def __init__(self, benchmark: str):
|
||||
self.benchmark = benchmark
|
||||
|
||||
def __enter__(self):
|
||||
self.t = time.perf_counter()
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
d = (time.perf_counter() - self.t)
|
||||
benchmarks.setdefault(self.benchmark, {'integral': 0.0, 'max': 0.0})
|
||||
benchmarks[self.benchmark]['integral'] += d
|
||||
benchmarks[self.benchmark]['max'] = max(benchmarks[self.benchmark]['max'], d)
|
||||
|
||||
|
||||
async def monitor():
|
||||
while True:
|
||||
await asyncio.sleep(10)
|
||||
dt = time.perf_counter() - _t
|
||||
print('Benchmarks:')
|
||||
for benchmark, metrics in benchmarks.items():
|
||||
print(benchmark, '=', metrics['integral'] / max(dt, .00001), ':', metrics['max'])
|
257
v6d3vote/run-bot.py
Normal file
257
v6d3vote/run-bot.py
Normal file
@ -0,0 +1,257 @@
|
||||
import asyncio
|
||||
import os
|
||||
import shlex
|
||||
from typing import Optional
|
||||
|
||||
# noinspection PyPackageRequirements
|
||||
import discord
|
||||
from ptvp35 import Db, KVJson
|
||||
from v6d0auth.config import root
|
||||
from v6d1tokens.client import request_token
|
||||
|
||||
from v6d3vote.config import prefix
|
||||
from v6d3vote.context import Context, of, at, Implicit, monitor, Explicit
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
token = loop.run_until_complete(request_token('vote'))
|
||||
client = discord.Client(
|
||||
intents=discord.Intents(
|
||||
members=True,
|
||||
guilds=True,
|
||||
bans=True,
|
||||
emojis=True,
|
||||
invites=True,
|
||||
guild_messages=True,
|
||||
reactions=True
|
||||
),
|
||||
)
|
||||
myroot = root / 'v6d3vote'
|
||||
myroot.mkdir(exist_ok=True)
|
||||
vote_db = Db(myroot / 'vote.db', kvrequest_type=KVJson)
|
||||
|
||||
|
||||
@client.event
|
||||
async def on_ready():
|
||||
print("ready")
|
||||
await client.change_presence(activity=discord.Game(
|
||||
name='феноменально',
|
||||
))
|
||||
|
||||
|
||||
async def handle_command(ctx: Context, name: str, args: list[str]) -> None:
|
||||
await of('commands', name)(ctx, args)
|
||||
|
||||
|
||||
@at('commands', 'help')
|
||||
async def help_(ctx: Context, args: list[str]) -> None:
|
||||
match args:
|
||||
case []:
|
||||
await ctx.reply('poll bot')
|
||||
case [name]:
|
||||
await ctx.reply(f'help for {name}: `{name} help`')
|
||||
|
||||
|
||||
class Poll:
|
||||
def __init__(
|
||||
self,
|
||||
message: discord.Message,
|
||||
votes: dict[discord.Member, str],
|
||||
emojis: dict[str, str],
|
||||
options: list[str],
|
||||
title: str
|
||||
):
|
||||
self.message = message
|
||||
self.votes = votes
|
||||
self.emojis = emojis
|
||||
self.reverse: dict[str, str] = {emoji: option for option, emoji in emojis.items()}
|
||||
self.options = options
|
||||
self.title = title
|
||||
|
||||
def saved(self):
|
||||
return {
|
||||
'votes': {
|
||||
str(member.id): option for member, option in self.votes.items()
|
||||
},
|
||||
'emojis': self.emojis,
|
||||
'options': self.options,
|
||||
'title': self.title
|
||||
}
|
||||
|
||||
def content(self):
|
||||
count: dict[str, int] = {}
|
||||
for _, option in self.votes.items():
|
||||
count[option] = count.get(option, 0) + 1
|
||||
return (
|
||||
f'{self.title}\n'
|
||||
+
|
||||
'\n'.join(f'{self.emojis[option]} `{count.get(option, 0)}` {option}' for option in self.options)
|
||||
)
|
||||
|
||||
async def save(self):
|
||||
await vote_db.set(
|
||||
self.message.id,
|
||||
self.saved()
|
||||
)
|
||||
await self.message.edit(
|
||||
content=self.content()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def create(cls, ctx: Context, options: list[tuple[str, discord.Emoji | str]], title: str):
|
||||
message: discord.Message = await ctx.reply('creating poll...')
|
||||
async with lock_for(message):
|
||||
poll = Poll(
|
||||
message,
|
||||
{},
|
||||
{option: str(emoji) for option, emoji in options},
|
||||
[option for option, _ in options],
|
||||
title
|
||||
)
|
||||
for _, emoji in options:
|
||||
await message.add_reaction(emoji)
|
||||
await poll.save()
|
||||
await ctx.message.delete()
|
||||
|
||||
@staticmethod
|
||||
async def load_votes(guild: discord.Guild, votes: dict[str, str]) -> dict[discord.Member, str]:
|
||||
loaded: dict[discord.Member, str] = {}
|
||||
for member, option in votes.items():
|
||||
try:
|
||||
loaded[guild.get_member(int(member)) or await guild.fetch_member(int(member))] = option
|
||||
except (ValueError, discord.HTTPException):
|
||||
pass
|
||||
return loaded
|
||||
|
||||
@classmethod
|
||||
async def load(cls, message: discord.Message) -> Optional['Poll']:
|
||||
saved: Optional[dict[str, dict[str, str] | list[str]]] = vote_db.get(message.id, None)
|
||||
if saved is None:
|
||||
return None
|
||||
# noinspection PyTypeChecker
|
||||
guild: discord.Guild = message.guild
|
||||
return cls(
|
||||
message,
|
||||
await cls.load_votes(guild, saved['votes']),
|
||||
saved['emojis'],
|
||||
saved['options'],
|
||||
saved.get('title', 'unnamed')
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def global_vote(cls, rrae: discord.RawReactionActionEvent):
|
||||
if rrae.user_id == client.user.id:
|
||||
return
|
||||
guild: discord.Guild = client.get_guild(rrae.guild_id) or await client.fetch_guild(rrae.guild_id)
|
||||
member: discord.Member = guild.get_member(rrae.user_id) or await guild.fetch_member(rrae.user_id)
|
||||
channel: discord.TextChannel = guild.get_channel(rrae.channel_id)
|
||||
message: discord.Message = await channel.fetch_message(rrae.message_id)
|
||||
if message.author != client.user:
|
||||
return
|
||||
async with lock_for(message):
|
||||
poll = await cls.load(message)
|
||||
if poll is None:
|
||||
return
|
||||
await poll.vote(member, rrae.emoji, rrae.event_type == 'REACTION_REMOVE')
|
||||
await poll.save()
|
||||
|
||||
async def vote(self, member: discord.Member, emoji: discord.Emoji | str, remove: bool):
|
||||
if str(emoji) in self.reverse:
|
||||
option = self.reverse[str(emoji)]
|
||||
if remove:
|
||||
if self.votes.get(member) == option:
|
||||
del self.votes[member]
|
||||
else:
|
||||
self.votes[member] = option
|
||||
for other_reaction in self.message.reactions:
|
||||
if str(other_reaction.emoji) != str(emoji):
|
||||
await self.message.remove_reaction(other_reaction.emoji, member)
|
||||
|
||||
|
||||
locks: dict[discord.Message, asyncio.Lock] = {}
|
||||
|
||||
|
||||
def lock_for(message: discord.Message) -> asyncio.Lock:
|
||||
if message is None:
|
||||
raise Explicit('not in a guild')
|
||||
if message in locks:
|
||||
return locks[message]
|
||||
else:
|
||||
return locks.setdefault(message, asyncio.Lock())
|
||||
|
||||
|
||||
async def poll_options(args: list[str]) -> list[tuple[str, discord.Emoji | str]]:
|
||||
options: list[tuple[str, discord.Emoji | str]] = []
|
||||
while args:
|
||||
match args:
|
||||
case [emoji, option, *args]:
|
||||
print(emoji)
|
||||
try:
|
||||
emoji = client.get_emoji(
|
||||
int(''.join(c for c in emoji.rsplit(':', 1)[-1] if c.isdecimal()))
|
||||
)
|
||||
except (discord.NotFound, ValueError):
|
||||
pass
|
||||
options.append((option, emoji))
|
||||
case _:
|
||||
raise Explicit('option not specified')
|
||||
return options
|
||||
|
||||
|
||||
@at('commands', 'poll')
|
||||
async def create_poll(ctx: Context, args: list[str]) -> None:
|
||||
match args:
|
||||
case ['help']:
|
||||
await ctx.reply('`poll title emoji option [emoji option ...]`')
|
||||
case []:
|
||||
raise Explicit('no options')
|
||||
case [title, *args]:
|
||||
await Poll.create(ctx, await poll_options(args), title)
|
||||
|
||||
|
||||
async def handle_args(message: discord.Message, args: list[str]):
|
||||
match args:
|
||||
case []:
|
||||
return
|
||||
case [command_name, *command_args]:
|
||||
ctx = Context(message)
|
||||
try:
|
||||
await handle_command(ctx, command_name, command_args)
|
||||
except Implicit:
|
||||
pass
|
||||
except Explicit as e:
|
||||
await ctx.reply(e.msg)
|
||||
|
||||
|
||||
@client.event
|
||||
async def on_message(message: discord.Message) -> None:
|
||||
if message.author.bot:
|
||||
return
|
||||
content: str = message.content
|
||||
if not content.startswith(prefix):
|
||||
return
|
||||
content = content.removeprefix(prefix)
|
||||
args = shlex.split(content)
|
||||
await handle_args(message, args)
|
||||
|
||||
|
||||
@client.event
|
||||
async def on_raw_reaction_add(rrae: discord.RawReactionActionEvent) -> None:
|
||||
await Poll.global_vote(rrae)
|
||||
|
||||
|
||||
@client.event
|
||||
async def on_raw_reaction_remove(rrae: discord.RawReactionActionEvent) -> None:
|
||||
await Poll.global_vote(rrae)
|
||||
|
||||
|
||||
async def main():
|
||||
async with vote_db:
|
||||
await client.login(token)
|
||||
if os.getenv('v6monitor'):
|
||||
loop.create_task(monitor())
|
||||
await client.connect()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
loop.run_until_complete(main())
|
Loading…
Reference in New Issue
Block a user