Merge branch 'master' of github.com:maubot/maubot
This commit is contained in:
commit
5dfad88cc3
24 changed files with 3015 additions and 2109 deletions
|
@ -36,3 +36,32 @@ push tag:
|
|||
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
|
||||
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $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
|
||||
|
|
|
@ -3,7 +3,7 @@ FROM node:12 AS frontend-builder
|
|||
COPY ./maubot/management/frontend /frontend
|
||||
RUN cd /frontend && yarn --prod && yarn build
|
||||
|
||||
FROM alpine:edge
|
||||
FROM alpine:3.10
|
||||
|
||||
ENV UID=1337 \
|
||||
GID=1337
|
||||
|
@ -11,7 +11,7 @@ ENV UID=1337 \
|
|||
COPY . /opt/maubot
|
||||
COPY --from=frontend-builder /frontend/build /opt/maubot/frontend
|
||||
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-sqlalchemy \
|
||||
py3-attrs \
|
||||
|
@ -24,7 +24,6 @@ RUN apk add --no-cache -X http://dl-cdn.alpinelinux.org/alpine/edge/testing \
|
|||
py3-pillow \
|
||||
py3-magic \
|
||||
py3-psycopg2 \
|
||||
py3-matplotlib \
|
||||
py3-ruamel.yaml \
|
||||
py3-jinja2 \
|
||||
py3-click \
|
||||
|
|
|
@ -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.
|
||||
* [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.
|
||||
* [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
|
||||
|
||||
|
|
|
@ -1 +1 @@
|
|||
__version__ = "0.1.0.dev27"
|
||||
__version__ = "0.1.0.dev30"
|
||||
|
|
|
@ -21,9 +21,10 @@ from aiohttp import ClientSession
|
|||
|
||||
from mautrix.errors import MatrixInvalidToken, MatrixRequestError
|
||||
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 .lib.store_proxy import ClientStoreProxy
|
||||
from .db import DBClient
|
||||
from .matrix import MaubotMatrixClient
|
||||
|
||||
|
@ -58,11 +59,13 @@ class Client:
|
|||
self.remote_avatar_url = None
|
||||
self.client = MaubotMatrixClient(mxid=self.id, base_url=self.homeserver,
|
||||
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_first_sync = True
|
||||
if self.autojoin:
|
||||
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_SUCCESSFUL, self._set_sync_ok(True))
|
||||
|
||||
|
@ -107,13 +110,13 @@ class Client:
|
|||
self.db_instance.enabled = False
|
||||
return
|
||||
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(
|
||||
timeline=RoomEventFilter(
|
||||
limit=50,
|
||||
),
|
||||
),
|
||||
))
|
||||
)))
|
||||
if self.displayname != "disable":
|
||||
await self.client.set_displayname(self.displayname)
|
||||
if self.avatar_url != "disable":
|
||||
|
@ -187,6 +190,13 @@ class Client:
|
|||
def all(cls) -> Iterable['Client']:
|
||||
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:
|
||||
if evt.state_key == self.id and evt.content.membership == Membership.INVITE:
|
||||
await self.client.join_room(evt.room_id)
|
||||
|
|
|
@ -281,7 +281,7 @@ class RegexArgument(Argument):
|
|||
def match(self, val: str, **kwargs) -> Tuple[str, Any]:
|
||||
orig_val = val
|
||||
if not self.pass_raw:
|
||||
val = val.split(" ")[0]
|
||||
val = re.split(r"\s", val, 1)[0]
|
||||
match = self.regex.match(val)
|
||||
if match:
|
||||
return (orig_val[:match.start()] + orig_val[match.end():],
|
||||
|
@ -299,7 +299,7 @@ class CustomArgument(Argument):
|
|||
if self.pass_raw:
|
||||
return self.matcher(val)
|
||||
orig_val = val
|
||||
val = val.split(" ")[0]
|
||||
val = re.split(r"\s", val, 1)[0]
|
||||
res = self.matcher(val)
|
||||
if res:
|
||||
return orig_val[len(val):], res
|
||||
|
@ -310,7 +310,7 @@ class SimpleArgument(Argument):
|
|||
def match(self, val: str, **kwargs) -> Tuple[str, Any]:
|
||||
if self.pass_raw:
|
||||
return "", val
|
||||
res = val.split(" ")[0]
|
||||
res = re.split(r"\s", val, 1)[0]
|
||||
return val[len(res):], res
|
||||
|
||||
|
||||
|
|
|
@ -39,6 +39,7 @@ log = logging.getLogger("maubot.instance")
|
|||
|
||||
yaml = YAML()
|
||||
yaml.indent(4)
|
||||
yaml.width = 200
|
||||
|
||||
|
||||
class PluginInstance:
|
||||
|
|
30
maubot/lib/store_proxy.py
Normal file
30
maubot/lib/store_proxy.py
Normal 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)
|
|
@ -57,6 +57,7 @@ class PluginMeta(SerializableAttrs['PluginMeta']):
|
|||
|
||||
maubot: Version = Version(__version__)
|
||||
database: bool = False
|
||||
config: bool = False
|
||||
webapp: bool = False
|
||||
license: str = ""
|
||||
extra_files: List[str] = []
|
||||
|
|
|
@ -18,6 +18,7 @@ from http import HTTPStatus
|
|||
from aiohttp import web
|
||||
from sqlalchemy.exc import OperationalError, IntegrityError
|
||||
|
||||
|
||||
class _Response:
|
||||
@property
|
||||
def body_not_json(self) -> web.Response:
|
||||
|
|
|
@ -15,12 +15,12 @@
|
|||
"dependencies": {
|
||||
"node-sass": "^4.12.0",
|
||||
"react": "^16.8.6",
|
||||
"react-ace": "^7.0.2",
|
||||
"react-ace": "^8.0.0",
|
||||
"react-contextmenu": "^2.11.0",
|
||||
"react-dom": "^16.8.6",
|
||||
"react-json-tree": "^0.11.2",
|
||||
"react-router-dom": "^5.0.1",
|
||||
"react-scripts": "3.0.1",
|
||||
"react-scripts": "3.3.0",
|
||||
"react-select": "^3.0.4"
|
||||
},
|
||||
"scripts": {
|
||||
|
|
|
@ -30,7 +30,8 @@ class Login extends Component {
|
|||
|
||||
inputChanged = event => this.setState({ [event.target.name]: event.target.value })
|
||||
|
||||
login = async () => {
|
||||
login = async evt => {
|
||||
evt.preventDefault()
|
||||
this.setState({ loading: true })
|
||||
const resp = await api.login(this.state.username, this.state.password)
|
||||
if (resp.token) {
|
||||
|
@ -53,17 +54,17 @@ class Login extends Component {
|
|||
</div>
|
||||
}
|
||||
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>
|
||||
<input type="text" placeholder="Username" value={this.state.username}
|
||||
name="username" onChange={this.inputChanged}/>
|
||||
<input type="password" placeholder="Password" value={this.state.password}
|
||||
name="password" onChange={this.inputChanged}/>
|
||||
<button onClick={this.login}>
|
||||
<button onClick={this.login} type="submit">
|
||||
{this.state.loading ? <Spinner/> : "Log in"}
|
||||
</button>
|
||||
{this.state.error && <div className="error">{this.state.error}</div>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,8 +16,8 @@
|
|||
import React from "react"
|
||||
import { Link, NavLink, Route, Switch, withRouter } from "react-router-dom"
|
||||
import AceEditor from "react-ace"
|
||||
import "brace/mode/yaml"
|
||||
import "brace/theme/github"
|
||||
import "ace-builds/src-noconflict/mode-yaml"
|
||||
import "ace-builds/src-noconflict/theme-github"
|
||||
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
|
||||
import { ReactComponent as NoAvatarIcon } from "../../res/bot.svg"
|
||||
import PrefTable, { PrefInput, PrefSelect, PrefSwitch } from "../../components/PreferenceTable"
|
||||
|
|
|
@ -22,6 +22,7 @@ import { ReactComponent as OrderAsc } from "../../res/sort-up.svg"
|
|||
import api from "../../api"
|
||||
import Spinner from "../../components/Spinner"
|
||||
|
||||
// eslint-disable-next-line no-extend-native
|
||||
Map.prototype.map = function(func) {
|
||||
const res = []
|
||||
for (const [key, value] of this) {
|
||||
|
@ -89,7 +90,7 @@ class InstanceDatabase extends Component {
|
|||
}
|
||||
|
||||
buildSQLQuery(table = this.state.selectedTable, resetContent = true) {
|
||||
let query = `SELECT * FROM ${table}`
|
||||
let query = `SELECT * FROM "${table}"`
|
||||
|
||||
if (this.order.size > 0) {
|
||||
const order = Array.from(this.order.entries()).reverse()
|
||||
|
@ -197,10 +198,10 @@ class InstanceDatabase extends Component {
|
|||
const val = values[index]
|
||||
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)
|
||||
this.setState({
|
||||
prevQuery: `DELETE FROM ${this.state.selectedTable} ...`,
|
||||
prevQuery: `DELETE FROM "${this.state.selectedTable}" ...`,
|
||||
rowCount: res.rowcount,
|
||||
})
|
||||
await this.reloadContent(false)
|
||||
|
@ -215,6 +216,7 @@ class InstanceDatabase extends Component {
|
|||
col: props.col,
|
||||
})
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
sqlEscape = str => str.replace(/[\0\x08\x09\x1a\n\r"'\\%]/g, char => {
|
||||
switch (char) {
|
||||
case "\0":
|
||||
|
|
|
@ -2,29 +2,29 @@
|
|||
font-family: 'Raleway'
|
||||
font-style: normal
|
||||
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-family: 'Raleway'
|
||||
font-style: normal
|
||||
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-family: 'Raleway'
|
||||
font-style: normal
|
||||
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-family: 'Fira Code'
|
||||
src: url('../fonts/firacode-regular.woff2') format('woff2')
|
||||
src: url('../../fonts/firacode-regular.woff2') format('woff2')
|
||||
font-weight: 400
|
||||
font-style: normal
|
||||
|
||||
@font-face
|
||||
font-family: 'Fira Code'
|
||||
src: url('../fonts/firacode-bold.woff2') format('woff2')
|
||||
src: url('../../fonts/firacode-bold.woff2') format('woff2')
|
||||
font-weight: 700
|
||||
font-style: normal
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -14,27 +14,24 @@
|
|||
# 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 Union, Awaitable, Optional, Tuple
|
||||
from markdown.extensions import Extension
|
||||
import markdown as md
|
||||
from html import escape
|
||||
import attr
|
||||
|
||||
from mautrix.client import Client as MatrixClient, SyncStream
|
||||
from mautrix.util.formatter import parse_html
|
||||
from mautrix.util import markdown
|
||||
from mautrix.types import (EventType, MessageEvent, Event, EventID, RoomID, MessageEventContent,
|
||||
MessageType, TextMessageEventContent, Format, RelatesTo)
|
||||
|
||||
|
||||
class EscapeHTML(Extension):
|
||||
def extendMarkdown(self, md):
|
||||
md.preprocessors.deregister("html_block")
|
||||
md.inlinePatterns.deregister("html")
|
||||
|
||||
|
||||
escape_html = EscapeHTML()
|
||||
|
||||
|
||||
def parse_markdown(markdown: str, allow_html: bool = False) -> Tuple[str, str]:
|
||||
html = md.markdown(markdown, extensions=[escape_html] if not allow_html else [])
|
||||
def parse_formatted(message: str, allow_html: bool = False, render_markdown: bool = True
|
||||
) -> Tuple[str, str]:
|
||||
if render_markdown:
|
||||
html = markdown.render(message, allow_html=allow_html)
|
||||
elif allow_html:
|
||||
html = message
|
||||
else:
|
||||
return message, escape(message)
|
||||
return parse_html(html), html
|
||||
|
||||
|
||||
|
@ -50,14 +47,15 @@ class MaubotMessageEvent(MessageEvent):
|
|||
|
||||
def respond(self, content: Union[str, MessageEventContent],
|
||||
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]:
|
||||
if isinstance(content, str):
|
||||
content = TextMessageEventContent(msgtype=MessageType.NOTICE, body=content)
|
||||
if markdown:
|
||||
if allow_html or markdown:
|
||||
content.format = Format.HTML
|
||||
content.body, content.formatted_body = parse_markdown(content.body,
|
||||
allow_html=html_in_markdown)
|
||||
content.body, content.formatted_body = parse_formatted(content.body,
|
||||
render_markdown=markdown,
|
||||
allow_html=allow_html)
|
||||
if edits:
|
||||
content.set_edit(edits)
|
||||
elif reply and not self.disable_reply:
|
||||
|
@ -66,9 +64,9 @@ class MaubotMessageEvent(MessageEvent):
|
|||
|
||||
def reply(self, content: Union[str, MessageEventContent],
|
||||
event_type: EventType = EventType.ROOM_MESSAGE, markdown: bool = True,
|
||||
html_in_markdown: bool = False) -> Awaitable[EventID]:
|
||||
return self.respond(content, event_type, markdown, reply=True,
|
||||
html_in_markdown=html_in_markdown)
|
||||
allow_html: bool = False) -> Awaitable[EventID]:
|
||||
return self.respond(content, event_type, markdown=markdown, reply=True,
|
||||
allow_html=allow_html)
|
||||
|
||||
def mark_read(self) -> Awaitable[None]:
|
||||
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],
|
||||
event_type: EventType = EventType.ROOM_MESSAGE, markdown: bool = True,
|
||||
html_in_markdown: bool = False) -> Awaitable[EventID]:
|
||||
return self.respond(content, event_type, markdown, edits=self,
|
||||
html_in_markdown=html_in_markdown)
|
||||
allow_html: bool = False) -> Awaitable[EventID]:
|
||||
return self.respond(content, event_type, markdown=markdown, edits=self,
|
||||
allow_html=allow_html)
|
||||
|
||||
|
||||
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,
|
||||
relates_to: Optional[RelatesTo] = None, **kwargs) -> Awaitable[EventID]:
|
||||
relates_to: Optional[RelatesTo] = None, **kwargs
|
||||
) -> Awaitable[EventID]:
|
||||
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 edits:
|
||||
raise ValueError("Can't use edits and relates_to at the same time.")
|
||||
|
|
|
@ -20,6 +20,7 @@ from asyncio import AbstractEventLoop
|
|||
|
||||
from sqlalchemy.engine.base import Engine
|
||||
from aiohttp import ClientSession
|
||||
from yarl import URL
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mautrix.util.config import BaseProxyConfig
|
||||
|
@ -35,7 +36,7 @@ class Plugin(ABC):
|
|||
config: Optional['BaseProxyConfig']
|
||||
database: Optional[Engine]
|
||||
webapp: Optional['PluginWebApp']
|
||||
webapp_url: Optional[str]
|
||||
webapp_url: Optional[URL]
|
||||
|
||||
def __init__(self, client: 'MaubotMatrixClient', loop: AbstractEventLoop, http: ClientSession,
|
||||
instance_id: str, log: Logger, config: Optional['BaseProxyConfig'],
|
||||
|
@ -49,12 +50,12 @@ class Plugin(ABC):
|
|||
self.config = config
|
||||
self.database = database
|
||||
self.webapp = webapp
|
||||
self.webapp_url = webapp_url
|
||||
self.webapp_url = URL(webapp_url) if webapp_url else None
|
||||
self._handlers_at_startup = []
|
||||
|
||||
async def internal_start(self) -> None:
|
||||
for key in dir(self):
|
||||
val = getattr(self, key)
|
||||
def register_handler_class(self, obj) -> None:
|
||||
for key in dir(obj):
|
||||
val = getattr(obj, key)
|
||||
try:
|
||||
if val.__mb_event_handler__:
|
||||
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)
|
||||
except AttributeError:
|
||||
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()
|
||||
|
||||
async def start(self) -> None:
|
||||
pass
|
||||
|
||||
async def pre_stop(self) -> None:
|
||||
pass
|
||||
|
||||
async def internal_stop(self) -> None:
|
||||
await self.pre_stop()
|
||||
for func, event_type in self._handlers_at_startup:
|
||||
self.client.remove_event_handler(event_type, func)
|
||||
if self.webapp is not None:
|
||||
|
|
23
maubot/standalone/Dockerfile
Normal file
23
maubot/standalone/Dockerfile
Normal 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
|
0
maubot/standalone/__init__.py
Normal file
0
maubot/standalone/__init__.py
Normal file
212
maubot/standalone/__main__.py
Normal file
212
maubot/standalone/__main__.py
Normal 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)
|
37
maubot/standalone/config.py
Normal file
37
maubot/standalone/config.py
Normal 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")
|
|
@ -2,7 +2,7 @@ mautrix
|
|||
aiohttp
|
||||
SQLAlchemy
|
||||
alembic
|
||||
Markdown
|
||||
commonmark
|
||||
ruamel.yaml
|
||||
attrs
|
||||
bcrypt
|
||||
|
|
5
setup.py
5
setup.py
|
@ -22,11 +22,11 @@ setuptools.setup(
|
|||
packages=setuptools.find_packages(),
|
||||
|
||||
install_requires=[
|
||||
"mautrix>=0.4.dev72,<0.5",
|
||||
"mautrix>=0.4,<0.5",
|
||||
"aiohttp>=3.0.1,<4",
|
||||
"SQLAlchemy>=1.2.3,<2",
|
||||
"alembic>=1.0.0,<2",
|
||||
"Markdown>=3.0.0,<4",
|
||||
"commonmark>=0.9.1,<1",
|
||||
"ruamel.yaml>=0.15.35,<0.17",
|
||||
"attrs>=18.1.0",
|
||||
"bcrypt>=3.1.4,<4",
|
||||
|
@ -47,6 +47,7 @@ setuptools.setup(
|
|||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.6",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
],
|
||||
entry_points="""
|
||||
[console_scripts]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue