Эх сурвалжийг харах

Add initial database and config

Tulir Asokan 4 жил өмнө
parent
commit
aaf7830a29

+ 103 - 0
mautrix_instagram/config.py

@@ -0,0 +1,103 @@
+# mautrix-instagram - A Matrix-Instagram puppeting bridge.
+# Copyright (C) 2020 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 Any, List, NamedTuple
+import os
+
+from mautrix.types import UserID
+from mautrix.client import Client
+from mautrix.util.config import ConfigUpdateHelper, ForbiddenKey, ForbiddenDefault
+from mautrix.bridge.config import BaseBridgeConfig
+
+Permissions = NamedTuple("Permissions", user=bool, admin=bool, level=str)
+
+
+class Config(BaseBridgeConfig):
+    def __getitem__(self, key: str) -> Any:
+        try:
+            return os.environ[f"MAUTRIX_INSTAGRAM_{key.replace('.', '_').upper()}"]
+        except KeyError:
+            return super().__getitem__(key)
+
+    @property
+    def forbidden_defaults(self) -> List[ForbiddenDefault]:
+        return [
+            *super().forbidden_defaults,
+            ForbiddenDefault("appservice.database", "postgres://username:password@hostname/db"),
+            ForbiddenDefault("bridge.permissions", ForbiddenKey("example.com")),
+        ]
+
+    def do_update(self, helper: ConfigUpdateHelper) -> None:
+        super().do_update(helper)
+        copy, copy_dict, base = helper
+
+        copy("homeserver.asmux")
+
+        copy("appservice.provisioning.enabled")
+        copy("appservice.provisioning.prefix")
+        copy("appservice.provisioning.shared_secret")
+        if base["appservice.provisioning.shared_secret"] == "generate":
+            base["appservice.provisioning.shared_secret"] = self._new_token()
+
+        copy("appservice.community_id")
+
+        copy("metrics.enabled")
+        copy("metrics.listen_port")
+
+        copy("bridge.username_template")
+        copy("bridge.displayname_template")
+
+        copy("bridge.displayname_max_length")
+
+        copy("bridge.initial_conversation_sync")
+        copy("bridge.sync_with_custom_puppets")
+        copy("bridge.sync_direct_chat_list")
+        copy("bridge.double_puppet_server_map")
+        copy("bridge.double_puppet_allow_discovery")
+        copy("bridge.login_shared_secret_map")
+        copy("bridge.federate_rooms")
+        copy("bridge.backfill.invite_own_puppet")
+        copy("bridge.backfill.initial_limit")
+        copy("bridge.backfill.disable_notifications")
+        copy("bridge.encryption.allow")
+        copy("bridge.encryption.default")
+        copy("bridge.encryption.key_sharing.allow")
+        copy("bridge.encryption.key_sharing.require_cross_signing")
+        copy("bridge.encryption.key_sharing.require_verification")
+        copy("bridge.private_chat_portal_meta")
+        copy("bridge.delivery_receipts")
+        copy("bridge.delivery_error_reports")
+        copy("bridge.resend_bridge_info")
+
+        copy("bridge.command_prefix")
+
+        copy_dict("bridge.permissions")
+
+    def _get_permissions(self, key: str) -> Permissions:
+        level = self["bridge.permissions"].get(key, "")
+        admin = level == "admin"
+        user = level == "user" or admin
+        return Permissions(user, admin, level)
+
+    def get_permissions(self, mxid: UserID) -> Permissions:
+        permissions = self["bridge.permissions"]
+        if mxid in permissions:
+            return self._get_permissions(mxid)
+
+        _, homeserver = Client.parse_user_id(mxid)
+        if homeserver in permissions:
+            return self._get_permissions(homeserver)
+
+        return self._get_permissions("*")

+ 16 - 0
mautrix_instagram/db/__init__.py

@@ -0,0 +1,16 @@
+from mautrix.util.async_db import Database
+
+from .upgrade import upgrade_table
+from .user import User
+from .puppet import Puppet
+from .portal import Portal
+from .message import Message
+from .reaction import Reaction
+
+
+def init(db: Database) -> None:
+    for table in (User, Puppet, Portal, Message, Reaction):
+        table.db = db
+
+
+__all__ = ["upgrade_table", "User", "Puppet", "Portal", "Message", "Reaction"]

