[feat] Heavy push to Docker

This commit is contained in:
Zeva Rose 2024-03-04 17:35:59 -07:00
parent bfd5087aa9
commit 5d164ede9b
14 changed files with 1319 additions and 1646 deletions

View file

@ -7,7 +7,8 @@
!/jarvis.png !/jarvis.png
!/jarvis_small.png !/jarvis_small.png
!/run.py !/run.py
!/config.yaml !/README.md
!/poetry.lock
# Needed for jarvis-compose # Needed for jarvis-compose
!/.git !/.git

8
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,8 @@
build dev:
stage: build
image: docker
before_script:
- echo "$ZEVARYX_REGISTRY_PASSWORD" | docker login -u "$ZEVARYX_REGISTRY_USERNAME" --password-stdin "$ZEVARYX_REGISTRY_URL"
script:
- docker build -t $ZEVARYX_REGISTRY_URL/jarvis-bot:$CI_COMMIT_BRANCH .
- docker push $ZEVARYX_REGISTRY_URL/jarvis-bot:$CI_COMMIT_BRANCH

View file

@ -1,11 +1,34 @@
FROM python:3.10 FROM python:3.12-bookworm as builder
RUN apt-get update RUN pip install poetry==1.7.1
RUN apt-get install ffmpeg libsm6 libxext6 -y
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
WORKDIR /app WORKDIR /app
COPY . /app COPY pyproject.toml poetry.lock README.md ./
RUN pip install --no-cache-dir .
RUN poetry install --without dev --no-root && rm -rf $POETRY_CACHE_DIR
FROM python:3.12-slim-bookworm as runtime
WORKDIR /app
RUN apt-get update && \
apt-get install -y \
libjpeg-dev \
libopenjp2-7-dev \
libgl-dev \
libglib2.0-dev
ENV VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"
COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY . /app/
CMD [ "python", "run.py" ] CMD [ "python", "run.py" ]

View file

@ -1,34 +0,0 @@
---
token: bot token
twitter:
consumer_key: key
consumer_secret: secret
access_token: access token
access_secret: access secret
mongo:
connect:
username: username
password: password
host: hostname
port: 27017
database: database
urls:
extra: urls
max_messages: 10000
gitlab_token: token
cogs:
- admin
- autoreact
- dev
- image
- gl
- remindme
- rolegiver
# - settings
- starboard
- twitter
- util
- verify
log_level: INFO
sync: false
#sync_commands: True

View file

