first commit

This commit is contained in:
Nils Büchner 2024-03-25 22:14:25 +01:00
commit c0337a90a2
12 changed files with 675 additions and 0 deletions

View file

@ -0,0 +1,34 @@
on: [push]
jobs:
build:
runs-on: docker
container:
image: ghcr.io/catthehacker/ubuntu:act-22.04
env:
PIP_INDEX_URL: https://pypi.haxxors.com/simple
UNSHARED_SECRET: ${{ secrets.UNSHARED_SECRET }}
HOMESERVER_URL: ${{ secrets.HOMESERVER_URL }}
HOMESERVER_SECRET: ${{ secrets.HOMESERVER_SECRET }}
HOMESERVER_DOMAIN: ${{ secrets.HOMESERVER_DOMAIN }}
ADMIN_PW: ${{ secrets.ADMIN_PW }}
PUBLIC_URL: ${{ secrets.PUBLIC_URL }}
steps:
- uses: actions/checkout@v4
- run: cp ubottu/config.yaml.deploy ubottu/config.yaml
- run: rm -f ubottu/config.yaml.deploy
- run: rm -f ubottu/config.yaml.default
- run: sed -i "s/%%UNSHARED_SECRET%%/${UNSHARED_SECRET}/g" ubottu/config.yaml
- run: sed -i "s/%%HOMESERVER_URL%%/${HOMESERVER_URL}/g" ubottu/config.yaml
- run: sed -i "s/%%HOMESERVER_SECRET%%/${HOMESERVER_SECRET}/g" ubottu/config.yaml
- run: sed -i "s/%%HOMESERVER_DOMAIN%%/${HOMESERVER_DOMAIN}/g" ubottu/config.yaml
- run: sed -i "s/%%ADMIN_PW%%/${ADMIN_PW}/g" ubottu/config.yaml
- run: sed -i "s/%%PUBLIC_URL%%/${PUBLIC_URL}/g" ubottu/config.yaml
- run: pip install maubot
- run: mbc build # Build the project
- run: mkdir -p output # Ensure output directory exists, `-p` to prevent error if already exists
- run: mv *.mbp output/ubottu-latest-py3.10.mbp # Move built file to output
- uses: actions/upload-artifact@v3
with:
name: ubottu-latest-py3.10.mbp.zip
path: output/ubottu-latest-py3.10.mbp