+ 62 - 0
mautrix_instagram/db/message.py

@@ -0,0 +1,62 @@
+# mautrix-instagram - A Matrix-Instagram puppeting bridge.
+# Copyright (C) 2020 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, ClassVar, TYPE_CHECKING
+
+from attr import dataclass
+
+from mautrix.types import RoomID, EventID
+from mautrix.util.async_db import Database
+
+fake_db = Database("") if TYPE_CHECKING else None
+
+
+@dataclass
+class Message:
+    db: ClassVar[Database] = fake_db
+
+    mxid: EventID
+    mx_room: RoomID
+    item_id: str
+    receiver: int
+
+    async def insert(self) -> None:
+        q = "INSERT INTO message (mxid, mx_room, item_id, receiver) VALUES ($1, $2, $3, $4)"
+        await self.db.execute(q, self.mxid, self.mx_room, self.item_id, self.receiver)
+
+    async def delete(self) -> None:
+        q = "DELETE FROM message WHERE item_id=$1 AND receiver=$2"
+        await self.db.execute(q, self.item_id, self.receiver)
+
+    @classmethod
+    async def delete_all(cls, room_id: RoomID) -> None:
+        await cls.db.execute("DELETE FROM message WHERE mx_room=$1", room_id)
+
+    @classmethod
+    async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Optional['Message']:
+        row = await cls.db.fetchrow("SELECT mxid, mx_room, item_id, receiver "
+                                    "FROM message WHERE mxid=$1 AND mx_room=$2", mxid, mx_room)
+        if not row:
+            return None
+        return cls(**row)
+
+    @classmethod
+    async def get_by_item_id(cls, item_id: str, receiver: int = 0) -> Optional['Message']:
+        row = await cls.db.fetchrow("SELECT mxid, mx_room, item_id, receiver "
+                                    "FROM message WHERE item_id=$1 AND receiver=$2",
+                                    item_id, receiver)
+        if not row:
+            return None
+        return cls(**row)

+ 91 - 0
mautrix_instagram/db/portal.py

@@ -0,0 +1,91 @@
+# mautrix-instagram - A Matrix-Instagram puppeting bridge.
+# Copyright (C) 2020 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, ClassVar, List, TYPE_CHECKING
+
+from attr import dataclass
+import asyncpg
+
+from mautrix.types import RoomID
+from mautrix.util.async_db import Database
+
+fake_db = Database("") if TYPE_CHECKING else None
+
+
+@dataclass
+class Portal:
+    db: ClassVar[Database] = fake_db
+
+    thread_id: str
+    receiver: int
+    other_user_pk: Optional[int]
+    mxid: Optional[RoomID]
+    name: Optional[str]
+    encrypted: bool
+
+    async def insert(self) -> None:
+        q = ("INSERT INTO portal (thread_id, receiver, other_user_pk, mxid, name, encrypted) "
+             "VALUES ($1, $2, $3, $4, $5, $6)")
+        await self.db.execute(q, self.thread_id, self.receiver, self.other_user_pk,
+                              self.mxid, self.name, self.encrypted)
+
+    async def update(self) -> None:
+        q = ("UPDATE portal SET other_user_pk=$3, mxid=$4, name=$5, encrypted=$6 "
+             "WHERE thread_id=$1 AND receiver=$2")
+        await self.db.execute(q, self.thread_id, self.receiver, self.other_user_pk,
+                              self.mxid, self.name, self.encrypted)
+
+    @classmethod
+    def _from_row(cls, row: asyncpg.Record) -> 'Portal':
+        return cls(**row)
+
+    @classmethod
+    async def get_by_mxid(cls, mxid: RoomID) -> Optional['Portal']:
+        q = ("SELECT thread_id, receiver, other_user_pk, mxid, name, encrypted "
+             "FROM portal WHERE mxid=$1")
+        row = await cls.db.fetchrow(q, mxid)
+        if not row:
+            return None
+        return cls._from_row(row)
+
+    @classmethod
+    async def get_by_thread_id(cls, thread_id: str, receiver: int = 0) -> Optional['Portal']:
+        q = ("SELECT thread_id, receiver, other_user_pk, mxid, name, encrypted "
+             "FROM portal WHERE thread_id=$1 AND receiver=$2")
+        row = await cls.db.fetchrow(q, thread_id, receiver)
+        if not row:
+            return None
+        return cls._from_row(row)
+
+    @classmethod
+    async def find_private_chats_of(cls, receiver: int) -> List['Portal']:
+        q = ("SELECT thread_id, receiver, other_user_pk, mxid, name, encrypted FROM portal "
+             "WHERE receiver=$1 AND other_user_pk IS NOT NULL")
+        rows = await cls.db.fetch(q, receiver)
+        return [cls._from_row(row) for row in rows]
+
+    @classmethod
+    async def find_private_chats_with(cls, other_user: int) -> List['Portal']:
+        q = ("SELECT thread_id, receiver, other_user_pk, mxid, name, encrypted FROM portal "
+             "WHERE other_user=$1")
+        rows = await cls.db.fetch(q, other_user)
+        return [cls._from_row(row) for row in rows]
+
+    @classmethod
+    async def all_with_room(cls) -> List['Portal']:
+        q = ("SELECT thread_id, receiver, other_user_pk, mxid, name, encrypted FROM portal "
+             'WHERE mxid IS NOT NULL')
+        rows = await cls.db.fetch(q)
+        return [cls._from_row(row) for row in rows]

