Merge branch 'master' of github.com:maubot/maubot

This commit is contained in:
Lorenz Steinert 2020-01-12 10:22:28 +01:00
commit 5dfad88cc3
No known key found for this signature in database
GPG key ID: 2584CA75748BFAE9
24 changed files with 3015 additions and 2109 deletions

View file

@ -36,3 +36,32 @@ push tag:
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA - docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME - docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME
build standalone:
stage: build
script:
- docker pull $CI_REGISTRY_IMAGE:standalone || true
- docker build --pull --cache-from $CI_REGISTRY_IMAGE:standalone --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-standalone . -f maubot/standalone/Dockerfile
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-standalone
push latest standalone:
stage: push
only:
- master
variables:
GIT_STRATEGY: none
script:
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-standalone
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-standalone $CI_REGISTRY_IMAGE:standalone
- docker push $CI_REGISTRY_IMAGE:standalone
push tag standalone:
stage: push
variables:
GIT_STRATEGY: none
except:
- master
script:
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-standalone
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-standalone $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME-standalone
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME-standalone

View file

@ -3,7 +3,7 @@ FROM node:12 AS frontend-builder
COPY ./maubot/management/frontend /frontend COPY ./maubot/management/frontend /frontend
RUN cd /frontend && yarn --prod && yarn build RUN cd /frontend && yarn --prod && yarn build
FROM alpine:edge FROM alpine:3.10
ENV UID=1337 \ ENV UID=1337 \
GID=1337 GID=1337
@ -11,7 +11,7 @@ ENV UID=1337 \
COPY . /opt/maubot COPY . /opt/maubot
COPY --from=frontend-builder /frontend/build /opt/maubot/frontend COPY --from=frontend-builder /frontend/build /opt/maubot/frontend
WORKDIR /opt/maubot WORKDIR /opt/maubot
RUN apk add --no-cache -X http://dl-cdn.alpinelinux.org/alpine/edge/testing \ RUN apk add --no-cache \
py3-aiohttp \ py3-aiohttp \
py3-sqlalchemy \ py3-sqlalchemy \
py3-attrs \ py3-attrs \
@ -24,7 +24,6 @@ RUN apk add --no-cache -X http://dl-cdn.alpinelinux.org/alpine/edge/testing \
py3-pillow \ py3-pillow \
py3-magic \ py3-magic \
py3-psycopg2 \ py3-psycopg2 \
py3-matplotlib \
py3-ruamel.yaml \ py3-ruamel.yaml \
py3-jinja2 \ py3-jinja2 \
py3-click \ py3-click \

View file