16
.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
venv
olm
.local
.cmake
.cache
.ubottu
.bashrc
.python_history
.bash_history
*.mbp
*.db
*.log
config.yaml
plugins/*
ubottu/maubot.db
ubottu/config.yaml

0
README.md Normal file
View file

6
base-config.yaml Normal file
View file

@ -0,0 +1,6 @@
whitelist:
- "@ravage:xentonix.net"
rooms:
- "!XBHBxuegpdVZEJBOIh:xentonix.net"
- "!TloppdJexqToFbZUZW:xentonix.net"
- "!EJhpCQHHqqcNicfiql:xentonix.net"

11
maubot.yaml Normal file
View file

@ -0,0 +1,11 @@
maubot: 0.1.0
id: com.ubuntu.ubottu
version: 1.0.0
license: MIT
modules:
- ubottu
main_class: Ubottu
config: true
webapp: true
extra_files:
- base-config.yaml

35
requirements.txt Normal file
View file

@ -0,0 +1,35 @@
aiohttp==3.9.3
aiosignal==1.3.1
aiosqlite==0.18.0
asyncpg==0.28.0
attrs==23.2.0
bcrypt==4.1.2
Brotli==1.1.0
certifi==2024.2.2
cffi==1.16.0
charset-normalizer==3.3.2
click==8.1.7
colorama==0.4.6
commonmark==0.9.1
feedparser==6.0.11
frozenlist==1.4.1
idna==3.6
Jinja2==3.1.3
MarkupSafe==2.1.5
maubot==0.4.2
mautrix==0.20.4
multidict==6.0.5
packaging==24.0
prompt-toolkit==3.0.43
pycparser==2.21
python-olm==3.2.16
questionary==1.10.0
requests==2.31.0
ruamel.yaml==0.17.40
ruamel.yaml.clib==0.2.8
setuptools==69.2.0
sgmllib3k==1.0.0
urllib3==2.2.1
wcwidth==0.2.13
wheel==0.43.0
yarl==1.9.4

1
ubottu/__init__.py Normal file
View file

@ -0,0 +1 @@
from .bot import Ubottu

6
ubottu/base-config.yaml Normal file
View file

@ -0,0 +1,6 @@
whitelist:
- "@ravage:xentonix.net"
rooms:
- "!XBHBxuegpdVZEJBOIh:xentonix.net"
- "!TloppdJexqToFbZUZW:xentonix.net"
- "!EJhpCQHHqqcNicfiql:xentonix.net"

198
ubottu/bot.py Normal file
View file

@ -0,0 +1,198 @@
import json
import sqlite3
import os
import re
import requests
from typing import Type, Tuple
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
from maubot import Plugin, MessageEvent
from maubot.handlers import command
from aiohttp.web import Request, Response, json_response
from pathlib import Path
from urllib.parse import urlparse, unquote
from .floodprotection import FloodProtection
from .packages import Apt
from launchpadlib.launchpad import Launchpad
class Config(BaseProxyConfig):
def do_update(self, helper: ConfigUpdateHelper) -> None:
helper.copy("whitelist")
helper.copy("rooms")
class Ubottu(Plugin):
def sanitize_string(self, input_string):
# Pattern includes single quotes, double quotes, semicolons, and common SQL comment markers
pattern = r"[\'\";]|(--)|(/\*)|(\*/)"
# Replace the identified patterns with an empty string
safe_string = re.sub(pattern, '', input_string)
return safe_string
async def pre_start(self) -> None:
#if await self.get_ubottu_db('https://ubottu.com/ubuntu3.db'):
# self.db = sqlite3.connect("/home/maubot/.ubottu/ubuntu3.db")
#else:
# return False
return True
async def start(self) -> None:
self.config.load_and_update()
self.flood_protection = FloodProtection()
async def get_ubottu_db(self, url):
"""Load a file from a URL into an in-memory filesystem."""
# Create a filename if required
u = urlparse(url)
fn = "/home/maubot/.ubottu/" + os.path.basename(u.path)
#if os.path.isfile(fn):
# return fn
requests.packages.urllib3.util.connection.HAS_IPV6 = False
with requests.get(url, stream=True) as r:
r.raise_for_status() # Checks if the request was successful
# Open the local file in binary write mode
with open(fn, 'wb+') as f:
for chunk in r.iter_content(chunk_size=8192):
# If you have a chunk of data, write it to the file
if chunk:
f.write(chunk)
f.close()
return fn
def check_access(self, sender, room_id):
if sender in self.config["whitelist"] and room_id in self.config["rooms"]:
return True
return False
def check_access_sender(self, sender):
if sender in self.config["whitelist"]:
return True
return False
#@command.new(name="email", aliases=["json"])
@command.new(name="jsontest", aliases=["json"])
async def email(self, evt: MessageEvent) -> None:
if self.check_access(evt.sender, evt.room_id):
url='https://xentonix.net/test.json'
resp = await self.http.get(url)
if resp.status == 200:
data = await resp.json()
#print(data)
await evt.reply(data['employees'][0]['email'])
async def lookup_factoid_irc(self, command_name, to_user, evt):
sql = "SELECT value FROM facts where name = '" + command_name + "' LIMIT 1"
db = self.db
cur = db.cursor()
cur.execute(sql)
rows = cur.fetchall()
row = None
for row in rows:
if row[0].startswith('<alias>'):
command_name = str(row[0]).replace('<alias> ', '')
sql = "SELECT value FROM facts where name = '" + command_name + "' LIMIT 1"
cur.execute(sql)
rows = cur.fetchall()
for row in rows:
break
break
if row is not None and row[0].startswith('<reply>'):
output = str(row[0]).replace('<reply> ', '')
if to_user:
await evt.respond(to_user + ': ' + output)
else:
await evt.respond(output)
return True
return False
async def lookup_factoid_matrix(self, command_name, to_user, evt):
api_url = 'http://127.0.0.1:8000/factoids/api/facts/'
url = api_url + command_name + '/?format=json'
resp = await self.http.get(url)
if resp and resp.status == 200:
data = await resp.json()
if data:
id = data['id']
name = data['name']
value = data['value']
ftype = data['ftype']
if ftype == 'ALIAS':
command_name = value
url = api_url + command_name + '/?format=json'
resp = await self.http.get(url)
if resp and resp.status == 200:
data = await resp.json()
value = data['value']
if to_user:
await evt.respond(to_user + ': ' + value)
else:
await evt.respond(value)
return True
return False
@command.passive("^!(.+)$")
async def command(self, evt: MessageEvent, match: Tuple[str]) -> None:
# allow all rooms and users, only enable flood protection
#if self.check_access(evt.sender, evt.room_id):
if self.flood_protection.flood_check(evt.sender):
args = []
to_user = ''
command_name = self.sanitize_string(match[0][1:].split(' ')[0])
full_command = re.sub(r'\s+', ' ', match[0][1:])
if full_command.count('|') > 0:
to_user = self.sanitize_string(full_command.split('|')[1].strip())
args = full_command.split('|')[0].strip().split(' ')[1:]
else:
args = full_command.strip().split(' ')[1:]
#reload stuff
if command_name == 'reload' and self.check_access_sender(evt.sender):
if self.pre_start():
await evt.respond('Reload completed')
else:
await evt.respond('Reload failed')
return True
#block !tr factoid to allow translation
if command_name == 'tr':
return False
if command_name == 'time' or command_name == 'utc':
if command_name == 'utc':
city = 'London'
else:
city = " ".join(args)
api_url = 'http://127.0.0.1:8000/factoids/api/citytime/' + city + '/?format=json'
resp = await self.http.get(api_url)
if resp and resp.status == 200:
data = await resp.json()
if data:
await evt.respond('The current time in ' + data['location'] + ' is ' + data['local_time'])
#!package lookup command
if command_name == 'package' or command_name == 'depends':
apt = Apt()
if len(args) == 0:
return False
if len(args) == 1:
if command_name == 'depends':
await evt.respond(apt.depends(args[0], 'noble', False))
else:
await evt.respond(apt.info(args[0], 'noble', False))
return True
if len(args) == 2:
if args[1] in ['jammy', 'noble', 'mantic']:
if command_name == 'depends':
await evt.respond(apt.info(args[0], args[1], False))
else:
await evt.respond(apt.depends(args[0], args[1], False))
return True
return False
# check for factoids IRC
#if await self.lookup_factoid_irc(command_name, to_user, evt):
# return True
# check for factoids matrix
if await self.lookup_factoid_matrix(command_name, to_user, evt):
return True
@classmethod
def get_config_class(cls) -> Type[BaseProxyConfig]:
return Config