+ 94 - 0
mautrix_instagram/db/puppet.py

@@ -0,0 +1,94 @@
+# mautrix-instagram - A Matrix-Instagram puppeting bridge.
+# Copyright (C) 2020 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, ClassVar, List, TYPE_CHECKING
+
+from attr import dataclass
+from yarl import URL
+import asyncpg
+
+from mautrix.types import UserID, SyncToken, ContentURI
+from mautrix.util.async_db import Database
+
+fake_db = Database("") if TYPE_CHECKING else None
+
+
+@dataclass
+class Puppet:
+    db: ClassVar[Database] = fake_db
+
+    pk: int
+    name: Optional[str]
+    username: Optional[str]
+    photo_id: Optional[str]
+    photo_mxc: Optional[ContentURI]
+
+    is_registered: bool
+
+    custom_mxid: Optional[UserID]
+    access_token: Optional[str]
+    next_batch: Optional[SyncToken]
+    base_url: Optional[URL]
+
+    async def insert(self) -> None:
+        q = ("INSERT INTO puppet (pk, name, username, photo_id, photo_mxc, is_registered,"
+             "                    custom_mxid, access_token, next_batch, base_url) "
+             "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)")
+        await self.db.execute(q, self.pk, self.name, self.username, self.photo_id, self.photo_mxc,
+                              self.is_registered, self.custom_mxid, self.access_token,
+                              self.next_batch, str(self.base_url) if self.base_url else None)
+
+    async def update(self) -> None:
+        q = ("UPDATE puppet SET name=$2, username=$3, photo_id=$4, photo_mxc=$5, is_registered=$6,"
+             "                  custom_mxid=$7, access_token=$8, next_batch=$9, base_url=$10 "
+             "WHERE pk=$1")
+        await self.db.execute(q, self.pk, self.name, self.username, self.photo_id, self.photo_mxc,
+                              self.is_registered, self.custom_mxid, self.access_token,
+                              self.next_batch, str(self.base_url) if self.base_url else None)
+
+    @classmethod
+    def _from_row(cls, row: asyncpg.Record) -> 'Puppet':
+        data = {**row}
+        base_url_str = data.pop("base_url")
+        base_url = URL(base_url_str) if base_url_str is not None else None
+        return cls(base_url=base_url, **data)
+
+    @classmethod
+    async def get_by_pk(cls, pk: int) -> Optional['Puppet']:
+        q = ("SELECT pk, name, username, photo_id, photo_mxc, is_registered,"
+             "       custom_mxid, access_token, next_batch, base_url "
+             "FROM puppet WHERE igpk=$1")
+        row = await cls.db.fetchrow(q, pk)
+        if not row:
+            return None
+        return cls._from_row(row)
+
+    @classmethod
+    async def get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']:
+        q = ("SELECT pk, name, username, photo_id, photo_mxc, is_registered,"
+             "       custom_mxid, access_token, next_batch, base_url "
+             "FROM puppet WHERE custom_mxid=$1")
+        row = await cls.db.fetchrow(q, mxid)
+        if not row:
+            return None
+        return cls._from_row(row)
+
+    @classmethod
+    async def all_with_custom_mxid(cls) -> List['Puppet']:
+        q = ("SELECT pk, name, username, photo_id, photo_mxc, is_registered,"
+             "       custom_mxid, access_token, next_batch, base_url "
+             "FROM puppet WHERE custom_mxid IS NOT NULL")
+        rows = await cls.db.fetch(q)
+        return [cls._from_row(row) for row in rows]