@ -1,4 +1,5 @@
"""JARVIS bot utility commands.""" """JARVIS bot utility commands."""
import asyncio import asyncio
import logging import logging
import platform import platform
@ -13,10 +14,8 @@ from interactions.models.discord.components import Button
from interactions.models.discord.embed import EmbedField from interactions.models.discord.embed import EmbedField
from interactions.models.discord.enums import ButtonStyle from interactions.models.discord.enums import ButtonStyle
from interactions.models.discord.file import File from interactions.models.discord.file import File
from rich.console import Console
from jarvis.utils import build_embed from jarvis.utils import build_embed
from jarvis.utils.updates import update
class BotutilCog(Extension): class BotutilCog(Extension):
@ -36,12 +35,6 @@ class BotutilCog(Extension):
await ctx.send(content) await ctx.send(content)
await ctx.message.delete() await ctx.message.delete()
@prefixed_command(name="stop")
async def _stop(self, ctx: PrefixedContext) -> None:
await ctx.send("Shutting down now")
loop = asyncio.get_running_loop()
loop.stop()
@prefixed_command(name="tail") @prefixed_command(name="tail")
async def _tail(self, ctx: PrefixedContext, count: int = 10) -> None: async def _tail(self, ctx: PrefixedContext, count: int = 10) -> None:
lines = [] lines = []
@ -56,7 +49,9 @@ class BotutilCog(Extension):
file_bytes.write(log.encode("UTF8")) file_bytes.write(log.encode("UTF8"))
file_bytes.seek(0) file_bytes.seek(0)
log = File(file_bytes, file_name=f"tail_{count}.log") log = File(file_bytes, file_name=f"tail_{count}.log")
await ctx.reply(content=f"Here's the last {count} lines of the log", file=log) await ctx.reply(
content=f"Here's the last {count} lines of the log", file=log
)
else: else:
await ctx.reply(content=f"```\n{log}\n```") await ctx.reply(content=f"```\n{log}\n```")
@ -79,62 +74,25 @@ class BotutilCog(Extension):
st_ts = int(self.bot.start_time.timestamp()) st_ts = int(self.bot.start_time.timestamp())
ut_ts = int(psutil.boot_time()) ut_ts = int(psutil.boot_time())
fields = ( fields = (
EmbedField(name="Operation System", value=platform.system() or "Unknown", inline=False), EmbedField(
name="Operation System",
value=platform.system() or "Unknown",
inline=False,
),
EmbedField(name="Version", value=platform.release() or "N/A", inline=False), EmbedField(name="Version", value=platform.release() or "N/A", inline=False),
EmbedField(name="System Start Time", value=f"<t:{ut_ts}:F> (<t:{ut_ts}:R>)"), EmbedField(
name="System Start Time", value=f"<t:{ut_ts}:F> (<t:{ut_ts}:R>)"
),
EmbedField(name="Python Version", value=platform.python_version()), EmbedField(name="Python Version", value=platform.python_version()),
EmbedField(name="Bot Start Time", value=f"<t:{st_ts}:F> (<t:{st_ts}:R>)"), EmbedField(name="Bot Start Time", value=f"<t:{st_ts}:F> (<t:{st_ts}:R>)"),
) )
embed = build_embed(title="System Info", description="", fields=fields) embed = build_embed(title="System Info", description="", fields=fields)
embed.set_image(url=self.bot.user.avatar.url) embed.set_image(url=self.bot.user.avatar.url)
components = Button(style=ButtonStyle.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}") components = Button(
style=ButtonStyle.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}"
)
await ctx.send(embeds=embed, components=components) await ctx.send(embeds=embed, components=components)
@prefixed_command(name="update")
async def _update(self, ctx: PrefixedContext) -> None:
status = await update(self.bot)
if status:
console = Console()
with console.capture() as capture:
console.print(status.table)
self.logger.debug(capture.get())
self.logger.debug(len(capture.get()))
added = "\n".join(status.added)
removed = "\n".join(status.removed)
changed = "\n".join(status.changed)
fields = [
EmbedField(name="Old Commit", value=status.old_hash),
EmbedField(name="New Commit", value=status.new_hash),
]
if added:
fields.append(EmbedField(name="New Modules", value=f"```\n{added}\n```"))
if removed:
fields.append(EmbedField(name="Removed Modules", value=f"```\n{removed}\n```"))
if changed:
fields.append(EmbedField(name="Changed Modules", value=f"```\n{changed}\n```"))
embed = build_embed("Update Status", description="Updates have been applied", fields=fields)
embed.set_thumbnail(url="https://dev.zevaryx.com/git.png")
self.logger.info("Updates applied")
content = f"```ansi\n{capture.get()}\n```"
components = Button(style=ButtonStyle.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}")
if len(content) < 3000:
await ctx.reply(content, embeds=embed, components=components)
else:
await ctx.reply(
f"Total Changes: {status.lines['total_lines']}",
embeds=embed,
components=components,
)
else:
embed = build_embed(title="Update Status", description="No changes applied", fields=[])
embed.set_thumbnail(url="https://dev.zevaryx.com/git.png")
components = Button(style=ButtonStyle.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}")
await ctx.reply(embeds=embed, components=components)
def setup(bot: Client) -> None: def setup(bot: Client) -> None:
"""Add BotutilCog to JARVIS""" """Add BotutilCog to JARVIS"""

View file

