Merge pull request #26 from tulir/python-rewrite

Rewrite in Python with Telethon
This commit is contained in:
Tulir Asokan
2018-01-29 12:51:32 +02:00
committed by GitHub
33 changed files with 3003 additions and 8915 deletions
+1 -1
View File
@@ -8,5 +8,5 @@ charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{yaml,yml}]
[*.{yaml,yml,py}]
indent_style = space
-174
View File
@@ -1,174 +0,0 @@
{
"env": {
"node": true,
"es6": true
},
"extends": "airbnb-base",
"parserOptions": {
"sourceType": "module",
"ecmaVersion": 8
},
"plugins": [
"import"
],
"rules": {
"indent": [
"error",
"tab",
{
"FunctionDeclaration": {
"parameters": 2,
"body": 1
},
"FunctionExpression": {
"parameters": 2,
"body": 1
},
"VariableDeclarator": 2,
"CallExpression": {
"arguments": 2
},
"MemberExpression": "off",
"ImportDeclaration": "first"
}
],
"object-curly-newline": [
"error",
{
"consistent": true
}
],
"one-var": [
"error",
{
"initialized": "never",
"uninitialized": "always"
}
],
"one-var-declaration-per-line": [
"error",
"initializations"
],
"quotes": [
"error",
"double"
],
"semi": [
"error",
"never"
],
"comma-dangle": [
"error",
"always-multiline"
],
"max-len": [
"warn",
120
],
"no-unused-vars": [
"error",
{
"vars": "all",
"args": "after-used",
"varsIgnorePattern": "_"
}
],
"space-before-function-paren": [
"error",
{
"anonymous": "never",
"named": "never",
"asyncArrow": "always"
}
],
"func-style": [
"warn",
"declaration",
{
"allowArrowFunctions": true
}
],
"id-length": [
"warn",
{
"max": 25,
"exceptions": [
"i",
"x",
"y",
"$",
"_"
]
}
],
"import/order": [
"warn",
{
"groups": [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index"
],
"newlines-between": "never"
}
],
"arrow-body-style": [
"error",
"as-needed"
],
"complexity": [
"warn",
11
],
"new-cap": [
"warn",
{
"newIsCap": true,
"capIsNew": true,
"capIsNewExceptions": ["MTProto"]
}
],
"no-empty": [
"error",
{
"allowEmptyCatch": true
}
],
"no-cond-assign": [
"error",
"except-parens"
],
"function-paren-newline": "off",
"no-labels": "off",
"no-control-regex": "off",
"no-void": "off",
"func-names": "off",
"no-continue": "off",
"default-case": "off",
"no-plusplus": "off",
"no-use-before-define": "off",
"no-restricted-syntax": "off",
"no-return-assign": "off",
"no-param-reassign": "off",
"arrow-parens": "off",
"no-nested-ternary": "off",
"no-new": "off",
"no-tabs": "off",
"no-prototype-builtins": "off",
"no-console": "off",
"class-methods-use-this": "off",
"prefer-destructuring": "off",
"camelcase": "off",
"spaced-comment": "off",
"no-bitwise": "off",
"no-case-declarations": "off",
"no-template-curly-in-string": "off",
"no-await-in-loop": "off",
"no-restricted-globals": "off",
"no-fallthrough": "off",
"no-underscore-dangle": "off"
}
}
+7 -2
View File
@@ -1,6 +1,11 @@
node_modules/
.idea/
jsdoc/
.venv
pip-selfcheck.json
*.pyc
__pycache__
config.yaml
registration.yaml
*.db
*.session
+67 -40
View File
@@ -1,6 +1,4 @@
# mautrix-telegram
**Work in progress: Expect bugs, do not use in production.**
A Matrix-Telegram puppeting bridge.
## Discussion
@@ -10,20 +8,26 @@ A Telegram chat will be created once the bridge is stable enough.
## Usage
### Setup
0. Clone the repository and install packages with `npm install`.
1. Create a copy of `example-config.yaml` and fill out the fields.
2. Generate the appservice registration with `./mautrix-telegram -g`.
0. Clone the repository
1. Set up the virtual environment
1. Create with `virtualenv -p /usr/bin/python3 .venv`
2. Activate with `source .venv/bin/activate`
2. Install dependencies with `pip install -r requirements.txt`
3. Create a copy of `example-config.yaml` and fill out the fields.
4. Generate the appservice registration with `python -m mautrix_telegram -g`.
You can use the `-c` and `-r` flags to change the location of the config and registration files.
They default to `config.yaml` and `registration.yaml` respectively.
3. Run the bridge `./mautrix-telegram`. You can also use forever: `forever start mautrix-telegram` (probably, I didn't actually test it).
4. Invite the appservice bot to a private room and view the commands with `help`.
5. Run the bridge `python -m mautrix_telegram`.
6. Invite the appservice bot to a private room and view the commands with `help`.
### Logging in
0. Make sure you have set up the bridge and have an open management room (a room with no other users than the appservice bot).
0. Make sure you have set up the bridge and have an open management room (a room with no other
users than the appservice bot).
1. Request a Telegram auth code with `login <phone number>`.
2. Send your auth code to the management room.
3. If you have two-factor authentication enabled, send your password to the room.
4. If all prior steps were executed successfully, the bridge should now create rooms for all your Telegram dialogs and invite you to them.
4. If all prior steps were executed successfully, the bridge should now create rooms for all your
Telegram groups and channels and invite you to them.
### Chatting
#### Group chats and channels
@@ -32,73 +36,96 @@ You should be automatically invited into portal rooms for your groups and channe
2. receive a messages in the chat or
3. receive an invite to the chat
Support for inviting users both Telegram and Matrix users to Telegram portal rooms is planned, but not yet implemented.
Inviting Telegram puppets to rooms should work. However, please don't invite non-puppet Matrix
users to portal rooms yet.
You can also create a Telegram chat for an existing Matrix room using `!tg create` in the room.
However, there are some restrictions:
* The room must have a title.
* The AS bot must be invited first (before puppets) and be given power level 100.
* The AS bot must be the only user to have power level 100.
#### Private messaging
You can start private chats by simply inviting the Matrix puppet of the Telegram user you want to chat with to a private room.
If you don't know the MXID of the puppet, you can search for users using the `search <query>` management command.
You can also initiate chats with the `pm` command using the username, phone number or user ID.
#### Bot commands
Initiating chats with bots is no different from initiating chats with real Telegram users.
The bridge translates `!commands` into `/commands`, which allows you to use Telegram bots without constantly escaping
the slash. Please note that when messaging a bot for the first time, it may expect you to run `!start` first. The bridge
does not do this automatically.
~~The bridge translates `!commands` into `/commands`, which allows you to use Telegram bots without constantly escaping
the slash.~~ Please note that when messaging a bot for the first time, it may expect you to run ~~`!start`~~ `/start` first.
The bridge does not do this automatically.
## Features & Roadmap
* Matrix → Telegram
* [x] Plaintext messages
* [x] Formatted messages
* [x] Bot commands (!command -> /command)
* [ ] Bot commands (!command -> /command)
* [x] Mentions
* [x] Locations
* [ ] Images
* [ ] Files
* [ ] Message redactions
* [ ] Presence (currently always shown as online on Telegram)
* [x] Rich quotes
* [ ] Locations (not implemented in Riot)
* [x] Images
* [x] Files
* [x] Message redactions
* [ ] Presence (may not be possible, currently always shown as online on Telegram)
* [ ] Typing notifications (may not be possible)
* [ ] Pinning messages
* [ ] Power level
* [x] Power level
* [ ] Membership actions
* [x] Inviting
* [x] Kicking
* [x] Inviting puppets
* [ ] Inviting Matrix users who have logged in to Telegram
* [ ] Kicking
* [ ] Joining/leaving
* [ ] Room metadata changes
* [x] Room invites
* Telegram → Matrix
* [x] Plaintext messages
* [x] Formatted messages
* [x] Bot commands (/command -> !command)
* [x] Mentions
* [x] Replies
* [x] Forwards
* [x] Images
* [x] Locations
* [ ] Stickers (somewhat works through document upload, no preview though)
* [x] Stickers
* [x] Audio messages
* [ ] Video messages
* [x] Video messages
* [x] Documents
* [ ] Message deletions
* [ ] Message deletions (no way to tell difference between user-specific deletion and global deletion)
* [ ] Message edits (not supported in Matrix)
* [x] Avatars
* [x] Presence
* [x] Typing notifications
* [ ] Pinning messages
* [ ] Admin status
* [x] Admin/chat creator status
* [x] Membership actions
* [x] Inviting
* [x] Kicking
* [x] Joining/leaving
* [x] Chat metadata changes
* [ ] Public channel username changes
* [x] Initial chat metadata
* [ ] Message edits
* Initiating chats
* [x] Automatic portal creation for groups/channels at startup
* [x] Automatic portal creation for groups/channels when receiving invite/message
* [x] Private chat creation by inviting Telegram user to new room
* [ ] Joining public channels/supergroups using room aliases
* [x] Searching for Telegram users using management commands
* [x] Creating new Telegram chats from Matrix
* [x] Creating Telegram chats for existing Matrix rooms
* [x] Supergroup upgrade
* Misc
* [ ] Use optional bot to relay messages for unauthenticated Matrix users
* [x] Properly handle upgrading groups to supergroups
* [x] Allow upgrading group to supergroup from Matrix
* [ ] Handle public channel username changes
* [x] Automatic portal creation
* [x] At startup
* [x] When receiving invite or message
* [x] Private chat creation by inviting Matrix puppet of Telegram user to new room
* [ ] Option to use bot to relay messages for unauthenticated Matrix users
* [ ] Option to use own Matrix account for messages sent from other Telegram clients
* [ ] Joining public channels/supergroups using room aliases
* [ ] Joining chats with room aliases
* [ ] Name of public channel/supergroup as alias
* [ ] (Maybe) Invite link token as alias
* Commands
* [x] Logging in and out (`login` + code entering, `logout`)
* [ ] Registering (`register`)
* [x] Searching for users (`search`)
* [ ] Searching contacts locally
* [x] Starting private chats (`pm`)
* [x] Joining chats with invite links (`join`)
* [x] Creating a Telegram chat for an existing Matrix room (`create`)
* [ ] Upgrading the chat of a portal room into a supergroup (`upgrade`)
* [ ] Getting the Telegram invite link to a Matrix room (`invitelink`)
+34 -28
View File
@@ -4,25 +4,41 @@ homeserver:
domain: matrix.org
# Application service host/registration related details
# Changing these values requires regeneration of the registration.
appservice:
# The protocol the homeserver should use when connecting to the appservice.
# The protocol the homeserver should use when connecting to this appservice.
# Usually "http" or "https".
protocol: http
# The hostname and port where the homeserver can find this appservice.
hostname: localhost
port: 8080
id: telegram
# Whether or not to enable debug messages in the console.
debug: false
# Path to the registration file. This is automatically updated when generating a registration.
registration: ./registration.yaml
# The unique ID of this appservice.
id: telegram
# Username of the appservice bot.
bot_username: telegrambot
bot_displayname: Telegram bridge bot
# 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"
# Bridge config
bridge:
# ${ID} is replaced with the user ID of the Telegram user.
username_template: "telegram_${ID}"
# ${DISPLAYNAME} is replaced with the display name of the Telegram user.
displayname_template: "${DISPLAYNAME} (Telegram)"
# Localpart template of MXIDs for Telegram users.
# {userid} is replaced with the user ID of the Telegram user.
username_template: "telegram_{userid}"
# Localpart template of room aliases for Telegram portal rooms.
# {groupname} is replaced with the name part of the public channel/group invite link ( https://t.me/{} )
alias_template: "telegram_{groupname}"
# Displayname template for Telegram users.
# {displayname} is replaced with the display name of the Telegram user.
displayname_template: "{displayname} (Telegram)"
# Set the preferred order of user identifiers which to use in the Matrix puppet display name.
# In the (hopefully unlikely) scenario that none of the given keys are found, the numeric user ID is used.
#
@@ -30,30 +46,20 @@ bridge:
# very well be empty.
#
# Valid keys:
# fullName (First and/or last name)
# fullNameReversed (Last and/or first name)
# firstName
# lastName
# username
# phoneNumber
# "full name" (First and/or last name)
# "full name reversed" (Last and/or first name)
# "first name"
# "last name"
# "username"
# "phone number"
displayname_preference:
- fullName
- full name
- username
- phoneNumber
# ${NAME} is replaced with the name part of the public channel/group invite link ( https://t.me/${NAME} )
alias_template: "telegram_${NAME}"
# Username of the bot. The registration must be regenerated to change this.
bot_username: telegrambot
- phone number
# Bridge management command configuration
commands:
# The prefix for all management commands.
# Can be removed to disable management commands in rooms with more than two users.
prefix: "!tg"
# Enables the !tg api ... commands for debugging.
# Do not enable this in production, it allows all whitelisted users to call any Telegram API methods freely.
allow_direct_api_calls: false
# The prefix for commands. Only required in non-management rooms.
command_prefix: "!tg"
# Whitelist of user IDs that are allowed to use this bridge. Leave empty to disable.
# You can enter a domain without the localpart to allow all users from that homeserver to use the bridge.
-1
View File
@@ -1 +0,0 @@
src/index.js
+4
View File
@@ -0,0 +1,4 @@
from .appservice import AppService
__version__ = "0.1.0"
__author__ = "Tulir Asokan <tulir@maunium.net>"
+208
View File
@@ -0,0 +1,208 @@
# matrix-appservice-python - A Matrix Application Service framework written in Python.
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# Partly based on github.com/Cadair/python-appservice-framework (MIT license)
import asyncio
import logging
import aiohttp
from aiohttp import web
from functools import partial
from contextlib import contextmanager
from .intent_api import HTTPAPI
class StateStore:
def __init__(self):
self.memberships = {}
self.power_levels = {}
def _get_membership(self, room, user):
return self.memberships.get(room, {}).get(user, "left")
def is_joined(self, room, user):
return self._get_membership(room, user) == "join"
def _set_membership(self, room, user, membership):
if room not in self.memberships:
self.memberships[room] = {}
self.memberships[room][user] = membership
def joined(self, room, user):
return self._set_membership(room, user, "join")
def invited(self, room, user):
return self._set_membership(room, user, "invite")
def left(self, room, user):
return self._set_membership(room, user, "left")
def has_power_level_data(self, room):
return room in self.power_levels
def has_power_level(self, room, user, event):
room_levels = self.power_levels.get(room, {})
required = room_levels["events"].get(event, 95)
has = room_levels["users"].get(user, 0)
return has >= required
def set_power_level(self, room, user, level):
if not room in self.power_levels:
self.power_levels[room] = {
"users": {},
"events": {},
}
self.power_levels[room]["users"][user] = level
def set_power_levels(self, room, content):
if "events" not in content:
content["events"] = {}
if "users" not in content:
content["users"] = {}
self.power_levels[room] = content
class AppService:
def __init__(self, server, domain, as_token, hs_token, bot_localpart, loop=None, log=None,
query_user=None, query_alias=None):
self.server = server
self.domain = domain
self.as_token = as_token
self.hs_token = hs_token
self.bot_mxid = f"@{bot_localpart}:{domain}"
self.state_store = StateStore()
self.transactions = []
self._http_session = None
self._intent = None
self.loop = loop or asyncio.get_event_loop()
self.log = log or logging.getLogger("mautrix_appservice")
self.query_user = query_user or (lambda: None)
self.query_alias = query_alias or (lambda: None)
self.event_handlers = []
self.app = web.Application(loop=self.loop)
self.app.router.add_route("PUT", "/transactions/{transaction_id}",
self._http_handle_transaction)
self.app.router.add_route("GET", "/rooms/{alias}", self._http_query_alias)
self.app.router.add_route("GET", "/users/{user_id}", self._http_query_user)
@property
def http_session(self):
if self._http_session is None:
raise AttributeError("the http_session attribute can only be used "
"from within the `AppService.run` context manager")
else:
return self._http_session
@property
def intent(self):
if self._intent is None:
raise AttributeError("the intent attribute can only be used from "
"within the `AppService.run` context manager")
else:
return self._intent
@contextmanager
def run(self, host="127.0.0.1", port=8080):
self._http_session = aiohttp.ClientSession(loop=self.loop)
self._intent = HTTPAPI(base_url=self.server, bot_mxid=self.bot_mxid, token=self.as_token,
log=self.log, state_store=self.state_store).bot_intent()
yield partial(aiohttp.web.run_app, self.app, host=host, port=port)
self._intent = None
self._http_session.close()
self._http_session = None
def _check_token(self, request):
try:
token = request.rel_url.query["access_token"]
except KeyError:
return False
if token != self.hs_token:
return False
return True
async def _http_query_user(self, request):
if not self._check_token(request):
return web.Response(status=401)
user_id = request.match_info["userId"]
try:
response = self.query_user(user_id)
except:
self.log.exception("Exception in user query handler")
return web.Response(status=500)
if not response:
return web.Response(status=404)
return web.json_response(response)
async def _http_query_alias(self, request):
if not self._check_token(request):
return web.Response(status=401)
alias = request.match_info["alias"]
try:
response = self.query_alias(alias)
except:
self.log.exception("Exception in alias query handler")
return web.Response(status=500)
if not response:
return web.Response(status=404)
return web.json_response(response)
async def _http_handle_transaction(self, request):
if not self._check_token(request):
return web.Response(status=401)
transaction_id = request.match_info["transaction_id"]
if transaction_id in self.transactions:
return web.Response(status=200)
json = await request.json()
try:
events = json["events"]
except KeyError:
return web.Response(status=400)
for event in events:
self.handle_matrix_event(event)
self.transactions.append(transaction_id)
return web.json_response({})
def handle_matrix_event(self, event):
for handler in self.event_handlers:
try:
handler(event)
except:
self.log.exception("Exception in Matrix event handler")
def matrix_event_handler(self, func):
self.event_handlers.append(func)
return func
+348
View File
@@ -0,0 +1,348 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import re
import json
import magic
import urllib.request
from matrix_client.api import MatrixHttpApi
from matrix_client.errors import MatrixRequestError
class HTTPAPI(MatrixHttpApi):
def __init__(self, base_url, bot_mxid=None, token=None, identity=None, log=None,
state_store=None):
self.base_url = base_url
self.token = token
self.identity = identity
self.txn_id = 0
self.bot_mxid = bot_mxid
self.intent_log = log.getChild("intent")
self.log = log.getChild("api")
self.validate_cert = True
self.state_store = state_store
self.children = {}
def user(self, user):
try:
return self.children[user]
except KeyError:
child = ChildHTTPAPI(user, self)
self.children[user] = child
return child
def bot_intent(self):
return IntentAPI(self.bot_mxid, self, state_store=self.state_store, log=self.intent_log)
def intent(self, user):
return IntentAPI(user, self.user(user), self, self.state_store, self.intent_log)
def _send(self, method, path, content=None, query_params={}, headers={},
api_path="/_matrix/client/r0"):
if not query_params:
query_params = {}
query_params["user_id"] = self.identity
log_content = content if not isinstance(content, bytes) else f"<{len(content)} bytes>"
self.log.debug("%s %s %s", method, path, log_content)
return super()._send(method, path, content, query_params, headers, api_path=api_path)
def create_room(self, alias=None, is_public=False, name=None, topic=None, is_direct=False,
invitees=(), initial_state=[]):
"""Perform /createRoom.
Args:
alias (str): Optional. The room alias name to set for this room.
is_public (bool): Optional. The public/private visibility.
name (str): Optional. The name for the room.
topic (str): Optional. The topic for the room.
invitees (list<str>): Optional. The list of user IDs to invite.
"""
content = {
"visibility": "public" if is_public else "private"
}
if alias:
content["room_alias_name"] = alias
if invitees:
content["invite"] = invitees
if name:
content["name"] = name
if topic:
content["topic"] = topic
if initial_state:
content["initial_state"] = initial_state
content["is_direct"] = is_direct
return self._send("POST", "/createRoom", content)
def set_presence(self, status="online", user=None):
content = {
"presence": status
}
user = user or self.identity
return self._send("PUT", f"/presence/{user}/status", content)
def set_typing(self, room_id, is_typing=True, timeout=5000, user=None):
content = {
"typing": is_typing
}
if is_typing:
content["timeout"] = timeout
user = user or self.identity
return self._send("PUT", f"/rooms/{room_id}/typing/{user}", content)
class ChildHTTPAPI(HTTPAPI):
def __init__(self, user, parent):
self.identity = user
self.token = parent.token
self.base_url = parent.base_url
self.validate_cert = parent.validate_cert
self.log = parent.log
self.parent = parent
@property
def txn_id(self):
return self.parent.txn_id
@txn_id.setter
def txn_id(self, value):
self.parent.txn_id = value
class IntentError(Exception):
def __init__(self, message, source):
super().__init__(message)
self.source = source
def matrix_error_code(err):
try:
data = json.loads(err.content)
return data["errcode"]
except:
return err.content
class IntentAPI:
mxid_regex = re.compile("@(.+):(.+)")
def __init__(self, mxid, client, bot=None, state_store=None, log=None):
self.client = client
self.bot = bot
self.mxid = mxid
self.log = log
results = self.mxid_regex.search(mxid)
if not results:
raise ValueError("invalid MXID")
self.localpart = results.group(1)
self.state_store = state_store
self.registered = False
def user(self, user):
if not self.bot:
return self.client.intent(user)
else:
self.log.warning("Called IntentAPI#user() of child intent object.")
return self.bot.intent(user)
# region User actions
def set_display_name(self, name):
self._ensure_registered()
return self.client.set_display_name(self.mxid, name)
def set_presence(self, status="online"):
self._ensure_registered()
return self.client.set_presence(status)
def set_avatar(self, url):
self._ensure_registered()
return self.client.set_avatar_url(self.mxid, url)
def upload_file(self, data, mime_type=None):
self._ensure_registered()
mime_type = mime_type or magic.from_buffer(data, mime=True)
return self.client.media_upload(data, mime_type)
def download_file(self, url):
self._ensure_registered()
url = self.client.get_download_url(url)
response = urllib.request.urlopen(url)
return response.read()
# endregion
# region Room actions
def create_room(self, alias=None, is_public=False, name=None, topic=None, is_direct=False,
invitees=(), initial_state=[]):
self._ensure_registered()
return self.client.create_room(alias, is_public, name, topic, is_direct, invitees,
initial_state)
def invite(self, room_id, user_id):
self._ensure_joined(room_id)
try:
response = self.client.invite_user(room_id, user_id)
self.state_store.invited(room_id, user_id)
return response
except MatrixRequestError as e:
if matrix_error_code(e) != "M_FORBIDDEN":
raise IntentError(f"Failed to invite {user_id} to {room_id}", e)
def set_room_avatar(self, room_id, avatar_url, info=None):
content = {
"url": avatar_url,
}
if info:
content["info"] = info
return self.send_state_event(room_id, "m.room.avatar", content)
def set_room_name(self, room_id, name):
self._ensure_joined(room_id)
self._ensure_has_power_level_for(room_id, "m.room.name")
return self.client.set_room_name(room_id, name)
def get_power_levels(self, room_id):
self._ensure_joined(room_id)
levels = self.client.get_power_levels(room_id)
self.state_store.set_power_levels(room_id, levels)
return levels
def set_power_levels(self, room_id, content):
response = self.send_state_event(room_id, "m.room.power_levels", content)
self.state_store.set_power_levels(room_id, content)
return response
def set_typing(self, room_id, is_typing=True, timeout=5000):
self._ensure_joined(room_id)
return self.client.set_typing(room_id, is_typing, timeout)
def send_notice(self, room_id, text, html=None):
return self.send_text(room_id, text, html, "m.notice")
def send_emote(self, room_id, text, html=None):
return self.send_text(room_id, text, html, "m.emote")
def send_image(self, room_id, url, info={}, text=None):
return self.send_file(room_id, url, info, text, "m.image")
def send_file(self, room_id, url, info={}, text=None, type="m.file"):
return self.send_message(room_id, {
"msgtype": type,
"url": url,
"body": text or "Uploaded file",
"info": info,
})
def send_text(self, room_id, text, html=None, type="m.text"):
if html:
if not text:
text = html
return self.send_message(room_id, {
"body": text,
"msgtype": type,
"format": "org.matrix.custom.html",
"formatted_body": html or text,
})
else:
return self.send_message(room_id, {
"body": text,
"msgtype": type,
})
def send_message(self, room_id, body):
return self.send_event(room_id, "m.room.message", body)
def error_and_leave(self, room_id, text, html=None):
self._ensure_joined(room_id)
self.send_notice(room_id, text, html=html)
self.leave_room(room_id)
def kick(self, room_id, user_id, message):
self._ensure_joined(room_id)
return self.client.kick_user(room_id, user_id, message)
def send_event(self, room_id, type, body, txn_id=None):
self._ensure_joined(room_id)
self._ensure_has_power_level_for(room_id, type)
return self.client.send_message_event(room_id, type, body, txn_id)
def send_state_event(self, room_id, type, body, state_key=""):
self._ensure_joined(room_id)
self._ensure_has_power_level_for(room_id, type)
return self.client.send_state_event(room_id, type, body, state_key)
def join_room(self, room_id):
return self._ensure_joined(room_id, ignore_cache=True)
def leave_room(self, room_id):
self.state_store.left(room_id, self.mxid)
return self.client.leave_room(room_id)
def get_room_memberships(self, room_id):
return self.client.get_room_members(room_id)
def get_room_members(self, room_id, allowed_memberships=("join",)):
memberships = self.get_room_memberships(room_id)
return [membership["state_key"] for membership in memberships["chunk"] if
membership["content"]["membership"] in allowed_memberships]
def get_room_state(self, room_id):
self._ensure_joined(room_id)
return self.client.get_room_state(room_id)
# endregion
# region Ensure functions
def _ensure_joined(self, room_id, ignore_cache=False):
if not ignore_cache and self.state_store.is_joined(room_id, self.mxid):
return
self._ensure_registered()
try:
self.client.join_room(room_id)
self.state_store.joined(room_id, self.mxid)
except MatrixRequestError as e:
if matrix_error_code(e) != "M_FORBIDDEN" and not self.bot:
raise IntentError(f"Failed to join room {room_id} as {self.mxid}", e)
try:
self.bot.invite_user(room_id, self.mxid)
self.client.join_room(room_id)
self.state_store.joined(room_id, self.mxid)
except MatrixRequestError as e2:
raise IntentError(f"Failed to join room {room_id} as {self.mxid}", e2)
def _ensure_registered(self):
if self.registered:
return
try:
self.client.register({"username": self.localpart})
except MatrixRequestError as e:
if matrix_error_code(e) != "M_USER_IN_USE":
self.log.exception(f"Failed to register {self.mxid}!")
# raise IntentError(f"Failed to register {self.mxid}", e)
self.registered = True
def _ensure_has_power_level_for(self, room_id, event_type):
if not self.state_store.has_power_level_data(room_id):
self.get_power_levels(room_id)
if self.state_store.has_power_level(room_id, self.mxid, event_type):
return
elif not self.bot:
pass
# raise IntentError(f"Power level of {self.mxid} is not enough for {event_type} in {room_id}")
# TODO implement
# endregion
+2
View File
@@ -0,0 +1,2 @@
__version__ = "0.1.0"
__author__ = "Tulir Asokan <tulir@maunium.net>"
+84
View File
@@ -0,0 +1,84 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import argparse
import sys
import logging
import sqlalchemy as sql
from sqlalchemy import orm
from mautrix_appservice import AppService
from .base import Base
from .config import Config
from .matrix import MatrixHandler
from .db import init as init_db
from .user import init as init_user
from .portal import init as init_portal
from .puppet import init as init_puppet
from .formatter import init as init_formatter
log = logging.getLogger("mau")
time_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s@%(name)s] %(message)s")
handler = logging.StreamHandler()
handler.setFormatter(time_formatter)
log.addHandler(handler)
parser = argparse.ArgumentParser(
description="A Matrix-Telegram puppeting bridge.",
prog="python -m mautrix-telegram")
parser.add_argument("-c", "--config", type=str, default="config.yaml",
metavar="<path>", help="the path to your config file")
parser.add_argument("-g", "--generate-registration", action="store_true",
help="generate registration and quit")
parser.add_argument("-r", "--registration", type=str, default="registration.yaml",
metavar="<path>", help="the path to save the generated registration to")
args = parser.parse_args()
config = Config(args.config, args.registration)
config.load()
if args.generate_registration:
config.generate_registration()
config.save()
print(f"Registration generated and saved to {config.registration_path}")
sys.exit(0)
if config["appservice.debug"]:
log.setLevel(logging.DEBUG)
log.debug("Debug messages enabled.")
db_engine = sql.create_engine(config.get("appservice.database", "sqlite:///mautrix-telegram.db"))
db_factory = orm.sessionmaker(bind=db_engine)
db_session = orm.scoping.scoped_session(db_factory)
Base.metadata.bind = db_engine
Base.metadata.create_all()
appserv = AppService(config["homeserver.address"], config["homeserver.domain"],
config["appservice.as_token"], config["appservice.hs_token"],
config["appservice.bot_username"], log=log.getChild("as"))
context = (appserv, db_session, log, config)
with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start:
init_db(db_session)
init_formatter(context)
init_portal(context)
init_puppet(context)
init_user(context)
MatrixHandler(context)
start()
+2
View File
@@ -0,0 +1,2 @@
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
+377
View File
@@ -0,0 +1,377 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from contextlib import contextmanager
import markdown
from telethon.errors import *
from telethon.tl.types import *
from telethon.tl.functions.contacts import SearchRequest
from telethon.tl.functions.messages import ImportChatInviteRequest, CheckChatInviteRequest
from telethon.tl.functions.channels import JoinChannelRequest
from . import puppet as pu, portal as po
command_handlers = {}
def command_handler(func):
command_handlers[func.__name__] = func
class CommandHandler:
def __init__(self, context):
self.az, self.db, log, self.config = context
self.log = log.getChild("commands")
self.command_prefix = self.config["bridge.command_prefix"]
self._room_id = None
self._is_management = False
self._is_portal = False
# region Utility functions for handling commands
def handle(self, room, sender, command, args, is_management, is_portal):
with self.handler(sender, room, command, args, is_management, is_portal) as handle_command:
try:
handle_command(self, sender, args)
except:
self.reply("Fatal error while handling command. Check logs for more details.")
self.log.exception(f"Fatal error handling command "
f"'$cmdprefix {command} {''.join(args)}' from {sender.mxid}")
@contextmanager
def handler(self, sender, room, command, args, is_management, is_portal):
self._room_id = room
try:
command = command_handlers[command]
except KeyError:
if sender.command_status and "next" in sender.command_status:
args.insert(0, command)
command = sender.command_status["next"]
else:
command = command_handlers["unknown_command"]
self._is_management = is_management
self._is_portal = is_portal
yield command
self._is_management = None
self._is_portal = None
self._room_id = None
def reply(self, message, allow_html=False, render_markdown=True):
if not self._room_id:
raise AttributeError("the reply function can only be used from within"
"the `CommandHandler.run` context manager")
message = message.replace("$cmdprefix+sp ",
"" if self._is_management else f"{self.command_prefix} ")
message = message.replace("$cmdprefix", self.command_prefix)
html = None
if render_markdown:
html = markdown.markdown(message, safe_mode="escape" if allow_html else False)
elif allow_html:
html = message
self.az.intent.send_notice(self._room_id, message, html=html)
# endregion
# region Command handlers
@command_handler
def ping(self, sender, args):
if not sender.logged_in:
return self.reply("You're not logged in.")
me = sender.client.get_me()
if me:
return self.reply(f"You're logged in as @{me.username}")
else:
return self.reply("You're not logged in.")
# region Authentication commands
@command_handler
def register(self, sender, args):
self.reply("Not yet implemented.")
@command_handler
def login(self, sender, args):
if not self._is_management:
return self.reply(
"`login` is a restricted command: you may only run it in management rooms.")
elif sender.logged_in:
return self.reply("You are already logged in.")
elif len(args) == 0:
return self.reply("**Usage:** `$cmdprefix+sp login <phone number>`")
phone_number = args[0]
sender.client.send_code_request(phone_number)
sender.client.sign_in(phone_number)
sender.command_status = {
"next": command_handlers["enter_code"],
"action": "Login",
}
return self.reply(f"Login code sent to {phone_number}. Please send the code here.")
@command_handler
def enter_code(self, sender, args):
if not sender.command_status:
return self.reply("Request a login code first with `$cmdprefix+sp login <phone>`")
elif len(args) == 0:
return self.reply("**Usage:** `$cmdprefix+sp enter_code <code>")
try:
user = sender.client.sign_in(code=args[0])
sender.post_login(user)
sender.command_status = None
return self.reply(f"Successfully logged in as @{user.username}")
except PhoneNumberUnoccupiedError:
return self.reply("That phone number has not been registered."
"Please register with `$cmdprefix+sp register <phone>`.")
except PhoneCodeExpiredError:
return self.reply(
"Phone code expired. Try again with `$cmdprefix+sp login <phone>`.")
except PhoneCodeInvalidError:
return self.reply("Invalid phone code.")
except PhoneNumberAppSignupForbiddenError:
return self.reply(
"Your phone number does not allow 3rd party apps to sign in.")
except PhoneNumberFloodError:
return self.reply(
"Your phone number has been temporarily blocked for flooding. "
"The block is usually applied for around a day.")
except PhoneNumberBannedError:
return self.reply("Your phone number has been banned from Telegram.")
except SessionPasswordNeededError:
sender.command_status = {
"next": command_handlers["enter_password"],
"action": "Login (password entry)",
}
return self.reply("Your account has two-factor authentication."
"Please send your password here.")
except:
self.log.exception()
return self.reply("Unhandled exception while sending code."
"Check console for more details.")
@command_handler
def enter_password(self, sender, args):
if not sender.command_status:
return self.reply("Request a login code first with `$cmdprefix+sp login <phone>`")
elif len(args) == 0:
return self.reply("**Usage:** `$cmdprefix+sp enter_password <password>")
try:
user = sender.client.sign_in(password=args[0])
sender.post_login(user)
sender.command_status = None
return self.reply(f"Successfully logged in as @{user.username}")
except PasswordHashInvalidError:
return self.reply("Incorrect password.")
except:
self.log.exception()
return self.reply("Unhandled exception while sending password. "
"Check console for more details.")
@command_handler
def logout(self, sender, args):
if not sender.logged_in:
return self.reply("You're not logged in.")
if sender.log_out():
return self.reply("Logged out successfully.")
return self.reply("Failed to log out.")
# endregion
# region Telegram interaction commands
@command_handler
def search(self, sender, args):
if len(args) == 0:
return self.reply("**Usage:** `$cmdprefix+sp search [-r|--remote] <query>")
elif not sender.tgid:
return self.reply("This command requires you to be logged in.")
force_remote = False
if args[0] in {"-r", "--remote"}:
args.pop(0)
query = " ".join(args)
if len(query) < 5:
return self.reply("Minimum length of query for remote search is 5 characters.")
found = sender.client(SearchRequest(q=query, limit=10))
print(found)
# reply = ["**People:**", ""]
reply = ["**Results from Telegram server:**", ""]
for result in found.users:
puppet = pu.Puppet.get(result.id)
puppet.update_info(sender, result)
reply.append(
f"* [{puppet.displayname}](https://matrix.to/#/{puppet.mxid}): {puppet.id}")
# reply.extend(("", "**Chats:**", ""))
# for result in found.chats:
# reply.append(f"* {result.title}")
return self.reply("\n".join(reply))
@command_handler
def pm(self, sender, args):
if len(args) == 0:
return self.reply("**Usage:** `$cmdprefix+sp pm <user identifier>`")
elif not sender.tgid:
return self.reply("This command requires you to be logged in.")
user = sender.client.get_entity(args[0])
if not user:
return self.reply("User not found.")
elif not isinstance(user, User):
return self.reply("That doesn't seem to be a user.")
portal = po.Portal.get_by_entity(user, sender.tgid)
portal.create_matrix_room(sender, user, [sender.mxid])
self.reply(f"Created private chat room with {pu.Puppet.get_displayname(user, False)}")
def _strip_prefix(self, value, prefixes):
for prefix in prefixes:
if value.startswith(prefix):
return value[len(prefix):]
return value
@command_handler
def join(self, sender, args):
if len(args) == 0:
return self.reply("**Usage:** `$cmdprefix+sp join <invite link>")
elif not sender.tgid:
return self.reply("This command requires you to be logged in.")
regex = re.compile(r"(?:https?://)?t(?:elegram)?\.(?:dog|me)(?:joinchat/)?/(.+)")
arg = regex.match(args[0])
if not arg:
return self.reply("That doesn't look like a Telegram invite link.")
arg = arg.group(1)
if arg.startswith("joinchat/"):
invite_hash = arg[len("joinchat/"):]
try:
check = sender.client(CheckChatInviteRequest(invite_hash))
print(check)
except InviteHashInvalidError:
return self.reply("Invalid invite link.")
except InviteHashExpiredError:
return self.reply("Invite link expired.")
try:
updates = sender.client(ImportChatInviteRequest(invite_hash))
except UserAlreadyParticipantError:
return self.reply("You are already in that chat.")
else:
channel = sender.client.get_entity(arg)
if not channel:
return self.reply("Channel/supergroup not found.")
updates = sender.client(JoinChannelRequest(channel))
for chat in updates.chats:
portal = po.Portal.get_by_entity(chat)
portal.create_matrix_room(sender, chat, [sender.mxid])
self.reply(f"Created room for {portal.title}")
@command_handler
def create(self, sender, args):
type = args[0] if len(args) > 0 else "group"
if type not in {"chat", "group", "supergroup", "channel"}:
return self.reply("**Usage:** `$cmdprefix+sp create [`group`/`supergroup`/`channel`]")
elif not sender.tgid:
return self.reply("This command requires you to be logged in.")
if po.Portal.get_by_mxid(self._room_id):
return self.reply("This is already a portal room.")
state = self.az.intent.get_room_state(self._room_id)
title = None
levels = None
for event in state:
if event["type"] == "m.room.name":
title = event["content"]["name"]
elif event["type"] == "m.room.power_levels":
levels = event["content"]
if not title:
return self.reply("Please set a title before creating a Telegram chat.")
elif (not levels or not levels["users"] or self.az.intent.mxid not in levels["users"] or
levels["users"][self.az.intent.mxid] < 100):
return self.reply(f"Please give "
f"[the bridge bot](https://matrix.to/#/{self.az.intent.mxid}) "
f"a power level of 100 before creating a Telegram chat.")
else:
for user, level in levels["users"].items():
if level >= 100 and user != self.az.intent.mxid:
return self.reply(f"Please make sure only the bridge bot has power level above"
f"99 before creating a Telegram chat.\n\n"
f"Use power level 95 instead of 100 for admins.")
supergroup = type == "supergroup"
types = {
"supergroup": "channel",
"channel": "channel",
"chat": "chat",
"group": "chat",
}
portal = po.Portal(tgid=None, mxid=self._room_id, title=title, peer_type=types[type])
try:
portal.create_telegram_chat(sender, supergroup=supergroup)
except ValueError as e:
return self.reply(e.args[0])
self.reply(f"Telegram chat created. ID: {portal.tgid}")
@command_handler
def upgrade(self, sender, args):
self.reply("Not yet implemented.")
# endregion
# region Command-related commands
@command_handler
def cancel(self, sender, args):
if sender.command_status:
action = sender.command_status["action"]
sender.command_status = None
return self.reply(f"{action} cancelled.")
else:
return self.reply("No ongoing command.")
@command_handler
def unknown_command(self, sender, args):
if self._is_management:
return self.reply("Unknown command. Try `help` for help.")
else:
return self.reply("Unknown command. Try `$cmdprefix help` for help.")
@command_handler
def help(self, sender, args):
if self._is_management:
management_status = ("This is a management room: prefixing commands"
"with `$cmdprefix` is not required.\n")
elif self._is_portal:
management_status = ("**This is a portal room**: you must always"
"prefix commands with `$cmdprefix`.\n"
"Management commands will not be sent to Telegram.")
else:
management_status = ("**This is not a management room**: you must"
"prefix commands with `$cmdprefix`.\n")
help = """
_**Generic bridge commands**: commands for using the bridge that aren't related to Telegram._
**help** - Show this help message.
**cancel** - Cancel an ongoing action (such as login).
_**Telegram actions**: commands for using the bridge to interact with Telegram._
**login** <_phone_> - Request an authentication code.
**logout** - Log out from Telegram.
**ping** - Check if you're logged into Telegram.
**search** [_-r|--remote_] <_query_> - Search your contacts or the Telegram servers for users.
**pm** <_identifier_> - Open a private chat with the given Telegram user. The identifier is either
the internal user ID, the username or the phone number.
**join** <_link_> - Join a chat with an invite link.
**create** [_type_] - Create a Telegram chat of the given type for the current Matrix room.
The type is either `group`, `supergroup` or `channel` (defaults to `group`).
**upgrade** - Upgrade a normal Telegram group to a supergroup.
"""
return self.reply(management_status + help)
# endregion
# endregion
+109
View File
@@ -0,0 +1,109 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import ruamel.yaml
import random
import string
yaml = ruamel.yaml.YAML()
class DictWithRecursion:
def __init__(self, data={}):
self._data = data
def _recursive_get(self, data, key, default_value):
if '.' in key:
key, next_key = key.split('.', 1)
next_data = data.get(key, {})
return self._recursive_get(next_data, next_key, default_value)
return data.get(key, default_value)
def get(self, key, default_value, allow_recursion=True):
if allow_recursion and '.' in key:
return self._recursive_get(self._data, key, default_value)
return self._data.get(key, default_value)
def __getitem__(self, key):
return self.get(key, None)
def _recursive_set(self, data, key, value):
if '.' in key:
key, next_key = key.split('.', 1)
if key not in data:
data[key] = {}
next_data = data.get(key, {})
self._recursive_set(next_data, next_key, value)
return
data[key] = value
def set(self, key, value, allow_recursion=True):
if allow_recursion and '.' in key:
self._recursive_set(self._data, key, value)
return
self._data[key] = value
def __setitem__(self, key, value):
self.set(key, value)
class Config(DictWithRecursion):
def __init__(self, path, registration_path):
super().__init__()
self.path = path
self.registration_path = registration_path
self._registration = None
def load(self):
with open(self.path, 'r') as stream:
self._data = yaml.load(stream)
def save(self):
with open(self.path, 'w') as stream:
yaml.dump(self._data, stream)
if self._registration and self.registration_path:
with open(self.registration_path, 'w') as stream:
yaml.dump(self._registration, stream)
def _new_token(self):
return "".join(random.choices(string.ascii_lowercase + string.digits, k=64))
def generate_registration(self):
homeserver = self["homeserver.domain"]
username_format = self.get("bridge.username_template", "telegram_{userid}").format(userid=".+")
alias_format = self.get("bridge.alias_template", "telegram_{groupname}").format(groupname=".+")
self.set("appservice.as_token", self._new_token())
self.set("appservice.hs_token", self._new_token())
appservice = self.get("appservice", {})
self._registration = {
"id": appservice.get("id", "telegram"),
"as_token": appservice.get("as_token"),
"hs_token": appservice.get("hs_token"),
"namespaces": {
"users": [{
"exclusive": True,
"regex": f"@{username_format}:{homeserver}"
}],
"aliases": [{
"exclusive": True,
"regex": f"#{alias_format}:{homeserver}"
}]
},
"url": f"{appservice.get('protocol')}://{appservice.get('hostname')}:{appservice.get('port')}",
"sender_localpart": appservice.get("bot_username"),
"rate_limited": False
}
+73
View File
@@ -0,0 +1,73 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from sqlalchemy import Column, ForeignKey, UniqueConstraint, Integer, String
from .base import Base
class Portal(Base):
query = None
__tablename__ = "portal"
# Telegram chat information
tgid = Column(Integer, primary_key=True)
tg_receiver = Column(Integer, primary_key=True)
peer_type = Column(String)
# Matrix portal information
mxid = Column(String, unique=True, nullable=True)
# Telegram chat metadata
username = Column(String, nullable=True)
title = Column(String, nullable=True)
photo_id = Column(String, nullable=True)
class Message(Base):
query = None
__tablename__ = "message"
mxid = Column(String)
mx_room = Column(String)
tgid = Column(Integer, primary_key=True)
user = Column(Integer, ForeignKey("user.tgid"), primary_key=True)
__table_args__ = (UniqueConstraint('mxid', 'mx_room', 'user', name='_mx_id_room'), )
class User(Base):
query = None
__tablename__ = "user"
mxid = Column(String, primary_key=True)
tgid = Column(Integer, nullable=True)
tg_username = Column(String, nullable=True)
class Puppet(Base):
query = None
__tablename__ = "puppet"
id = Column(Integer, primary_key=True)
displayname = Column(String, nullable=True)
username = Column(String, nullable=True)
photo_id = Column(String, nullable=True)
def init(db_session):
Portal.query = db_session.query_property()
Message.query = db_session.query_property()
User.query = db_session.query_property()
Puppet.query = db_session.query_property()
+314
View File
@@ -0,0 +1,314 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import re
from html import escape, unescape
from html.parser import HTMLParser
from collections import deque
from telethon.tl.types import *
from . import user as u, puppet as p
from .db import Message as DBMessage
log = None
# region Matrix to Telegram
class MessageEntityReply(MessageEntityUnknown):
def __init__(self, offset=0, length=0, msg_id=0):
super().__init__(offset, length)
self.msg_id = msg_id
class MatrixParser(HTMLParser):
mention_regex = re.compile("https://matrix.to/#/(@.+)")
reply_regex = re.compile(r"https://matrix.to/#/(!.+?)/(\$.+)")
def __init__(self, user_id=None):
super().__init__()
self._user_id = user_id
self.text = ""
self.entities = []
self._building_entities = {}
self._list_counter = 0
self._open_tags = deque()
self._open_tags_meta = deque()
self._previous_ended_line = True
self._building_reply = False
def handle_starttag(self, tag, attrs):
self._open_tags.appendleft(tag)
self._open_tags_meta.appendleft(0)
attrs = dict(attrs)
EntityType = None
args = {}
if tag == "strong" or tag == "b":
EntityType = MessageEntityBold
elif tag == "em" or tag == "i":
EntityType = MessageEntityItalic
elif tag == "code":
try:
pre = self._building_entities["pre"]
try:
pre.language = attrs["class"][len("language-"):]
except KeyError:
pass
except KeyError:
EntityType = MessageEntityCode
elif tag == "pre":
EntityType = MessageEntityPre
args["language"] = ""
elif tag == "a":
try:
url = attrs["href"]
except KeyError:
return
mention = self.mention_regex.search(url)
reply = self.reply_regex.search(url)
if mention:
mxid = mention.group(1)
puppet_match = p.Puppet.mxid_regex.search(mxid)
if puppet_match:
user = p.Puppet.get(puppet_match.group(1), create=False)
else:
user = u.User.get_by_mxid(mxid, create=False)
if not user:
return
if user.username:
EntityType = MessageEntityMention
url = f"@{user.username}"
else:
EntityType = MessageEntityMentionName
args["user_id"] = user.tgid
elif reply and self._user_id and (
len(self.entities) == 0 and len(self._building_entities) == 0):
room_id = reply.group(1)
message_id = reply.group(2)
message = DBMessage.query.filter(DBMessage.mxid == message_id
and DBMessage.mx_room == room_id
and DBMessage.user == self._user_id).one_or_none()
if not message:
return
EntityType = MessageEntityReply
args["msg_id"] = message.tgid
self._building_reply = True
url = None
elif url.startswith("mailto:"):
url = url[len("mailto:"):]
EntityType = MessageEntityEmail
else:
if self.get_starttag_text() == url:
EntityType = MessageEntityUrl
else:
EntityType = MessageEntityTextUrl
args["url"] = url
url = None
self._open_tags_meta.popleft()
self._open_tags_meta.appendleft(url)
if EntityType and tag not in self._building_entities:
self._building_entities[tag] = EntityType(offset=len(self.text), length=0, **args)
def _list_depth(self):
depth = 0
for tag in self._open_tags:
if tag == "ol" or tag == "ul":
depth += 1
return depth
def handle_data(self, text):
text = unescape(text)
if self._building_reply:
return
previous_tag = self._open_tags[0] if len(self._open_tags) > 0 else ""
list_format_offset = 0
if previous_tag == "a":
url = self._open_tags_meta[0]
if url:
text = url
elif len(self._open_tags) > 1 and self._previous_ended_line and previous_tag == "li":
list_type = self._open_tags[1]
indent = (self._list_depth() - 1) * 4 * " "
text = text.strip("\n")
if len(text) == 0:
return
elif list_type == "ul":
text = f"{indent}* {text}"
list_format_offset = len(indent) + 2
elif list_type == "ol":
n = self._open_tags_meta[1]
n += 1
self._open_tags_meta[1] = n
text = f"{indent}{n}. {text}"
list_format_offset = len(indent) + 3
for tag, entity in self._building_entities.items():
entity.length += len(text.strip("\n"))
entity.offset += list_format_offset
if text.endswith("\n"):
self._previous_ended_line = True
else:
self._previous_ended_line = False
self.text += text
def handle_endtag(self, tag):
try:
self._open_tags.popleft()
self._open_tags_meta.popleft()
except IndexError:
pass
if tag == "a":
self._building_reply = False
if (tag == "ul" or tag == "ol") and self.text.endswith("\n"):
self.text = self.text[:-1]
entity = self._building_entities.pop(tag, None)
if entity:
self.entities.append(entity)
def matrix_to_telegram(html, user_id=None):
try:
parser = MatrixParser(user_id)
parser.feed(html)
return parser.text, parser.entities
except:
log.exception("Failed to convert Matrix format:\nhtml=%s", html)
# endregion
# region Telegram to Matrix
def telegram_event_to_matrix(evt, source):
text = evt.message
html = telegram_to_matrix(evt.message, evt.entities) if evt.entities else None
if evt.fwd_from:
if not html:
html = escape(text)
id = evt.fwd_from.from_id
user = u.User.get_by_tgid(id)
if user:
fwd_from = f"<a href='https://matrix.to/#/{user.mxid}'>{user.mxid}</a>"
else:
puppet = p.Puppet.get(id, create=False)
if puppet and puppet.displayname:
fwd_from = f"<a href='https://matrix.to/#/{puppet.mxid}'>{puppet.displayname}</a>"
else:
user = source.client.get_entity(id)
if user:
fwd_from = p.Puppet.get_displayname(user, format=False)
if not fwd_from:
fwd_from = "Unknown user"
html = (f"Forwarded message from <b>{fwd_from}</b><br/>"
f"<blockquote>{html}</blockquote>")
if evt.reply_to_msg_id:
msg = DBMessage.query.get((evt.reply_to_msg_id, source.tgid))
quote = f"<a href=\"https://matrix.to/#/{msg.mx_room}/{msg.mxid}\">Quote<br></a>"
if html:
html = quote + html
else:
html = quote + escape(text)
return text, html
def telegram_to_matrix(text, entities):
try:
return _telegram_to_matrix(text, entities)
except:
log.exception("Failed to convert Telegram format:\n"
"message=%s\n"
"entities=%s",
text, entities)
def _telegram_to_matrix(text, entities):
if not entities:
return text
html = []
last_offset = 0
for entity in entities:
if entity.offset > last_offset:
html.append(escape(text[last_offset:entity.offset]))
elif entity.offset < last_offset:
continue
skip_entity = False
entity_text = escape(text[entity.offset:entity.offset + entity.length])
entity_type = type(entity)
if entity_type == MessageEntityBold:
html.append(f"<strong>{entity_text}</strong>")
elif entity_type == MessageEntityItalic:
html.append(f"<em>{entity_text}</em>")
elif entity_type == MessageEntityCode:
html.append(f"<code>{entity_text}</code>")
elif entity_type == MessageEntityPre:
if entity.language:
html.append("<pre>"
f"<code class='language-{entity.language}'>{entity_text}</code>"
"</pre>")
else:
html.append(f"<pre><code>{entity_text}</code></pre>")
elif entity_type == MessageEntityMention:
username = entity_text[1:]
user = u.User.find_by_username(username)
if user:
mxid = user.mxid
else:
puppet = p.Puppet.find_by_username(username)
mxid = puppet.mxid if puppet else None
if mxid:
html.append(f"<a href='https://matrix.to/#/{mxid}'>{entity_text}</a>")
else:
skip_entity = True
elif entity_type == MessageEntityMentionName:
user = u.User.get_by_tgid(entity.user_id)
if user:
mxid = user.mxid
else:
puppet = p.Puppet.get(entity.user_id, create=False)
mxid = puppet.mxid if puppet else None
if mxid:
html.append(f"<a href='https://matrix.to/#/{mxid}'>{entity_text}</a>")
else:
skip_entity = True
elif entity_type == MessageEntityEmail:
html.append(f"<a href='mailto:{entity_text}'>{entity_text}</a>")
elif entity_type == MessageEntityUrl:
html.append(f"<a href='{entity_text}'>{entity_text}</a>")
elif entity_type == MessageEntityTextUrl:
html.append(f"<a href='{escape(entity.url)}'>{entity_text}</a>")
elif entity_type == MessageEntityBotCommand:
html.append(f"<font color='blue'>!{entity_text[1:]}")
elif entity_type == MessageEntityHashtag:
html.append(f"<font color='blue'>{entity_text}</font>")
else:
skip_entity = True
last_offset = entity.offset + (0 if skip_entity else entity.length)
html.append(text[last_offset:])
return "".join(html)
# endregion
def init(context):
global log
_, _, parent_log, _ = context
log = parent_log.getChild("formatter")
+201
View File
@@ -0,0 +1,201 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from matrix_client.errors import MatrixRequestError
from .user import User
from .portal import Portal
from .puppet import Puppet
from .commands import CommandHandler
class MatrixHandler:
def __init__(self, context):
self.az, self.db, log, self.config = context
self.log = log.getChild("mx")
self.commands = CommandHandler(context)
self.az.matrix_event_handler(self.handle_event)
self.az.intent.set_display_name(
self.config.get("appservice.bot_displayname", "Telegram bridge bot"))
def is_puppet(self, mxid):
match = Puppet.mxid_regex.match(mxid)
return True if match else False
def get_puppet(self, mxid):
match = Puppet.mxid_regex.match(mxid)
if not match:
return None
return Puppet.get(int(match.group(1)))
def handle_puppet_invite(self, room, puppet, inviter):
self.log.debug(f"{inviter} invited puppet for {puppet.tgid} to {room}")
if not inviter.logged_in:
puppet.intent.error_and_leave(
room, text="Please log in before inviting Telegram puppets.")
return
portal = Portal.get_by_mxid(room)
if portal:
if portal.peer_type == "user":
puppet.intent.error_and_leave(
room, text="You can not invite additional users to private chats.")
return
portal.invite_telegram(inviter, puppet)
puppet.intent.join_room(room)
return
try:
members = self.az.intent.get_room_members(room)
except MatrixRequestError:
members = []
if self.az.intent.mxid not in members:
if len(members) > 1:
puppet.intent.error_and_leave(room, text=None, html=(
f"Please invite "
f"<a href='https://matrix.to/#/{self.az.intent.mxid}'>the bridge bot</a> "
f"first if you want to create a Telegram chat."))
return
puppet.intent.join_room(room)
existing_portal = Portal.get_by_tgid(puppet.tgid, inviter.tgid, "user")
if existing_portal:
try:
puppet.intent.invite(existing_portal.mxid, inviter.mxid)
puppet.intent.send_notice(room, text=None, html=(
"You already have a private chat with me: "
f"<a href='https://matrix.to/#/{existing_portal.mxid}'>"
"Link to room"
"</a>"))
puppet.intent.leave_room(room)
return
except MatrixRequestError:
existing_portal.delete()
portal = Portal(tgid=puppet.tgid, tg_receiver=inviter.tgid, peer_type="user", mxid=room)
portal.save()
puppet.intent.send_notice(room, "Portal to private chat created.")
else:
puppet.intent.join_room(room)
puppet.intent.send_notice(room, "This puppet will remain inactive until a Telegram "
"chat is created for this room.")
def handle_invite(self, room, user, inviter):
inviter = User.get_by_mxid(inviter)
if not inviter.whitelisted:
return
elif user == self.az.bot_mxid:
self.az.intent.join_room(room)
return
puppet = self.get_puppet(user)
if puppet:
self.handle_puppet_invite(room, puppet, inviter)
return
# These can probably be ignored
self.log.debug(f"{inviter} invited {user} to {room}")
def handle_join(self, room, user):
user = User.get_by_mxid(user)
portal = Portal.get_by_mxid(room)
if not portal:
return
if not user.whitelisted:
portal.main_intent.kick(room, user.mxid,
"You are not whitelisted on this Telegram bridge.")
return
elif not user.logged_in:
portal.main_intent.kick(room, user.mxid,
"You are not logged into this Telegram bridge.")
return
self.log.debug(f"{user} joined {room}")
# TODO join Telegram chat if applicable
def handle_part(self, room, user):
self.log.debug(f"{user} left {room}")
# user = User.get_by_mxid(user, create=False)
def is_command(self, message):
text = message.get("body", "")
prefix = self.config["bridge.command_prefix"]
is_command = text.startswith(prefix)
if is_command:
text = text[len(prefix) + 1:]
return is_command, text
def handle_message(self, room, sender, message, event_id):
self.log.debug(f"{sender} sent {message} to ${room}")
is_command, text = self.is_command(message)
sender = User.get_by_mxid(sender)
portal = Portal.get_by_mxid(room)
if sender.has_full_access and portal and not is_command:
portal.handle_matrix_message(sender, message, event_id)
return
if message["msgtype"] != "m.text":
return
is_management = len(self.az.intent.get_room_members(room)) == 2
if is_command or is_management:
try:
command, arguments = text.split(" ", 1)
args = arguments.split(" ")
except ValueError:
# Not enough values to unpack, i.e. no arguments
command = text
args = []
self.commands.handle(room, sender, command, args, is_management,
is_portal=portal is not None)
def handle_redaction(self, room, sender, event_id):
portal = Portal.get_by_mxid(room)
sender = User.get_by_mxid(sender)
if sender.has_full_access and portal:
portal.handle_matrix_deletion(sender, event_id)
def handle_power_levels(self, room, sender, new, old):
portal = Portal.get_by_mxid(room)
sender = User.get_by_mxid(sender)
if sender.has_full_access and portal:
sender = User.get_by_mxid(sender)
portal.handle_matrix_power_levels(sender, new["users"], old["users"])
def filter_matrix_event(self, event):
return event["sender"] == self.az.bot_mxid or self.is_puppet(event["sender"])
def handle_event(self, evt):
if self.filter_matrix_event(evt):
return
self.log.debug("Received event: %s", evt)
type = evt["type"]
content = evt.get("content", {})
if type == "m.room.member":
membership = content.get("membership", {})
if membership == "invite":
self.handle_invite(evt["room_id"], evt["state_key"], evt["sender"])
elif membership == "leave":
self.handle_part(evt["room_id"], evt["state_key"])
elif membership == "join":
self.handle_join(evt["room_id"], evt["state_key"])
elif type == "m.room.message":
self.handle_message(evt["room_id"], evt["sender"], content, evt["event_id"])
elif type == "m.room.redaction":
self.handle_redaction(evt["room_id"], evt["sender"], evt["redacts"])
elif type == "m.room.power_levels":
self.handle_power_levels(evt["room_id"], evt["sender"], evt["content"],
evt["prev_content"])
+652
View File
@@ -0,0 +1,652 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from telethon.tl.functions.messages import (GetFullChatRequest, EditChatAdminRequest,
CreateChatRequest, AddChatUserRequest)
from telethon.tl.functions.channels import (GetParticipantsRequest, CreateChannelRequest,
InviteToChannelRequest)
from telethon.errors.rpc_error_list import ChatAdminRequiredError, LocationInvalidError
from telethon.tl.types import *
from PIL import Image
from io import BytesIO
import mimetypes
import magic
from .db import Portal as DBPortal, Message as DBMessage
from . import puppet as p, user as u, formatter
mimetypes.init()
config = None
class Portal:
log = None
db = None
az = None
by_mxid = {}
by_tgid = {}
def __init__(self, tgid, peer_type, tg_receiver=None, mxid=None, username=None, title=None,
photo_id=None):
self.mxid = mxid
self.tgid = tgid
self.tg_receiver = tg_receiver or tgid
self.peer_type = peer_type
self.username = username
self.title = title
self.photo_id = photo_id
self._main_intent = None
if tgid:
self.by_tgid[self.tgid_full] = self
if mxid:
self.by_mxid[mxid] = self
@property
def tgid_full(self):
return self.tgid, self.tg_receiver
@property
def tgid_log(self):
if self.tgid == self.tg_receiver:
return self.tgid
return f"{self.tg_receiver}<->{self.tgid}"
@property
def peer(self):
if self.peer_type == "user":
return PeerUser(user_id=self.tgid)
elif self.peer_type == "chat":
return PeerChat(chat_id=self.tgid)
elif self.peer_type == "channel":
return PeerChannel(channel_id=self.tgid)
# region Matrix room info updating
@property
def main_intent(self):
if not self._main_intent:
direct = self.peer_type == "user"
puppet = p.Puppet.get(self.tgid) if direct else None
self._main_intent = puppet.intent if direct else self.az.intent
return self._main_intent
def invite_matrix(self, users=[]):
if isinstance(users, str):
self.main_intent.invite(self.mxid, users)
else:
for user in users:
self.main_intent.invite(self.mxid, user)
def update_after_create(self, user, entity, direct, puppet=None):
if not direct:
self.update_info(user, entity)
users, participants = self.get_users(user, entity)
self.sync_telegram_users(user, users)
self.update_telegram_participants(participants)
else:
if not puppet:
puppet = p.Puppet.get(self.tgid)
puppet.update_info(user, entity)
puppet.intent.join_room(self.mxid)
def create_matrix_room(self, user, entity=None, invites=[], update_if_exists=True):
if not entity:
entity = user.client.get_entity(self.peer)
self.log.debug("Fetched data: %s", entity)
direct = self.peer_type == "user"
if self.mxid:
if update_if_exists:
self.update_after_create(user, entity, direct)
self.invite_matrix(invites)
return self.mxid
self.log.debug(f"Creating room for {self.tgid_log}")
try:
title = entity.title
except AttributeError:
title = None
puppet = p.Puppet.get(self.tgid) if direct else None
intent = puppet.intent if direct else self.az.intent
# TODO set room alias if public channel.
room = intent.create_room(invitees=invites, name=title, is_direct=direct)
if not room:
raise Exception(f"Failed to create room for {self.tgid_log}")
self.mxid = room["room_id"]
self.by_mxid[self.mxid] = self
self.save()
power_level_requirement = 0 if self.peer_type == "chat" else 50
levels = self.main_intent.get_power_levels(self.mxid)
levels["ban"] = 100
levels["invite"] = 50
levels["events"]["m.room.name"] = power_level_requirement
levels["events"]["m.room.avatar"] = power_level_requirement
levels["events"]["m.room.topic"] = 50 if self.peer_type == "channel" else 100
levels["events"]["m.room.power_levels"] = 95
self.main_intent.set_power_levels(self.mxid, levels)
self.update_after_create(user, entity, direct, puppet)
def sync_telegram_users(self, source, users=[]):
for entity in users:
puppet = p.Puppet.get(entity.id)
puppet.update_info(source, entity)
puppet.intent.join_room(self.mxid)
def add_telegram_user(self, user_id, source=None):
puppet = p.Puppet.get(user_id)
if source:
entity = source.client.get_entity(user_id)
puppet.update_info(source, entity)
puppet.intent.join_room(self.mxid)
user = u.User.get_by_tgid(user_id)
if user:
self.main_intent.invite(self.mxid, user.mxid)
def delete_telegram_user(self, user_id, kick_message=None):
puppet = p.Puppet.get(user_id)
user = u.User.get_by_tgid(user_id)
if kick_message:
self.main_intent.kick(self.mxid, puppet.mxid, kick_message)
else:
puppet.intent.leave_room(self.mxid)
if user:
self.main_intent.kick(self.mxid, user.mxid, kick_message or "Left Telegram chat")
def update_info(self, user, entity=None):
if self.peer_type == "user":
self.log.warn(f"Called update_info() for direct chat portal {self.tgid_log}")
return
self.log.debug(f"Updating info of {self.tgid_log}")
if not entity:
entity = user.client.get_entity(self.peer)
self.log.debug("Fetched data: %s", entity)
changed = False
if self.peer_type == "channel":
if self.username != entity.username:
# TODO update room alias
self.username = entity.username
changed = True
changed = self.update_title(entity.title, self.main_intent) or changed
if isinstance(entity.photo, ChatPhoto):
changed = self.update_avatar(user, entity.photo.photo_big, self.main_intent) or changed
if changed:
self.save()
def update_title(self, title, intent=None):
if self.title != title:
self.title = title
self.main_intent.set_room_name(self.mxid, self.title)
return True
return False
def get_largest_photo_size(self, photo):
return max(photo.sizes, key=(lambda photo: (
len(photo.bytes) if isinstance(photo, PhotoCachedSize) else photo.size)))
def update_avatar(self, user, photo, intent=None):
photo_id = f"{photo.volume_id}-{photo.local_id}"
if self.photo_id != photo_id:
try:
file = user.download_file(photo)
except LocationInvalidError:
return False
uploaded = self.main_intent.upload_file(file)
self.main_intent.set_room_avatar(self.mxid, uploaded["content_uri"])
self.photo_id = photo_id
return True
return False
def get_users(self, user, entity):
if self.peer_type == "chat":
chat = user.client(GetFullChatRequest(chat_id=self.tgid))
return chat.users, chat.full_chat.participants.participants
elif self.peer_type == "channel":
try:
participants = user.client(GetParticipantsRequest(
entity, ChannelParticipantsRecent(), offset=0, limit=100, hash=0
))
return participants.users, participants.participants
except ChatAdminRequiredError:
return [], []
elif self.peer_type == "user":
return [entity], []
# endregion
# region Matrix event handling
def _get_file_meta(self, body, mime):
file_name = None
try:
current_extension = body[body.rindex("."):]
if mimetypes.types_map[current_extension] == mime:
file_name = body
else:
file_name = f"matrix_upload{mimetypes.guess_extension(mime)}"
except (ValueError, KeyError):
file_name = f"matrix_upload{mimetypes.guess_extension(mime)}"
return file_name, None if file_name == body else body
def handle_matrix_message(self, sender, message, event_id):
type = message["msgtype"]
if type == "m.text":
if "format" in message and message["format"] == "org.matrix.custom.html":
message, entities = formatter.matrix_to_telegram(message["formatted_body"],
sender.tgid)
reply_to = None
if len(entities) > 0 and isinstance(entities[0], formatter.MessageEntityReply):
reply = entities.pop(0)
# message = message[:reply.offset] + message[reply.offset + reply.length:]
reply_to = reply.msg_id
response = sender.send_message(self.peer, message, entities=entities,
reply_to=reply_to)
else:
response = sender.send_message(self.peer, message["body"])
elif type in {"m.image", "m.file", "m.audio", "m.video"}:
file = self.main_intent.download_file(message["url"])
info = message["info"]
mime = info["mimetype"]
file_name, caption = self._get_file_meta(message["body"], mime)
attributes = [DocumentAttributeFilename(file_name=file_name)]
if "w" in info and "h" in info:
attributes.append(DocumentAttributeImageSize(w=info["w"], h=info["h"]))
response = sender.send_file(self.peer, file, mime, caption, attributes, file_name)
else:
self.log.debug("Unhandled Matrix event: %s", message)
return
self.db.add(
DBMessage(tgid=response.id, mx_room=self.mxid, mxid=event_id, user=sender.tgid))
self.db.commit()
def handle_matrix_deletion(self, deleter, event_id):
message = DBMessage.query.filter(DBMessage.mxid == event_id and
DBMessage.user == deleter.tgid and
DBMessage.mx_room == self.mxid).one_or_none()
if not message:
return
deleter.client.delete_messages(self.peer, [message.tgid])
def handle_matrix_power_levels(self, sender, new_users, old_users):
for user, level in new_users.items():
puppet_match = p.Puppet.mxid_regex.search(user)
if puppet_match:
user_id = int(puppet_match.group(1))
else:
mx_user = u.User.get_by_mxid(user, create=False)
if not mx_user or not mx_user.tgid:
continue
user_id = mx_user.tgid
if user not in old_users or level != old_users[user]:
sender.client(
EditChatAdminRequest(chat_id=self.tgid, user_id=user_id, is_admin=level >= 50))
# endregion
# region Telegram chat info updating
def _get_telegram_users_in_matrix_room(self):
user_tgids = set()
user_mxids = self.main_intent.get_room_members(self.mxid, ("join", "invite"))
for user in user_mxids:
if user == self.az.intent.mxid:
continue
mx_user = u.User.get_by_mxid(user, create=False)
if mx_user and mx_user.tgid:
user_tgids.add(mx_user.tgid)
puppet_match = p.Puppet.mxid_regex.match(user)
if puppet_match:
user_tgids.add(int(puppet_match.group(1)))
return user_tgids
def create_telegram_chat(self, source, supergroup=False):
if not self.mxid:
raise ValueError("Can't create Telegram chat for portal without Matrix room.")
elif self.tgid:
raise ValueError("Can't create Telegram chat for portal with existing Telegram chat.")
invites = self._get_telegram_users_in_matrix_room()
if len(invites) < 2:
# TODO when we get the option for a bot, this won't happen when the bot is activated.
raise ValueError("Not enough Telegram users to create a chat")
invites = [source.client.get_input_entity(id) for id in invites]
if self.peer_type == "chat":
updates = source.client(CreateChatRequest(title=self.title, users=invites))
elif self.peer_type == "channel":
updates = source.client(CreateChannelRequest(title=self.title, megagroup=supergroup))
# TODO invite people
else:
raise ValueError("Invalid peer type for Telegram chat creation")
entity = updates.chats[0]
self.tgid = entity.id
self.tg_receiver = self.tgid
self.update_info(source, entity)
self.save()
def invite_telegram(self, source, puppet):
if self.peer_type == "chat":
source.client(AddChatUserRequest(chat_id=self.tgid, user_id=puppet.tgid, fwd_limit=0))
elif self.peer_type == "channel":
source.client(InviteToChannelRequest(channel=self.peer,
users=[InputUser(user_id=puppet.tgid)],
fwd_limit=0))
else:
raise ValueError("Invalid peer type for Telegram user invite")
# endregion
# region Telegram event handling
def handle_telegram_typing(self, user, event):
if self.mxid:
user.intent.set_typing(self.mxid, is_typing=True)
def handle_telegram_photo(self, source, sender, media):
largest_size = self.get_largest_photo_size(media.photo)
file = source.download_file(largest_size.location)
mime_type = magic.from_buffer(file, mime=True)
uploaded = sender.intent.upload_file(file, mime_type)
info = {
"h": largest_size.h,
"w": largest_size.w,
"size": len(largest_size.bytes) if (
isinstance(largest_size, PhotoCachedSize)) else largest_size.size,
"orientation": 0,
"mimetype": mime_type,
}
name = media.caption
sender.intent.set_typing(self.mxid, is_typing=False)
return sender.intent.send_image(self.mxid, uploaded["content_uri"], info=info, text=name)
@staticmethod
def convert_webp(file, to="png"):
try:
image = Image.open(BytesIO(file)).convert("RGBA")
new_file = BytesIO()
image.save(new_file, to)
return f"image/{to}", new_file.getvalue()
except:
return "image/webp", file
def handle_telegram_document(self, source, sender, media):
file = source.download_file(media.document)
mime_type = magic.from_buffer(file, mime=True)
dont_change_mime = False
if mime_type == "image/webp":
mime_type, file = self.convert_webp(file, to="png")
dont_change_mime = True
uploaded = sender.intent.upload_file(file, mime_type)
name = media.caption
for attr in media.document.attributes:
if not name and isinstance(attr, DocumentAttributeFilename):
name = attr.file_name
if not dont_change_mime:
(mime_from_name, _) = mimetypes.guess_type(name)
mime_type = mime_from_name or mime_type
elif isinstance(attr, DocumentAttributeSticker):
name = f"Sticker for {attr.alt}"
mime_type = media.document.mime_type or mime_type
info = {
"size": media.document.size,
"mimetype": mime_type,
}
type = "m.file"
if mime_type.startswith("video/"):
type = "m.video"
elif mime_type.startswith("audio/"):
type = "m.audio"
elif mime_type.startswith("image/"):
type = "m.image"
sender.intent.set_typing(self.mxid, is_typing=False)
return sender.intent.send_file(self.mxid, uploaded["content_uri"], info=info, text=name,
type=type)
def handle_telegram_location(self, source, sender, location):
long = location.long
lat = location.lat
long_char = "E" if long > 0 else "W"
lat_char = "N" if lat > 0 else "S"
rounded_long = abs(round(long * 100000) / 100000)
rounded_lat = abs(round(lat * 100000) / 100000)
body = f"{rounded_lat}° {lat_char}, {rounded_long}° {long_char}"
url = f"https://maps.google.com/?q={lat},{long}"
formatted_body = f"Location: <a href='{url}'>{body}</a>"
# At least Riot ignores formatting in m.location messages, so we'll add a plaintext link.
body = f"Location: {body}\n{url}"
return sender.intent.send_message(self.mxid, {
"msgtype": "m.location",
"geo_uri": f"geo:{lat},{long}",
"body": body,
"format": "org.matrix.custom.html",
"formatted_body": formatted_body,
})
def handle_telegram_text(self, source, sender, evt):
self.log.debug(f"Sending {evt.message} to {self.mxid} by {sender.id}")
text, html = formatter.telegram_event_to_matrix(evt, source)
sender.intent.set_typing(self.mxid, is_typing=False)
return sender.intent.send_text(self.mxid, text, html=html)
def handle_telegram_message(self, source, sender, evt):
if not self.mxid:
self.create_matrix_room(source, invites=[source.mxid])
if evt.message:
response = self.handle_telegram_text(source, sender, evt)
elif evt.media:
if isinstance(evt.media, MessageMediaPhoto):
response = self.handle_telegram_photo(source, sender, evt.media)
elif isinstance(evt.media, MessageMediaDocument):
response = self.handle_telegram_document(source, sender, evt.media)
elif isinstance(evt.media, MessageMediaGeo):
response = self.handle_telegram_location(source, sender, evt.media.geo)
else:
self.log.debug("Unhandled Telegram media: %s", evt.media)
return
else:
self.log.debug("Unhandled Telegram message: %s", evt)
return
self.db.add(DBMessage(tgid=evt.id, mx_room=self.mxid, mxid=response["event_id"],
user=source.tgid))
self.db.commit()
def handle_telegram_action(self, source, sender, action):
if not self.mxid:
create_and_exit = (MessageActionChatCreate, MessageActionChannelCreate)
create_and_continue = (MessageActionChatAddUser, MessageActionChatJoinedByLink)
if isinstance(action, create_and_exit + create_and_continue):
self.create_matrix_room(source, invites=[source.mxid])
if not isinstance(action, create_and_continue):
return
if isinstance(action, MessageActionChatEditTitle):
if self.update_title(action.title, self.main_intent):
self.save()
elif isinstance(action, MessageActionChatEditPhoto):
largest_size = self.get_largest_photo_size(action.photo)
if self.update_avatar(source, largest_size.location, self.main_intent):
self.save()
elif isinstance(action, MessageActionChatAddUser):
for user_id in action.users:
self.add_telegram_user(user_id, source)
elif isinstance(action, MessageActionChatJoinedByLink):
self.add_telegram_user(sender.id, source)
elif isinstance(action, MessageActionChatDeleteUser):
kick_message = None
if sender.id != action.user_id:
kick_message = f"Kicked by {sender.displayname}"
self.delete_telegram_user(action.user_id, kick_message)
elif isinstance(action, MessageActionChatMigrateTo):
self.peer_type = "channel"
self.migrate_and_save(action.channel_id)
sender.intent.send_emote(self.mxid, "upgraded this group to a supergroup.")
else:
self.log.debug("Unhandled Telegram action in %s: %s", self.title, action)
def set_telegram_admin(self, puppet, user):
levels = self.main_intent.get_power_levels(self.mxid)
if user:
levels["users"][user.mxid] = 50
if puppet:
levels["users"][puppet.mxid] = 50
self.main_intent.set_power_levels(self.mxid, levels)
def update_telegram_participants(self, participants):
levels = self.main_intent.get_power_levels(self.mxid)
levels["events"]["m.room.power_levels"] = 50
for participant in participants:
puppet = p.Puppet.get(participant.user_id)
user = u.User.get_by_tgid(participant.user_id)
new_level = 0
if isinstance(participant, ChatParticipantAdmin):
new_level = 50
elif isinstance(participant, ChatParticipantCreator):
new_level = 95
if user:
levels["users"][user.mxid] = new_level
if puppet:
levels["users"][puppet.mxid] = new_level
self.main_intent.set_power_levels(self.mxid, levels)
def set_telegram_admins_enabled(self, enabled):
level = 50 if enabled else 10
levels = self.main_intent.get_power_levels(self.mxid)
print(levels)
levels["invite"] = level
levels["events"]["m.room.name"] = level
levels["events"]["m.room.avatar"] = level
self.main_intent.set_power_levels(self.mxid, levels)
# endregion
# region Database conversion
def to_db(self):
return self.db.merge(
DBPortal(tgid=self.tgid, tg_receiver=self.tg_receiver, peer_type=self.peer_type,
mxid=self.mxid, username=self.username, title=self.title,
photo_id=self.photo_id))
def migrate_and_save(self, new_id):
existing = DBPortal.query.get(self.tgid_full)
if existing:
self.db.object_session(existing).delete(existing)
self.by_tgid[self.tgid_full] = None
self.tgid = new_id
self.by_tgid[self.tgid_full] = self
self.save()
def save(self):
self.to_db()
self.db.commit()
def delete(self):
self.db.delete(self.to_db())
@classmethod
def from_db(cls, db_portal):
return Portal(tgid=db_portal.tgid, tg_receiver=db_portal.tg_receiver,
peer_type=db_portal.peer_type, mxid=db_portal.mxid,
username=db_portal.username, title=db_portal.title,
photo_id=db_portal.photo_id)
# endregion
# region Class instance lookup
@classmethod
def get_by_mxid(cls, mxid):
try:
return cls.by_mxid[mxid]
except KeyError:
pass
portal = DBPortal.query.filter(DBPortal.mxid == mxid).one_or_none()
if portal:
return cls.from_db(portal)
return None
@classmethod
def get_by_tgid(cls, tgid, tg_receiver=None, peer_type=None):
tg_receiver = tg_receiver or tgid
tgid_full = (tgid, tg_receiver)
try:
return cls.by_tgid[tgid_full]
except KeyError:
pass
portal = DBPortal.query.get(tgid_full)
if portal:
return cls.from_db(portal)
if peer_type:
portal = Portal(tgid, peer_type=peer_type, tg_receiver=tg_receiver)
cls.db.add(portal.to_db())
portal.save()
return portal
return None
@classmethod
def get_by_entity(cls, entity, receiver_id=None):
entity_type = type(entity)
if entity_type in {Chat, ChatFull}:
type_name = "chat"
id = entity.id
elif entity_type in {PeerChat, InputPeerChat}:
type_name = "chat"
id = entity.chat_id
elif entity_type in {Channel, ChannelFull}:
type_name = "channel"
id = entity.id
elif entity_type in {PeerChannel, InputPeerChannel, InputChannel}:
type_name = "channel"
id = entity.channel_id
elif entity_type in {User, UserFull}:
type_name = "user"
id = entity.id
elif entity_type in {PeerUser, InputPeerUser, InputUser}:
type_name = "user"
id = entity.user_id
else:
raise ValueError(f"Unknown entity type {entity_type.__name__}")
return cls.get_by_tgid(id, receiver_id if type_name == "user" else id, type_name)
# endregion
def init(context):
global config
Portal.az, Portal.db, log, config = context
Portal.log = log.getChild("portal")
+157
View File
@@ -0,0 +1,157 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import re
from telethon.tl.types import UserProfilePhoto
from telethon.errors.rpc_error_list import LocationInvalidError
from .db import Puppet as DBPuppet
config = None
class Puppet:
log = None
db = None
az = None
mxid_regex = None
cache = {}
def __init__(self, id=None, username=None, displayname=None, photo_id=None):
self.id = id
self.localpart = config.get("bridge.username_template", "telegram_{userid}").format(
userid=self.id)
hs = config["homeserver"]["domain"]
self.mxid = f"@{self.localpart}:{hs}"
self.username = username
self.displayname = displayname
self.photo_id = photo_id
self.intent = self.az.intent.user(self.mxid)
self.cache[id] = self
@property
def tgid(self):
return self.id
def to_db(self):
return self.db.merge(
DBPuppet(id=self.id, username=self.username, displayname=self.displayname,
photo_id=self.photo_id))
@classmethod
def from_db(cls, db_puppet):
return Puppet(db_puppet.id, db_puppet.username, db_puppet.displayname, db_puppet.photo_id)
def save(self):
self.to_db()
self.db.commit()
@staticmethod
def get_displayname(info, format=True):
data = {
"phone number": info.phone,
"username": info.username,
"full name": " ".join([info.first_name or "", info.last_name or ""]).strip(),
"full name reversed": " ".join([info.first_name or "", info.last_name or ""]).strip(),
"first name": info.first_name,
"last name": info.last_name,
}
preferences = config.get("bridge", {}).get("displayname_preference",
["full name", "username", "phone"])
for preference in preferences:
name = data[preference]
if name:
break
if not name:
name = info.id
if not format:
return name
return config.get("bridge.displayname_template", "{displayname} (Telegram)").format(
displayname=name)
def update_info(self, source, info):
changed = False
if self.username != info.username:
self.username = info.username
changed = True
changed = self.update_displayname(source, info) or changed
if isinstance(info.photo, UserProfilePhoto):
changed = self.update_avatar(source, info.photo.photo_big)
if changed:
self.save()
def update_displayname(self, source, info):
displayname = self.get_displayname(info)
if displayname != self.displayname:
self.intent.set_display_name(displayname)
self.displayname = displayname
return True
def update_avatar(self, source, photo):
photo_id = f"{photo.volume_id}-{photo.local_id}"
if self.photo_id != photo_id:
try:
file = source.download_file(photo)
except LocationInvalidError:
return False
uploaded = self.intent.upload_file(file)
self.intent.set_avatar(uploaded["content_uri"])
self.photo_id = photo_id
return True
return False
@classmethod
def get(cls, id, create=True):
try:
return cls.cache[id]
except KeyError:
pass
puppet = DBPuppet.query.get(id)
if puppet:
return cls.from_db(puppet)
if create:
puppet = cls(id)
cls.db.add(puppet.to_db())
cls.db.commit()
return puppet
return None
@classmethod
def find_by_username(cls, username):
for _, puppet in cls.cache.items():
if puppet.username == username:
return puppet
puppet = DBPuppet.query.filter(DBPuppet.username == username).one_or_none()
if puppet:
return cls.from_db(puppet)
return None
def init(context):
global config
Puppet.az, Puppet.db, log, config = context
Puppet.log = log.getChild("puppet")
localpart = config.get("bridge.username_template", "telegram_{userid}").format(userid="(.+)")
hs = config["homeserver"]["domain"]
Puppet.mxid_regex = re.compile(f"@{localpart}:{hs}")
+326
View File
@@ -0,0 +1,326 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from io import BytesIO
from telethon import TelegramClient
from telethon.tl.types import *
from telethon.tl.types import User as TLUser
from telethon.tl.functions.messages import SendMessageRequest, SendMediaRequest
from .db import User as DBUser
from . import portal as po, puppet as pu
config = None
class User:
log = None
db = None
az = None
by_mxid = {}
by_tgid = {}
def __init__(self, mxid, tgid=None, username=None):
self.mxid = mxid
self.tgid = tgid
self.username = username
self.command_status = None
self.connected = False
self.client = None
whitelist = config.get("bridge", {}).get("whitelist", [self.mxid])
self.whitelisted = self.mxid in whitelist
if not self.whitelisted:
homeserver = self.mxid[self.mxid.index(":") + 1:]
self.whitelisted = homeserver in whitelist
self.by_mxid[mxid] = self
if tgid:
self.by_tgid[tgid] = self
@property
def logged_in(self):
return self.client.is_user_authorized()
@property
def has_full_access(self):
return self.logged_in and self.whitelisted
# region Database conversion
def to_db(self):
return self.db.merge(DBUser(mxid=self.mxid, tgid=self.tgid, tg_username=self.username))
def save(self):
self.to_db()
self.db.commit()
@classmethod
def from_db(cls, db_user):
return User(db_user.mxid, db_user.tgid, db_user.tg_username)
# endregion
# region Telegram connection management
def start(self):
self.client = TelegramClient(self.mxid,
config["telegram.api_id"],
config["telegram.api_hash"],
update_workers=2)
self.connected = self.client.connect()
if self.logged_in:
self.post_login()
self.client.add_update_handler(self.update_catch)
return self
def post_login(self, info=None):
self.sync_dialogs()
self.update_info(info)
def stop(self):
self.client.disconnect()
self.client = None
self.connected = False
# endregion
# region Telegram actions that need custom methods
def update_info(self, info=None):
info = info or self.client.get_me()
changed = False
if self.username != info.username:
self.username = info.username
changed = True
if self.tgid != info.id:
self.tgid = info.id
self.by_tgid[self.tgid] = self
if changed:
self.save()
def log_out(self):
self.connected = False
if self.tgid:
try:
del self.by_tgid[self.tgid]
except KeyError:
pass
self.tgid = None
self.save()
return self.client.log_out()
def send_message(self, entity, message, reply_to=None, entities=None, link_preview=True):
entity = self.client.get_input_entity(entity)
request = SendMessageRequest(
peer=entity,
message=message,
entities=entities,
no_webpage=not link_preview,
reply_to_msg_id=self.client._get_reply_to(reply_to)
)
result = self.client(request)
if isinstance(result, UpdateShortSentMessage):
return Message(
id=result.id,
to_id=entity,
message=message,
date=result.date,
out=result.out,
media=result.media,
entities=result.entities
)
return self.client._get_response_message(request, result)
def send_file(self, entity, file, mime_type=None, caption=None, attributes=[], file_name=None,
reply_to=None):
entity = self.client.get_input_entity(entity)
reply_to = self.client._get_reply_to(reply_to)
file_handle = self.client.upload_file(file, file_name=file_name, use_cache=False)
if mime_type == "image/png":
media = InputMediaUploadedPhoto(file_handle, caption or "")
else:
attr_dict = {type(attr): attr for attr in attributes}
media = InputMediaUploadedDocument(
file=file_handle,
mime_type=mime_type or "application/octet-stream",
attributes=list(attr_dict.values()),
caption=caption or "")
request = SendMediaRequest(entity, media, reply_to_msg_id=reply_to)
return self.client._get_response_message(request, self.client(request))
def download_file(self, location):
if isinstance(location, Document):
location = InputDocumentFileLocation(location.id, location.access_hash,
location.version)
elif not isinstance(location, (InputFileLocation, InputDocumentFileLocation)):
location = InputFileLocation(location.volume_id, location.local_id, location.secret)
file = BytesIO()
self.client.download_file(location, file)
data = file.getvalue()
file.close()
return data
def sync_dialogs(self):
dialogs = self.client.get_dialogs(limit=30)
for dialog in dialogs:
entity = dialog.entity
if (isinstance(entity, (TLUser, ChatForbidden, ChannelForbidden)) or (
isinstance(entity, Chat) and entity.deactivated)):
continue
portal = po.Portal.get_by_entity(entity)
portal.create_matrix_room(self, entity, invites=[self.mxid])
# endregion
# region Telegram update handling
def update_catch(self, update):
try:
self.update(update)
except:
self.log.exception("Failed to handle Telegram update")
def update(self, update):
if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewMessage,
UpdateNewChannelMessage)):
self.update_message(update)
elif isinstance(update, (UpdateChatUserTyping, UpdateUserTyping)):
self.update_typing(update)
elif isinstance(update, UpdateUserStatus):
self.update_status(update)
elif isinstance(update, (UpdateChatAdmins, UpdateChatParticipantAdmin)):
self.update_admin(update)
elif isinstance(update, UpdateChatParticipants):
portal = po.Portal.get_by_tgid(update.participants.chat_id, peer_type="chat")
portal.update_telegram_participants(update.participants.participants)
else:
self.log.debug("Unhandled update: %s", update)
def update_admin(self, update):
portal = po.Portal.get_by_tgid(update.chat_id, peer_type="chat")
if isinstance(update, UpdateChatAdmins):
portal.set_telegram_admins_enabled(update.enabled)
elif isinstance(update, UpdateChatParticipantAdmin):
puppet = pu.Puppet.get(update.user_id)
user = User.get_by_tgid(update.user_id)
portal.set_telegram_admin(puppet, user)
def update_typing(self, update):
if isinstance(update, UpdateUserTyping):
portal = po.Portal.get_by_tgid(update.user_id, self.tgid, "user")
else:
portal = po.Portal.get_by_tgid(update.chat_id, peer_type="chat")
sender = pu.Puppet.get(update.user_id)
return portal.handle_telegram_typing(sender, update)
def update_status(self, update):
puppet = pu.Puppet.get(update.user_id)
if isinstance(update.status, UserStatusOnline):
puppet.intent.set_presence("online")
elif isinstance(update.status, UserStatusOffline):
puppet.intent.set_presence("offline")
return
def get_message_details(self, update):
if isinstance(update, UpdateShortChatMessage):
portal = po.Portal.get_by_tgid(update.chat_id, peer_type="chat")
sender = pu.Puppet.get(update.from_id)
elif isinstance(update, UpdateShortMessage):
portal = po.Portal.get_by_tgid(update.user_id, self.tgid, "user")
sender = pu.Puppet.get(self.tgid if update.out else update.user_id)
elif isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)):
update = update.message
sender = pu.Puppet.get(update.from_id)
portal = po.Portal.get_by_entity(update.to_id, receiver_id=self.tgid)
return update, sender, portal
def update_message(self, update):
update, sender, portal = self.get_message_details(update)
if isinstance(update, MessageService):
if isinstance(update.action, MessageActionChannelMigrateFrom):
self.log.debug(f"Ignoring action %s to %s by %d", update.action, portal.tgid_log,
sender.id)
return
self.log.debug("Handling action %s to %s by %d", update.action, portal.tgid_log,
sender.id)
portal.handle_telegram_action(self, sender, update.action)
else:
self.log.debug("Handling message %s to %s by %d", update, portal.tgid_log, sender.tgid)
portal.handle_telegram_message(self, sender, update)
# endregion
# region Class instance lookup
@classmethod
def get_by_mxid(cls, mxid, create=True):
try:
return cls.by_mxid[mxid]
except KeyError:
pass
user = DBUser.query.get(mxid)
if user:
return cls.from_db(user).start()
if create:
user = cls(mxid)
cls.db.add(user.to_db())
cls.db.commit()
return user.start()
return None
@classmethod
def get_by_tgid(cls, tgid):
try:
return cls.by_tgid[tgid]
except KeyError:
pass
user = DBUser.query.filter(DBUser.tgid == tgid).one_or_none()
if user:
return cls.from_db(user).start()
return None
@classmethod
def find_by_username(cls, username):
for _, user in cls.by_tgid.items():
if user.username == username:
return user
puppet = DBUser.query.filter(DBUser.tg_username == username).one_or_none()
if puppet:
return cls.from_db(puppet)
return None
# endregion
def init(context):
global config
User.az, User.db, log, config = context
User.log = log.getChild("user")
users = [User.from_db(user) for user in DBUser.query.all()]
for user in users:
user.start()
-4638
View File
File diff suppressed because it is too large Load Diff
-36
View File
@@ -1,36 +0,0 @@
{
"name": "mautrix-telegram",
"version": "0.1.0",
"description": "A Matrix-Telegram puppeting bridge",
"author": "Tulir Asokan <tulir@maunium.net>",
"license": "GPL-3.0",
"main": "src/index.js",
"repository": {
"type": "git",
"url": "https://github.com/tulir/mautrix-telegram.git"
},
"dependencies": {
"chalk": "^2.3.0",
"colors": "1.1.x",
"commander": "2.12.x",
"escape-html": "1.0.x",
"file-type": "7.4.x",
"marked": "0.3.x",
"matrix-appservice-bridge": "1.x.x",
"matrix-js-sdk": "0.x.x",
"md5": "2.2.x",
"sanitize-html": "1.16.x",
"string-similarity": "1.2.x",
"telegram-mtproto": "3.2.7",
"yamljs": "0.3.x"
},
"devDependencies": {
"eslint": "4.15.x",
"eslint-config-airbnb-base": "12.1.x",
"eslint-plugin-import": "2.8.x",
"jsdoc": "3.5.x"
},
"scripts": {
"gen-jsdoc": "./node_modules/.bin/jsdoc src/ --recurse --package package.json --readme README.md --destination jsdoc"
}
}
+9
View File
@@ -0,0 +1,9 @@
aiohttp
-e git+git://github.com/Cadair/matrix-python-sdk#egg=matrix_client
#matrix-client
ruamel.yaml
python-magic
SQLAlchemy
Telethon
Markdown
Pillow
+28
View File
@@ -0,0 +1,28 @@
import setuptools
setuptools.setup(
name="mautrix_telegram",
version="0.1.0",
url="https://github.com/tulir/mautrix-telegram",
author="Tulir Asokan",
author_email="tulir@maunium.net",
description="A Matrix-Telegram puppeting bridge.",
long_description=open("README.md").read(),
packages=setuptools.find_packages(),
install_requires=["telethon", "matrix-client", "sqlalchemy"],
classifiers=[
"Development Status :: 2 - Pre-Alpha",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.6",
],
entry_points="""
[console_scripts]
mautrix-telegram=mautrix_telegram.__main__:main
""",
)
-728
View File
@@ -1,728 +0,0 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge
// Copyright (C) 2017 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
const { Bridge } = require("matrix-appservice-bridge")
const escapeHTML = require("escape-html")
const sanitizeHTML = require("sanitize-html")
const marked = require("marked")
const commands = require("./commands")
const MatrixUser = require("./matrix-user")
const TelegramUser = require("./telegram-user")
const TelegramPeer = require("./telegram-peer")
const Portal = require("./portal")
const chalk = require("chalk")
/**
* The base class for the bridge.
*/
class MautrixTelegram {
/**
* Create a MautrixTelegram instance with the given config data.
*
* @param config The data from the config file.
*/
constructor(config) {
/**
* The app config.
* @type {Object}
*/
this.config = config
/**
* A special-cased {@link TelegramUser} that is used to send broadcasts to a channel.
* @type {TelegramUser}
*/
this.channelTelegramSender = new TelegramUser(this, -1)
/**
* MXID -> {@link MatrixUser} cache.
* @private
* @type {Map<string, MatrixUser>}
*/
this.matrixUsersByID = new Map()
/**
* Telegram ID -> {@link MatrixUser} cache.
* @priavte
* @type {Map<number, MatrixUser>}
*/
this.matrixUsersByTelegramID = new Map()
/**
* Telegram ID -> {@link TelegramUser} cache.
* @private
* @type {Map<number, TelegramUser>}
*/
this.telegramUsersByID = new Map()
/**
* Telegram peer ID -> {@link Portal} cache.
* @private
* @type {Map<number, Portal>}
*/
this.portalsByPeerID = new Map()
/**
* Matrix room ID -> {@link Portal} cache.
* @private
* @type {Map<string, Portal>}
*/
this.portalsByRoomID = new Map()
/**
* List of management rooms.
* @type {string[]}
*/
this.managementRooms = []
/**
* A regular expression that matches MXIDs of Telegram user bridged by this bridge.
* @type {RegExp}
*/
this.usernameRegex = new RegExp(
`^@${
this.config.bridge.username_template.replace("${ID}", "([0-9]+)")
}:${
this.config.homeserver.domain
}$`)
const self = this
/**
* The matrix-appservice-bridge Bridge instance.
* @private
* @type {Bridge}
*/
this.bridge = new Bridge({
homeserverUrl: config.homeserver.address,
domain: config.homeserver.domain,
registration: config.appservice.registration,
controller: {
onUserQuery(/*user*/) {
return {}
},
onLog: msg => self.debug("blue", msg),
async onEvent(request/*, context*/) {
try {
await self.handleMatrixEvent(request.getData())
} catch (err) {
console.error("Matrix event handling failed:", err)
console.error(err.stack)
}
},
},
})
}
debug(color, ...message) {
if (this.config.appservice.debug) {
if (!chalk[color]) {
message.unshift(`[Invalid color: ${color}]`)
color = "bgRed"
}
console.log(chalk[color](...message))
}
}
debugErr(color, ...message) {
if (this.config.appservice.debug) {
if (!chalk[color]) {
message.unshift(`[Invalid color: ${color}]`)
color = "bgRed"
}
console.error(chalk[color](...message))
}
}
info(...message) {
console.log(...message)
}
warn(...message) {
console.error(chalk.yellow(...message))
}
/**
* Start the bridge.
*/
async run() {
this.info("Appservice listening on port %s", this.config.appservice.port)
await this.bridge.run(this.config.appservice.port, {})
// Load all Matrix users to cache
const userEntries = await this.bridge.getUserStore().select({ type: "matrix" })
for (const entry of userEntries) {
const user = MatrixUser.fromEntry(this, entry)
this.matrixUsersByID.set(entry.id, user)
if (user.telegramUserID) {
this.matrixUsersByTelegramID.set(user.telegramUserID, user)
}
}
}
/**
* The {@link MatrixClient} object for the appservice bot.
*/
get bot() {
return this.bridge.getBot()
}
/**
* The {@link Intent} object for the appservice bot.
*/
get botIntent() {
return this.bridge.getIntent()
}
/**
* Get the {@link Intent} for the Telegram user with the given ID.
*
* This does not care if a {@link TelegramUser} object for the user ID exists.
* It simply returns an intent for a Matrix puppet user with the correct MXID.
*
* @param {number} id The ID of the Telegram user.
* @returns {Intent} The Matrix puppet intent for the given Telegram user.
*/
getIntentForTelegramUser(id) {
if (id === -1) {
return this.botIntent
}
return this.bridge.getIntentFromLocalpart(this.getUsernameForTelegramUser(id))
}
/**
* Get the Matrix username localpart for the Telegram user with the given ID.
*
* @param {number} id The ID of the Telegram user.
* @returns {string} The Matrix username localpart for the given Telegram user.
*/
getUsernameForTelegramUser(id) {
return this.config.bridge.username_template.replace("${ID}", id)
}
/**
* Get the full Matrix ID ({@code @localpart:homeserver.tld}) for the Telegram user with the given ID.
*
* @param {number} id The ID of the Telegram user.
* @returns {string} The full Matrix ID for the given Telegram user.
*/
getMXIDForTelegramUser(id) {
return `@${this.getUsernameForTelegramUser(id)}:${this.config.homeserver.domain}`
}
/**
* Get the matrix.to link for the Matrix puppet of the Telegram user with the given ID.
*
* @param {number} id The ID of the Telegram user.
* @returns {string} A matrix.to link that points to the Matrix puppet of the given user.
*/
getMatrixToLinkForTelegramUser(id) {
return `https://matrix.to/#/${this.getMXIDForTelegramUser(id)}`
}
/**
* Get a {@link Portal} by Telegram peer or peer ID.
*
* This will either get the room from the room cache or the bridge room database.
* If the room is not found, a new {@link Portal} object is created.
*
* You may set the {@code opts.createIfNotFound} parameter to change whether or not to create the Portal
* automatically. However, if the peer is just the ID, a new room will not be created in any case.
*
* @param {TelegramPeer|number} peer The TelegramPeer object OR the ID of the peer whose portal to get.
* If only a peer ID is given, it is assumed that the peer is a chat or a
* channel. Searching for user peers requires the receiver ID, thus here it
* requires the full TelegramPeer object.
* @param {object} [opts] Additional options.
* @param {boolean} opts.createIfNotFound Whether or not to create the room if it is not found
* @returns {Portal} The Portal object.
*/
async getPortalByPeer(peer, { createIfNotFound = true } = {}) {
if (typeof peer === "number") {
peer = {
id: peer,
}
createIfNotFound = false
} else if (!(peer instanceof TelegramPeer)) {
throw new Error("Invalid argument: peer is not a number or a TelegramPeer.")
}
let portal = this.portalsByPeerID.get(peer.id)
if (portal) {
return portal
}
const query = {
type: "portal",
id: peer.id,
}
if (peer.type === "user") {
query.receiverID = peer.receiverID
}
const entries = await this.bridge.getRoomStore().select(query)
// Handle possible db query race conditions
portal = this.portalsByPeerID.get(peer.id)
if (portal) {
return portal
}
if (entries.length) {
portal = Portal.fromEntry(this, entries[0])
} else if (createIfNotFound) {
portal = new Portal(this, undefined, peer)
} else {
return undefined
}
this.portalsByPeerID.set(peer.id, portal)
if (portal.roomID) {
this.portalsByRoomID.set(portal.roomID, portal)
}
return portal
}
/**
* Get a {@link Portal} by Matrix room ID.
*
* This will either get the room from the room cache or the bridge room database.
* If the room is not found, this function WILL NOT create a new room,
* but rather just return {@code undefined}.
*
* @param {string} id The Matrix room ID of the portal to get.
* @returns {Portal} The Portal object.
*/
async getPortalByRoomID(id) {
let portal = this.portalsByRoomID.get(id)
if (portal) {
return portal
}
// Check if we have it stored in the by-peer map
// FIXME this is probably useless
for (const [_, portalByPeer] of this.portalsByPeerID) {
if (portalByPeer.roomID === id) {
this.portalsByRoomID.set(id, portalByPeer)
return portalByPeer
}
}
const entries = await this.bridge.getRoomStore().select({
type: "portal",
roomID: id,
})
// Handle possible db query race conditions
portal = this.portalsByRoomID.get(id)
if (portal) {
return portal
}
if (entries.length) {
portal = Portal.fromEntry(this, entries[0])
} else {
// Don't create portals based on room ID
return undefined
}
this.portalsByPeerID.set(portal.id, portal)
this.portalsByRoomID.set(id, portal)
return portal
}
/**
* Get a {@link TelegramUser} by ID.
*
* This will either get the user from the user cache or the bridge user database.
* If the user is not found, a new {@link TelegramUser} instance is created.
*
* @param {number} id The internal Telegram ID of the user to get.
* @returns {TelegramUser} The TelegramUser object.
*/
async getTelegramUser(id, { createIfNotFound = true } = {}) {
if (id === -1) {
return this.channelTelegramSender
}
// TODO remove this after bugs are fixed
if (isNaN(parseInt(id, 10))) {
const err = new Error("Fatal: non-int Telegram user ID")
console.error(err.stack)
throw err
}
let user = this.telegramUsersByID.get(id)
if (user) {
return user
}
const entries = await this.bridge.getUserStore().select({
type: "remote",
id,
})
// Handle possible db query race conditions
if (this.telegramUsersByID.has(id)) {
return this.telegramUsersByID.get(id)
}
if (entries.length) {
user = TelegramUser.fromEntry(this, entries[0])
} else if (createIfNotFound) {
user = new TelegramUser(this, id)
} else {
return undefined
}
this.telegramUsersByID.set(id, user)
return user
}
/**
* Get a {@link MatrixUser} by Telegram user ID.
*
* This will either get the user from the user cache or the bridge user database.
*
* @param {number} id The Telegram user ID of the Matrix user to get.
* @returns {MatrixUser} The MatrixUser object.
*/
async getMatrixUserByTelegramID(id) {
let user = this.matrixUsersByTelegramID.get(id)
if (user) {
return user
}
// Check if we have the user stored in the by- map
// FIXME this should be made useless by making sure we always add to the second map when appropriate
for (const [_, userByMXID] of this.matrixUsersByID) {
if (userByMXID.telegramUserID === id) {
this.matrixUsersByTelegramID.set(id, userByMXID)
return userByMXID
}
}
const entries = this.bridge.getUserStore().select({
type: "matrix",
telegramID: id,
})
// Handle possible db query race conditions
if (this.matrixUsersByTelegramID.has(id)) {
return this.matrixUsersByTelegramID.get(id)
}
if (entries.length) {
user = MatrixUser.fromEntry(this, entries[0])
} else {
return undefined
}
this.matrixUsersByID.set(user.userID, user)
this.matrixUsersByTelegramID.set(id, user)
return user
}
/**
* Get a {@link MatrixUser} by ID.
*
* This will either get the user from the user cache or the bridge user database.
* If the user is not found, a new {@link MatrixUser} instance is created.
*
* @param {string} id The MXID of the Matrix user to get.
* @returns {MatrixUser} The MatrixUser object.
*/
async getMatrixUser(id, { createIfNotFound = true } = {}) {
let user = this.matrixUsersByID.get(id)
if (user) {
return user
}
const entries = this.bridge.getUserStore().select({
type: "matrix",
id,
})
// Handle possible db query race conditions
if (this.matrixUsersByID.has(id)) {
return this.matrixUsersByID.get(id)
}
if (entries.length) {
user = MatrixUser.fromEntry(this, entries[0])
} else if (createIfNotFound) {
user = new MatrixUser(this, id)
} else {
return undefined
}
this.matrixUsersByID.set(id, user)
if (user.telegramUserID) {
this.matrixUsersByID.set(user.telegramUserID, user)
}
return user
}
/**
* Save a user to the bridge user database.
*
* @param {MatrixUser|TelegramUser} user The user object to save.
*/
putUser(user) {
const entry = user.toEntry()
return this.bridge.getUserStore()
.upsert({
type: entry.type,
id: entry.id,
}, entry)
}
/**
* Save a room to the bridge room database.
*
* @param {Room} room The Room object to save.
*/
putRoom(room) {
const entry = room.toEntry()
return this.bridge.getRoomStore()
.upsert({
type: entry.type,
id: entry.id,
}, entry)
}
/**
* Get the members in the given room.
*
* @param {string} roomID The ID of the room to search.
* @param {Intent} [intent] The Intent object to use when reading the room state.
* Uses {@link #botIntent} by default.
* @returns {string[]} The list of MXIDs who are in the room.
*/
async getRoomMembers(roomID, intent = this.botIntent) {
const roomState = await intent.roomState(roomID)
const members = []
for (const event of roomState) {
if (event.type === "m.room.member" && event.membership === "join") {
members.push(event.user_id)
}
}
return members
}
async getRoomTitle(roomID, intent = this.botIntent) {
const roomState = await intent.roomState(roomID)
for (const event of roomState) {
if (event.type === "m.room.name") {
return event.content.name
}
}
return undefined
}
async handlePart(sender, evt) {
// TODO handle kicking real Matrix users who have logged in with Telegram?
const capture = this.usernameRegex.exec(evt.state_key)
if (!capture) {
return
}
const telegramID = +capture[1]
if (!telegramID || isNaN(telegramID)) {
return
}
const user = await this.getTelegramUser(telegramID)
const portal = await this.getPortalByRoomID(evt.room_id)
if (!portal) {
return
}
await portal.kickTelegram(sender.telegramPuppet, user)
}
/**
* Handle an invite to a Matrix room.
*
* @param {MatrixUser} sender The user who sent this invite.
* @param {MatrixEvent} evt The invite event.
*/
async handleInvite(sender, evt) {
const asBotID = this.bridge.getBot().getUserId()
if (evt.state_key === asBotID) {
// Accept all AS bot invites.
try {
await this.botIntent.join(evt.room_id)
} catch (err) {
console.error(`Failed to join room ${evt.room_id}:`, err)
if (err instanceof Error) {
console.error(err.stack)
}
}
return
}
if (evt.sender === asBotID || evt.sender === evt.state_key) {
return
}
// Check if the invited user is a Telegram user.
const capture = this.usernameRegex.exec(evt.state_key)
if (!capture) {
return
}
const telegramID = +capture[1]
if (!telegramID || isNaN(telegramID)) {
return
}
const intent = this.getIntentForTelegramUser(telegramID)
try {
await intent.join(evt.room_id)
const members = await this.getRoomMembers(evt.room_id, intent)
const user = await this.getTelegramUser(telegramID)
if (members.length < 2) {
console.warn(`No members in room ${evt.room_id}`)
await intent.leave(evt.room_id)
} else if (members.length === 2) {
const peer = user.toPeer(sender.telegramPuppet)
const portal = await this.getPortalByPeer(peer)
if (portal.roomID) {
await intent.sendMessage(evt.room_id, {
msgtype: "m.notice",
body: "You already have a private chat room with me!\nI'll re-invite you to that room.",
})
try {
await intent.invite(portal.roomID, sender.userID)
} catch (_) {}
await intent.leave(evt.room_id)
} else {
portal.roomID = evt.room_id
await portal.save()
await intent.sendMessage(portal.roomID, {
msgtype: "m.notice",
body: "Portal to Telegram private chat created.",
})
await user.updateInfo(sender.telegramPuppet, undefined, { updateAvatar: true })
}
} else if (!members.includes(asBotID)) {
await intent.sendMessage(evt.room_id, {
msgtype: "m.notice",
body: "Inviting additional Telegram users to private chats or non-portal rooms is not supported.",
})
await intent.leave(evt.room_id)
} else {
const portal = await this.getPortalByRoomID(evt.room_id)
if (portal) {
await portal.inviteTelegram(sender.telegramPuppet, user)
}
}
} catch (err) {
console.error(`Failed to process invite to room ${evt.room_id} for Telegram user ${telegramID}: ${err}`)
if (err instanceof Error) {
console.error(err.stack)
}
}
}
/**
* Handle a single received Matrix event.
*
* @param {MatrixEvent} evt The Matrix event that occurred.
*/
async handleMatrixEvent(evt) {
const user = await this.getMatrixUser(evt.sender)
if (!user.whitelisted) {
return
}
const asBotID = this.bridge.getBot().getUserId()
if (evt.type === "m.room.member") {
if (evt.content.membership === "invite") {
await this.handleInvite(user, evt)
return
} else if (evt.content.membership === "leave") {
await this.handlePart(user, evt)
return
}
}
if (evt.sender === asBotID || evt.type !== "m.room.message" || !evt.content) {
// Ignore own messages and non-message events.
return
}
const cmdprefix = this.config.bridge.commands.prefix
const hasCommandPrefix = cmdprefix && evt.content.body.startsWith(`${cmdprefix} `)
const portal = await this.getPortalByRoomID(evt.room_id)
if (portal && !hasCommandPrefix) {
portal.handleMatrixEvent(user, evt)
return
}
let isManagement = this.managementRooms.includes(evt.room_id) || hasCommandPrefix
if (!isManagement) {
const roomMembers = await this.getRoomMembers(evt.room_id)
if (roomMembers.length === 2 && roomMembers.includes(asBotID)) {
this.managementRooms.push(evt.room_id)
isManagement = true
}
}
if (isManagement) {
const prefixLength = cmdprefix.length + 1
if (cmdprefix && evt.content.body.startsWith(`${cmdprefix} `)) {
evt.content.body = evt.content.body.substr(prefixLength)
}
const args = evt.content.body.split(" ")
const command = args.shift()
const replyFunc = (reply, { allowHTML = false, markdown = true } = {}) => {
reply = reply.replace("$cmdprefix", cmdprefix || "")
if (!markdown && !allowHTML) {
reply = escapeHTML(reply)
}
if (markdown) {
reply = marked(reply, {
sanitize: !allowHTML,
})
}
this.botIntent.sendMessage(
evt.room_id, {
body: sanitizeHTML(reply),
formatted_body: reply,
msgtype: "m.notice",
format: "org.matrix.custom.html",
})
}
commands.run(user, command, args, replyFunc, {
app: this,
evt,
roomID: evt.room_id,
isManagement,
isPortal: !!portal,
})
}
}
/**
* Check whether the given user ID is allowed to use this bridge.
*
* @param {string} userID The full Matrix ID to check (@user:homeserver.tld)
* @returns {boolean} Whether or not the user should be allowed to use the bridge.
*/
checkWhitelist(userID) {
if (!this.config.bridge.whitelist || this.config.bridge.whitelist.length === 0) {
return true
}
userID = userID.toLowerCase()
const userIDCapture = /@.+:(.+)/.exec(userID)
const homeserver = userIDCapture && userIDCapture.length > 1 ? userIDCapture[1] : undefined
for (let whitelisted of this.config.bridge.whitelist) {
whitelisted = whitelisted.toLowerCase()
if (whitelisted === userID || (homeserver && whitelisted === homeserver)) {
return true
}
}
return false
}
}
module.exports = MautrixTelegram
-425
View File
@@ -1,425 +0,0 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge
// Copyright (C) 2017 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
const makePasswordHash = require("telegram-mtproto").plugins.makePasswordHash
const Portal = require("./portal")
const commands = {}
/**
* Module containing all management commands.
*
* @module commands
*/
/**
* Run management command.
*
* @param {string} sender The MXID of the user who sent the command.
* @param {string} command The command itself.
* @param {Array<string>} args A list of arguments.
* @param {function} reply A function that is called to reply to the command.
* @param {object} extra Extra information that the handlers may find useful.
* @param {MautrixTelegram} extra.app The app main class instance.
* @param {MatrixEvent} extra.evt The event that caused this call.
* @param {string} extra.roomID The ID of the Matrix room the command was sent to.
* @param {boolean} extra.isManagement Whether or not the Matrix room is a management room.
* @param {boolean} extra.isPortal Whether or not the Matrix room is a portal to a Telegram chat.
*/
function run(sender, command, args, reply, extra) {
const commandFunc = this.commands[command]
if (!commandFunc) {
if (sender.commandStatus) {
if (command === "cancel") {
reply(`${sender.commandStatus.action} cancelled.`)
sender.commandStatus = undefined
return undefined
}
args.unshift(command)
return sender.commandStatus.next(sender, args, reply, extra)
}
reply("Unknown command. Try `$cmdprefix help` for help.")
return undefined
}
try {
return commandFunc(sender, args, reply, extra)
} catch (err) {
reply(`Error running command: ${err}.`)
if (err instanceof Error) {
reply(["```", err.stack, "```"].join(""))
console.error(err.stack)
}
}
return undefined
}
commands.cancel = () => "Nothing to cancel."
commands.help = (sender, args, reply, { isManagement, isPortal }) => {
let replyMsg = ""
if (isManagement) {
replyMsg += "This is a management room: prefixing commands with `$cmdprefix` is not required.\n"
} else if (isPortal) {
replyMsg += "**This is a portal room**: you must always prefix commands with `$cmdprefix`.\n" +
"Management commands will not be sent to Telegram.\n"
} else {
replyMsg += "**This is not a management room**: you must prefix commands with `$cmdprefix`.\n"
}
replyMsg += `
_**Generic bridge commands**: commands for using the bridge that aren't related to Telegram._<br/>
**help** - Show this help message.<br/>
**cancel** - Cancel an ongoing action (such as login).<br/>
**setManagement** - Mark the room as a management room.<br/>
**unsetManagement** - Undo management room marking.
_**Telegram actions**: commands for using the bridge to interact with Telegram._<br/>
**login** &lt;_phone_&gt; - Request an authentication code.<br/>
**logout** - Log out from Telegram.<br/>
**search** [_-r|--remote_] &lt;_query_&gt; - Search your contacts or the Telegram servers for users.<br/>
**create** &lt;_group/channel_&gt; [_room ID_] - Create a Telegram chat of the given type for a Matrix room.
If the room ID is not specified, a chat for the current room is created.<br/>
**upgrade** - Upgrade a normal Telegram group to a supergroup.
_**Temporary commands**: commands that will be replaced with more Matrix-y actions later._<br/>
**pm** &lt;_id_&gt; - Open a private chat with the given Telegram user ID.
_**Debug commands**: commands to help in debugging the bridge. Disabled by default._<br/>
**api** &lt;_method_&gt; &lt;_args_&gt; - Call a Telegram API method. Args is always a single JSON object.
`
reply(replyMsg, { allowHTML: true })
}
commands.setManagement = (sender, _, reply, { app, roomID, isPortal }) => {
if (isPortal) {
reply("You may not mark portal rooms as management rooms.")
return
}
app.managementRooms.push(roomID)
reply("Room marked as management room. You can now run commands without the `$cmdprefix` prefix.")
}
commands.unsetManagement = (sender, _, reply, { app, roomID }) => {
app.managementRooms.splice(app.managementRooms.indexOf(roomID), 1)
reply("Room unmarked as management room. You must now include the `$cmdprefix` prefix when running commands.")
}
/////////////////////////////
// Authentication handlers //
/////////////////////////////
/**
* Two-factor authentication handler.
*/
commands.enterPassword = async (sender, args, reply, { isManagement }) => {
if (!isManagement) {
reply("Logging in is considered a confidential action, and thus is only allowed in management rooms.")
return
} else if (args.length === 0) {
reply("**Usage:** `$cmdprefix <password> [salt]`")
return
}
let salt
if (!sender.commandStatus || !sender.commandStatus.salt) {
if (args.length > 1) {
salt = args[1]
} else {
reply("No password salt found. Did you enter your phone code already?")
return
}
} else {
salt = sender.commandStatus.salt
}
const hash = makePasswordHash(salt, args[0])
try {
await sender.checkPassword(hash)
reply(`Logged in successfully as ${sender.telegramPuppet.getDisplayName()}.`)
sender.commandStatus = undefined
} catch (err) {
reply(`**Login failed:** ${err}`)
if (err instanceof Error) {
reply(["```", err.stack, "```"].join(""))
console.error(err.stack)
}
}
}
/*
* Login code send handler.
*/
commands.enterCode = async (sender, args, reply, { isManagement }) => {
if (!isManagement) {
reply("Logging in is considered a confidential action, and thus is only allowed in management rooms.")
return
} else if (args.length === 0) {
reply("**Usage:** `$cmdprefix <authentication code>`")
return
}
try {
const data = await sender.signInToTelegram(args[0])
if (data.status === "ok") {
reply(`Logged in successfully as ${sender.telegramPuppet.getDisplayName()}.`)
sender.commandStatus = undefined
} else if (data.status === "need-password") {
reply(`You have two-factor authentication enabled. Password hint: ${data.hint}
Enter your password using \`$cmdprefix <password>\``)
sender.commandStatus = {
action: "Two-factor authentication",
next: commands.enterPassword,
salt: data.salt,
}
} else {
reply(`Unexpected sign in response, status=${data.status}`)
}
} catch (err) {
reply(`**Login failed:** ${err}`)
if (err instanceof Error) {
reply(["```", err.stack, "```"].join(""))
console.error(err.stack)
}
}
}
/*
* Login code request handler.
*/
commands.login = async (sender, args, reply, { isManagement }) => {
if (!isManagement) {
reply("Logging in is considered a confidential action, and thus is only allowed in management rooms.")
return
} else if (args.length === 0) {
reply("**Usage:** `$cmdprefix login <phone number>`")
return
}
try {
/*const data = */
await sender.sendTelegramCode(args[0])
reply(`Login code sent to ${args[0]}.\nEnter the code using \`$cmdprefix <code>\``)
sender.commandStatus = {
action: "Phone code authentication",
next: commands.enterCode,
}
} catch (err) {
reply(`**Failed to send code:** ${err}`)
if (err instanceof Error) {
reply(["```", err.stack, "```"].join(""))
console.error(err.stack)
}
}
}
commands.register = async (sender, args, reply) => {
reply("Registration has not yet been implemented. Please use the official apps for now.")
}
commands.logout = async (sender, args, reply) => {
try {
sender.logOutFromTelegram()
reply("Logged out successfully.")
} catch (err) {
reply(`**Failed to log out:** ${err}`)
if (err instanceof Error) {
reply(["```", err.stack, "```"].join(""))
console.error(err.stack)
}
}
}
//////////////////////////////
// General command handlers //
//////////////////////////////
commands.create = async (sender, args, reply, { app, roomID }) => {
if (args.length < 1 || (args[0] !== "group" && args[0] !== "channel")) {
reply("**Usage:** `$cmdprefix create <group/channel>`")
return
} else if (!sender._telegramPuppet) {
reply("This command requires you to be logged in.")
return
} else if (args[0] === "channel") {
reply("Creating channels is not yet supported.")
return
}
if (args.length > 1) {
roomID = args[1]
}
// TODO make sure that the AS bot is in the room.
const title = await app.getRoomTitle(roomID)
if (!title) {
reply("Please set a room name before creating a Telegram chat.")
return
}
let portal = await app.getPortalByRoomID(roomID)
if (portal) {
reply("This is already a portal room.")
return
}
portal = new Portal(app, roomID)
try {
await portal.createTelegramChat(sender.telegramPuppet, title)
reply(`Telegram chat created. ID: ${portal.id}`)
if (app.managementRooms.includes(roomID)) {
app.managementRooms.splice(app.managementRooms.indexOf(roomID), 1)
}
} catch (err) {
reply(`Failed to create Telegram chat: ${err}`)
}
}
commands.upgrade = async (sender, args, reply, { app, roomID }) => {
if (!sender._telegramPuppet) {
reply("This command requires you to be logged in.")
return
}
const portal = await app.getPortalByRoomID(roomID)
if (!portal) {
reply("This is not a portal room.")
return
}
await portal.upgradeTelegramChat(sender.telegramPuppet)
}
commands.search = async (sender, args, reply, { app }) => {
if (args.length < 1) {
reply("**Usage:** `$cmdprefix search [-r|--remote] <query>`")
return
} else if (!sender._telegramPuppet) {
reply("This command requires you to be logged in.")
return
}
const msg = []
if (args[0] !== "-r" && args[0] !== "--remote") {
const contactResults = await sender.searchContacts(args.join(" "))
if (contactResults.length > 0) {
msg.push("**Following results found from local contacts:**")
msg.push("")
for (const { match, contact } of contactResults) {
msg.push(`- <a href="${
app.getMatrixToLinkForTelegramUser(contact.id)}">${contact.getDisplayName()}</a>: ${contact.id} (${match}% match)`)
}
msg.push("")
msg.push("To force searching from Telegram servers, add `-r` before the search query.")
reply(msg.join("\n"), { allowHTML: true })
return
}
} else {
args.shift()
msg.push("-r flag found: forcing remote search")
msg.push("")
}
const query = args.join(" ")
if (query.length < 5) {
reply("Failed to search server: Query is too short.")
return
}
const telegramResults = await sender.searchTelegram(query)
if (telegramResults.length > 0) {
msg.push("**Following results received from Telegram server:**")
for (const user of telegramResults) {
msg.push(`- <a href="${
app.getMatrixToLinkForTelegramUser(user.id)}">${user.getDisplayName()}</a>: ${user.id}`)
}
} else {
msg.push("**No users found.**")
}
reply(msg.join("\n"), { allowHTML: true })
}
commands.pm = async (sender, args, reply, { app }) => {
if (args.length < 1) {
reply("**Usage:** `$cmdprefix pm <id>`")
return
} else if (!sender._telegramPuppet) {
reply("This command requires you to be logged in.")
return
}
const user = await app.getTelegramUser(+args[0], { createIfNotFound: false })
if (!user) {
reply("User info not saved. Try searching for the user first?")
return
}
const peer = user.toPeer(sender.telegramPuppet)
const userInfo = await peer.getInfo(sender.telegramPuppet)
await user.updateInfo(sender.telegramPuppet, userInfo)
const portal = await app.getPortalByPeer(peer)
await portal.createMatrixRoom(sender.telegramPuppet, {
invite: [sender.userID],
})
}
////////////////////////////
// Debug command handlers //
////////////////////////////
commands.api = async (sender, args, reply, { app }) => {
if (!app.config.bridge.commands.allow_direct_api_calls) {
reply("Direct API calls are forbidden on this mautrix-telegram instance.")
return
}
const apiMethod = args.shift()
let apiArgs
try {
apiArgs = JSON.parse(args.join(" "))
} catch (err) {
reply("Invalid API method parameters. Usage: $cmdprefix api <method> <json data>")
return
}
try {
reply(`Calling ${apiMethod} with the following arguments:\n${JSON.stringify(apiArgs, "", " ")}`)
const response = await sender.telegramPuppet.client(apiMethod, apiArgs)
reply(`API call successful. Response:
<pre><code class="language-json">
${JSON.stringify(response, "", " ")}
</code></pre>`, { allowHTML: true })
} catch (err) {
reply(`API call errored. Response:\n${JSON.stringify(err, "", " ")}`)
}
}
function timeout(promise, ms = 2500) {
return new Promise((resolve, reject) => {
promise.then(resolve, reject)
setTimeout(() => reject(new Error("API call response not received")), ms)
})
}
commands.ping = async (sender, args, reply) => {
try {
await timeout(sender.telegramPuppet.client("contacts.getContacts", {}))
reply("Connection seems OK.")
} catch (err) {
reply(`Not connected: ${err}`)
}
}
module.exports = {
commands,
run,
}
-360
View File
@@ -1,360 +0,0 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge
// Copyright (C) 2017 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
/**
* Utility functions to convert between Telegram and Matrix (HTML) formatting.
* <br><br>
* <b>WARNING: This module contains headache-causing regular expressions and other duct tape.</b>
*
* @module formatter
*/
String.prototype.insert = function(at, str) {
return this.slice(0, at) + str + this.slice(at)
}
/**
* Add a simple HTML tag to the given tag list.
*
* @param {Object[]} tags The tag list.
* @param {Object} entity The Telegram format entity.
* @param {number} entity.offset The index where the format entity starts.
* @param {number} entity.length The length of the format entity.
* @param {string} tag The HTML tag to add.
* @param {number} priority The tag priority to use when sorting tags at the same index.
* @private
*/
function addSimpleTag(tags, entity, tag, priority = 0) {
tags.push([entity.offset, `<${tag}>`, -priority])
tags.push([entity.offset + entity.length, `</${tag}>`, priority])
}
/**
* Add a HTML tag to the given tag list.
*
* @param {Object[]} tags The tag list.
* @param {Object} entity The Telegram format entity.
* @param {number} entity.offset The index where the format entity starts.
* @param {number} entity.length The length of the format entity.
* @param {string} tag The HTML tag to add.
* @param {string} attrs The HTML attributes to add to the tag.
* @param {number} priority The tag priority to use when sorting tags at the same index.
* @private
*/
function addTag(tags, entity, tag, attrs, priority = 0) {
tags.push([entity.offset, `<${tag} ${attrs}>`, -priority])
tags.push([entity.offset + entity.length, `</${tag}>`, priority])
}
/**
* Convert a Telegram entity-formatted message to a Matrix HTML-formatted message.
* <br><br>
* <b>WARNING: I am not responsible for possible severe headaches caused by reading any part of this function.</b>
*
* @param {string} message The plaintext message.
* @param {Array} entities The Telegram formatting entities.
* @param {MautrixTelegram} app The app main class instance to use when reformatting mentions.
*/
function telegramToMatrix(message, entities, app) {
const tags = []
// Decreasing priority counter used to ensure that formattings right next to eachother don't flip like this:
// *bold*_italic_ --> <strong>bold<em></strong>italic</em>
let pc = 9001
// Convert Telegram formatting entities into a weird custom indexed HTML tag format thingy.
for (const entity of entities) {
let url, tag, mxid
switch (entity._) {
case "messageEntityBold":
tag = tag || "strong"
case "messageEntityItalic":
tag = tag || "em"
case "messageEntityCode":
tag = tag || "code"
addSimpleTag(tags, entity, tag, --pc)
break
case "messageEntityPre":
pc--
addSimpleTag(tags, entity, "pre", pc)
addTag(tags, entity, "code", `class="language-${entity.language}"`, pc + 1)
break
case "messageEntityBotCommand":
// TODO bridge bot commands differently?
message = `${message.substr(0, entity.offset)}!${message.substr(entity.offset + 1)}`
case "messageEntityHashtag":
addTag(tags, entity, "font", "color=\"blue\"", --pc)
break
case "messageEntityMentionName":
let user = app.matrixUsersByTelegramID.get(entity.user_id)
if (!user) {
// TODO this loop step should be made useless
for (const userByMXID of app.matrixUsersByID.values()) {
if (userByMXID.telegramUserID === entity.user_id) {
user = userByMXID
app.matrixUsersByTelegramID.set(userByMXID.telegramUserID, userByMXID)
break
}
}
}
mxid = user ?
user.userID :
app.getMXIDForTelegramUser(entity.user_id)
case "messageEntityMention":
if (!mxid) {
const username = message.substr(entity.offset + 1, entity.length - 1)
for (const userByMXID of app.matrixUsersByID.values()) {
if (userByMXID._telegramPuppet && userByMXID._telegramPuppet.data.username === username) {
mxid = userByMXID.userID
break
}
}
if (!mxid) {
for (const userByID of app.telegramUsersByID.values()) {
if (userByID.username === username) {
mxid = userByID.mxid
break
}
}
}
}
if (!mxid) {
continue
}
addTag(tags, entity, "a", `href="https://matrix.to/#/${mxid}"`)
break
case "messageEntityEmail":
url = url || `mailto:${message.substr(entity.offset, entity.length)}`
case "messageEntityUrl":
url = url || message.substr(entity.offset, entity.length)
case "messageEntityTextUrl":
url = url || entity.url
addTag(tags, entity, "a", `href="${url}"`, --pc)
break
}
}
// Sort tags in a mysterious way (it seems to work, don't touch it!).
//
// The important thing is that the tags are sorted last to first,
// so when replacing by index, the index doesn't need to be adapted.
tags.sort(([aIndex, , aPriority], [bIndex, , bPriority]) => bIndex - aIndex || aPriority - bPriority)
// Insert tags into message
for (const [index, replacement] of tags) {
message = message.insert(index, replacement)
}
message = message.replace(/\n/g, "<br/>\n")
return message
}
// Formatting that is converted back to text
const linebreaks = /<br(.*?)>(\n)?/g
const paragraphs = /<p>([^]*?)<\/p>/g
const headers = /<h([0-6])>([^]*?)<\/h[0-6]>/g
const unorderedLists = /<ul>([^]*?)<\/ul>/g
const orderedLists = /<ol>([^]*?)<\/ol>/g
const listEntries = /<li>([^]*?)<\/li>/g
const blockquotes = /<blockquote>([^]*?)<\/blockquote>/g
// Formatting that is brutally murdered
const strikedText = /<del>([^]*?)<\/del>/g
const underlinedText = /<u>([^]*?)<\/u>/g
// Formatting that is converted to Telegram entity formatting
const boldText = /<(strong)>()([^]*?)<\/strong>/g
const italicText = /<(em)>()([^]*?)<\/em>/g
const codeblocks = /<(pre><code)>()([^]*?)<\/code><\/pre>/g
const codeblocksWithSyntaxHighlight = /<(pre><code class)="language-(.*?)">([^]*?)<\/code><\/pre>/g
const inlineCode = /<(code)>()(.*?)<\/code>/g
const emailAddresses = /<a href="(mailto):(.*?)">([^]*?)<\/a>/g
const mentions = /<a href="https:\/\/(matrix\.to)\/#\/(@.+?)">(.*?)<\/a>/g
const hyperlinks = /<(a href)="(.*?)">([^]*?)<\/a>/g
const commands = /(\s|^)!([^\s]+)/g
const REGEX_CAPTURE_GROUP_COUNT = 3
RegExp.any = function(...regexes) {
let components = []
for (const regex of regexes) {
if (regex instanceof RegExp) {
components = components.concat(regex._components || regex.source)
}
}
return new RegExp(`(?:${components.join(")|(?:")})`)
}
const regexMonster = RegExp.any(boldText, italicText, codeblocks,
codeblocksWithSyntaxHighlight, inlineCode, emailAddresses,
mentions, hyperlinks)
const NUMBER_OF_REGEXES_EATEN_BY_MONSTER = 8
function regexMonsterMatchParser(match) {
match.pop() // Remove full string
const index = match.pop()
let identifier, arg, text
for (let i = 0; i < NUMBER_OF_REGEXES_EATEN_BY_MONSTER; i++) {
if (match[i * REGEX_CAPTURE_GROUP_COUNT]) {
identifier = match[i * REGEX_CAPTURE_GROUP_COUNT]
arg = match[(i * REGEX_CAPTURE_GROUP_COUNT) + 1]
text = match[(i * REGEX_CAPTURE_GROUP_COUNT) + 2]
}
}
return { index, identifier, arg, text }
}
function regexMonsterHandler(identifier, arg, text, index, app) {
let entity, entityClass, argField
switch (identifier) {
case "strong":
entityClass = "Bold"
break
case "em":
entityClass = "Italic"
break
case "pre><code":
case "pre><code class":
argField = "language"
entityClass = "Pre"
break
case "code":
entityClass = "Code"
break
case "mailto":
entityClass = "email"
// Force text to be the email address
text = arg
break
case "a href":
if (arg === text) {
entityClass = "Url"
} else {
entityClass = "TextUrl"
argField = "url"
}
case "matrix.to":
if (app) {
const match = app.usernameRegex.exec(arg)
if (!match || match.length < 2) {
break
}
const userID = match[1]
const user = app.telegramUsersByID.get(+userID)
if (!user) {
break
}
if (user.username) {
entityClass = "Mention"
text = `@${user.username}`
} else {
text = user.getDisplayName()
entity = {
_: "inputMessageEntityMentionName",
offset: index,
length: text.length,
user_id: {
_: "inputUser",
user_id: user.id,
},
}
}
}
break
}
if (!entity && entityClass) {
entity = {
_: `messageEntity${entityClass}`,
offset: index,
length: text.length,
}
if (argField) {
entity[argField] = arg
}
}
return { replacement: text, entity }
}
/**
* Convert a Matrix HTML-formatted message to a Telegram entity-formatted message.
*
* @param {string} message The HTML-formatted message.
* @returns {{message: string, entities: Array}} The Telegram entity-formatted message.
*/
function matrixToTelegram(message, isHTML, app) {
const entities = []
message = message.replace(commands, (_, prefix, command, index) => {
entities.push({
_: "messageEntityBotCommand",
offset: index + prefix.length,
length: command.length + 1,
})
return `${prefix}/${command}`
})
if (!isHTML) {
return { message, entities }
}
// First replace all the things that don't get converted into Telegram entities
message = message.replace(linebreaks, "\n")
message = message.replace(paragraphs, "$1\n")
message = message.replace(headers, (_, count, text) => `${"#".repeat(count)} ${text}`)
message = message.replace(unorderedLists, (_, list) => list.replace(listEntries, "- $1"))
message = message.replace(orderedLists, (_, list) => {
let n = 0
return list.replace(listEntries, (fullMatch, text) => `${++n}. ${text}`)
})
message = message.replace(blockquotes, (_, quote) => quote
.split("\n")
.map(line => line.trim())
.filter(line => line.length > 0)
.map(line => `> ${line}`)
.join("\n"))
// Just remove these, they have no textual or Telegramical representation.
message = message.replace(strikedText, (_, text) => text)
message = message.replace(underlinedText, (_, text) => text)
message = message.trim()
const regexMonsterReplacer = (match, ...args) => {
const { index, identifier, arg, text } = regexMonsterMatchParser(args)
if (!identifier) {
// This shouldn't happen
console.warn(`Warning: Match found but parsing failed for match "${match}"`)
return match
}
const { replacement, entity } = regexMonsterHandler(identifier, arg, text, index, app)
if (entity) {
entities.push(entity)
}
return replacement || text
}
// We replace matches iteratively to make sure the indexes of matches are correct.
let oldMessage = message
message = message.replace(regexMonster, regexMonsterReplacer)
while (oldMessage !== message) {
oldMessage = message
message = message.replace(regexMonster, regexMonsterReplacer)
}
return { message, entities }
}
module.exports = { telegramToMatrix, matrixToTelegram }
-67
View File
@@ -1,67 +0,0 @@
#!/usr/bin/env node
// mautrix-telegram - A Matrix-Telegram puppeting bridge
// Copyright (C) 2017 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
const { AppServiceRegistration } = require("matrix-appservice-bridge")
const program = require("commander")
const YAML = require("yamljs")
const fs = require("fs")
const MautrixTelegram = require("./app")
const pkg = require("../package.json")
program
.version(pkg.version)
.option("-c, --config <path>", "the file to load the config from. defaults to ./config.yaml")
.option("-g, --generate-registration", "generate a registration based on the config")
.option("-r, --registration <path>", "the file to save the registration to. defaults to ./registration.yaml")
.parse(process.argv)
// commander doesn't seem to set default values automatically.
program.registration = program.registration || "./registration.yaml"
program.config = program.config || "./config.yaml"
const config = YAML.load(program.config)
if (program.generateRegistration) {
const registration = {
id: config.appservice.id,
hs_token: AppServiceRegistration.generateToken(),
as_token: AppServiceRegistration.generateToken(),
namespaces: {
users: [{
exclusive: true,
regex: `@${config.bridge.username_template.replace("${ID}", ".+")}:${config.homeserver.domain}`,
}],
aliases: [{
exclusive: true,
regex: `#${config.bridge.alias_template.replace("${NAME}", ".+")}:${config.homeserver.domain}`,
}],
rooms: [],
},
url: `${config.appservice.protocol}://${config.appservice.hostname}:${config.appservice.port}`,
sender_localpart: config.bridge.bot_username,
rate_limited: false,
}
fs.writeFileSync(program.registration, YAML.stringify(registration, 10))
config.appservice.registration = program.registration
fs.writeFileSync(program.config, YAML.stringify(config, 10))
console.log("Registration generated and saved to", program.registration)
process.exit()
}
const app = new MautrixTelegram(config)
app.run()
-402
View File
@@ -1,402 +0,0 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge
// Copyright (C) 2017 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
const md5 = require("md5")
const TelegramPuppet = require("./telegram-puppet")
const TelegramPeer = require("./telegram-peer")
const strSim = require("string-similarity")
const chalk = require("chalk")
/**
* MatrixUser represents a Matrix user who probably wants to control their
* Telegram account from Matrix.
*/
class MatrixUser {
constructor(app, userID) {
this.app = app
this.userID = userID
this.whitelisted = app.checkWhitelist(userID)
this.phoneNumber = undefined
this.phoneCodeHash = undefined
this.commandStatus = undefined
this.puppetData = undefined
this.contacts = []
this.chats = []
this._telegramPuppet = undefined
}
/**
* Get the user ID of the Telegram user this Matrix user controls.
*
* @returns {number|undefined} The Telegram user ID, or undefined if not logged in.
*/
get telegramUserID() {
return this._telegramPuppet
? this._telegramPuppet.userID || undefined
: undefined
}
/**
* Convert a database entry into a MatrixUser.
*
* @param {MautrixTelegram} app The app main class instance.
* @param {Object} entry The database entry.
* @returns {MatrixUser} The loaded MatrixUser.
*/
static fromEntry(app, entry) {
if (entry.type !== "matrix") {
throw new Error("MatrixUser can only be created from entry type \"matrix\"")
}
const user = new MatrixUser(app, entry.id)
user.phoneNumber = entry.data.phoneNumber
user.phoneCodeHash = entry.data.phoneCodeHash
user.setContactIDs(entry.data.contactIDs)
user.setChatIDs(entry.data.chatIDs)
if (entry.data.puppet) {
user.puppetData = entry.data.puppet
// Create the telegram puppet instance
user.telegramPuppet
}
return user
}
/**
* Convert this MatrixUser into a database entry.
*
* @returns {Object} A user store database entry.
*/
toEntry() {
if (this._telegramPuppet) {
this.puppetData = this._telegramPuppet.toSubentry()
}
return {
type: "matrix",
id: this.userID,
telegramID: this.telegramUserID,
data: {
phoneNumber: this.phoneNumber,
phoneCodeHash: this.phoneCodeHash,
contactIDs: this.contactIDs,
chatIDs: this.chatIDs,
puppet: this.puppetData,
},
}
}
/**
* Get the telegram puppet this Matrix user controls.
* If one doesn't exist, it'll be created based on the {@link #puppetData} field.
*
* @returns {TelegramPuppet} The Telegram account controller.
*/
get telegramPuppet() {
if (!this._telegramPuppet) {
this._telegramPuppet = TelegramPuppet.fromSubentry(this.app, this, this.puppetData || {})
}
return this._telegramPuppet
}
/**
* Get the IDs of all the Telegram contacts of this user.
*
* @returns {number[]} A list of Telegram user IDs.
*/
get contactIDs() {
return this.contacts.map(contact => contact.id)
}
/**
* Get the IDs of all the Telegram chats this user is in.
*
* @returns {number[]} A list of Telegram chat IDs.
*/
get chatIDs() {
return this.chats.map(chat => chat.id)
}
/**
* Update the contacts of this user based on a list of Telegram user IDs.
*
* @param {number[]} list The list of Telegram user IDs.
*/
async setContactIDs(list) {
if (!list) {
return
}
this.contacts = await Promise.all(list.map(id => this.app.getTelegramUser(id)))
}
/**
* Update the chats of this user based on a list of Telegram chat IDs.
*
* @param {number[]} list The list of Telegram chat IDs.
*/
async setChatIDs(list) {
if (!list) {
return
}
this.chats = await Promise.all(list.map(id => this.app.getPortalByPeer(id)))
}
/**
* Synchronize the contacts of this user.
*
* @returns {boolean} Whether or not anything changed.
*/
async syncContacts() {
const contacts = await this.telegramPuppet.client("contacts.getContacts", {
hash: md5(this.contactIDs.join(",")),
})
if (contacts._ === "contacts.contactsNotModified") {
return false
}
for (const [index, contact] of Object.entries(contacts.users)) {
const telegramUser = await this.app.getTelegramUser(contact.id)
await telegramUser.updateInfo(this.telegramPuppet, contact, true)
contacts.users[index] = telegramUser
}
this.contacts = contacts.users
await this.save()
return true
}
/**
* Synchronize the chats (groups, channels) of this user.
*
* @param {object} [opts] Additional options.
* @param {boolean} opts.createRooms Whether or not portal rooms should be automatically created.
* Defaults to {@code true}
* @returns {boolean} Whether or not anything changed.
*/
async syncChats({ createRooms = true } = {}) {
const dialogs = await this.telegramPuppet.client("messages.getDialogs", {})
let changed = false
for (const user of dialogs.users) {
this.app.debug("cyan", "Syncing data for", this.telegramPuppet.userID, JSON.stringify(user, "", " "))
if (!user.self) {
continue
}
// Automatically create Saved Messages room
const peer = new TelegramPeer("user", user.id, {
receiverID: user.id,
accessHash: user.access_hash,
})
const portal = await this.app.getPortalByPeer(peer)
if (createRooms) {
try {
await portal.createMatrixRoom(this.telegramPuppet, {
invite: [this.userID],
})
} catch (err) {
console.error(err)
console.error(err.stack)
}
}
}
this.chats = []
for (const dialog of dialogs.chats) {
if (dialog._ === "chatForbidden" || dialog._ === "channelForbidden" || dialog.deactivated || dialog.left) {
continue
}
this.app.debug("cyan", "Syncing data for ", this.telegramPuppet.userID, JSON.stringify(dialog, "", " "))
const peer = new TelegramPeer(dialog._, dialog.id, {
accessHash: dialog.access_hash,
})
const portal = await this.app.getPortalByPeer(peer)
this.chats.push(portal)
if (createRooms) {
if (peer.type === "channel") {
portal.accessHashes.set(this.telegramPuppet.userID, dialog.access_hash)
}
try {
await portal.createMatrixRoom(this.telegramPuppet, {
invite: [this.userID],
})
} catch (err) {
console.error(`Failed to create a room for ${dialog._} ${dialog.id}`)
console.error(err)
continue
}
}
if (await portal.updateInfo(this.telegramPuppet, dialog)) {
changed = true
}
}
await this.save()
return changed
}
/**
* Add a {@link Portal} to the chat list of this user.
*
* This should only be used for non-private chat portals.
*
* @param {Portal} portal The portal to add.
*/
async join(portal) {
if (!this.chats.includes(portal.id)) {
this.chats.push(portal.id)
await this.save()
}
}
/**
* Remove a {@link Portal} from the chat list of this user.
*
* This should only be used for non-private chat portals.
*
* @param {Portal} portal The portal to remove.
*/
async leave(portal) {
const chatIDIndex = this.chats.indexOf(portal.id)
if (chatIDIndex > -1) {
this.chats.splice(chatIDIndex, 1)
await this.save()
}
}
/**
* Search for contacts of this user.
*
* @param {string} query The search query.
* @param {object} [opts] Additional options.
* @param {number} opts.maxResults The maximum number of results to show.
* @param {number} opts.minSimilarity The minimum query similarity, below which results should be ignored.
* @returns {Object[]} The search results.
*/
async searchContacts(query, { maxResults = 5, minSimilarity = 0.45 } = {}) {
const results = []
for (const contact of this.contacts) {
let displaynameSimilarity = 0
let usernameSimilarity = 0
let numberSimilarity = 0
if (contact.firstName || contact.lastName) {
displaynameSimilarity = strSim.compareTwoStrings(query, contact.getFirstAndLastName())
}
if (contact.username) {
usernameSimilarity = strSim.compareTwoStrings(query, contact.username)
}
if (contact.phoneNumber) {
numberSimilarity = strSim.compareTwoStrings(query, contact.phoneNumber)
}
const similarity = Math.max(displaynameSimilarity, usernameSimilarity, numberSimilarity)
if (similarity >= minSimilarity) {
results.push({
similarity,
match: Math.round(similarity * 1000) / 10,
contact,
})
}
}
return results
.sort((a, b) => b.similarity - a.similarity)
.slice(0, maxResults)
}
/**
* Search for non-contact Telegram users from the point of view of this user.
* @param {string} query The search query.
* @param {object} [opts] Additional options.
* @param {number} opts.maxResults The maximum number of results to show.
* @returns {Object[]} The search results.
*/
async searchTelegram(query, { maxResults = 5 } = {}) {
const results = await this.telegramPuppet.client("contacts.search", {
q: query,
limit: maxResults,
})
const resultUsers = []
for (const userInfo of results.users) {
const user = await this.app.getTelegramUser(userInfo.id)
user.updateInfo(this.telegramPuppet, userInfo)
resultUsers.push(user)
}
return resultUsers
}
/**
* Request a Telegarm phone code for logging in (or registering)
*
* @param {string} phoneNumber The phone number.
* @returns {Object} The code send result as returned by {@link TelegramPuppet#sendCode()}.
*/
async sendTelegramCode(phoneNumber) {
if (this._telegramPuppet && this._telegramPuppet.userID) {
throw new Error("You are already logged in. Please log out before logging in again.")
}
switch (this.telegramPuppet.checkPhone(phoneNumber)) {
case "unregistered":
throw new Error("That number has not been registered. Please register it first.")
case "invalid":
throw new Error("Invalid phone number.")
}
const result = await this.telegramPuppet.sendCode(phoneNumber)
this.phoneNumber = phoneNumber
this.phoneCodeHash = result.phone_code_hash
await this.save()
return result
}
/**
* Log out from Telegram.
*/
async logOutFromTelegram() {
this.telegramPuppet.logOut()
// TODO kick user from all portals
this._telegramPuppet = undefined
this.puppetData = undefined
await this.save()
}
/**
* Sign in to Telegram with a phone code sent using {@link #sendTelegramCode()}.
*
* @param {number} phoneCode The phone code.
* @returns {Object} The sign in result as returned by {@link TelegramPuppet#signIn()}.
*/
async signInToTelegram(phoneCode) {
if (!this.phoneNumber) throw new Error("Phone number not set")
if (!this.phoneCodeHash) throw new Error("Phone code not sent")
const result = await this.telegramPuppet.signIn(this.phoneNumber, this.phoneCodeHash, phoneCode)
this.phoneCodeHash = undefined
await this.save()
return result
}
/**
* Finish signing in to Telegram using the two-factor auth password.
*
* @param {string} password_hash The salted hash of the password.
* @returns {Object} The sign in result as returned by {@link TelegramPuppet#checkPassword()}
*/
async checkPassword(password_hash) {
const result = await this.telegramPuppet.checkPassword(password_hash)
await this.save()
return result
}
/**
* Save this MatrixUser to the database.
*/
save() {
return this.app.putUser(this)
}
}
module.exports = MatrixUser
-902
View File
@@ -1,902 +0,0 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge
// Copyright (C) 2017 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
const md5 = require("md5")
const TelegramPeer = require("./telegram-peer")
const formatter = require("./formatter")
/**
* Portal represents a portal from a Matrix room to a Telegram chat.
*/
class Portal {
constructor(app, roomID, peer) {
this.app = app
this.type = "portal"
this.roomID = roomID
this.peer = peer
this.accessHashes = new Map()
// deduplicate duplication caused by telegram-mtproto bugs
this.lastMessageIDs = new Map()
// deduplicate duplication caused by multiple users
this.messageHashes = []
}
/**
* Get the peer ID of this portal.
*
* @returns {number} The ID of the peer of the Telegram side of this portal.
*/
get id() {
return this.peer.id
}
/**
* Get the receiver ID of this portal. Only applicable for private chat portals.
*
* @returns {number} The ID of the receiving user of this portal.
*/
get receiverID() {
return this.peer.receiverID
}
/**
* Convert a database entry into a Portal.
*
* @param {MautrixTelegram} app The app main class instance.
* @param {Object} entry The database entry.
* @returns {Portal} The loaded Portal.
*/
static fromEntry(app, entry) {
if (entry.type !== "portal") {
throw new Error("MatrixUser can only be created from entry type \"portal\"")
}
const portal = new Portal(app, entry.roomID || entry.data.roomID, TelegramPeer.fromSubentry(entry.data.peer))
portal.photo = entry.data.photo
portal.avatarURL = entry.data.avatarURL
if (portal.peer.type === "channel") {
portal.accessHashes = new Map(entry.data.accessHashes)
}
return portal
}
/**
* Synchronize the user list of this portal.
*
* @param {TelegramPuppet} telegramPOV The Telegram account whose point of view the data is/should be fetched from.
* @param {UserFull[]} [users] The list of {@link https://tjhorner.com/tl-schema/type/UserFull user info}
* objects.
* @returns {boolean} Whether or not syncing was successful. It can only be unsuccessful if the
* user list was not provided and an access hash was not found for the given
* Telegram user.
*/
async syncTelegramUsers(telegramPOV, users) {
if (!users) {
if (!await this.loadAccessHash(telegramPOV)) {
return false
}
const data = await this.peer.getInfo(telegramPOV)
users = data.users
}
for (const userData of users) {
const user = await this.app.getTelegramUser(userData.id)
// We don't want to update avatars here, as it would likely cause a flood error
await user.updateInfo(telegramPOV, userData, { updateAvatar: false })
await user.intent.join(this.roomID)
}
return true
}
/**
* Copy a photo from Telegram to Matrix.
*
* @param {TelegramPuppet} telegramPOV The Telegram account whose point of view the image should be downloaded from.
* @param {TelegramUser} sender The user who sent the photo.
* @param {Photo} photo The Telegram {@link https://tjhorner.com/tl-schema/type/Photo Photo} object.
* @returns {Object} The uploaded Matrix photo object.
*/
async copyTelegramPhoto(telegramPOV, sender, photo) {
const size = photo.sizes.slice(-1)[0]
const uploaded = await this.copyTelegramFile(telegramPOV, sender, size.location, photo.id)
uploaded.info.h = size.h
uploaded.info.w = size.w
uploaded.info.size = size.size
uploaded.info.orientation = 0
return uploaded
}
/**
* Copy a file from Telegram to Matrix.
*
* @param {TelegramPuppet} telegramPOV The Telegram account whose point of view the file should be downloaded from.
* @param {TelegramUser} sender The user who sent the file.
* @param {FileLocation} location The Telegram {@link https://tjhorner.com/tl-schema/type/FileLocation
* FileLocation}.
* @returns {Object} The uploaded Matrix file object.
*/
async copyTelegramFile(telegramPOV, sender, location, id) {
id = id || location.id
const file = await telegramPOV.getFile(location)
const uploaded = await sender.intent.getClient().uploadContent({
stream: file.buffer,
name: `${id}.${file.extension}`,
type: file.mimetype,
}, { rawResponse: false })
uploaded.matrixtype = file.matrixtype
uploaded.info = {
mimetype: file.mimetype,
size: location.size,
}
return uploaded
}
/**
* Update the avatar of this portal to the given photo.
*
* @param {TelegramPuppet} telegramPOV The Telegram account whose point of view the avatar should be downloaded
* from, if necessary.
* @param {ChatPhoto} photo The Telegram {@link https://tjhorner.com/tl-schema/type/ChatPhoto ChatPhoto}
* object.
* @returns {boolean} Whether or not the photo was updated.
*/
async updateAvatar(telegramPOV, photo) {
if (!photo || this.peer.type === "user") {
return false
}
if (this.photo && this.avatarURL &&
this.photo.dc_id === photo.dc_id &&
this.photo.volume_id === photo.volume_id &&
this.photo.local_id === photo.local_id) {
return false
}
const file = await telegramPOV.getFile(photo)
const name = `${photo.volume_id}_${photo.local_id}.${file.extension}`
const uploaded = await this.app.botIntent.getClient().uploadContent({
stream: file.buffer,
name,
type: file.mimetype,
}, { rawResponse: false })
this.avatarURL = uploaded.content_uri
this.photo = {
dc_id: photo.dc_id,
volume_id: photo.volume_id,
local_id: photo.local_id,
}
await this.app.botIntent.setRoomAvatar(this.roomID, this.avatarURL)
return true
}
/**
* Load the access hash for the given puppet.
*
* @param {TelegramPuppet} telegramPOV The puppet whose access hash to load.
* @returns {boolean} As specified by {@link TelegramPeer#loadAccessHash(app, telegramPOV)}.
*/
loadAccessHash(telegramPOV) {
return this.peer.loadAccessHash(this.app, telegramPOV, { portal: this })
}
/**
* Handle a Telegram typing event.
*
* @param {Object} evt The custom event object.
* @param {number} evt.from The ID of the Telegram user who is typing.
* @param {TelegramPeer} evt.to The peer where the user is typing.
* @param {TelegramPuppet} evt.source The source where this event was captured.
*/
async handleTelegramTyping(evt) {
if (!this.isMatrixRoomCreated()) {
return
}
const typer = await this.app.getTelegramUser(evt.from)
// The Intent API currently doesn't allow you to set the
// typing timeout. Once it does, we should set it to ~5.5s
// as Telegram resends typing notifications every 5 seconds.
typer.intent.sendTyping(this.roomID, true/*, 5500*/)
}
/**
* Add a Telegram user to this room.
*
* This makes the Matrix puppet of that Telegram user join this room. If the Telegram user is also a puppet
* controlled by a Matrix user, that Matrix user is invited as well.
*
* @param {number} userID The Telegram ID of the user to add.
*/
async addUser(userID) {
const matrixUser = await this.app.getMatrixUserByTelegramID(userID)
if (matrixUser) {
matrixUser.join(this)
this.inviteMatrix(matrixUser.userID)
}
const telegramUser = await this.app.getTelegramUser(userID)
await telegramUser.intent.join(this.roomID)
}
/**
* Remove a Telegram user from this room.
*
* This makes the Matrix puppet of the given Telegram user leave this room. If the Telegram user is also a puppet
* controlled by a Matrix user, that Matrix user is kicked with the message "Left Telegram chat".
*
* @param {number} userID The Telegram ID of the user to remove.
*/
async deleteUser(userID) {
const matrixUser = await this.app.getMatrixUserByTelegramID(userID)
if (matrixUser) {
matrixUser.leave(this)
this.kickMatrix(matrixUser.userID, "Left Telegram chat")
}
const telegramUser = await this.app.getTelegramUser(userID)
telegramUser.intent.leave(this.roomID)
}
/**
* Handle a Telegram service message event.
*
* @param {Object} evt The custom event object.
* @param {number} evt.from The ID of the Telegram user who caused the service message.
* @param {TelegramPeer} evt.to The peer to which the message was sent.
* @param {TelegramPuppet} evt.source The source where this event was captured.
* @param {MessageAction} evt.action The Telegram {@link https://tjhorner.com/tl-schema/type/MessageAction
* MessageAction} object.
*/
async handleTelegramServiceMessage(evt) {
if (!this.isMatrixRoomCreated()) {
if (evt.action._ === "messageActionChatDeleteUser") {
// We don't care about user deletions on chats without portals
return
}
this.app.debug("magenta", "Service message received, creating room for", evt.to.id)
await this.createMatrixRoom(evt.source, { invite: [evt.source.matrixUser.userID] })
return
}
if (evt.id) {
const last = this.lastMessageIDs.get(evt.source.userID)
if (last && evt.id <= last) {
this.app.debug(`Received old/duplicate message with ID ${evt.id} (latest ID: ${last})`)
return
}
}
this.lastMessageIDs.set(evt.source.userID, evt.id)
switch (evt.action._) {
case "messageActionChatCreate":
// Portal gets created at beginning if it doesn't exist
// Falls through to invite everyone in initial user list
case "messageActionChatAddUser":
for (const userID of evt.action.users) {
await this.addUser(userID)
}
break
case "messageActionChatJoinedByLink":
await this.addUser(evt.from)
break
case "messageActionChannelCreate":
// Portal gets created at beginning if it doesn't exist
// Channels don't send initial user lists 3:<
break
case "messageActionChatMigrateTo":
this.peer.id = evt.action.channel_id
this.peer.type = "channel"
const accessHash = await this.peer.fetchAccessHashFromServer(evt.source)
if (!accessHash) {
console.error("Failed to fetch access hash for mirgrated channel!")
break
}
this.accessHashes.set(evt.source.userID, accessHash)
await this.save()
const sender = await this.app.getTelegramUser(evt.from)
await sender.sendEmote(this.roomID, "upgraded this group to a supergroup.")
break
case "messageActionChatDeleteUser":
await this.deleteUser(evt.action.user_id)
break
case "messageActionChatEditPhoto":
const sizes = evt.action.photo.sizes
let largestSize = sizes[0]
let largestSizePixels = largestSize.w * largestSize.h
for (const size of sizes) {
const pixels = size.w * size.h
if (pixels > largestSizePixels) {
largestSizePixels = pixels
largestSize = size
}
}
// TODO once permissions are synced, make the avatar change event come from the user who changed the avatar
await this.updateAvatar(evt.source, largestSize.location)
break
case "messageActionChatEditTitle":
this.peer.title = evt.action.title
await this.save()
const intent = await this.getMainIntent()
await intent.setRoomName(this.roomID, this.peer.title)
break
default:
this.app.warn("Unhandled service message of type", evt.action._, "from", evt.from)
this.app.warn(JSON.stringify(evt.action, "", " "))
}
}
/**
* Context: Matrix user X is logged into mautrix-telegram and has a private chat portal room with Telegram user Y.
* X sends message to Y from another Telegram client.
*
* Problem: We can't control X's Matrix account. We also can't make sure that X's Telegram account's Matrix puppet
* is always in private chat portal rooms, since X could create a private chat portal by inviting Y's
* puppet without giving it, the only AS-controllable user in the room, any power.
*
* Solution: When encountering an error caused by the above situation, this function is called.
* This function first tries to invite X's Matrix puppet to the room.
* If that fails, text messages are sent through the other user as notices and other messages are dropped.
*
* @param {Object} evt The custom event object (see #handleTelegramMessage(evt))
* @param {TelegramUser} sender The Telegram user object of the sender.
* @returns {boolean} Whether or not the puppet for the sender was successfully invited.
*/
async tryFixPrivateChatForOutgoingMessage(evt, sender) {
try {
const intent = await this.getMainIntent()
await intent.invite(this.roomID, sender.mxid)
return true
} catch (_) {
const receiver = await this.app.getTelegramUser(evt.to.id, { createIfNotFound: false })
if (receiver) {
if (evt.text) {
receiver.sendNotice(this.roomID, `[Your message from another client] ${evt.text}`)
}
}
}
return false
}
/**
* @typedef PortalMessage A portal message event.
*
* @property {string} [text]
* @property {string} [caption]
*
* @property {number} from
* @property {number} [fwdFrom]
*
* @property {Object} to
* @property {number} to.id
*
* @property {Object} [geo]
* @property {number} geo.lat
* @property {number} geo.long
*
* @property {Object} [document]
* @property {number} document.id
* @property {Object} [photo]
* @property {number} photo.id
*/
/**
* Get a deduplication hash of the given event. The hash is formed of the text or caption, source, forward source
* and target. For documents and photos, the file ID is included and for locations the longitude and latitude are
* included.
*
* @param {PortalMessage} evt The event.
* @returns {string} An md5 hash of the data.
*/
hash(evt) {
let base = (evt.text || evt.caption) + evt.from + evt.fwdFrom + evt.to.id
if (evt.geo) {
base += evt.geo.lat
base += evt.geo.long
} else if (evt.document) {
base += evt.document.id
} else if (evt.photo) {
base += evt.photo.id
}
return md5(base)
}
/**
* Hash the given event and check if it has been recently handled.
*
* @param {PortalMessage} evt The event.
* @returns {boolean} Whether or not the event has been recently handled.
*/
deduplicate(evt) {
const hashed = this.hash(evt)
if (this.messageHashes.includes(hashed)) {
return true
}
this.messageHashes.unshift(hashed)
if (this.messageHashes.length > 20) {
this.messageHashes.length = 20
}
return false
}
/**
* Handle a Telegram service message event.
*
* @param {Object} evt The custom event object.
* @param {number} evt.from The ID of the Telegram user who sent the message.
* @param {number} evt.fwdFrom The ID of the Telegram user who originally sent the message.
* @param {TelegramPeer} evt.to The peer to which the message was sent.
* @param {TelegramPuppet} evt.source The source where this event was captured.
* @param {string} evt.text The text in the message.
* @param {string} [evt.caption] The image/file caption.
* @param {MessageEntity[]} [evt.entities] The Telegram {@link https://tjhorner.com/tl-schema/type/MessageEntity
* formatting entities} in the message.
* @param {messageMediaPhoto} [evt.photo] The Telegram {@link https://tjhorner.com/tl-schema/constructor/messageMediaPhoto Photo} attached to the message.
* @param {messageMediaDocument} [evt.document] The Telegram {@link https://tjhorner.com/tl-schema/constructor/messageMediaDocument Document} attached to the message.
* @param {messageMediaGeo} [evt.geo] The Telegram {@link https://tjhorner.com/tl-schema/constructor/messageMediaGeo Location} attached to the message.
*/
async handleTelegramMessage(evt) {
if (!this.isMatrixRoomCreated()) {
try {
const result = await this.createMatrixRoom(evt.source, { invite: [evt.source.matrixUser.userID] })
if (!result.roomID) {
return
}
} catch (err) {
console.error("Error creating room:", err)
console.error(err.stack)
return
}
}
if (this.deduplicate(evt)) {
return
}
const sender = await this.app.getTelegramUser(evt.from)
try {
await sender.intent.sendTyping(this.roomID, false)
} catch (err) {
if (evt.to.type === "user") {
if (!await this.tryFixPrivateChatForOutgoingMessage(evt, sender)) {
return
}
await sender.intent.sendTyping(this.roomID, false)
} else {
throw err
}
}
// TODO display forwards (evt.fwdFrom)
if (evt.text && evt.text.length > 0) {
if (evt.entities) {
evt.html = formatter.telegramToMatrix(evt.text, evt.entities, this.app)
sender.sendHTML(this.roomID, evt.html)
} else {
sender.sendText(this.roomID, evt.text)
}
}
if (evt.photo) {
const photo = await this.copyTelegramPhoto(evt.source, sender, evt.photo)
photo.name = evt.caption || "Uploaded photo"
sender.sendFile(this.roomID, photo)
} else if (evt.document) {
// TODO handle stickers better
const file = await this.copyTelegramFile(evt.source, sender, evt.document)
if (evt.caption) {
file.name = evt.caption
} else if (file.matrixtype === "m.audio") {
file.name = "Uploaded audio"
} else if (file.matrixtype === "m.video") {
file.name = "Uploaded video"
} else {
file.name = "Uploaded document"
}
sender.sendFile(this.roomID, file)
} else if (evt.geo) {
sender.sendLocation(this.roomID, evt.geo)
}
}
/**
* Handle a Matrix event.
*
* @param {MatrixUser} sender The user who sent the message.
* @param {Object} evt The {@link https://matrix.org/docs/spec/client_server/r0.3.0.html#event-structure
* Matrix event}.
*/
async handleMatrixEvent(sender, evt) {
await this.loadAccessHash(sender.telegramPuppet)
switch (evt.content.msgtype) {
case "m.text":
const { message, entities } = formatter.matrixToTelegram(
evt.content.formatted_body || evt.content.body,
evt.content.format === "org.matrix.custom.html",
this.app)
this.deduplicate({
text: message,
date: Math.round(Date.now() / 1000),
from: sender.telegramPuppet.userID,
fwdFrom: 0,
to: {
id: this.peer.id,
},
})
await sender.telegramPuppet.sendMessage(this.peer, message, entities)
break
case "m.video":
case "m.audio":
case "m.file":
// TODO upload document
//break
case "m.image":
const intent = await this.getMainIntent()
await intent.sendMessage(this.roomID, {
msgtype: "m.notice",
body: "Sending files is not yet supported.",
})
break
case "m.location":
const [, lat, long] = /geo:([-]?[0-9]+\.[0-9]+)+,([-]?[0-9]+\.[0-9]+)/.exec()
this.deduplicate({
text: message,
date: Math.round(Date.now() / 1000),
from: sender.telegramPuppet.userID,
fwdFrom: 0,
to: {
id: this.peer.id,
},
geo: { lat, long },
})
await sender.telegramPuppet.sendMedia(this.peer, {
_: "inputMediaGeoPoint",
geo_point: {
_: "inputGeoPoint",
lat: +lat,
long: +long,
},
})
break
default:
this.app.warn("Unhandled event:", JSON.stringify(evt, "", " "))
}
}
/**
* @returns {boolean} Whether or not a Matrix room has been created for this Portal.
*/
isMatrixRoomCreated() {
return !!this.roomID
}
/**
* Get the primary intent object for this Portal.
*
* For groups and channels, this is always the AS bot intent.
* For private chats, it is the intent of the other user.
*
* @returns {Intent} The primary intent.
*/
async getMainIntent() {
return this.peer.type === "user"
? (await this.app.getTelegramUser(this.peer.id)).intent
: this.app.botIntent
}
async inviteTelegram(telegramPOV, user) {
if (this.peer.type === "chat") {
const updates = await telegramPOV.client("messages.addChatUser", {
chat_id: this.peer.id,
user_id: user.toPeer(telegramPOV).toInputObject(),
fwd_limit: 50,
})
this.app.debug("green", "Chat invite result:", JSON.stringify(updates, "", " "))
} else if (this.peer.type === "channel") {
const updates = await telegramPOV.client("channels.inviteToChannel", {
channel: this.peer.toInputObject(),
users: [user.toPeer(telegramPOV).toInputObject()],
})
this.app.debug("green", "Channel invite result:", JSON.stringify(updates, "", " "))
} else {
throw new Error(`Can't invite user to peer type ${this.peer.type}`)
}
}
async kickTelegram(telegramPOV, user) {
let updates
if (this.peer.type === "chat") {
updates = await telegramPOV.client("messages.deleteChatUser", {
chat_id: this.peer.id,
user_id: user.toPeer(telegramPOV).toInputObject(),
})
} else if (this.peer.type === "channel") {
this.loadAccessHash(telegramPOV)
updates = await telegramPOV.client("channels.kickFromChannel", {
channel: this.peer.toInputObject(),
user_id: user.toPeer(telegramPOV).toInputObject(),
kicked: true,
})
} else {
throw new Error(`Can't invite user to peer type ${this.peer.type}`)
}
await telegramPOV.handleUpdate(updates)
}
/**
* Invite one or more Matrix users to this Portal.
*
* @param {string[]|string} users The MXID or list of MXIDs to invite.
*/
async inviteMatrix(users) {
const intent = await this.getMainIntent()
// TODO check membership before inviting?
if (Array.isArray(users)) {
for (const userID of users) {
if (typeof userID === "string") {
try {
await intent.invite(this.roomID, userID)
} catch (err) {
if (err.httpStatus !== 403) {
console.error(`Failed to invite ${userID} to ${this.roomID}:`)
console.error(err)
}
}
}
}
} else if (typeof users === "string") {
try {
await intent.invite(this.roomID, users)
} catch (err) {
if (err.httpStatus !== 403) {
console.error(`Failed to invite ${users} to ${this.roomID}:`)
console.error(err)
}
}
}
}
/**
* Kick one or more Matrix users from this Portal.
*
* @param {string[]|string} users The MXID or list of MXIDs to kick.
* @param {string} reason The reason for kicking the user(s).
*/
async kickMatrix(users, reason) {
const intent = await this.getMainIntent()
if (Array.isArray(users)) {
for (const userID of users) {
if (typeof userID === "string") {
intent.kick(this.roomID, users, reason)
}
}
} else if (typeof users === "string") {
intent.kick(this.roomID, users, reason)
}
}
async createTelegramChat(telegramPOV, title) {
const members = await this.app.getRoomMembers(this.roomID)
const telegramInviteIDs = []
const asBotID = this.app.bot.getUserId()
for (const member of members) {
if (member === asBotID) {
continue
}
const user = await this.app.getMatrixUser(member)
if (user._telegramPuppet) {
telegramInviteIDs.push(user.telegramPuppet.userID)
}
const match = this.app.usernameRegex.exec(member)
if (!match || match.length < 2) {
continue
}
telegramInviteIDs.push(+match[1])
}
if (telegramInviteIDs.length < 2) {
// TODO once we have the option for a bot, this error will need to be changed.
throw new Error("Not enough users")
}
const telegramInvites = []
for (const userID of telegramInviteIDs) {
const user = await this.app.getTelegramUser(userID, { createIfNotFound: false })
if (!user) {
continue
}
telegramInvites.push(user.toPeer(telegramPOV).toInputObject())
}
const createUpdates = await telegramPOV.client("messages.createChat", {
title,
users: telegramInvites,
})
const chat = createUpdates.chats[0]
this.peer = new TelegramPeer("chat", chat.id, { title })
await this.save()
}
async upgradeTelegramChat(telegramPOV) {
if (this.peer.type !== "chat") {
throw new Error("Can't upgrade non-chat portal.")
}
const updates = await telegramPOV.client("messages.migrateChat", {
chat_id: this.id,
})
await telegramPOV.handleUpdate(updates)
}
/**
* Create a Matrix room for this portal.
*
* @param {TelegramPuppet} telegramPOV
* @param {string|string[] invite
* @param {boolean} inviteEvenIfNotCreated
* @returns {{created: boolean, roomID: string}}
*/
async createMatrixRoom(telegramPOV, { invite = [], inviteEvenIfNotCreated = true } = {}) {
if (this.roomID) {
if (invite && inviteEvenIfNotCreated) {
await this.inviteMatrix(invite)
}
return {
created: false,
roomID: this.roomID,
}
}
if (this.creatingMatrixRoom) {
await new Promise(resolve => setTimeout(resolve, 1000))
return {
created: false,
roomID: this.roomID,
}
}
this.creatingMatrixRoom = true
if (!await this.loadAccessHash(telegramPOV)) {
this.creatingMatrixRoom = false
throw new Error(`Failed to load access hash for ${this.peer.type} ${this.peer.username || this.peer.id}.`)
}
let room, info, users
try {
({ info, users } = await this.peer.getInfo(telegramPOV))
if (this.peer.type === "chat") {
room = await this.app.botIntent.createRoom({
options: {
name: info.title,
topic: info.about,
visibility: "private",
invite,
},
})
} else if (this.peer.type === "channel") {
room = await this.app.botIntent.createRoom({
options: {
name: info.title,
topic: info.about,
visibility: info.username ? "public" : "private",
room_alias_name: info.username
? this.app.config.bridge.alias_template.replace("${NAME}", info.username)
: undefined,
invite,
},
})
} else if (this.peer.type === "user") {
const user = await this.app.getTelegramUser(info.id)
await user.updateInfo(telegramPOV, info, { updateAvatar: true })
room = await user.intent.createRoom({
createAsClient: true,
options: {
name: this.peer.id === this.peer.receiverID
? "Saved Messages (Telegram)"
: undefined, //user.getDisplayName(),
topic: "Telegram private chat",
visibility: "private",
invite,
},
})
} else {
this.creatingMatrixRoom = false
throw new Error(`Unrecognized peer type: ${this.peer.type}`)
}
} catch (err) {
this.creatingMatrixRoom = false
throw err instanceof Error ? err : new Error(err)
}
this.roomID = room.room_id
this.creatingMatrixRoom = false
this.app.portalsByRoomID.set(this.roomID, this)
await this.save()
if (this.peer.type !== "user") {
try {
await this.syncTelegramUsers(telegramPOV, users)
if (info.photo && info.photo.photo_big) {
await this.updateAvatar(telegramPOV, info.photo.photo_big)
}
} catch (err) {
console.error(err)
if (err instanceof Error) {
console.error(err.stack)
}
}
}
return {
created: true,
roomID: this.roomID,
}
}
async updateInfo(telegramPOV, dialog) {
if (!dialog) {
this.app.warn("updateInfo called without dialog data")
const { user } = this.peer.getInfo(telegramPOV)
if (!user) {
throw new Error("Dialog data not given and fetching data failed")
}
dialog = user
}
let changed = false
if (this.peer.type === "channel") {
if (telegramPOV && this.accessHashes.get(telegramPOV.userID) !== dialog.access_hash) {
this.accessHashes.set(telegramPOV.userID, dialog.access_hash)
changed = true
}
}
if (this.peer.type === "user") {
const user = await this.app.getTelegramUser(this.peer.id)
await user.updateInfo(telegramPOV, dialog)
} else if (dialog.photo && dialog.photo.photo_big) {
changed = await this.updateAvatar(telegramPOV, dialog.photo.photo_big) || changed
}
changed = this.peer.updateInfo(dialog) || changed
if (changed) {
this.save()
}
return changed
}
/**
* Convert this Portal into a database entry.
*
* @returns {Object} A room store database entry.
*/
toEntry() {
return {
type: this.type,
id: this.id,
receiverID: this.receiverID,
roomID: this.roomID,
data: {
peer: this.peer.toSubentry(),
photo: this.photo,
avatarURL: this.avatarURL,
accessHashes: this.peer.type === "channel"
? Array.from(this.accessHashes)
: undefined,
},
}
}
/**
* Save this Portal to the database.
*/
save() {
return this.app.putRoom(this)
}
}
module.exports = Portal
-275
View File
@@ -1,275 +0,0 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge
// Copyright (C) 2017 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
/**
* TelegramPeer represents some Telegram entity that can be messaged.
*
* The possible peer types are chat (groups), channel (includes supergroups) and user.
*/
class TelegramPeer {
constructor(type, id, { accessHash, receiverID, username, title } = {}) {
this.type = type
this.id = id
this.accessHash = accessHash
this.receiverID = receiverID
this.username = username
this.title = title
}
/**
* Create a TelegramPeer based on peer data received from Telegram.
*
* @param {Object} peer The data received from Telegram.
* @param {number} sender The user ID of the other person, in case the peer is an user referring to the receiver.
* @param {number} receiverID The user ID of the receiver (in case peer type is {@code user})
* @returns {TelegramPeer}
*/
static fromTelegramData(peer, sender, receiverID) {
switch (peer._) {
case "peerChat":
return new TelegramPeer("chat", peer.chat_id)
case "peerUser":
const args = {
accessHash: peer.access_hash,
receiverID,
}
if (sender === receiverID && peer.user_id !== receiverID) {
return new TelegramPeer("user", peer.user_id, args)
}
return new TelegramPeer("user", sender, args)
case "peerChannel":
return new TelegramPeer("channel", peer.channel_id, {
accessHash: peer.access_hash,
})
default:
throw new Error(`Unrecognized peer type ${peer._}`)
}
}
/**
* Load the access hash for a specific puppeted Telegram user from the channel portal or TelegramUser info.
*
* @param {MautrixTelegram} app The app main class instance.
* @param {TelegramPuppet} telegramPOV The puppeted Telegram user for whom the access hash is needed.
* @param {Portal} [portal] Optional channel {@link Portal} instance to avoid calling {@link app#getPortalByPeer(peer)}.
* Only used if {@link #type} is {@code user}.
* @param {TelegramUser} [user] Optional {@link TelegramUser} instance to avoid calling {@link app#getTelegramUser(id)}.
* Only used if {@link #type} is {@code channel}.
* @returns {boolean} Whether or not the access hash was found and loaded.
*/
async loadAccessHash(app, telegramPOV, { portal, user } = {}) {
if (this.type === "chat") {
return true
} else if (this.type === "user") {
user = user || await app.getTelegramUser(this.id)
if (user.accessHashes.has(telegramPOV.userID)) {
this.accessHash = user.accessHashes.get(telegramPOV.userID)
return true
}
return false
} else if (this.type === "channel") {
portal = portal || await app.getPortalByPeer(this)
if (portal.accessHashes.has(telegramPOV.userID)) {
this.accessHash = portal.accessHashes.get(telegramPOV.userID)
return true
}
return false
}
return false
}
/**
* Update info based on a Telegram dialog.
*
* @param dialog The dialog data sent by Telegram.
* @returns {boolean} Whether or not something was changed.
*/
async updateInfo(dialog) {
let changed = false
if (dialog.username && (this.type === "channel" || this.type === "user")) {
if (this.username !== dialog.username) {
this.username = dialog.username
changed = true
}
}
if (dialog.title && this.title !== dialog.title) {
this.title = dialog.title
changed = true
}
return changed
}
async fetchAccessHashFromServer(telegramPOV) {
const data = await this.getInfoFromDialogs(telegramPOV)
if (!data) {
return undefined
}
this.accessHash = data.access_hash
return this.accessHash
}
async getInfoFromDialogs(telegramPOV) {
const dialogs = await telegramPOV.client("messages.getDialogs", {})
if (this.type === "user") {
for (const user of dialogs.users) {
if (user.id === this.id) {
return user
}
}
} else {
for (const chat of dialogs.chats) {
if (chat.id === this.id) {
return chat
}
}
}
return undefined
}
/**
* Get info about this peer from the Telegram servers.
*
* @param {TelegramPuppet} telegramPOV The Telegram user whose point of view the data should be fetched from.
* @returns {{info: Object, users: Array<Object>}} The info sent by Telegram. For user-type peers, the users array
* is unnecessary.
*/
async getInfo(telegramPOV) {
let info, users
switch (this.type) {
case "user":
info = await telegramPOV.client("users.getFullUser", {
id: this.toInputObject(),
})
users = [info.user]
info = info.user
break
case "chat":
info = await telegramPOV.client("messages.getFullChat", {
chat_id: this.id,
})
users = info.users
info = info.chats[0]
break
case "channel":
info = await telegramPOV.client("channels.getFullChannel", {
channel: this.toInputObject(),
})
info = info.chats[0]
try {
const participants = await telegramPOV.client("channels.getParticipants", {
channel: this.toInputObject(),
filter: { _: "channelParticipantsRecent" },
offset: 0,
limit: 1000,
})
users = participants.users
} catch (err) {
// Getting channel participants apparently requires admin.
// TODO figure out what to do about that ^
users = []
}
break
default:
throw new Error(`Unknown peer type ${this.type}`)
}
return {
info,
users,
}
}
/**
* Create a Telegram InputPeer object based on the data in this TelegramPeer.
*
* @returns {Object} The Telegram InputPeer object.
*/
toInputPeer() {
switch (this.type) {
case "chat":
return {
_: "inputPeerChat",
chat_id: this.id,
}
case "user":
return {
_: "inputPeerUser",
user_id: this.id,
access_hash: this.accessHash,
}
case "channel":
return {
_: "inputPeerChannel",
channel_id: this.id,
access_hash: this.accessHash,
}
default:
throw new Error(`Unrecognized peer type ${this.type}`)
}
}
/**
* Create a Telegram input* object (i.e. inputUser or inputChannel) based on the data in this TelegramPeer.
*
* @returns {Object} The Telegram input* object.
*/
toInputObject() {
switch (this.type) {
case "chat":
throw new Error(`Unsupported type ${this.type}`)
case "user":
return {
_: "inputUser",
user_id: this.id,
access_hash: this.accessHash,
}
case "channel":
return {
_: "inputChannel",
channel_id: this.id,
access_hash: this.accessHash,
}
default:
throw new Error(`Unrecognized type ${this.type}`)
}
}
/**
* Load the data in a database subentry to a new TelegramPeer object.
*
* @param {Object} entry The database subentry.
* @returns {TelegramPeer} The created TelegramPeer object.
*/
static fromSubentry(entry) {
return new TelegramPeer(entry.type, entry.id, entry)
}
/**
* Convert this TelegramPeer into a subentry that can be stored in the database.
*
* @returns {Object} The database-storable subentry.
*/
toSubentry() {
return {
type: this.type,
id: this.id,
username: this.username,
title: this.title,
receiverID: this.receiverID,
}
}
}
module.exports = TelegramPeer
-580
View File
@@ -1,580 +0,0 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge
// Copyright (C) 2017 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
const telegram = require("telegram-mtproto")
const { nextRandomInt } = require("telegram-mtproto/lib/bin")
const fileType = require("file-type")
const pkg = require("../package.json")
const TelegramPeer = require("./telegram-peer")
/**
* @module telegram-puppet
*/
/**
* Mapping from Telegram file types to MIME types and extensions.
* @private
*/
function metaFromFileType(type) {
const extension = type.substr("storage.file".length).toLowerCase()
let fileClass, mimetype, matrixtype
switch (type) {
case "storage.fileGif":
case "storage.fileJpeg":
case "storage.filePng":
case "storage.fileWebp":
fileClass = "image"
break
case "storage.fileMov":
mimetype = "quicktime"
case "storage.fileMp4":
fileClass = "video"
break
case "storage.fileMp3":
mimetype = "mpeg"
fileClass = "audio"
break
case "storage.filePartial":
throw new Error("Partial files should be completed before fetching their type.")
case "storage.fileUnknown":
fileClass = "application"
mimetype = "octet-stream"
matrixtype = "m.file"
break
default:
return undefined
}
mimetype = `${fileClass}/${mimetype || extension}`
matrixtype = matrixtype || `m.${fileClass}`
return { mimetype, extension, matrixtype }
}
/**
* Mapping from MIME type to Matrix file type. Used when determining MIME type and extension using magic numbers.
*
* @param {string} mime The MIME type.
* @returns {string} The corresponding Matrix file type.
* @private
*/
function matrixFromMime(mime) {
if (mime.startsWith("audio/")) {
return "m.audio"
} else if (mime.startsWith("video/")) {
return "m.video"
} else if (mime.startsWith("image/")) {
return "m.image"
}
return "m.file"
}
/**
* TelegramPuppet represents a Telegram account being controlled from Matrix.
*/
class TelegramPuppet {
constructor(app, { userID, matrixUser, data, api_hash, api_id, server_config, api_config }) {
this._client = undefined
this.userID = userID
this.matrixUser = matrixUser
this.data = data
this.app = app
this.serverConfig = Object.assign({}, server_config)
this.apiHash = api_hash
this.apiID = api_id
this.pts = 0
this.date = 0
this.lastID = 0
this.puppetStorage = {
get: async (key) => {
let value = this.data[key]
if (typeof value === "string" && value.startsWith("b64:")) {
value = Array.from(Buffer.from(value.substr("b64:".length), "base64"))
}
return value
},
set: async (key, value) => {
if (Array.isArray(value)) {
value = `b64:${Buffer.from(value).toString("base64")}`
}
if (this.data[key] === value) {
return
}
this.data[key] = value
await this.matrixUser.save()
},
remove: async (...keys) => {
keys.forEach((key) => delete this.data[key])
await this.matrixUser.save()
},
clear: async () => {
this.data = {}
await this.matrixUser.save()
},
}
this.apiConfig = Object.assign({}, {
app_version: pkg.version,
lang_code: "en",
api_id,
initConnection: 0x69796de9,
layer: 57,
invokeWithLayer: 0xda9b0d0d,
}, api_config)
if (this.data.dc && this.data[`dc${this.data.dc}_auth_key`]) {
this.listen()
}
}
static fromSubentry(app, matrixUser, data) {
const userID = data.userID
delete data.userID
return new TelegramPuppet(app, Object.assign({
userID,
matrixUser,
data,
}, app.config.telegram))
}
toSubentry() {
return Object.assign({
userID: this.userID,
}, this.data)
}
get client() {
if (!this._client) {
this._client = telegram.MTProto({
api: this.apiConfig,
server: this.serverConfig,
app: { storage: this.puppetStorage },
})
}
return this._client
}
async checkPhone(phone_number) {
try {
const status = this.client("auth.checkPhone", { phone_number })
if (status.phone_registered) {
return "registered"
}
return "unregistered"
} catch (err) {
if (err.message === "PHONE_NUMBER_INVALID") {
return "invalid"
}
throw err
}
}
sendCode(phone_number) {
return this.client("auth.sendCode", {
phone_number,
current_number: true,
api_id: this.apiID,
api_hash: this.apiHash,
})
}
logOut() {
clearInterval(this.loop)
return this.client("auth.logOut")
}
async signIn(phone_number, phone_code_hash, phone_code) {
try {
const result = await
this.client("auth.signIn", {
phone_number, phone_code, phone_code_hash,
})
return this.signInComplete(result)
} catch (err) {
if (err.type !== "SESSION_PASSWORD_NEEDED" && err.message !== "SESSION_PASSWORD_NEEDED") {
console.error("Unknown login error:", JSON.stringify(err, "", " "))
throw err
}
const password = await
this.client("account.getPassword", {})
return {
status: "need-password",
hint: password.hint,
salt: password.current_salt,
}
}
}
async checkPassword(password_hash) {
const result = await this.client("auth.checkPassword", { password_hash })
return this.signInComplete(result)
}
getDisplayName() {
if (this.data.firstName || this.data.lastName) {
return [this.data.firstName, this.data.lastName].filter(s => !!s).join(" ")
} else if (this.data.username) {
return this.data.username
}
return this.data.phone_number
}
signInComplete(data) {
this.userID = data.user.id
this.data.username = data.user.username
this.data.firstName = data.user.first_name
this.data.lastName = data.user.last_name
this.data.phoneNumber = data.user.phone_number
this.matrixUser.save()
this.listen()
return {
status: "ok",
}
}
async sendMessage(peer, message, entities) {
if (!message) {
throw new Error("Invalid parameter: message is undefined.")
}
const payload = {
peer: peer.toInputPeer(),
message,
entities,
random_id: [nextRandomInt(0xFFFFFFFF), nextRandomInt(0xFFFFFFFF)],
}
if (!payload.entities) {
// Everything breaks if we send undefined things :/
delete payload.entities
}
const result = await this.client("messages.sendMessage", payload)
return result
}
async sendMedia(peer, media) {
if (!media) {
throw new Error("Invalid parameter: media is undefined.")
}
const result = await this.client("messages.sendMedia", {
peer: peer.toInputPeer(),
media,
random_id: [nextRandomInt(0xFFFFFFFF), nextRandomInt(0xFFFFFFFF)],
})
// TODO use result? (maybe the ID)
return result
}
async onUpdate(update) {
if (!update) {
this.app.error("Oh noes! Empty update")
return
}
let to, from, portal
switch (update._) {
// Telegram user status handling.
case "updateUserStatus":
const user = await this.app.getTelegramUser(update.user_id)
const presence = update.status._ === "userStatusOnline" ? "online" : "offline"
await user.intent.getClient().setPresence({ presence })
return
//
// Telegram typing event handling
//
case "updateUserTyping":
to = new TelegramPeer("user", update.user_id, { receiverID: this.userID })
/* falls through */
case "updateChatUserTyping":
to = to || new TelegramPeer("chat", update.chat_id)
portal = await this.app.getPortalByPeer(to)
await portal.handleTelegramTyping({
from: update.user_id,
to,
source: this,
})
return
//
// Telegram message handling/parsing.
// The actual handling happens after the switch.
//
case "updateShortMessage":
to = new TelegramPeer("user", update.user_id, { receiverID: this.userID })
from = update.out ? this.userID : update.user_id
break
case "updateShortChatMessage":
to = new TelegramPeer("chat", update.chat_id)
from = update.from_id
break
case "updateNewChannelMessage":
// TODO use message.post_author
from = -1
case "updateNewMessage":
this.pts = update.pts
update = update.message // Message defined at message#90dddc11 in layer 71
from = update.from_id || from
to = TelegramPeer.fromTelegramData(update.to_id, update.from_id, this.userID)
break
case "updateReadMessages":
case "updateReadHistoryOutbox":
case "updateReadHistoryInbox":
case "updateDeleteMessages":
case "updateRestoreMessages":
// TODO we probably want to handle those five updates properly
this.pts = update.pts
return
case "updateDraftMessage":
this.app.debug("yellow", `Message draft received: ${JSON.stringify(update, "", " ")}`)
// Ignore, we can't do anything with drafts.
return
default:
// Unknown update type
this.app.warn(`Update of unknown type ${update._} received: ${JSON.stringify(update, "", " ")}`)
return
}
if (!to) {
// This shouldn't happen
this.app.warn("No target found for update", update)
return
}
if (update._ === "messageService" && update.action._ === "messageActionChannelMigrateFrom") {
return
}
portal = await this.app.getPortalByPeer(to)
if (update._ === "messageService") {
await portal.handleTelegramServiceMessage({
from,
to,
source: this,
action: update.action,
})
return
}
await portal.handleTelegramMessage({
from,
to,
id: update.id,
date: update.date,
fwdFrom: update.fwd_from ? update.fwd_from.from_id : 0,
source: this,
text: update.message,
entities: update.entities,
photo: update.media && update.media._ === "messageMediaPhoto"
? update.media.photo
: undefined,
document: update.media && update.media._ === "messageMediaDocument"
? update.media.document
: undefined,
geo: update.media && update.media._ === "messageMediaGeo"
? update.media.geo
: undefined,
caption: update.media
? update.media.caption
: undefined,
})
}
async receiveUsers(users) {
this.app.debug("green", "Handling received users:", JSON.stringify(users, "", " "))
for (const user of users) {
const telegramUser = await this.app.getTelegramUser(user.id)
await telegramUser.updateInfo(this, user, true)
}
}
async receiveChats(chats) {
this.app.debug("green", "Handling received chats:", JSON.stringify(chats, "", " "))
for (const chat of chats) {
const peer = new TelegramPeer(chat._, chat.id, {
accessHash: chat.access_hash,
})
const portal = await this.app.getPortalByPeer(peer)
await portal.updateInfo(this, chat)
}
}
async handleUpdatesTooLong() {
if (this.pts === 0 || this.date === 0) {
this.app.warn("updatesTooLong received, but we don't have timestamps :(")
return
}
this.app.debug("magenta", "Handling updatesTooLong", this.pts, this.date)
const data = await this.client("updates.getDifference", {
pts: this.pts,
date: this.date,
qts: -1,
})
if (data._ === "updates.differenceEmpty") {
this.date = data.date
return
}
await this.receiveUsers(data.users)
await this.receiveChats(data.chats)
const state = data.state || data.intermediate_state
this.app.debug("cyan", `updates.getDifference -> ${data._}`)
this.app.debug("cyan", "====================================================================================================================================================")
this.app.debug("magenta", `diff.new_messages: ${JSON.stringify(data.new_messages, "", " ")}`)
this.app.debug("cyan", "====================================================================================================================================================")
this.app.debug("magenta", `diff.other_updates: ${JSON.stringify(data.other_updates, "", " ")}`)
this.app.debug("cyan", "====================================================================================================================================================")
this.app.debug("cyan", `Current timestamps: pts=${this.pts}, date=${this.date}, unix=${Date.now() / 1000}`)
this.app.debug("magenta", `diff.state: ${JSON.stringify(state, "", " ")}`)
this.pts = state.pts
this.date = state.date
/*for (const message of data.new_messages) {
await this.onUpdate({
_: "updateNewMessage",
pts: this.pts,
message,
})
}*/
for (const update of data.other_updates) {
await this.onUpdate(update)
}
if (data._ === "updates.differenceSlice") {
//await this.handleUpdatesTooLong()
}
}
async handleUpdate(data) {
if (!data.update || data.update._ !== "updateUserStatus") {
this.app.debug("green", "Raw event for", this.userID, JSON.stringify(data, "", " "))
}
try {
switch (data._) {
case "updateShort":
this.date = data.date
await this.onUpdate(data.update)
break
case "updates":
this.date = data.date
await this.receiveUsers(data.users)
await this.receiveChats(data.chats)
for (const update of data.updates) {
await this.onUpdate(update)
}
break
case "updateShortMessage":
case "updateShortChatMessage":
await this.onUpdate(data)
break
case "updatesTooLong":
await this.handleUpdatesTooLong()
break
default:
this.app.warn("Unrecognized update type:", data._)
}
} catch (err) {
this.app.warn("Error handling update:", err)
}
}
async listen() {
this.client.bus.untypedMessage.observe(data => this.handleUpdate(data.message))
try {
// FIXME updating status crashes or freezes
//console.log("Updating online status...")
//const statusUpdate = await this.client("account.updateStatus", { offline: false })
//console.log(statusUpdate)
this.app.info("Fetching initial state...")
const state = await this.client("updates.getState", {})
this.pts = state.pts
this.date = state.date
this.app.debug("green", "Initial state:", JSON.stringify(state, "", " "))
} catch (err) {
console.error("Error getting initial state:", err)
}
try {
this.app.info("Updating contact list...")
const changed = await this.matrixUser.syncContacts()
if (!changed) {
this.app.info("Contacts were up-to-date")
} else {
this.app.info("Contacts updated")
}
} catch (err) {
console.error("Failed to update contacts:", err)
}
try {
this.app.info("Updating dialogs...")
const changed = await this.matrixUser.syncChats()
if (!changed) {
this.app.info("Dialogs were up-to-date")
} else {
this.app.info("Dialogs updated")
}
} catch (err) {
console.error("Failed to update dialogs:", err)
}
this.loop = setInterval(async () => {
try {
await this.client("updates.getState", {})
} catch (err) {
console.error("Error updating state:", err)
console.error(err.stack)
}
}, 1000)
}
async uploadFile() {
}
async getFile(location) {
if (location.volume_id && location.local_id) {
location = {
_: "inputFileLocation",
volume_id: location.volume_id,
local_id: location.local_id,
secret: location.secret,
}
} else if (location.id && location.access_hash) {
location = {
_: "inputDocumentFileLocation",
id: location.id,
access_hash: location.access_hash,
}
} else {
throw new Error("Unrecognized file location type.")
}
const file = await this.client("upload.getFile", {
location,
offset: 0,
// Max download size: 100mb
limit: 100 * 1024 * 1024,
})
file.buffer = Buffer.from(file.bytes)
if (file.type._ === "storage.filePartial") {
const { mime, ext } = fileType(file.buffer)
file.mimetype = mime
file.extension = ext
file.matrixtype = matrixFromMime(mime)
} else {
const meta = metaFromFileType(file.type._)
if (meta) {
file.mimetype = meta.mimetype
file.extension = meta.extension
file.matrixtype = meta.matrixtype
}
}
return file
}
}
module.exports = TelegramPuppet
-256
View File
@@ -1,256 +0,0 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge
// Copyright (C) 2017 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
const sanitizeHTML = require("sanitize-html")
const TelegramPeer = require("./telegram-peer")
/**
* TelegramUser represents a Telegram user who probably has an
* appservice-managed Matrix account.
*/
class TelegramUser {
constructor(app, id, user) {
this.app = app
this.id = id
this.accessHashes = new Map()
this._intent = undefined
if (user) {
this.updateInfo(undefined, user)
}
}
static fromEntry(app, entry) {
if (entry.type !== "remote") {
throw new Error("TelegramUser can only be created from entry type \"remote\"")
}
const user = new TelegramUser(app, entry.id)
const data = entry.data
user.firstName = data.firstName
user.lastName = data.lastName
user.username = data.username
user.phoneNumber = data.phoneNumber
user.photo = data.photo
user.avatarURL = data.avatarURL
user.accessHashes = new Map(data.accessHashes)
return user
}
toPeer(telegramPOV) {
return new TelegramPeer("user", this.id, {
accessHash: this.accessHashes.get(telegramPOV.userID),
receiverID: telegramPOV.userID,
})
}
toEntry() {
return {
type: "remote",
id: this.id,
data: {
firstName: this.firstName,
lastName: this.lastName,
username: this.username,
phoneNumber: this.phoneNumber,
photo: this.photo,
avatarURL: this.avatarURL,
accessHashes: Array.from(this.accessHashes),
},
}
}
async updateInfo(telegramPOV, user, { updateAvatar = false } = {}) {
if (!user) {
this.app.warn("updateInfo called without user data")
user = await telegramPOV.client("users.getFullUser", {
id: this.toPeer(telegramPOV).toInputObject(),
})
if (!user) {
throw new Error("User data not given and fetching data failed")
}
}
let changed = false
if (user.first_name || user.last_name || user.username) {
if (this.firstName !== user.first_name) {
this.firstName = user.first_name
changed = true
}
if (this.lastName !== user.last_name) {
this.lastName = user.last_name
changed = true
}
if (user.username && this.username !== user.username) {
this.username = user.username
changed = true
}
}
if (user.access_hash && telegramPOV && this.accessHashes.get(telegramPOV.userID) !== user.access_hash) {
this.accessHashes.set(telegramPOV.userID, user.access_hash)
changed = true
}
const userInfo = await this.intent.getProfileInfo(this.mxid, "displayname")
if (userInfo.displayname !== this.getDisplayName()) {
this.intent.setDisplayName(this.app.config.bridge.displayname_template
.replace("${DISPLAYNAME}", this.getDisplayName()))
}
if (updateAvatar && this.updateAvatar(telegramPOV, user)) {
changed = true
}
if (changed) {
this.save()
}
return changed
}
get intent() {
if (!this._intent) {
this._intent = this.app.getIntentForTelegramUser(this.id)
}
return this._intent
}
get mxid() {
return this.intent.client.credentials.userId
}
getFirstAndLastName() {
return [this.firstName, this.lastName].filter(s => !!s).join(" ")
}
getLastAndFirstName() {
return [this.lastName, this.firstName].filter(s => !!s).join(" ")
}
getDisplayName() {
for (const preference of this.app.config.bridge.displayname_preference) {
if (preference === "fullName") {
if (this.firstName || this.lastName) {
return this.getFirstAndLastName()
}
} else if (preference === "fullNameReversed") {
if (this.firstName || this.lastName) {
return this.getLastAndFirstName()
}
} else if (this[preference]) {
return this[preference]
}
}
return this.id
}
save() {
return this.app.putUser(this)
}
sendHTML(roomID, html) {
return this.intent.sendMessage(roomID, {
msgtype: "m.text",
format: "org.matrix.custom.html",
formatted_body: html,
body: sanitizeHTML(html),
})
}
sendNotice(roomID, text) {
return this.intent.sendMessage(roomID, {
msgtype: "m.notice",
body: text,
})
}
sendEmote(roomID, text) {
return this.intent.sendMessage(roomID, {
msgtype: "m.emote",
body: text,
})
}
sendText(roomID, text) {
return this.intent.sendText(roomID, text)
}
sendFile(roomID, file) {
return this.intent.sendMessage(roomID, {
msgtype: file.matrixtype || "m.file",
url: file.content_uri,
body: file.name || "Uploaded file",
info: file.info,
})
}
sendLocation(roomID, { long = 0.0, lat = 0.0, body } = {}) {
if (!body) {
const longChar = long > 0 ? "E" : "W"
const latChar = lat > 0 ? "N" : "S"
const roundedLong = Math.abs(Math.round(long * 100000) / 100000)
const roundedLat = Math.abs(Math.round(lat * 100000) / 100000)
body = `Location: ${roundedLat}° ${latChar}, ${roundedLong}° ${longChar}`
}
return this.intent.sendMessage(roomID, {
msgtype: "m.location",
geo_uri: `geo:${lat},${long}`,
body,
})
}
uploadContent(opts) {
return this.intent.getClient()
.uploadContent({
stream: opts.stream,
name: opts.name,
type: opts.type,
}, {
rawResponse: false,
})
}
async updateAvatar(telegramPOV, user) {
if (!user.photo) {
return false
}
const photo = user.photo.photo_big
if (this.photo && this.avatarURL &&
this.photo.dc_id === photo.dc_id &&
this.photo.volume_id === photo.volume_id &&
this.photo.local_id === photo.local_id) {
return false
}
const file = await telegramPOV.getFile(photo)
const name = `${photo.volume_id}_${photo.local_id}.${file.extension}`
const uploaded = await this.uploadContent({
stream: Buffer.from(file.bytes),
name,
type: file.mimetype,
})
this.avatarURL = uploaded.content_uri
this.photo = {
dc_id: photo.dc_id,
volume_id: photo.volume_id,
local_id: photo.local_id,
}
await this.intent.setAvatarUrl(this.avatarURL)
await this.save()
return true
}
}
module.exports = TelegramUser