+ 70 - 0
mautrix_instagram/db/reaction.py

@@ -0,0 +1,70 @@
+# mautrix-instagram - A Matrix-Instagram puppeting bridge.
+# Copyright (C) 2020 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, ClassVar, TYPE_CHECKING
+
+from attr import dataclass
+
+from mautrix.types import RoomID, EventID
+from mautrix.util.async_db import Database
+
+fake_db = Database("") if TYPE_CHECKING else None
+
+
+@dataclass
+class Reaction:
+    db: ClassVar[Database] = fake_db
+
+    mxid: EventID
+    mx_room: RoomID
+    ig_item_id: str
+    ig_receiver: int
+    ig_sender: int
+    reaction: str
+
+    async def insert(self) -> None:
+        q = ("INSERT INTO reaction (mxid, mx_room, ig_item_id, ig_receiver, ig_sender, reaction) "
+             "VALUES ($1, $2, $3, $4, $5, $6)")
+        await self.db.execute(q, self.mxid, self.mx_room, self.ig_item_id, self.ig_receiver,
+                              self.ig_sender, self.reaction)
+
+    async def edit(self, mx_room: RoomID, mxid: EventID, reaction: ReactionKey) -> None:
+        await self.db.execute("UPDATE reaction SET mxid=$1, mx_room=$2, reaction=$3 "
+                              "WHERE ig_item_id=$4 AND ig_receiver=$5 AND ig_sender=$6",
+                              mxid, mx_room, reaction.value, self.ig_item_id, self.ig_receiver,
+                              self.ig_sender)
+
+    async def delete(self) -> None:
+        q = "DELETE FROM reaction WHERE ig_item_id=$1 AND ig_receiver=$2 AND ig_sender=$3"
+        await self.db.execute(q, self.ig_item_id, self.ig_receiver, self.ig_sender)
+
+    @classmethod
+    async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Optional['Reaction']:
+        q = ("SELECT mxid, mx_room, ig_item_id, ig_receiver, ig_sender, reaction "
+             "FROM reaction WHERE mxid=$1 AND mx_room=$2")
+        row = await cls.db.fetchrow(q, mxid, mx_room)
+        if not row:
+            return None
+        return cls(**row)
+
+    @classmethod
+    async def get_by_item_id(cls, ig_item_id: str, ig_receiver: int, ig_sender: int,
+                             ) -> Optional['Reaction']:
+        q = ("SELECT mxid, mx_room, ig_item_id, ig_receiver, ig_sender, reaction "
+             "FROM reaction WHERE ig_item_id=$1 AND ig_sender=$2 AND ig_receiver=$3")
+        row = await cls.db.fetchrow(q, ig_item_id, ig_sender, ig_receiver)
+        if not row:
+            return None
+        return cls(**row)

+ 79 - 0
mautrix_instagram/db/upgrade.py