@ -36,7 +36,7 @@ from tzlocal import get_localzone
from jarvis import const as jconst from jarvis import const as jconst
from jarvis.data import pigpen from jarvis.data import pigpen
from jarvis.data.robotcamo import emotes, hk, names from jarvis.data.robotcamo import emotes, hk, names
from jarvis.utils import build_embed, get_repo_hash from jarvis.utils import build_embed
JARVIS_LOGO = Image.open("jarvis_small.png").convert("RGBA") JARVIS_LOGO = Image.open("jarvis_small.png").convert("RGBA")
@ -148,14 +148,7 @@ Tips will be used to pay server costs, and any excess will go to local animal sh
) )
) )
self.bot.logger.debug("Getting repo information") self.bot.logger.debug("Getting repo information")
repo_url = f"https://git.zevaryx.com/stark-industries/jarvis/jarvis-bot/-/tree/{get_repo_hash()}" repo_url = "https://git.zevaryx.com/stark-industries/jarvis/jarvis-bot/"
fields.append(
EmbedField(
name="Git Hash",
value=f"[{get_repo_hash()[:7]}]({repo_url})",
inline=True,
)
)
fields.append( fields.append(
EmbedField(name="Online Since", value=f"<t:{uptime}:F>", inline=False) EmbedField(name="Online Since", value=f"<t:{uptime}:F>", inline=False)
) )

View file

@ -11,6 +11,7 @@ No longer being a part of the community and hearing about *certain users* talkin
that request said features, means that I am no longer putting any energy into this. If you want to keep that request said features, means that I am no longer putting any energy into this. If you want to keep
it running, make a PR to fix things. it running, make a PR to fix things.
""" """
import logging import logging
import re import re
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@ -97,7 +98,7 @@ class DbrandCog(Extension):
self.base_url = "https://dbrand.com/" self.base_url = "https://dbrand.com/"
self._session = aiohttp.ClientSession() self._session = aiohttp.ClientSession()
self._session.headers.update({"Content-Type": "application/json"}) self._session.headers.update({"Content-Type": "application/json"})
self.api_url = load_config().urls["dbrand_shipping"] # self.api_url = load_config().urls["dbrand_shipping"]
self.cache = {} self.cache = {}
def __del__(self): def __del__(self):
@ -401,7 +402,4 @@ class DbrandCog(Extension):
def setup(bot: Client) -> None: def setup(bot: Client) -> None:
"""Add dbrandcog to JARVIS""" """Add dbrandcog to JARVIS"""
if load_config().urls.get("dbrand_shipping"): DbrandCog(bot)
DbrandCog(bot)
else:
bot.logger.info("Missing dbrand shipping URL, not loading dbrand cog")

View file

