Compare commits
8 commits
Author | SHA1 | Date | |
---|---|---|---|
f4bc26ac13 | |||
8089dd36e5 | |||
34a24e599a | |||
26d34967a0 | |||
c9d4e0312d | |||
aea53325ad | |||
485dffaafc | |||
cc676a082d |
4 changed files with 218 additions and 12 deletions
23
README.md
23
README.md
|
@ -6,6 +6,8 @@ SPDX-License-Identifier: CC0-1.0
|
|||
|
||||
## maubot-osc
|
||||
|
||||

|
||||
|
||||
`maubot-osc` is a simple [maubot](https://maubot.xyz/) plugin to query an [Open Build Service](https://openbuildservice.org/) instance. Basically it replicates a few of the features of the `osc` command line tool used to interact with the OBS.
|
||||
|
||||
### Features
|
||||
|
@ -21,6 +23,7 @@ These are in addition to what maubot requires by itself.
|
|||
|
||||
- lxml
|
||||
- jinja2
|
||||
- [cryptocode](https://github.com/gdavid7/cryptocode)
|
||||
|
||||
### Installation
|
||||
|
||||
|
@ -34,6 +37,7 @@ Then you need to modify a few configuration parameters for your instance:
|
|||
- `trigger_token`: An OBS access token with the `runservice` capability. See "Creating tokens" below.
|
||||
- `rebuild_token`: An OBS access token with the `rebuild` capability. See "Creating tokens" below.
|
||||
- `secret`: A random string used to encrypt the password in the configuration file.
|
||||
- `acl`: User IDs (`@user:homeserver.com`) which are allowed to interact with the bot. See "Access control lists" below.
|
||||
|
||||
### Creating tokens
|
||||
|
||||
|
@ -59,9 +63,26 @@ Save both tokens in the configuration. Should you need to, you can view them lat
|
|||
|
||||
Make sure your bot instance is in a room (refer to the maubot docs for how) and then type `!osc help` for help.
|
||||
|
||||
### Access control lists (ACLs)
|
||||
|
||||
By default, no user is allowed to perform any operation. You need to change the `acl` section of the configuration to add user IDs (`@user:homeserver.com`) allowed to interact with the bot. Users in the `admin` list have full powers over the bot, while users in the `user` list can only perform read-only (e.g. status querying) actions.
|
||||
|
||||
This example shows how it works in practice:
|
||||
|
||||
```yaml
|
||||
acl:
|
||||
admin:
|
||||
# Fred and Sue will be able to perform all commands
|
||||
- @fred:myhome.com
|
||||
- @sue:otherserver.com
|
||||
user:
|
||||
# Phil will only be able to run read-only (status, etc.) commands
|
||||
- @phil:elsewhere.com
|
||||
```
|
||||
|
||||
### Notice
|
||||
|
||||
I made this in about three or four hours for my own use. Don't expect high quality code or a lot of polish.
|
||||
I made this for my own use. Don't expect high quality code or a lot of polish.
|
||||
|
||||
### License
|
||||
|
||||
|
|
|
@ -34,3 +34,8 @@ repo_aliases:
|
|||
repository: "KDE_Unstable_Frameworks_openSUSE_Factory"
|
||||
state: "failed"
|
||||
arch: "all"
|
||||
acl:
|
||||
admin:
|
||||
- "@CHANGE_ME"
|
||||
user: []
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
# SPDX-License-Identifier: CC0-1.0
|
||||
maubot: 0.1.0
|
||||
id: org.dennogumi.osc
|
||||
version: 0.2.2
|
||||
version: 0.4.0
|
||||
license: AGPL-3.0-or-later
|
||||
modules:
|
||||
- oscbot
|
||||
|
|
|
@ -2,10 +2,12 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from time import sleep
|
||||
from typing import Optional, List, Type, Tuple
|
||||
|
||||
import aiohttp
|
||||
import cryptocode
|
||||
import more_itertools as mit
|
||||
from lxml import objectify
|
||||
from jinja2 import BaseLoader, Environment
|
||||
|
||||
|
@ -22,10 +24,8 @@ Only showing packages with {{ state }} state.
|
|||
|
||||
{% endif %}"""
|
||||
|
||||
REPO_TEMPLATE = """
|
||||
#### {{ repo.name }} - {{ repo.arch }}
|
||||
|
||||
{% for package in repo.packages %}
|
||||
STATUS_TEMPLATE = """
|
||||
{% for package in packages %}
|
||||
{%if package.status != "disabled" %}
|
||||
{% set build_log_url = "{0}/package/live_build_log/{1}/{2}/{3}/{4}/".format(
|
||||
base_url, project, package.name, repo.name, repo.arch) %}
|
||||
|
@ -36,6 +36,21 @@ REPO_TEMPLATE = """
|
|||
{% endfor %}
|
||||
"""
|
||||
|
||||
ALIAS_TEMPLATE = """
|
||||
### Configured aliases
|
||||
|
||||
{% for alias, contents in repo_aliases.items() %}
|
||||
#### {{ alias }}
|
||||
|
||||
- project: {{ contents.project }}
|
||||
- repository: {{ contents.repository }}
|
||||
- package(s): {{ contents.package }}
|
||||
- architecture(s): {{ contents.arch }}
|
||||
- state: {{ contents.state }}
|
||||
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BuildResult:
|
||||
|
@ -68,6 +83,7 @@ class Config(BaseProxyConfig):
|
|||
|
||||
helper.copy("username")
|
||||
helper.copy("repo_aliases")
|
||||
helper.copy("acl")
|
||||
|
||||
|
||||
class OSCBot(Plugin):
|
||||
|
@ -99,6 +115,18 @@ class OSCBot(Plugin):
|
|||
|
||||
return (project, package, repository, state, arch)
|
||||
|
||||
def check_acl(self, user_id: str, need_admin: bool = False) -> bool:
|
||||
|
||||
acls = self.config["acl"]
|
||||
if not need_admin:
|
||||
if user_id in acls["admin"] or acls["user"]:
|
||||
return True
|
||||
else:
|
||||
if user_id in acls["admin"]:
|
||||
return True
|
||||
self.log.debug(f"Denied operation as {user_id}")
|
||||
return False
|
||||
|
||||
async def parse_rebuilpac(
|
||||
self,
|
||||
project: str,
|
||||
|
@ -200,6 +228,120 @@ class OSCBot(Plugin):
|
|||
async def osc(self) -> None:
|
||||
pass
|
||||
|
||||
@osc.subcommand("alias", help="Manage aliases")
|
||||
async def alias(self) -> None:
|
||||
pass
|
||||
|
||||
@alias.subcommand("list", help="List configured aliases")
|
||||
async def list_aliases(self, evt: MessageEvent) -> None:
|
||||
|
||||
if not self.check_acl(evt.sender, need_admin=False):
|
||||
await evt.reply("You are not authorized to perform this action.")
|
||||
return
|
||||
|
||||
body = self.template.from_string(ALIAS_TEMPLATE)
|
||||
|
||||
if self.config.get("repo_aliases", None) is None:
|
||||
await evt.reply("No aliases defined.")
|
||||
return
|
||||
|
||||
message = body.render(repo_aliases=self.config["repo_aliases"])
|
||||
await evt.respond(message, markdown=True)
|
||||
|
||||
@alias.subcommand("edit", help="Edit configured aliases")
|
||||
@command.argument("alias", "alias name")
|
||||
@command.argument("project", "project name")
|
||||
@command.argument("package", "package name", required=False)
|
||||
@command.argument("repository", "repository", required=False)
|
||||
@command.argument("arch", "architecture", required=False)
|
||||
@command.argument("state", "state", required=False)
|
||||
async def edit_alias(self, evt: MessageEvent,
|
||||
alias: str,
|
||||
project: Optional[str] = None,
|
||||
package: Optional[str] = None,
|
||||
repository: Optional[str] = None,
|
||||
arch: Optional[str] = None,
|
||||
state: Optional[str] = None) -> None:
|
||||
|
||||
if not self.check_acl(evt.sender, need_admin=True):
|
||||
await evt.reply("You are not authorized to perform this action.")
|
||||
return
|
||||
|
||||
if alias not in self.config["repo_aliases"]:
|
||||
await evt.respond(f"Unknown alias {alias}")
|
||||
return
|
||||
|
||||
alias_data = self.config["repo_aliases"][alias]
|
||||
|
||||
if project:
|
||||
alias_data["project"] = project
|
||||
|
||||
if repository:
|
||||
alias_data["repository"] = repository
|
||||
|
||||
if package:
|
||||
alias_data["package"] = package
|
||||
|
||||
if arch:
|
||||
alias_data["arch"] = alias
|
||||
|
||||
if state:
|
||||
alias_data["state"] = state
|
||||
|
||||
self.config["repo_aliases"][alias] = alias_data
|
||||
self.config.save()
|
||||
await evt.reply(f"Alias {alias} successfully changed.")
|
||||
|
||||
@alias.subcommand("create", help="Create a new alias")
|
||||
@command.argument("alias", "alias name")
|
||||
@command.argument("project", "project name")
|
||||
@command.argument("package", "package name", required=False)
|
||||
@command.argument("repository", "repository", required=False)
|
||||
@command.argument("arch", "architecture", required=False)
|
||||
@command.argument("state", "state", required=False)
|
||||
async def create_alias(self, evt: MessageEvent,
|
||||
alias: str,
|
||||
project: Optional[str] = None,
|
||||
package: Optional[str] = None,
|
||||
repository: Optional[str] = None,
|
||||
arch: Optional[str] = None,
|
||||
state: Optional[str] = None) -> None:
|
||||
|
||||
if not self.check_acl(evt.sender, need_admin=True):
|
||||
await evt.reply("You are not authorized to perform this action.")
|
||||
return
|
||||
|
||||
repository = "all" if not repository else repository
|
||||
package = "all" if not package else package
|
||||
arch = "all" if not arch else arch
|
||||
state = "all" if not state else state
|
||||
alias_data = {
|
||||
"project": project,
|
||||
"package": package,
|
||||
"repository": repository,
|
||||
"arch": arch,
|
||||
"state": state
|
||||
}
|
||||
self.config["repo_aliases"][alias] = alias_data
|
||||
self.config.save()
|
||||
await evt.reply(f"Alias {alias} successfully created.")
|
||||
|
||||
@alias.subcommand("delete", help="Delete an alias", aliases=("rm", ))
|
||||
@command.argument("alias", "alias name")
|
||||
async def delete_alias(self, evt: MessageEvent, alias: str):
|
||||
|
||||
if not self.check_acl(evt.sender, need_admin=True):
|
||||
await evt.reply("You are not authorized to perform this action.")
|
||||
return
|
||||
|
||||
if alias not in self.config["repo_aliases"]:
|
||||
await evt.respond(f"Unknown alias {alias}")
|
||||
return
|
||||
|
||||
del self.config["repo_aliases"][alias]
|
||||
self.config.save()
|
||||
await evt.reply(f"Alias {alias} successfully deleted.")
|
||||
|
||||
@osc.subcommand(
|
||||
"rebuildpac", aliases=("rb",),
|
||||
help="Rebuild a package or all packages in the repositories")
|
||||
|
@ -213,9 +355,18 @@ class OSCBot(Plugin):
|
|||
repository: Optional[str] = None,
|
||||
arch: Optional[str] = None) -> None:
|
||||
|
||||
if not self.check_acl(evt.sender, need_admin=True):
|
||||
await evt.reply("You are not authorized to perform this action.")
|
||||
return
|
||||
|
||||
if project in self.config["repo_aliases"]:
|
||||
# We're not interested in state and we query package explicitly
|
||||
project, _, repository, _, arch = self.get_alias(project)
|
||||
else:
|
||||
repository = None if repository == "all" else repository
|
||||
arch = None if arch == "all" else arch
|
||||
|
||||
package = None if package == "all" else package
|
||||
repository = None if repository == "all" else repository
|
||||
arch = None if arch == "all" else arch
|
||||
|
||||
result, status = await self.parse_rebuilpac(project, package,
|
||||
repository,
|
||||
|
@ -236,6 +387,10 @@ class OSCBot(Plugin):
|
|||
project: str,
|
||||
package: str) -> None:
|
||||
|
||||
if not self.check_acl(evt.sender, need_admin=True):
|
||||
await evt.reply("You are not authorized to perform this action.")
|
||||
return
|
||||
|
||||
token = self.config["trigger_token"]
|
||||
trigger_url = f"{self.config['api_url']}/trigger/runservice"
|
||||
params = {"project": project, "package": package}
|
||||
|
@ -265,6 +420,10 @@ class OSCBot(Plugin):
|
|||
repository: Optional[str] = None,
|
||||
arch: Optional[str] = None) -> None:
|
||||
|
||||
if not self.check_acl(evt.sender, need_admin=False):
|
||||
await evt.reply("You are not authorized to perform this action.")
|
||||
return
|
||||
|
||||
if project in self.config["repo_aliases"]:
|
||||
project, package, repository, state, arch = self.get_alias(project)
|
||||
else:
|
||||
|
@ -287,8 +446,29 @@ class OSCBot(Plugin):
|
|||
|
||||
base_url = self.config["instance_url"]
|
||||
|
||||
if len(response) > 50:
|
||||
await evt.respond(f"Too many repositories ({len(response)})"
|
||||
f"to display in project {project}, "
|
||||
"please narrow down your search.")
|
||||
return
|
||||
|
||||
for repository in response:
|
||||
body = self.template.from_string(REPO_TEMPLATE)
|
||||
message = body.render(repo=repository, project=project,
|
||||
base_url=base_url)
|
||||
await evt.respond(message, markdown=True)
|
||||
await evt.respond(f"#### {repository.name} - {repository.arch}",
|
||||
markdown=True)
|
||||
|
||||
# Just so that someone doesn't flood by listing the whole
|
||||
# openSUSE:Factory
|
||||
if len(repository.packages) > 1000:
|
||||
await evt.respond(
|
||||
f"Too many packages ({len(repository.packages)}) "
|
||||
"to display, please narrow down your search.")
|
||||
continue
|
||||
# To avoid creating too large messages, we chunk packages in
|
||||
# groups of 100
|
||||
for packagelist in mit.chunked(repository.packages, 100):
|
||||
body = self.template.from_string(STATUS_TEMPLATE)
|
||||
message = body.render(packages=packagelist, base_url=base_url,
|
||||
project=project, repo=repository)
|
||||
await evt.respond(message, markdown=True)
|
||||
# Wait 200 milliseconds to avoid triggering rate limiting
|
||||
sleep(0.2)
|
||||
|
|
Loading…
Add table
Reference in a new issue