@@ -0,0 +1,79 @@
+# mautrix-instagram - A Matrix-Instagram puppeting bridge.
+# Copyright (C) 2020 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 asyncpg import Connection
+
+from mautrix.util.async_db import UpgradeTable
+
+upgrade_table = UpgradeTable()
+
+
+@upgrade_table.register(description="Initial revision")
+async def upgrade_v1(conn: Connection) -> None:
+    await conn.execute("""CREATE TABLE portal (
+        thread_id     TEXT,
+        receiver      BIGINT,
+        other_user_pk BIGINT,
+        mxid          TEXT,
+        name          TEXT,
+        encrypted     BOOLEAN NOT NULL DEFAULT false,
+        PRIMARY KEY (thread_id, receiver)
+    )""")
+    await conn.execute("""CREATE TABLE "user" (
+        mxid        VARCHAR(255) PRIMARY KEY,
+        igpk        BIGINT,
+        state       jsonb,
+        notice_room VARCHAR(255)
+    )""")
+    await conn.execute("""CREATE TABLE puppet (
+        pk            BIGINT PRIMARY KEY,
+        name          TEXT,
+        username      TEXT,
+        photo_id      TEXT,
+        photo_mxc     TEXT,
+        is_registered BOOLEAN NOT NULL DEFAULT false,
+        custom_mxid   TEXT,
+        access_token  TEXT,
+        next_batch    TEXT,
+        base_url      TEXT
+    )""")
+    await conn.execute("""CREATE TABLE user_portal (
+        "user"          BIGINT,
+        portal          TEXT,
+        portal_receiver BIGINT,
+        in_community    BOOLEAN NOT NULL DEFAULT false,
+        FOREIGN KEY (portal, portal_receiver) REFERENCES portal(thread_id, receiver)
+            ON UPDATE CASCADE ON DELETE CASCADE
+    )""")
+    await conn.execute("""CREATE TABLE message (
+        mxid     TEXT NOT NULL,
+        mx_room  TEXT NOT NULL,
+        item_id  TEXT,
+        receiver BIGINT,
+        PRIMARY KEY (item_id, receiver),
+        UNIQUE (mxid, mx_room)
+    )""")
+    await conn.execute("""CREATE TABLE reaction (
+        mxid        TEXT NOT NULL,
+        mx_room     TEXT NOT NULL,
+        ig_item_id  TEXT,
+        ig_receiver BIGINT,
+        ig_sender   BIGINT,
+        reaction    TEXT NOT NULL,
+        PRIMARY KEY (ig_item_id, ig_receiver, ig_sender),
+        FOREIGN KEY (ig_item_id, ig_receiver) REFERENCES message(item_id, receiver)
+            ON DELETE CASCADE ON UPDATE CASCADE,
+        UNIQUE (mxid, mx_room)
+    )""")

+ 75 - 0
mautrix_instagram/db/user.py

@@ -0,0 +1,75 @@
+# mautrix-instagram - A Matrix-Instagram puppeting bridge.
+# Copyright (C) 2020 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, ClassVar, List, TYPE_CHECKING
+
+from attr import dataclass
+import asyncpg
+
+from mauigpapi.state import AndroidState
+from mautrix.types import UserID, RoomID
+from mautrix.util.async_db import Database
+
+fake_db = Database("") if TYPE_CHECKING else None
+
+
+@dataclass
+class User:
+    db: ClassVar[Database] = fake_db
+
+    mxid: UserID
+    igpk: Optional[int]
+    state: Optional[AndroidState]
+    notice_room: Optional[RoomID]
+
+    async def insert(self) -> None:
+        q = ('INSERT INTO "user" (mxid, igpk, state, notice_room) '
+             'VALUES ($1, $2, $3, $4)')
+        await self.db.execute(q, self.mxid, self.igpk,
+                              self.state.serialize() if self.state else None, self.notice_room)
+
+    async def update(self) -> None:
+        await self.db.execute('UPDATE "user" SET igpk=$2, state=$3, notice_room=$4 '
+                              'WHERE mxid=$1', self.mxid, self.igpk,
+                              self.state.serialize() if self.state else None, self.notice_room)
+
+    @classmethod
+    def _from_row(cls, row: asyncpg.Record) -> 'User':
+        data = {**row}
+        state_str = data.pop("state")
+        return cls(state=AndroidState.parse_json(state_str) if state_str else None, **data)
+
+    @classmethod
+    async def get_by_mxid(cls, mxid: UserID) -> Optional['User']:
+        q = 'SELECT mxid, igpk, state, notice_room FROM "user" WHERE mxid=$1'
+        row = await cls.db.fetchrow(q, mxid)
+        if not row:
+            return None
+        return cls._from_row(row)
+
+    @classmethod
+    async def get_by_igpk(cls, igpk: int) -> Optional['User']:
+        q = 'SELECT mxid, igpk, state, notice_room FROM "user" WHERE igpk=$1'
+        row = await cls.db.fetchrow(q, igpk)
+        if not row:
+            return None
+        return cls._from_row(row)
+
+    @classmethod
+    async def all_logged_in(cls) -> List['User']:
+        q = ("SELECT mxid, igp, state, notice_room "
+             'FROM "user" WHERE igpk IS NOT NULL AND state IS NOT NULL')
+        rows = await cls.db.fetch(q)
+        return [cls._from_row(row) for row in rows]