@ -1,22 +1,12 @@
"""Load the config for JARVIS""" """Load the config for JARVIS"""
from enum import Enum from enum import Enum
from os import environ
from pathlib import Path
from typing import Optional from typing import Optional
import orjson as json from pydantic_settings import BaseSettings, SettingsConfigDict
import yaml
from dotenv import load_dotenv
from jarvis_core.util import find_all
from pydantic import BaseModel
try:
from yaml import CLoader as Loader
except ImportError:
from yaml import Loader
class Mongo(BaseModel): class Mongo(BaseSettings):
"""MongoDB config.""" """MongoDB config."""
host: list[str] | str = "localhost" host: list[str] | str = "localhost"
@ -25,7 +15,7 @@ class Mongo(BaseModel):
port: int = 27017 port: int = 27017
class Redis(BaseModel): class Redis(BaseSettings):
"""Redis config.""" """Redis config."""
host: str = "localhost" host: str = "localhost"
@ -33,7 +23,7 @@ class Redis(BaseModel):
password: Optional[str] = None password: Optional[str] = None
class Mastodon(BaseModel): class Mastodon(BaseSettings):
"""Mastodon config.""" """Mastodon config."""
token: str token: str
@ -47,7 +37,7 @@ class Environment(Enum):
develop = "develop" develop = "develop"
class Config(BaseModel): class Config(BaseSettings, case_sensitive=False):
"""JARVIS config model.""" """JARVIS config model."""
token: str token: str
@ -55,109 +45,19 @@ class Config(BaseModel):
erapi: str erapi: str
"""exchangerate-api.org API token""" """exchangerate-api.org API token"""
environment: Environment = Environment.develop environment: Environment = Environment.develop
mongo: Mongo mongo: Mongo = Mongo()
redis: Redis redis: Redis = Redis()
mastodon: Optional[Mastodon] = None
urls: Optional[dict[str, str]] = None
sync: bool = False sync: bool = False
log_level: str = "INFO" log_level: str = "INFO"
jurigged: bool = False jurigged: bool = False
model_config = SettingsConfigDict(
_config: Config = None env_file=".env", env_file_encoding="utf-8", env_nested_delimiter="."
def _load_json() -> Config | None:
path = Path("config.json")
config = None
if path.exists():
with path.open() as f:
j = json.loads(f.read())
config = Config(**j)
return config
def _load_yaml() -> Config | None:
path = Path("config.yaml")
config = None
if path.exists():
with path.open() as f:
y = yaml.load(f.read(), Loader=Loader)
config = Config(**y)
return config
def _load_env() -> Config | None:
load_dotenv()
data = {}
mongo = {}
redis = {}
mastodon = {}
urls = {}
mongo_keys = find_all(lambda x: x.upper().startswith("MONGO"), environ.keys())
redis_keys = find_all(lambda x: x.upper().startswith("REDIS"), environ.keys())
mastodon_keys = find_all(lambda x: x.upper().startswith("MASTODON"), environ.keys())
url_keys = find_all(lambda x: x.upper().startswith("URLS"), environ.keys())
config_keys = (
mongo_keys
+ redis_keys
+ mastodon_keys
+ url_keys
+ ["TOKEN", "SYNC", "LOG_LEVEL", "JURIGGED"]
) )
for item, value in environ.items():
if item not in config_keys:
continue
if item in mongo_keys: def load_config() -> Config:
key = "_".join(item.split("_")[1:]).lower()
mongo[key] = value
elif item in redis_keys:
key = "_".join(item.split("_")[1:]).lower()
redis[key] = value
elif item in mastodon_keys:
key = "_".join(item.split("_")[1:]).lower()
mastodon[key] = value
elif item in url_keys:
key = "_".join(item.split("_")[1:]).lower()
urls[key] = value
else:
if item == "SYNC":
value = value.lower() in ["yes", "true"]
data[item.lower()] = value
data["mongo"] = mongo
data["redis"] = redis
if all(x is not None for x in mastodon.values()):
data["mastodon"] = mastodon
data["urls"] = {k: v for k, v in urls if v}
return Config(**data)
def load_config(method: Optional[str] = None) -> Config:
""" """
Load the config using the specified method first Load the config using the specified method first
Args:
method: Method to use first
""" """
global _config return Config()
if _config is not None:
return _config
methods = {"yaml": _load_yaml, "json": _load_json, "env": _load_env}
method_names = list(methods.keys())
if method and method in method_names:
method_names.remove(method)
method_names.insert(0, method)
for method in method_names:
if _config := methods[method]():
return _config
raise FileNotFoundError("Missing one of: config.yaml, config.json, .env")

View file

@ -1,7 +1,5 @@
"""JARVIS constants.""" """JARVIS constants."""
from importlib.metadata import version as _v from importlib.metadata import version as _v
try: __version__ = "2.5.2"
__version__ = _v("jarvis")
except Exception:
__version__ = "0.0.0"

View file