@ -29,6 +29,8 @@ Matrix room: [#maubot:maunium.net](https://matrix.to/#/#maubot:maunium.net)
* [exec](https://github.com/maubot/exec) - A bot that executes code. * [exec](https://github.com/maubot/exec) - A bot that executes code.
* [commitstrip](https://github.com/maubot/commitstrip) - A bot to view CommitStrips. * [commitstrip](https://github.com/maubot/commitstrip) - A bot to view CommitStrips.
* [supportportal](https://github.com/maubot/supportportal) - A bot to manage customer support on Matrix. * [supportportal](https://github.com/maubot/supportportal) - A bot to manage customer support on Matrix.
* [gitlab](https://github.com/maubot/gitlab) - A GitLab client and webhook receiver.
* [github](https://github.com/maubot/github) - A GitHub client and webhook receiver.
Open a pull request or join the Matrix room linked above to get your plugin listed here Open a pull request or join the Matrix room linked above to get your plugin listed here

View file

@ -1 +1 @@
__version__ = "0.1.0.dev27" __version__ = "0.1.0.dev30"

View file

@ -21,9 +21,10 @@ from aiohttp import ClientSession
from mautrix.errors import MatrixInvalidToken, MatrixRequestError from mautrix.errors import MatrixInvalidToken, MatrixRequestError
from mautrix.types import (UserID, SyncToken, FilterID, ContentURI, StrippedStateEvent, Membership, from mautrix.types import (UserID, SyncToken, FilterID, ContentURI, StrippedStateEvent, Membership,
EventType, Filter, RoomFilter, RoomEventFilter) StateEvent, EventType, Filter, RoomFilter, RoomEventFilter)
from mautrix.client import InternalEventType from mautrix.client import InternalEventType
from .lib.store_proxy import ClientStoreProxy
from .db import DBClient from .db import DBClient
from .matrix import MaubotMatrixClient from .matrix import MaubotMatrixClient
@ -58,11 +59,13 @@ class Client:
self.remote_avatar_url = None self.remote_avatar_url = None
self.client = MaubotMatrixClient(mxid=self.id, base_url=self.homeserver, self.client = MaubotMatrixClient(mxid=self.id, base_url=self.homeserver,
token=self.access_token, client_session=self.http_client, token=self.access_token, client_session=self.http_client,
log=self.log, loop=self.loop, store=self.db_instance) log=self.log, loop=self.loop,
store=ClientStoreProxy(self.db_instance))
self.client.ignore_initial_sync = True self.client.ignore_initial_sync = True
self.client.ignore_first_sync = True self.client.ignore_first_sync = True
if self.autojoin: if self.autojoin:
self.client.add_event_handler(EventType.ROOM_MEMBER, self._handle_invite) self.client.add_event_handler(EventType.ROOM_MEMBER, self._handle_invite)
self.client.add_event_handler(EventType.ROOM_TOMBSTONE, self._handle_tombstone)
self.client.add_event_handler(InternalEventType.SYNC_ERRORED, self._set_sync_ok(False)) self.client.add_event_handler(InternalEventType.SYNC_ERRORED, self._set_sync_ok(False))
self.client.add_event_handler(InternalEventType.SYNC_SUCCESSFUL, self._set_sync_ok(True)) self.client.add_event_handler(InternalEventType.SYNC_SUCCESSFUL, self._set_sync_ok(True))
@ -107,13 +110,13 @@ class Client:
self.db_instance.enabled = False self.db_instance.enabled = False
return return
if not self.filter_id: if not self.filter_id:
self.db_instance.filter_id = await self.client.create_filter(Filter( self.db_instance.edit(filter_id=await self.client.create_filter(Filter(
room=RoomFilter( room=RoomFilter(
timeline=RoomEventFilter( timeline=RoomEventFilter(
limit=50, limit=50,
), ),
), ),
)) )))
if self.displayname != "disable": if self.displayname != "disable":
await self.client.set_displayname(self.displayname) await self.client.set_displayname(self.displayname)
if self.avatar_url != "disable": if self.avatar_url != "disable":
@ -187,6 +190,13 @@ class Client:
def all(cls) -> Iterable['Client']: def all(cls) -> Iterable['Client']:
return (cls.get(user.id, user) for user in DBClient.all()) return (cls.get(user.id, user) for user in DBClient.all())
async def _handle_tombstone(self, evt: StateEvent) -> None:
if not evt.content.replacement_room:
self.log.info(f"{evt.room_id} tombstoned with no replacement, ignoring")
return
_, server = self.client.parse_user_id(evt.sender)
await self.client.join_room(evt.content.replacement_room, servers=[server])
async def _handle_invite(self, evt: StrippedStateEvent) -> None: async def _handle_invite(self, evt: StrippedStateEvent) -> None:
if evt.state_key == self.id and evt.content.membership == Membership.INVITE: if evt.state_key == self.id and evt.content.membership == Membership.INVITE:
await self.client.join_room(evt.room_id) await self.client.join_room(evt.room_id)

View file

@ -281,7 +281,7 @@ class RegexArgument(Argument):
def match(self, val: str, **kwargs) -> Tuple[str, Any]: def match(self, val: str, **kwargs) -> Tuple[str, Any]:
orig_val = val orig_val = val
if not self.pass_raw: if not self.pass_raw:
val = val.split(" ")[0] val = re.split(r"\s", val, 1)[0]
match = self.regex.match(val) match = self.regex.match(val)
if match: if match:
return (orig_val[:match.start()] + orig_val[match.end():], return (orig_val[:match.start()] + orig_val[match.end():],
@ -299,7 +299,7 @@ class CustomArgument(Argument):
if self.pass_raw: if self.pass_raw:
return self.matcher(val) return self.matcher(val)
orig_val = val orig_val = val
val = val.split(" ")[0] val = re.split(r"\s", val, 1)[0]
res = self.matcher(val) res = self.matcher(val)
if res: if res:
return orig_val[len(val):], res return orig_val[len(val):], res
@ -310,7 +310,7 @@ class SimpleArgument(Argument):
def match(self, val: str, **kwargs) -> Tuple[str, Any]: def match(self, val: str, **kwargs) -> Tuple[str, Any]:
if self.pass_raw: if self.pass_raw:
return "", val return "", val
res = val.split(" ")[0] res = re.split(r"\s", val, 1)[0]
return val[len(res):], res return val[len(res):], res

View file

@ -39,6 +39,7 @@ log = logging.getLogger("maubot.instance")
yaml = YAML() yaml = YAML()
yaml.indent(4) yaml.indent(4)
yaml.width = 200
class PluginInstance: class PluginInstance:

30
maubot/lib/store_proxy.py Normal file
View file

@ -0,0 +1,30 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.client import ClientStore
from mautrix.types import SyncToken
class ClientStoreProxy(ClientStore):
def __init__(self, db_instance) -> None:
self.db_instance = db_instance
@property
def next_batch(self) -> SyncToken:
return self.db_instance.next_batch
@next_batch.setter
def next_batch(self, value: SyncToken) -> None:
self.db_instance.edit(next_batch=value)

View file

@ -57,6 +57,7 @@ class PluginMeta(SerializableAttrs['PluginMeta']):
maubot: Version = Version(__version__) maubot: Version = Version(__version__)
database: bool = False database: bool = False
config: bool = False
webapp: bool = False webapp: bool = False
license: str = "" license: str = ""
extra_files: List[str] = [] extra_files: List[str] = []

View file

@ -18,6 +18,7 @@ from http import HTTPStatus
from aiohttp import web from aiohttp import web
from sqlalchemy.exc import OperationalError, IntegrityError from sqlalchemy.exc import OperationalError, IntegrityError
class _Response: class _Response:
@property @property
def body_not_json(self) -> web.Response: def body_not_json(self) -> web.Response:

View file

@ -15,12 +15,12 @@
"dependencies": { "dependencies": {
"node-sass": "^4.12.0", "node-sass": "^4.12.0",
"react": "^16.8.6", "react": "^16.8.6",
"react-ace": "^7.0.2", "react-ace": "^8.0.0",
"react-contextmenu": "^2.11.0", "react-contextmenu": "^2.11.0",
"react-dom": "^16.8.6", "react-dom": "^16.8.6",
"react-json-tree": "^0.11.2", "react-json-tree": "^0.11.2",
"react-router-dom": "^5.0.1", "react-router-dom": "^5.0.1",
"react-scripts": "3.0.1", "react-scripts": "3.3.0",
"react-select": "^3.0.4" "react-select": "^3.0.4"
}, },
"scripts": { "scripts": {

View file

@ -30,7 +30,8 @@ class Login extends Component {
inputChanged = event => this.setState({ [event.target.name]: event.target.value }) inputChanged = event => this.setState({ [event.target.name]: event.target.value })
login = async () => { login = async evt => {
evt.preventDefault()
this.setState({ loading: true }) this.setState({ loading: true })
const resp = await api.login(this.state.username, this.state.password) const resp = await api.login(this.state.username, this.state.password)
if (resp.token) { if (resp.token) {
@ -53,17 +54,17 @@ class Login extends Component {
</div> </div>
} }
return <div className="login-wrapper"> return <div className="login-wrapper">
<div className={`login ${this.state.error && "errored"}`}> <form className={`login ${this.state.error && "errored"}`} onSubmit={this.login}>
<h1>Maubot Manager</h1> <h1>Maubot Manager</h1>
<input type="text" placeholder="Username" value={this.state.username} <input type="text" placeholder="Username" value={this.state.username}
name="username" onChange={this.inputChanged}/> name="username" onChange={this.inputChanged}/>
<input type="password" placeholder="Password" value={this.state.password} <input type="password" placeholder="Password" value={this.state.password}
name="password" onChange={this.inputChanged}/> name="password" onChange={this.inputChanged}/>
<button onClick={this.login}> <button onClick={this.login} type="submit">
{this.state.loading ? <Spinner/> : "Log in"} {this.state.loading ? <Spinner/> : "Log in"}
</button> </button>
{this.state.error && <div className="error">{this.state.error}</div>} {this.state.error && <div className="error">{this.state.error}</div>}
</div> </form>
</div> </div>
} }
} }

View file

@ -16,8 +16,8 @@
import React from "react" import React from "react"
import { Link, NavLink, Route, Switch, withRouter } from "react-router-dom" import { Link, NavLink, Route, Switch, withRouter } from "react-router-dom"
import AceEditor from "react-ace" import AceEditor from "react-ace"
import "brace/mode/yaml" import "ace-builds/src-noconflict/mode-yaml"
import "brace/theme/github" import "ace-builds/src-noconflict/theme-github"
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg" import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
import { ReactComponent as NoAvatarIcon } from "../../res/bot.svg" import { ReactComponent as NoAvatarIcon } from "../../res/bot.svg"
import PrefTable, { PrefInput, PrefSelect, PrefSwitch } from "../../components/PreferenceTable" import PrefTable, { PrefInput, PrefSelect, PrefSwitch } from "../../components/PreferenceTable"

View file

@ -22,6 +22,7 @@ import { ReactComponent as OrderAsc } from "../../res/sort-up.svg"
import api from "../../api" import api from "../../api"
import Spinner from "../../components/Spinner" import Spinner from "../../components/Spinner"
// eslint-disable-next-line no-extend-native
Map.prototype.map = function(func) { Map.prototype.map = function(func) {
const res = [] const res = []
for (const [key, value] of this) { for (const [key, value] of this) {
@ -89,7 +90,7 @@ class InstanceDatabase extends Component {
} }
buildSQLQuery(table = this.state.selectedTable, resetContent = true) { buildSQLQuery(table = this.state.selectedTable, resetContent = true) {
let query = `SELECT * FROM ${table}` let query = `SELECT * FROM "${table}"`
if (this.order.size > 0) { if (this.order.size > 0) {
const order = Array.from(this.order.entries()).reverse() const order = Array.from(this.order.entries()).reverse()
@ -197,10 +198,10 @@ class InstanceDatabase extends Component {
const val = values[index] const val = values[index]
condition.push(`${key}='${this.sqlEscape(val.toString())}'`) condition.push(`${key}='${this.sqlEscape(val.toString())}'`)
} }
const query = `DELETE FROM ${this.state.selectedTable} WHERE ${condition.join(" AND ")}` const query = `DELETE FROM "${this.state.selectedTable}" WHERE ${condition.join(" AND ")}`
const res = await api.queryInstanceDatabase(this.props.instanceID, query) const res = await api.queryInstanceDatabase(this.props.instanceID, query)
this.setState({ this.setState({
prevQuery: `DELETE FROM ${this.state.selectedTable} ...`, prevQuery: `DELETE FROM "${this.state.selectedTable}" ...`,
rowCount: res.rowcount, rowCount: res.rowcount,
}) })
await this.reloadContent(false) await this.reloadContent(false)
@ -215,6 +216,7 @@ class InstanceDatabase extends Component {
col: props.col, col: props.col,
}) })
// eslint-disable-next-line no-control-regex
sqlEscape = str => str.replace(/[\0\x08\x09\x1a\n\r"'\\%]/g, char => { sqlEscape = str => str.replace(/[\0\x08\x09\x1a\n\r"'\\%]/g, char => {
switch (char) { switch (char) {
case "\0": case "\0":

View file

@ -2,29 +2,29 @@
font-family: 'Raleway' font-family: 'Raleway'
font-style: normal font-style: normal
font-weight: 300 font-weight: 300
src: local('Raleway Light'), local('Raleway-Light'), url('../fonts/raleway-light.woff2') format('woff2') src: local('Raleway Light'), local('Raleway-Light'), url('../../fonts/raleway-light.woff2') format('woff2')
@font-face @font-face
font-family: 'Raleway' font-family: 'Raleway'
font-style: normal font-style: normal
font-weight: 400 font-weight: 400
src: local('Raleway'), local('Raleway-Regular'), url('../fonts/raleway-regular.woff2') format('woff2') src: local('Raleway'), local('Raleway-Regular'), url('../../fonts/raleway-regular.woff2') format('woff2')
@font-face @font-face
font-family: 'Raleway' font-family: 'Raleway'
font-style: normal font-style: normal
font-weight: 700 font-weight: 700
src: local('Raleway Bold'), local('Raleway-Bold'), url('../fonts/raleway-bold.woff2') format('woff2') src: local('Raleway Bold'), local('Raleway-Bold'), url('../../fonts/raleway-bold.woff2') format('woff2')
@font-face @font-face
font-family: 'Fira Code' font-family: 'Fira Code'
src: url('../fonts/firacode-regular.woff2') format('woff2') src: url('../../fonts/firacode-regular.woff2') format('woff2')
font-weight: 400 font-weight: 400
font-style: normal font-style: normal
@font-face @font-face
font-family: 'Fira Code' font-family: 'Fira Code'
src: url('../fonts/firacode-bold.woff2') format('woff2') src: url('../../fonts/firacode-bold.woff2') format('woff2')
font-weight: 700 font-weight: 700
font-style: normal font-style: normal

File diff suppressed because it is too large Load diff

View file

@ -14,27 +14,24 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Union, Awaitable, Optional, Tuple from typing import Union, Awaitable, Optional, Tuple
from markdown.extensions import Extension from html import escape
import markdown as md
import attr import attr
from mautrix.client import Client as MatrixClient, SyncStream from mautrix.client import Client as MatrixClient, SyncStream
from mautrix.util.formatter import parse_html from mautrix.util.formatter import parse_html
from mautrix.util import markdown
from mautrix.types import (EventType, MessageEvent, Event, EventID, RoomID, MessageEventContent, from mautrix.types import (EventType, MessageEvent, Event, EventID, RoomID, MessageEventContent,
MessageType, TextMessageEventContent, Format, RelatesTo) MessageType, TextMessageEventContent, Format, RelatesTo)
class EscapeHTML(Extension): def parse_formatted(message: str, allow_html: bool = False, render_markdown: bool = True
def extendMarkdown(self, md): ) -> Tuple[str, str]:
md.preprocessors.deregister("html_block") if render_markdown:
md.inlinePatterns.deregister("html") html = markdown.render(message, allow_html=allow_html)
elif allow_html:
html = message
escape_html = EscapeHTML() else:
return message, escape(message)
def parse_markdown(markdown: str, allow_html: bool = False) -> Tuple[str, str]:
html = md.markdown(markdown, extensions=[escape_html] if not allow_html else [])
return parse_html(html), html return parse_html(html), html
@ -50,14 +47,15 @@ class MaubotMessageEvent(MessageEvent):
def respond(self, content: Union[str, MessageEventContent], def respond(self, content: Union[str, MessageEventContent],
event_type: EventType = EventType.ROOM_MESSAGE, markdown: bool = True, event_type: EventType = EventType.ROOM_MESSAGE, markdown: bool = True,
html_in_markdown: bool = False, reply: bool = False, allow_html: bool = False, reply: bool = False,
edits: Optional[Union[EventID, MessageEvent]] = None) -> Awaitable[EventID]: edits: Optional[Union[EventID, MessageEvent]] = None) -> Awaitable[EventID]:
if isinstance(content, str): if isinstance(content, str):
content = TextMessageEventContent(msgtype=MessageType.NOTICE, body=content) content = TextMessageEventContent(msgtype=MessageType.NOTICE, body=content)
if markdown: if allow_html or markdown:
content.format = Format.HTML content.format = Format.HTML
content.body, content.formatted_body = parse_markdown(content.body, content.body, content.formatted_body = parse_formatted(content.body,
allow_html=html_in_markdown) render_markdown=markdown,
allow_html=allow_html)
if edits: if edits:
content.set_edit(edits) content.set_edit(edits)
elif reply and not self.disable_reply: elif reply and not self.disable_reply:
@ -66,9 +64,9 @@ class MaubotMessageEvent(MessageEvent):
def reply(self, content: Union[str, MessageEventContent], def reply(self, content: Union[str, MessageEventContent],
event_type: EventType = EventType.ROOM_MESSAGE, markdown: bool = True, event_type: EventType = EventType.ROOM_MESSAGE, markdown: bool = True,
html_in_markdown: bool = False) -> Awaitable[EventID]: allow_html: bool = False) -> Awaitable[EventID]:
return self.respond(content, event_type, markdown, reply=True, return self.respond(content, event_type, markdown=markdown, reply=True,
html_in_markdown=html_in_markdown) allow_html=allow_html)
def mark_read(self) -> Awaitable[None]: def mark_read(self) -> Awaitable[None]:
return self.client.send_receipt(self.room_id, self.event_id, "m.read") return self.client.send_receipt(self.room_id, self.event_id, "m.read")
@ -78,17 +76,19 @@ class MaubotMessageEvent(MessageEvent):
def edit(self, content: Union[str, MessageEventContent], def edit(self, content: Union[str, MessageEventContent],
event_type: EventType = EventType.ROOM_MESSAGE, markdown: bool = True, event_type: EventType = EventType.ROOM_MESSAGE, markdown: bool = True,
html_in_markdown: bool = False) -> Awaitable[EventID]: allow_html: bool = False) -> Awaitable[EventID]:
return self.respond(content, event_type, markdown, edits=self, return self.respond(content, event_type, markdown=markdown, edits=self,
html_in_markdown=html_in_markdown) allow_html=allow_html)
class MaubotMatrixClient(MatrixClient): class MaubotMatrixClient(MatrixClient):
def send_markdown(self, room_id: RoomID, markdown: str, msgtype: MessageType = MessageType.TEXT, def send_markdown(self, room_id: RoomID, markdown: str, *, allow_html: bool = False,
msgtype: MessageType = MessageType.TEXT,
edits: Optional[Union[EventID, MessageEvent]] = None, edits: Optional[Union[EventID, MessageEvent]] = None,
relates_to: Optional[RelatesTo] = None, **kwargs) -> Awaitable[EventID]: relates_to: Optional[RelatesTo] = None, **kwargs
) -> Awaitable[EventID]:
content = TextMessageEventContent(msgtype=msgtype, format=Format.HTML) content = TextMessageEventContent(msgtype=msgtype, format=Format.HTML)
content.body, content.formatted_body = parse_markdown(markdown) content.body, content.formatted_body = parse_formatted(markdown, allow_html=allow_html)
if relates_to: if relates_to:
if edits: if edits:
raise ValueError("Can't use edits and relates_to at the same time.") raise ValueError("Can't use edits and relates_to at the same time.")

View file

@ -20,6 +20,7 @@ from asyncio import AbstractEventLoop
from sqlalchemy.engine.base import Engine from sqlalchemy.engine.base import Engine
from aiohttp import ClientSession from aiohttp import ClientSession
from yarl import URL
if TYPE_CHECKING: if TYPE_CHECKING:
from mautrix.util.config import BaseProxyConfig from mautrix.util.config import BaseProxyConfig
@ -35,7 +36,7 @@ class Plugin(ABC):
config: Optional['BaseProxyConfig'] config: Optional['BaseProxyConfig']
database: Optional[Engine] database: Optional[Engine]
webapp: Optional['PluginWebApp'] webapp: Optional['PluginWebApp']
webapp_url: Optional[str] webapp_url: Optional[URL]
def __init__(self, client: 'MaubotMatrixClient', loop: AbstractEventLoop, http: ClientSession, def __init__(self, client: 'MaubotMatrixClient', loop: AbstractEventLoop, http: ClientSession,
instance_id: str, log: Logger, config: Optional['BaseProxyConfig'], instance_id: str, log: Logger, config: Optional['BaseProxyConfig'],
@ -49,12 +50,12 @@ class Plugin(ABC):
self.config = config self.config = config
self.database = database self.database = database
self.webapp = webapp self.webapp = webapp
self.webapp_url = webapp_url self.webapp_url = URL(webapp_url) if webapp_url else None
self._handlers_at_startup = [] self._handlers_at_startup = []
async def internal_start(self) -> None: def register_handler_class(self, obj) -> None:
for key in dir(self): for key in dir(obj):
val = getattr(self, key) val = getattr(obj, key)
try: try:
if val.__mb_event_handler__: if val.__mb_event_handler__:
self._handlers_at_startup.append((val, val.__mb_event_type__)) self._handlers_at_startup.append((val, val.__mb_event_type__))
@ -67,12 +68,23 @@ class Plugin(ABC):
self.webapp.add_route(method=method, path=path, handler=val, **kwargs) self.webapp.add_route(method=method, path=path, handler=val, **kwargs)
except AttributeError: except AttributeError:
pass pass
async def pre_start(self) -> None:
pass
async def internal_start(self) -> None:
await self.pre_start()
self.register_handler_class(self)
await self.start() await self.start()
async def start(self) -> None: async def start(self) -> None:
pass pass
async def pre_stop(self) -> None:
pass
async def internal_stop(self) -> None: async def internal_stop(self) -> None:
await self.pre_stop()
for func, event_type in self._handlers_at_startup: for func, event_type in self._handlers_at_startup:
self.client.remove_event_handler(event_type, func) self.client.remove_event_handler(event_type, func)
if self.webapp is not None: if self.webapp is not None:

View file

@ -0,0 +1,23 @@
FROM docker.io/alpine:3.10
COPY . /opt/maubot
RUN cd /opt/maubot \
&& apk add --no-cache --virtual .build-deps \
python3-dev \
libffi-dev \
build-base \
&& apk add --no-cache \
py3-aiohttp \
py3-sqlalchemy \
py3-attrs \
py3-bcrypt \
py3-cffi \
ca-certificates \
su-exec \
py3-psycopg2 \
py3-ruamel.yaml \
py3-jinja2 \
py3-packaging \
py3-markdown \
&& pip3 install . \
&& apk del .build-deps

View file

View file

@ -0,0 +1,212 @@
# supportportal - A maubot plugin to manage customer support on Matrix.
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional
from aiohttp import ClientSession
import logging.config
import importlib
import argparse
import asyncio
import signal
import copy
import sys
from ruamel.yaml import YAML
from ruamel.yaml.comments import CommentedMap
import sqlalchemy as sql
from mautrix.util.config import RecursiveDict
from mautrix.util.db import Base
from mautrix.types import (UserID, Filter, RoomFilter, RoomEventFilter, StrippedStateEvent,
EventType, Membership)
from .config import Config
from ..plugin_base import Plugin
from ..loader import PluginMeta
from ..matrix import MaubotMatrixClient
from ..lib.store_proxy import ClientStoreProxy
from ..__meta__ import __version__
from supportportal import SupportPortalBot
parser = argparse.ArgumentParser(
description="A plugin-based Matrix bot system -- standalone mode.",
prog="python -m maubot.standalone")
parser.add_argument("-c", "--config", type=str, default="config.yaml",
metavar="<path>", help="the path to your config file")
parser.add_argument("-b", "--base-config", type=str, default="example-config.yaml",
metavar="<path>", help="the path to the example config "
"(for automatic config updates)")
parser.add_argument("-m", "--meta", type=str, default="maubot.yaml",
metavar="<path>", help="the path to your plugin metadata file")
args = parser.parse_args()
config = Config(args.config, args.base_config)
config.load()
try:
config.update()
except Exception as e:
print("Failed to update config:", e)
logging.config.dictConfig(copy.deepcopy(config["logging"]))
log = logging.getLogger("maubot.init")
log.debug(f"Loading plugin metadata from {args.meta}")
yaml = YAML()
with open(args.meta, "r") as meta_file:
meta: PluginMeta = PluginMeta.deserialize(yaml.load(meta_file.read()))
if "/" in meta.main_class:
module, main_class = meta.main_class.split("/", 1)
else:
module = meta.modules[0]
main_class = meta.main_class
bot_module = importlib.import_module(module)
plugin = getattr(bot_module, main_class)
log.info(f"Initializing standalone {meta.id} v{meta.version} on maubot {__version__}")
class NextBatch(Base):
__tablename__ = "standalone_next_batch"
user_id: str = sql.Column(sql.String(255), primary_key=True)
next_batch: str = sql.Column(sql.String(255))
filter_id: str = sql.Column(sql.String(255))
@classmethod
def get(cls, user_id: UserID) -> Optional['NextBatch']:
return cls._select_one_or_none(cls.c.user_id == user_id)
log.debug("Opening database")
db = sql.create_engine(config["database"])
Base.metadata.bind = db
Base.metadata.create_all()
NextBatch.bind(db)
user_id = config["user.credentials.id"]
homeserver = config["user.credentials.homeserver"]
access_token = config["user.credentials.access_token"]
nb = NextBatch.get(user_id)
if not nb:
nb = NextBatch(user_id=user_id, next_batch="", filter_id="")
nb.insert()
bot_config = None
if meta.config:
log.debug("Loading config")
config_class = SupportPortalBot.get_config_class()
def load() -> CommentedMap:
return config["plugin_config"]
def load_base() -> RecursiveDict[CommentedMap]:
return RecursiveDict(config.load_base()["plugin_config"], CommentedMap)
def save(data: RecursiveDict[CommentedMap]) -> None:
config["plugin_config"] = data
config.save()
try:
bot_config = config_class(load=load, load_base=load_base, save=save)
bot_config.load_and_update()
except Exception:
log.fatal("Failed to load plugin config", exc_info=True)
sys.exit(1)
loop = asyncio.get_event_loop()
client: MaubotMatrixClient = None
bot: Plugin = None
async def main():
http_client = ClientSession(loop=loop)
global client, bot
client = MaubotMatrixClient(mxid=user_id, base_url=homeserver, token=access_token,
client_session=http_client, loop=loop, store=ClientStoreProxy(nb),
log=logging.getLogger("maubot.client").getChild(user_id))
while True:
try:
whoami_user_id = await client.whoami()
except Exception:
log.exception("Failed to connect to homeserver, retrying in 10 seconds...")
await asyncio.sleep(10)
continue
if whoami_user_id != user_id:
log.fatal(f"User ID mismatch: configured {user_id}, but server said {whoami_user_id}")
sys.exit(1)
break
if config["user.sync"]:
if not nb.filter_id:
nb.edit(filter_id=await client.create_filter(Filter(
room=RoomFilter(timeline=RoomEventFilter(limit=50)),
)))
client.start(nb.filter_id)
if config["autojoin"]:
log.debug("Autojoin is enabled")
@client.on(EventType.ROOM_MEMBER)
async def _handle_invite(evt: StrippedStateEvent) -> None:
if evt.state_key == client.mxid and evt.content.membership == Membership.INVITE:
await client.join_room(evt.room_id)
displayname, avatar_url = config["user.displayname"], config["user.avatar_url"]
if avatar_url != "disable":
await client.set_avatar_url(avatar_url)
if displayname != "disable":
await client.set_displayname(displayname)
bot = plugin(client=client, loop=loop, http=http_client, instance_id="__main__",
log=logging.getLogger("maubot.instance.__main__"), config=bot_config,
database=db if meta.database else None, webapp=None, webapp_url=None)
await bot.internal_start()
try:
log.info("Starting plugin")
loop.run_until_complete(main())
except Exception:
log.fatal("Failed to start plugin", exc_info=True)
sys.exit(1)
signal.signal(signal.SIGINT, signal.default_int_handler)
signal.signal(signal.SIGTERM, signal.default_int_handler)
try:
log.info("Startup completed, running forever")
loop.run_forever()
except KeyboardInterrupt:
log.info("Interrupt received, stopping")
client.stop()
loop.run_until_complete(bot.internal_stop())
loop.close()
sys.exit(0)
except Exception:
log.fatal("Fatal error in bot", exc_info=True)
sys.exit(1)

View file

@ -0,0 +1,37 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.config import BaseFileConfig, ConfigUpdateHelper
class Config(BaseFileConfig):
def do_update(self, helper: ConfigUpdateHelper) -> None:
copy, _, base = helper
copy("user.credentials.id")
copy("user.credentials.homeserver")
copy("user.credentials.access_token")
copy("user.sync")
copy("user.autojoin")
copy("user.displayname")
copy("user.avatar_url")
if "database" in base:
copy("database")
if "plugin_config" in base:
copy("plugin_config")
if "server" in base:
copy("server.hostname")
copy("server.port")
copy("server.public_url")
copy("logging")

View file

@ -2,7 +2,7 @@ mautrix
aiohttp aiohttp
SQLAlchemy SQLAlchemy
alembic alembic
Markdown commonmark
ruamel.yaml ruamel.yaml
attrs attrs
bcrypt bcrypt

View file

@ -22,11 +22,11 @@ setuptools.setup(
packages=setuptools.find_packages(), packages=setuptools.find_packages(),
install_requires=[ install_requires=[
"mautrix>=0.4.dev72,<0.5", "mautrix>=0.4,<0.5",
"aiohttp>=3.0.1,<4", "aiohttp>=3.0.1,<4",
"SQLAlchemy>=1.2.3,<2", "SQLAlchemy>=1.2.3,<2",
"alembic>=1.0.0,<2", "alembic>=1.0.0,<2",
"Markdown>=3.0.0,<4", "commonmark>=0.9.1,<1",
"ruamel.yaml>=0.15.35,<0.17", "ruamel.yaml>=0.15.35,<0.17",
"attrs>=18.1.0", "attrs>=18.1.0",
"bcrypt>=3.1.4,<4", "bcrypt>=3.1.4,<4",
@ -47,6 +47,7 @@ setuptools.setup(
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
], ],
entry_points=""" entry_points="""
[console_scripts] [console_scripts]