123
ubottu/config.yaml.deploy Normal file
View file

@ -0,0 +1,123 @@
# The full URI to the database. SQLite and Postgres are fully supported.
# Other DBMSes supported by SQLAlchemy may or may not work.
# Format examples:
# SQLite: sqlite:filename.db
# Postgres: postgresql://username:password@hostname/dbname
database: sqlite:maubot.db
# Separate database URL for the crypto database. "default" means use the same database as above.
crypto_database: default
# Additional arguments for asyncpg.create_pool() or sqlite3.connect()
# https://magicstack.github.io/asyncpg/current/api/index.html#asyncpg.pool.create_pool
# https://docs.python.org/3/library/sqlite3.html#sqlite3.connect
# For sqlite, min_size is used as the connection thread pool size and max_size is ignored.
database_opts:
min_size: 1
max_size: 10
plugin_directories:
# The directory where uploaded new plugins should be stored.
upload: ./plugins
# The directories from which plugins should be loaded.
# Duplicate plugin IDs will be moved to the trash.
load:
- ./plugins
trash: ./trash
# Configuration for storing plugin databases
plugin_databases:
# The directory where SQLite plugin databases should be stored.
sqlite: ./plugins
# The connection URL for plugin databases. If null, all plugins will get SQLite databases.
# If set, plugins using the new asyncpg interface will get a Postgres connection instead.
# Plugins using the legacy SQLAlchemy interface will always get a SQLite connection.
#
# To use the same connection pool as the default database, set to "default"
# (the default database above must be postgres to do this).
#
# When enabled, maubot will create separate Postgres schemas in the database for each plugin.
# To view schemas in psql, use `\dn`. To view enter and interact with a specific schema,
# use `SET search_path = name` (where `name` is the name found with `\dn`) and then use normal
# SQL queries/psql commands.
postgres:
# Maximum number of connections per plugin instance.
postgres_max_conns_per_plugin: 3
# Overrides for the default database_opts when using a non-"default" postgres connection string.
postgres_opts: {}
server:
# The IP and port to listen to.
hostname: 127.0.0.1
port: 28316
# Public base URL where the server is visible.
public_url: %%PUBLIC_URL%%
# The base path for the UI.
ui_base_path: /_matrix/maubot
# The base path for plugin endpoints. The instance ID will be appended directly.
plugin_base_path: /_matrix/maubot/plugin/
# Override path from where to load UI resources.
# Set to false to using pkg_resources to find the path.
override_resource_path: false
# The shared secret to sign API access tokens.
# Set to "generate" to generate and save a new token at startup.
unshared_secret: %%UNSHARED_SECRET%%
# Known homeservers. This is required for the `mbc auth` command and also allows
# more convenient access from the management UI. This is not required to create
# clients in the management UI, since you can also just type the homeserver URL
# into the box there.
homeservers:
%%HOMESERVER_DOMAIN%%:
# Client-server API URL
url: %%HOMESERVER_URL%%
# registration_shared_secret from synapse config
secret: %%HOMESERVER_SECRET%%
# List of administrator users. Plaintext passwords will be bcrypted on startup. Set empty password
# to prevent normal login. Root is a special user that can't have a password and will always exist.
admins:
admin: %%ADMIN_PW%%
api_features:
login: true
plugin: true
plugin_upload: true
instance: true
instance_database: true
client: true
client_proxy: true
client_auth: true
dev_open: true
log: true
# Python logging configuration.
#
# See section 16.7.2 of the Python documentation for more info:
# https://docs.python.org/3.6/library/logging.config.html#configuration-dictionary-schema
logging:
version: 1
formatters:
colored:
(): maubot.lib.color_log.ColorFormatter
format: '[%(asctime)s] [%(levelname)s@%(name)s] %(message)s'
normal:
format: '[%(asctime)s] [%(levelname)s@%(name)s] %(message)s'
handlers:
file:
class: logging.handlers.RotatingFileHandler
formatter: normal
filename: ./maubot.log
maxBytes: 10485760
backupCount: 10
console:
class: logging.StreamHandler
formatter: colored
loggers:
maubot:
level: DEBUG
mautrix:
level: DEBUG
aiohttp:
level: INFO
root:
level: DEBUG
handlers: [file, console]