@ -2,7 +2,6 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from pkgutil import iter_modules from pkgutil import iter_modules
import git
from interactions.models.discord.embed import Embed, EmbedField from interactions.models.discord.embed import Embed, EmbedField
from interactions.models.discord.guild import AuditLogEntry from interactions.models.discord.guild import AuditLogEntry
from interactions.models.discord.user import Member from interactions.models.discord.user import Member
@ -66,25 +65,4 @@ def modlog_embed(
def get_extensions(path: str) -> list: def get_extensions(path: str) -> list:
"""Get JARVIS cogs.""" """Get JARVIS cogs."""
vals = [x.name for x in iter_modules(path)] vals = [x.name for x in iter_modules(path)]
return [f"jarvis.cogs.{x}" for x in vals] return [f"jarvis.cogs.{x}" for x in vals]
def update() -> int:
"""JARVIS update utility."""
repo = git.Repo(".")
dirty = repo.is_dirty()
current_hash = repo.head.object.hexsha
origin = repo.remotes.origin
origin.fetch()
if current_hash != origin.refs["main"].object.hexsha:
if dirty:
return 2
origin.pull()
return 0
return 1
def get_repo_hash() -> str:
"""JARVIS current branch hash."""
repo = git.Repo(".")
return repo.head.object.hexsha

View file

@ -1,27 +1,19 @@
"""JARVIS update handler.""" """JARVIS update handler."""
import asyncio
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from importlib import import_module from importlib import import_module
from inspect import getmembers, isclass from inspect import getmembers, isclass
from pkgutil import iter_modules from pkgutil import iter_modules
from types import FunctionType, ModuleType from types import FunctionType, ModuleType
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional from typing import Any, Callable, Dict, List
import git from interactions.client.utils.misc_utils import find_all
from interactions.client.errors import ExtensionNotFound
from interactions.client.utils.misc_utils import find, find_all
from interactions.models.internal.application_commands import SlashCommand from interactions.models.internal.application_commands import SlashCommand
from interactions.models.internal.extension import Extension from interactions.models.internal.extension import Extension
from rich.table import Table from rich.table import Table
import jarvis.cogs import jarvis.cogs
if TYPE_CHECKING:
from interactions.client.client import Client
_logger = logging.getLogger(__name__)
@dataclass @dataclass
class UpdateResult: class UpdateResult:
@ -61,153 +53,3 @@ def get_all_commands(module: ModuleType = jarvis.cogs) -> Dict[str, Callable]:
values = cog.__dict__.values() values = cog.__dict__.values()
commands[cog.__module__] = find_all(lambda x: isinstance(x, SlashCommand), values) commands[cog.__module__] = find_all(lambda x: isinstance(x, SlashCommand), values)
return {k: v for k, v in commands.items() if v} return {k: v for k, v in commands.items() if v}
def get_git_changes(repo: git.Repo) -> dict:
"""Get all Git changes"""
logger = _logger
logger.debug("Getting all git changes")
current_hash = repo.head.ref.object.hexsha
tracking = repo.head.ref.tracking_branch()
file_changes = {}
for commit in tracking.commit.iter_items(repo, f"{repo.head.ref.path}..{tracking.path}"):
if commit.hexsha == current_hash:
break
files = commit.stats.files
file_changes.update({key: {"insertions": 0, "deletions": 0, "lines": 0} for key in files.keys()})
for file, stats in files.items():
if file not in file_changes:
file_changes[file] = {"insertions": 0, "deletions": 0, "lines": 0}
for key, val in stats.items():
file_changes[file][key] += val
logger.debug("Found %i changed files", len(file_changes))
table = Table(title="File Changes")
table.add_column("File", justify="left", style="white", no_wrap=True)
table.add_column("Insertions", justify="center", style="green")
table.add_column("Deletions", justify="center", style="red")
table.add_column("Lines", justify="center", style="magenta")
i_total = 0
d_total = 0
l_total = 0
for file, stats in file_changes.items():
i_total += stats["insertions"]
d_total += stats["deletions"]
l_total += stats["lines"]
table.add_row(
file,
str(stats["insertions"]),
str(stats["deletions"]),
str(stats["lines"]),
)
logger.debug("%i insertions, %i deletions, %i total", i_total, d_total, l_total)
table.add_row("Total", str(i_total), str(d_total), str(l_total))
return {
"table": table,
"lines": {"inserted_lines": i_total, "deleted_lines": d_total, "total_lines": l_total},
}
async def update(bot: "Client") -> Optional[UpdateResult]:
"""
Update JARVIS and return an UpdateResult.
Args:
bot: Bot instance
Returns:
UpdateResult object
"""
logger = _logger
repo = git.Repo(".")
current_hash = repo.head.object.hexsha
origin = repo.remotes.origin
origin.fetch()
remote_hash = origin.refs[repo.active_branch.name].object.hexsha
if current_hash != remote_hash:
logger.info("Updating from %s to %s", current_hash, remote_hash)
current_commands = get_all_commands()
changes = get_git_changes(repo)
origin.pull()
await asyncio.sleep(3)
new_commands = get_all_commands()
logger.info("Checking if any modules need reloaded...")
reloaded = []
loaded = []
unloaded = []
logger.debug("Checking for removed cogs")
for module in current_commands.keys():
if module not in new_commands:
logger.debug("Module %s removed after update", module)
bot.unload_extension(module)
unloaded.append(module)
logger.debug("Checking for new/modified commands")
for module, commands in new_commands.items():
logger.debug("Processing %s", module)
if module not in current_commands:
bot.load_extension(module)
loaded.append(module)
elif len(current_commands[module]) != len(commands):
try:
bot.reload_extension(module)
except ExtensionNotFound:
bot.load_extension(module)
reloaded.append(module)
else:
for command in commands:
old_command = find(lambda x: x.resolved_name == command.resolved_name, current_commands[module])
# Extract useful info
old_args = old_command.options
if old_args:
old_arg_names = [x.name for x in old_args]
new_args = command.options
if new_args:
new_arg_names = [x.name for x in new_args]
# No changes
if not old_args and not new_args:
continue
# Check if number arguments have changed
if len(old_args) != len(new_args):
try:
bot.reload_extension(module)
except ExtensionNotFound:
bot.load_extension(module)
reloaded.append(module)
elif any(x not in old_arg_names for x in new_arg_names) or any(
x not in new_arg_names for x in old_arg_names
):
try:
bot.reload_extension(module)
except ExtensionNotFound:
bot.load_extension(module)
reloaded.append(module)
elif any(new_args[idx].type != x.type for idx, x in enumerate(old_args)):
try:
bot.reload_extension(module)
except ExtensionNotFound:
bot.load_extension(module)
reloaded.append(module)
return UpdateResult(
old_hash=current_hash,
new_hash=remote_hash,
added=loaded,
removed=unloaded,
changed=reloaded,
**changes,
)
return None

2457
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "jarvis" name = "jarvis"
version = "2.5.2" version = "2.5.3"
description = "JARVIS admin bot" description = "JARVIS admin bot"
authors = ["Zevaryx <zevaryx@gmail.com>"] authors = ["Zevaryx <zevaryx@gmail.com>"]
@ -37,6 +37,7 @@ pydantic = ">=2.3.0,<3"
orjson = "^3.8.8" orjson = "^3.8.8"
croniter = "^1.4.1" croniter = "^1.4.1"
erapi = { git = "https://git.zevaryx.com/zevaryx-technologies/erapi.git" } erapi = { git = "https://git.zevaryx.com/zevaryx-technologies/erapi.git" }
pydantic-settings = "^2.2.1"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pre-commit = "^2.21.0" pre-commit = "^2.21.0"

View file

@ -1,5 +1,6 @@
# Base Config, required # Base Config, required
TOKEN= TOKEN=
ERAPI=
# Base Config, optional # Base Config, optional
ENVIRONMENT=develop ENVIRONMENT=develop
@ -17,22 +18,3 @@ MONGO_PORT=27017
REDIS_HOST=localhost REDIS_HOST=localhost
REDIS_USERNAME= REDIS_USERNAME=
REDIS_PASSWORD= REDIS_PASSWORD=
# Mastodon, optional
MASTODON_TOKEN=
MASTODON_URL=
# Reddit, optional
REDDIT_USER_AGENT=
REDDIT_CLIENT_SECRET=
REDDIT_CLIENT_ID=
# Twitter, optional
TWITTER_CONSUMER_KEY=
TWITTER_CONSUMER_SECRET=
TWITTER_ACCESS_TOKEN=
TWITTER_ACCESS_SECRET=
TWITTER_BEARER_TOKEN=
# URLs, optional
URL_DBRAND=