Compare commits

..

No commits in common. "master" and "v0.5.0" have entirely different histories.

18 changed files with 33 additions and 121 deletions

View file

@ -9,14 +9,14 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
python-version: "3.12"
- uses: isort/isort-action@master
with:
sortPaths: "./maubot"
- uses: psf/black@stable
with:
src: "./maubot"
version: "24.10.0"
version: "24.2.0"
- name: pre-commit
run: |
pip install pre-commit

View file

@ -10,7 +10,7 @@ default:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
build frontend:
image: node:24-alpine
image: node:20-alpine
stage: build frontend
before_script: []
variables:

View file

@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v4.5.0
hooks:
- id: trailing-whitespace
exclude_types: [markdown]
@ -8,7 +8,7 @@ repos:
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/psf/black
rev: 24.10.0
rev: 24.2.0
hooks:
- id: black
language_version: python3

View file

@ -1,30 +1,3 @@
# v0.5.2 (2025-05-05)
* Improved tombstone handling to ensure that the tombstone sender has
permissions to invite users to the target room.
* Fixed autojoin and online flags not being applied if set during client
creation (thanks to [@bnsh] in [#258]).
* Fixed plugin web apps not being cleared properly when unloading plugins.
[@bnsh]: https://github.com/bnsh
[#258]: https://github.com/maubot/maubot/pull/258
# v0.5.1 (2025-01-03)
* Updated Docker image to Alpine 3.21.
* Updated media upload/download endpoints in management frontend
(thanks to [@domrim] in [#253]).
* Fixed plugin web app base path not including a trailing slash
(thanks to [@jkhsjdhjs] in [#240]).
* Changed markdown parsing to cut off plaintext body if necessary to allow
longer formatted messages.
* Updated dependencies to fix Python 3.13 compatibility.
[@domrim]: https://github.com/domrim
[@jkhsjdhjs]: https://github.com/jkhsjdhjs
[#253]: https://github.com/maubot/maubot/pull/253
[#240]: https://github.com/maubot/maubot/pull/240
# v0.5.0 (2024-08-24)
* Dropped Python 3.9 support.

View file

@ -1,9 +1,9 @@
FROM node:24 AS frontend-builder
FROM node:20 AS frontend-builder
COPY ./maubot/management/frontend /frontend
RUN cd /frontend && yarn --prod && yarn build
FROM alpine:3.22
FROM alpine:3.20
RUN apk add --no-cache \
python3 py3-pip py3-setuptools py3-wheel \
@ -11,8 +11,6 @@ RUN apk add --no-cache \
su-exec \
yq \
py3-aiohttp \
py3-aiodns \
py3-brotli \
py3-attrs \
py3-bcrypt \
py3-cffi \

View file

@ -1,4 +1,4 @@
FROM alpine:3.22
FROM alpine:3.20
RUN apk add --no-cache \
python3 py3-pip py3-setuptools py3-wheel \

View file

@ -1 +1 @@
__version__ = "0.5.2"
__version__ = "0.5.0"

View file

@ -350,41 +350,11 @@ class Client(DBClient):
}
async def _handle_tombstone(self, evt: StateEvent) -> None:
if evt.state_key != "":
return
if not evt.content.replacement_room:
self.log.info(f"{evt.room_id} tombstoned with no replacement, ignoring")
return
is_joined = await self.client.state_store.is_joined(
evt.content.replacement_room,
self.client.mxid,
)
if is_joined:
self.log.debug(
f"Ignoring tombstone from {evt.room_id} to {evt.content.replacement_room} "
f"sent by {evt.sender}: already joined to replacement room"
)
return
self.log.debug(
f"Following tombstone from {evt.room_id} to {evt.content.replacement_room} "
f"sent by {evt.sender}"
)
_, server = self.client.parse_user_id(evt.sender)
room_id = await self.client.join_room(evt.content.replacement_room, servers=[server])
power_levels = await self.client.get_state_event(room_id, EventType.ROOM_POWER_LEVELS)
create_event = await self.client.get_state_event(
room_id, EventType.ROOM_CREATE, format="event"
)
if power_levels.get_user_level(evt.sender, create_event) < power_levels.invite:
self.log.warning(
f"{evt.room_id} was tombstoned into {room_id} by {evt.sender},"
" but the sender doesn't have invite power levels, leaving..."
)
await self.client.leave_room(
room_id,
f"Followed tombstone from {evt.room_id} by {evt.sender},"
" but sender doesn't have sufficient power level for invites",
)
await self.client.join_room(evt.content.replacement_room, servers=[server])
async def _handle_invite(self, evt: StrippedStateEvent) -> None:
if evt.state_key == self.id and evt.content.membership == Membership.INVITE:
@ -504,14 +474,8 @@ class Client(DBClient):
self.start_sync()
async def _update_remote_profile(self) -> None:
try:
profile = await self.client.get_profile(self.id)
self.remote_displayname, self.remote_avatar_url = (
profile.displayname,
profile.avatar_url,
)
except Exception:
self.log.warning("Failed to update own profile from server", exc_info=True)
profile = await self.client.get_profile(self.id)
self.remote_displayname, self.remote_avatar_url = profile.displayname, profile.avatar_url
async def delete(self) -> None:
try:

View file

@ -1,4 +1,5 @@
# 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
@ -54,8 +55,7 @@ server:
port: 29316
# Public base URL where the server is visible.
public_url: https://example.com
# The base path for the UI. Note that this does not change the API path.
# Add a path prefix to public_url if you want everything on a subpath.
# 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/
@ -79,9 +79,8 @@ homeservers:
# When this is empty, `mbc auth --register` won't work, but `mbc auth` (login) will.
secret: null
# List of administrator users. Each key is a username and the value is the password.
# 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.
# 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:
root: ""

View file

@ -78,8 +78,8 @@ async def _create_client(user_id: UserID | None, data: dict) -> web.Response:
)
client.enabled = data.get("enabled", True)
client.sync = data.get("sync", True)
await client.update_autojoin(data.get("autojoin", True), save=False)
await client.update_online(data.get("online", True), save=False)
client.autojoin = data.get("autojoin", True)
client.online = data.get("online", True)
client.displayname = data.get("displayname", "disable")
client.avatar_url = data.get("avatar_url", "disable")
await client.update()

View file

@ -205,7 +205,7 @@ export const getClients = () => defaultGet("/clients")
export const getClient = id => defaultGet(`/clients/${id}`)
export async function uploadAvatar(id, data, mime) {
const resp = await fetch(`${BASE_PATH}/proxy/${id}/_matrix/media/v3/upload`, {
const resp = await fetch(`${BASE_PATH}/proxy/${id}/_matrix/media/r0/upload`, {
headers: getHeaders(mime),
body: data,
method: "POST",
@ -217,9 +217,8 @@ export function getAvatarURL({ id, avatar_url }) {
if (!avatar_url?.startsWith("mxc://")) {
return null
}
avatar_url = avatar_url.substring("mxc://".length)
// Note: the maubot backend will replace the query param with an authorization header
return `${BASE_PATH}/proxy/${id}/_matrix/client/v1/media/download/${avatar_url}?access_token=${
avatar_url = avatar_url.substr("mxc://".length)
return `${BASE_PATH}/proxy/${id}/_matrix/media/r0/download/${avatar_url}?access_token=${
localStorage.accessToken}`
}

View file

@ -70,9 +70,9 @@ class Client extends BaseMainView {
get initialState() {
return {
id: "",
displayname: "disable",
displayname: "",
homeserver: "",
avatar_url: "disable",
avatar_url: "",
access_token: "",
device_id: "",
fingerprint: null,

View file

@ -15,7 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Any, Awaitable
from typing import Awaitable
from html import escape
import asyncio
@ -62,10 +62,7 @@ async def parse_formatted(
html = message
else:
return message, escape(message)
text = (await MaubotHTMLParser().parse(html)).text
if len(text) > 100 and len(text) + len(html) > 40000:
text = text[:100] + "[long message cut off]"
return text, html
return (await MaubotHTMLParser().parse(html)).text, html
class MaubotMessageEvent(MessageEvent):
@ -88,7 +85,6 @@ class MaubotMessageEvent(MessageEvent):
reply: bool | str = False,
in_thread: bool | None = None,
edits: EventID | MessageEvent | None = None,
extra_content: dict[str, Any] | None = None,
) -> EventID:
"""
Respond to the message.
@ -108,7 +104,6 @@ class MaubotMessageEvent(MessageEvent):
the root if necessary.
edits: An event ID or MessageEvent to edit. If set, the reply and in_thread parameters
are ignored, as edits can't change the reply or thread status.
extra_content: Extra content to add to the event.
Returns:
The ID of the response event.
@ -145,9 +140,6 @@ class MaubotMessageEvent(MessageEvent):
)
else:
content.set_reply(self)
if extra_content:
for k, v in extra_content.items():
content[k] = v
return await self.client.send_message_event(self.room_id, event_type, content)
def reply(
@ -157,7 +149,6 @@ class MaubotMessageEvent(MessageEvent):
markdown: bool = True,
allow_html: bool = False,
in_thread: bool | None = None,
extra_content: dict[str, Any] | None = None,
) -> Awaitable[EventID]:
"""
Reply to the message. The parameters are the same as :meth:`respond`,
@ -175,7 +166,6 @@ class MaubotMessageEvent(MessageEvent):
thread. If set to ``False``, the response will never be in a thread. If set to
``True``, the response will always be in a thread, creating one with this event as
the root if necessary.
extra_content: Extra content to add to the event.
Returns:
The ID of the response event.
@ -187,7 +177,6 @@ class MaubotMessageEvent(MessageEvent):
reply=True,
in_thread=in_thread,
allow_html=allow_html,
extra_content=extra_content,
)
def mark_read(self) -> Awaitable[None]:
@ -264,18 +253,14 @@ class MaubotMatrixClient(MatrixClient):
markdown: str,
*,
allow_html: bool = False,
render_markdown: bool = True,
msgtype: MessageType = MessageType.TEXT,
edits: EventID | MessageEvent | None = None,
relates_to: RelatesTo | None = None,
extra_content: dict[str, Any] = None,
**kwargs,
) -> EventID:
content = TextMessageEventContent(msgtype=msgtype, format=Format.HTML)
content.body, content.formatted_body = await parse_formatted(
markdown,
allow_html=allow_html,
render_markdown=render_markdown,
markdown, allow_html=allow_html
)
if relates_to:
if edits:
@ -283,9 +268,6 @@ class MaubotMatrixClient(MatrixClient):
content.relates_to = relates_to
elif edits:
content.set_edit(edits)
if extra_content:
for k, v in extra_content.items():
content[k] = v
return await self.send_message(room_id, content, **kwargs)
def dispatch_event(self, event: Event, source: SyncStream) -> list[asyncio.Task]:

View file

@ -40,8 +40,6 @@ class PluginWebApp(web.UrlDispatcher):
self._resources = []
self._named_resources = {}
self._middleware = []
self._resource_index = {}
self._matched_sub_app_resources = []
async def handle(self, request: web.Request) -> web.StreamResponse:
match_info = await self.resolve(request)

View file

@ -64,14 +64,14 @@ class MaubotServer:
if request.path.startswith(path):
request = request.clone(
rel_url=request.rel_url.with_path(
request.rel_url.path[len(path) - 1 :]
request.rel_url.path[len(path) :]
).with_query(request.query_string)
)
return await app.handle(request)
return web.Response(status=404)
def get_instance_subapp(self, instance_id: str) -> tuple[PluginWebApp, str]:
subpath = self.config["server.plugin_base_path"] + instance_id + "/"
subpath = self.config["server.plugin_base_path"] + instance_id
url = self.config["server.public_url"] + subpath
try:
return self.plugin_routes[subpath], url
@ -82,7 +82,7 @@ class MaubotServer:
def remove_instance_webapp(self, instance_id: str) -> None:
try:
subpath = self.config["server.plugin_base_path"] + instance_id + "/"
subpath = self.config["server.plugin_base_path"] + instance_id
self.plugin_routes.pop(subpath).clear()
except KeyError:
return

View file

@ -1,4 +1,4 @@
FROM docker.io/alpine:3.21
FROM docker.io/alpine:3.20
RUN apk add --no-cache \
python3 py3-pip py3-setuptools py3-wheel \

View file

@ -1,8 +1,8 @@
mautrix>=0.20.9rc3,<0.21
mautrix>=0.20.6,<0.21
aiohttp>=3,<4
yarl>=1,<2
asyncpg>=0.20,<1
aiosqlite>=0.16,<1
asyncpg>=0.20,<0.30
aiosqlite>=0.16,<0.21
commonmark>=0.9,<1
ruamel.yaml>=0.15.35,<0.19
attrs>=18.1.0

View file

@ -53,7 +53,6 @@ setuptools.setup(
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
],
entry_points="""
[console_scripts]