25
ubottu/floodprotection.py Normal file
View file

@ -0,0 +1,25 @@
from collections import defaultdict
from time import time
class FloodProtection:
def __init__(self):
self.user_commands = defaultdict(list) # Stores timestamps of commands for each user
self.max_commands = 3
self.time_window = 60 # 60 seconds
def flood_check(self, user_id):
"""Check if a user can send a command based on flood protection limits."""
current_time = time()
if user_id not in self.user_commands:
self.user_commands[user_id] = [current_time]
return True # Allow the command if the user has no recorded commands
# Remove commands outside the time window
self.user_commands[user_id] = [timestamp for timestamp in self.user_commands[user_id] if current_time - timestamp < self.time_window]
if len(self.user_commands[user_id]) < self.max_commands:
self.user_commands[user_id].append(current_time)
return True # Allow the command if under the limit
# Otherwise, do not allow the command
return False

220
ubottu/packages.py Normal file
View file

@ -0,0 +1,220 @@
# -*- Encoding: utf-8 -*-
###
# Copyright (c) 2006-2007 Dennis Kaarsemaker
# Copyright (c) 2008-2010 Terence Simpson
# Copyright (c) 2017- Krytarik Raido
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of version 2 of the GNU General Public License as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
###
import warnings
warnings.filterwarnings("ignore", "apt API not stable yet", FutureWarning)
import subprocess, os, apt, re
#import supybot.utils as utils
from email.parser import FeedParser
def component(arg):
if '/' in arg:
return arg[:arg.find('/')]
return 'main'
def description(pkg):
if 'Description-en' in pkg:
return pkg['Description-en'].split('\n')[0]
elif 'Description' in pkg:
return pkg['Description'].split('\n')[0]
return "Description not available"
class Apt:
def __init__(self):
self.aptdir = os.path.expanduser('~') + '/apt-data'
self.distros = []
#self.plugin = "plugin"
#self.log = "apt.log"
os.environ["LANG"] = "C.UTF-8"
if self.aptdir:
self.distros = sorted([x[:-5] for x in os.listdir(self.aptdir) if x.endswith('.list')])
def apt_cache(self, distro, cmd, pkg):
return subprocess.check_output(['apt-cache',
'-oAPT::Architecture=amd64',
'-oAPT::Architectures::=i386',
'-oAPT::Architectures::=amd64',
'-oDir::State::Lists=%s/%s' % (self.aptdir, distro),
'-oDir::State::Status=%s/%s.status' % (self.aptdir, distro),
'-oDir::Etc::SourceList=%s/%s.list' % (self.aptdir, distro),
'-oDir::Etc::SourceParts=""',
'-oDir::Cache=%s/cache' % self.aptdir] +
cmd + [pkg.lower()]).decode('utf-8')
def apt_file(self, distro, pkg):
return subprocess.check_output(['apt-file',
'-oAPT::Architecture=amd64',
'-oAPT::Architectures::=i386',
'-oAPT::Architectures::=amd64',
'-oDir::State::Lists=%s/%s' % (self.aptdir, distro),
'-oDir::State::Status=%s/%s.status' % (self.aptdir, distro),
'-oDir::Etc::SourceList=%s/%s.list' % (self.aptdir, distro),
'-oDir::Etc::SourceParts=""',
'-oDir::Cache=%s/cache' % self.aptdir,
'-l', '-i', 'search', pkg]).decode('utf-8')
def _parse(self, pkg):
parser = FeedParser()
parser.feed(pkg)
return parser.close()
def find(self, pkg, distro, filelookup=True):
if distro.split('-')[0] in ('oldstable', 'stable', 'unstable', 'testing', 'experimental'):
pkgTracURL = "https://packages.debian.org"
else:
pkgTracURL = "https://packages.ubuntu.com"
try:
data = self.apt_cache(distro, ['search', '-n'], pkg)
except subprocess.CalledProcessError as e:
data = e.output
if not data:
if filelookup:
try:
data = self.apt_file(distro, pkg).split()
except subprocess.CalledProcessError as e:
if e.returncode == 1:
return 'Package/file %s does not exist in %s' % (pkg, distro)
#self.log.error("PackageInfo/packages: Please update the cache for %s" % distro)
return "Cache out of date, please contact the administrator"
except OSError:
#self.log.error("PackageInfo/packages: apt-file is not installed")
return "Please use %s/ to search for files" % pkgTracURL
if data:
if len(data) > 10:
return "File %s found in %s and %d others <%s/search?searchon=contents&keywords=%s&mode=exactfilename&suite=%s&arch=any>" % (pkg, ', '.join(data[:10]), len(data)-10, pkgTracURL, utils.web.urlquote(pkg), distro)
return "File %s found in %s" % (pkg, ', '.join(data))
return 'Package/file %s does not exist in %s' % (pkg, distro)
return "No packages matching '%s' could be found" % pkg
pkgs = [x.split()[0] for x in data.split('\n') if x]
if len(pkgs) > 10:
return "Found: %s and %d others <%s/search?keywords=%s&searchon=names&suite=%s&section=all>" % (', '.join(pkgs[:10]), len(pkgs)-10, pkgTracURL, utils.web.urlquote(pkg), distro)
else:
return "Found: %s" % ', '.join(pkgs)
def raw_info(self, pkg, distro, isSource, archlookup=True):
try:
data = self.apt_cache(distro, ['show'] if not isSource else ['showsrc', '--only-source'], pkg)
except subprocess.CalledProcessError:
data = ''
if not data:
return 'Package %s does not exist in %s' % (pkg, distro)
maxp = {'Version': '0~'}
packages = list(map(self._parse, [x for x in data.split('\n\n') if x]))
for p in packages:
if apt.apt_pkg.version_compare(maxp['Version'], p['Version']) <= 0:
maxp = p
if isSource:
bdeps = maxp.get('Build-Depends')
vcs = maxp.get('Vcs-Browser')
for (key, value) in list(maxp.items()):
if key.startswith('Build-Depends-'):
bdeps = "%s, %s" % (bdeps, value) if bdeps else value
elif key.startswith('Vcs-') and not vcs:
vcs = "%s (%s)" % (value, key[4:])
maxp['Builddeps'] = bdeps
maxp['Vcs'] = vcs
return maxp
if not maxp.get('Source'):
maxp['Sourcepkg'] = maxp['Package']
else:
maxp['Sourcepkg'] = maxp['Source'].split()[0]
if not archlookup:
return maxp
try:
data2 = self.apt_cache(distro, ['showsrc', '--only-source'], maxp['Sourcepkg'])
except subprocess.CalledProcessError:
data2 = ''
if not data2:
return maxp
maxp2 = {'Version': '0~'}
packages2 = list(map(self._parse, [x for x in data2.split('\n\n') if x]))
for p in packages2:
if apt.apt_pkg.version_compare(maxp2['Version'], p['Version']) <= 0:
maxp2 = p
archs = re.match(r'.*^ %s \S+ \S+ \S+ arch=(?P<arch>\S+)$' % re.escape(pkg), maxp2['Package-List'],
re.I | re.M | re.DOTALL)
if archs:
archs = archs.group('arch').split(',')
if not ('any' in archs or 'all' in archs):
maxp['Architectures'] = ', '.join(archs)
return maxp
def info(self, pkg, distro, isSource):
maxp = self.raw_info(pkg, distro, isSource)
if isinstance(maxp, str):
return maxp
if isSource:
return "%s (%s, %s): Packages %s. Maintained by %s%s" % (
maxp['Package'], maxp['Version'], distro, maxp['Binary'].replace('\n',''),
re.sub(r' <\S+>$', '', maxp.get('Original-Maintainer', maxp['Maintainer'])),
" @ %s" % maxp['Vcs'] if maxp['Vcs'] else "")
return "{} ({}, {}): {}. In component {}, is {}. Built by {}. Size {:,} kB / {:,} kB{}".format(
maxp['Package'], maxp['Version'], distro, description(maxp), component(maxp['Section']),
maxp['Priority'], maxp['Sourcepkg'], int((int(maxp['Size'])/1024)+1), int(maxp['Installed-Size']),
". (Only available for %s.)" % maxp['Architectures'] if maxp.get('Architectures') else "")
def depends(self, pkg, distro, isSource):
maxp = self.raw_info(pkg, distro, isSource, archlookup=False)
if isinstance(maxp, str):
return maxp
if isSource:
return "%s (%s, %s): Build depends on %s" % (
maxp['Package'], maxp['Version'], distro, maxp.get('Builddeps', "nothing").replace('\n',''))
return "%s (%s, %s): Depends on %s%s" % (
maxp['Package'], maxp['Version'], distro, maxp.get('Depends', "nothing").replace('\n',''),
". Recommends %s" % maxp['Recommends'].replace('\n','') if maxp.get('Recommends') else "")
# Simple test
if __name__ == "__main__":
import sys
argv = sys.argv
argc = len(argv)
if argc == 1:
print("Need at least one arg")
sys.exit(1)
if argc > 3:
print("Only takes 2 args")
sys.exit(1)
class FakePlugin:
class FakeLog:
def error(*args, **kwargs):
pass
def __init__(self):
self.log = self.FakeLog()
def registryValue(self, *args, **kwargs):
return os.path.expanduser('~') + '/apt-data'
try:
(command, lookup) = argv[1].split(None, 1)
except:
print("Need something to look up")
sys.exit(1)
dist = "noble"
if argc == 3:
dist = argv[2]
plugin = FakePlugin()
aptlookup = Apt(plugin)
print(getattr(aptlookup, command)(lookup, dist))