+ 205 - 0
mautrix_instagram/example-config.yaml

@@ -0,0 +1,205 @@
+# Homeserver details
+homeserver:
+    # The address that this appservice can use to connect to the homeserver.
+    address: https://example.com
+    # The domain of the homeserver (for MXIDs, etc).
+    domain: example.com
+    # Whether or not to verify the SSL certificate of the homeserver.
+    # Only applies if address starts with https://
+    verify_ssl: true
+    asmux: false
+
+# Application service host/registration related details
+# Changing these values requires regeneration of the registration.
+appservice:
+    # The address that the homeserver can use to connect to this appservice.
+    address: http://localhost:29330
+    # When using https:// the TLS certificate and key files for the address.
+    tls_cert: false
+    tls_key: false
+
+    # The hostname and port where this appservice should listen.
+    hostname: 0.0.0.0
+    port: 29330
+    # The maximum body size of appservice API requests (from the homeserver) in mebibytes
+    # Usually 1 is enough, but on high-traffic bridges you might need to increase this to avoid 413s
+    max_body_size: 1
+
+    # The full URI to the database. Only Postgres is currently supported.
+    database: postgres://username:password@hostname/db
+
+    # Provisioning API part of the web server for automated portal creation and fetching information.
+    # Used by things like mautrix-manager (https://github.com/tulir/mautrix-manager).
+    provisioning:
+        # Whether or not the provisioning API should be enabled.
+        enabled: true
+        # The prefix to use in the provisioning API endpoints.
+        prefix: /_matrix/provision/v1
+        # The shared secret to authorize users of the API.
+        # Set to "generate" to generate and save a new token.
+        shared_secret: generate
+
+    # The unique ID of this appservice.
+    id: instagram
+    # Username of the appservice bot.
+    bot_username: instagrambot
+    # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
+    # to leave display name/avatar as-is.
+    bot_displayname: Instagram bridge bot
+    bot_avatar: mxc://maunium.net/JxjlbZUlCPULEeHZSwleUXQv
+
+    # Community ID for bridged users (changes registration file) and rooms.
+    # Must be created manually.
+    #
+    # Example: "+instagram:example.com". Set to false to disable.
+    community_id: false
+
+    # Whether or not to receive ephemeral events via appservice transactions.
+    # Requires MSC2409 support (i.e. Synapse 1.22+).
+    # You should disable bridge -> sync_with_custom_puppets when this is enabled.
+    ephemeral_events: false
+
+    # Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
+    as_token: "This value is generated when generating the registration"
+    hs_token: "This value is generated when generating the registration"
+
+# Prometheus telemetry config. Requires prometheus-client to be installed.
+metrics:
+    enabled: false
+    listen_port: 8000
+
+# Bridge config
+bridge:
+    # Localpart template of MXIDs for Instagram users.
+    # {userid} is replaced with the user ID of the Instagram user.
+    username_template: "instagram_{userid}"
+    # Displayname template for Instagram users.
+    # {displayname} is replaced with the display name of the Instagram user.
+    # {username} is replaced with the username of the Instagram user.
+    displayname_template: "{displayname} (Instagram)"
+
+    # Maximum length of displayname
+    displayname_max_length: 100
+
+    # Number of conversations to sync (and create portals for) on login.
+    # Set 0 to disable automatic syncing.
+    initial_conversation_sync: 10
+    # Whether or not to use /sync to get read receipts and typing notifications
+    # when double puppeting is enabled
+    sync_with_custom_puppets: true
+    # Whether or not to update the m.direct account data event when double puppeting is enabled.
+    # Note that updating the m.direct event is not atomic (except with mautrix-asmux)
+    # and is therefore prone to race conditions.
+    sync_direct_chat_list: false
+    # Allow using double puppeting from any server with a valid client .well-known file.
+    double_puppet_allow_discovery: false
+    # Servers to allow double puppeting from, even if double_puppet_allow_discovery is false.
+    double_puppet_server_map:
+        example.com: https://example.com
+    # Shared secret for https://github.com/devture/matrix-synapse-shared-secret-auth
+    #
+    # If set, custom puppets will be enabled automatically for local users
+    # instead of users having to find an access token and run `login-matrix`
+    # manually.
+    # If using this for other servers than the bridge's server,
+    # you must also set the URL in the double_puppet_server_map.
+    login_shared_secret_map:
+        example.com: foo
+    # Whether or not created rooms should have federation enabled.
+    # If false, created portal rooms will never be federated.
+    federate_rooms: true
+    # Settings for backfilling messages from Instagram.
+    backfill:
+        # Whether or not the Instagram users of logged in Matrix users should be
+        # invited to private chats when backfilling history from Instagram. This is
+        # usually needed to prevent rate limits and to allow timestamp massaging.
+        invite_own_puppet: true
+        # Maximum number of messages to backfill initially.
+        # Set to 0 to disable backfilling when creating portal.
+        initial_limit: 0
+        # Maximum number of messages to backfill if messages were missed while
+        # the bridge was disconnected.
+        # Set to 0 to disable backfilling missed messages.
+        missed_limit: 1000
+        # If using double puppeting, should notifications be disabled
+        # while the initial backfill is in progress?
+        disable_notifications: false
+    # End-to-bridge encryption support options. You must install the e2be optional dependency for
+    # this to work. See https://github.com/tulir/mautrix-telegram/wiki/End‐to‐bridge-encryption
+    encryption:
+        # Allow encryption, work in group chat rooms with e2ee enabled
+        allow: false
+        # Default to encryption, force-enable encryption in all portals the bridge creates
+        # This will cause the bridge bot to be in private chats for the encryption to work properly.
+        default: false
+        # Options for automatic key sharing.
+        key_sharing:
+            # Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
+            # You must use a client that supports requesting keys from other users to use this feature.
+            allow: false
+            # Require the requesting device to have a valid cross-signing signature?
+            # This doesn't require that the bridge has verified the device, only that the user has verified it.
+            # Not yet implemented.
+            require_cross_signing: false
+            # Require devices to be verified by the bridge?
+            # Verification by the bridge is not yet implemented.
+            require_verification: true
+    # Whether or not to explicitly set the avatar and room name for private
+    # chat portal rooms. This will be implicitly enabled if encryption.default is true.
+    private_chat_portal_meta: false
+    # Whether or not the bridge should send a read receipt from the bridge bot when a message has
+    # been sent to Instagram.
+    delivery_receipts: false
+    # Whether or not delivery errors should be reported as messages in the Matrix room.
+    delivery_error_reports: false
+    # Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run.
+    # This field will automatically be changed back to false after it,
+    # except if the config file is not writable.
+    resend_bridge_info: false
+
+    # The prefix for commands. Only required in non-management rooms.
+    command_prefix: "!ig"
+
+    # Permissions for using the bridge.
+    # Permitted values:
+    #       user - Use the bridge with puppeting.
+    #      admin - Use and administrate the bridge.
+    # Permitted keys:
+    #        * - All Matrix users
+    #   domain - All users on that homeserver
+    #     mxid - Specific user
+    permissions:
+        "example.com": "user"
+        "@admin:example.com": "admin"
+
+
+# Python logging configuration.
+#
+# See section 16.7.2 of the Python documentation for more info:
+# https://docs.python.org/3.6/library/logging.config.html#configuration-dictionary-schema
+logging:
+    version: 1
+    formatters:
+        colored:
+            (): mautrix_instagram.util.ColorFormatter
+            format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
+        normal:
+            format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
+    handlers:
+        file:
+            class: logging.handlers.RotatingFileHandler
+            formatter: normal
+            filename: ./mautrix-instagram.log
+            maxBytes: 10485760
+            backupCount: 10
+        console:
+            class: logging.StreamHandler
+            formatter: colored
+    loggers:
+        mau:
+            level: DEBUG
+        aiohttp:
+            level: INFO
+    root:
+        level: DEBUG
+        handlers: [file, console]