mirror of
https://github.com/mautrix/telegram.git
synced 2026-05-17 07:25:46 +03:00
Start Python rewrite
Initial setup, config parsing and registration generation is included.
This commit is contained in:
@@ -8,5 +8,5 @@ charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{yaml,yml}]
|
||||
[*.{yaml,yml,py}]
|
||||
indent_style = space
|
||||
|
||||
174
.eslintrc.json
174
.eslintrc.json
@@ -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"
|
||||
}
|
||||
}
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,6 +1,11 @@
|
||||
node_modules/
|
||||
.idea/
|
||||
jsdoc/
|
||||
|
||||
.venv
|
||||
pip-selfcheck.json
|
||||
*.pyc
|
||||
__pycache__
|
||||
|
||||
config.yaml
|
||||
registration.yaml
|
||||
*.db
|
||||
*.session
|
||||
|
||||
81
README.md
81
README.md
@@ -1,5 +1,6 @@
|
||||
# mautrix-telegram
|
||||
**Work in progress: Expect bugs, do not use in production.**
|
||||
**This is the python rewrite branch and can not yet be used.**
|
||||
**For a somewhat functional JavaScript version, check the master branch.**
|
||||
|
||||
A Matrix-Telegram puppeting bridge.
|
||||
|
||||
@@ -10,13 +11,17 @@ 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).
|
||||
@@ -48,11 +53,11 @@ does not do this automatically.
|
||||
|
||||
## Features & Roadmap
|
||||
* Matrix → Telegram
|
||||
* [x] Plaintext messages
|
||||
* [x] Formatted messages
|
||||
* [x] Bot commands (!command -> /command)
|
||||
* [x] Mentions
|
||||
* [x] Locations
|
||||
* [ ] Plaintext messages
|
||||
* [ ] Formatted messages
|
||||
* [ ] Bot commands (!command -> /command)
|
||||
* [ ] Mentions
|
||||
* [ ] Locations
|
||||
* [ ] Images
|
||||
* [ ] Files
|
||||
* [ ] Message redactions
|
||||
@@ -61,44 +66,44 @@ does not do this automatically.
|
||||
* [ ] Pinning messages
|
||||
* [ ] Power level
|
||||
* [ ] Membership actions
|
||||
* [x] Inviting
|
||||
* [x] Kicking
|
||||
* [ ] Inviting
|
||||
* [ ] Kicking
|
||||
* [ ] Joining/leaving
|
||||
* [ ] Room metadata changes
|
||||
* [x] Room invites
|
||||
* [ ] Room invites
|
||||
* Telegram → Matrix
|
||||
* [x] Plaintext messages
|
||||
* [x] Formatted messages
|
||||
* [x] Bot commands (/command -> !command)
|
||||
* [x] Mentions
|
||||
* [x] Images
|
||||
* [x] Locations
|
||||
* [ ] Plaintext messages
|
||||
* [ ] Formatted messages
|
||||
* [ ] Bot commands (/command -> !command)
|
||||
* [ ] Mentions
|
||||
* [ ] Images
|
||||
* [ ] Locations
|
||||
* [ ] Stickers (somewhat works through document upload, no preview though)
|
||||
* [x] Audio messages
|
||||
* [ ] Audio messages
|
||||
* [ ] Video messages
|
||||
* [x] Documents
|
||||
* [ ] Documents
|
||||
* [ ] Message deletions
|
||||
* [x] Presence
|
||||
* [x] Typing notifications
|
||||
* [ ] Presence
|
||||
* [ ] Typing notifications
|
||||
* [ ] Pinning messages
|
||||
* [ ] Admin status
|
||||
* [x] Membership actions
|
||||
* [x] Inviting
|
||||
* [x] Kicking
|
||||
* [x] Joining/leaving
|
||||
* [x] Chat metadata changes
|
||||
* [x] Initial chat metadata
|
||||
* [ ] Membership actions
|
||||
* [ ] Inviting
|
||||
* [ ] Kicking
|
||||
* [ ] Joining/leaving
|
||||
* [ ] Chat metadata changes
|
||||
* [ ] 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
|
||||
* [ ] Automatic portal creation for groups/channels at startup
|
||||
* [ ] Automatic portal creation for groups/channels when receiving invite/message
|
||||
* [ ] 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
|
||||
* [ ] Searching for Telegram users using management commands
|
||||
* [ ] Creating new Telegram chats from Matrix
|
||||
* [ ] Creating Telegram chats for existing Matrix rooms
|
||||
* 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
|
||||
* [ ] Properly handle upgrading groups to supergroups
|
||||
* [ ] Allow upgrading group to supergroup from Matrix
|
||||
* [ ] Handle public channel username changes
|
||||
|
||||
@@ -4,25 +4,40 @@ 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
|
||||
|
||||
# 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.
|
||||
# {} is replaced with the user ID of the Telegram user.
|
||||
username_template: "telegram_{}"
|
||||
# Localpart template of room aliases for Telegram portal rooms.
|
||||
# {} is replaced with the name part of the public channel/group invite link ( https://t.me/{} )
|
||||
alias_template: "telegram_{}"
|
||||
# Displayname template for Telegram users.
|
||||
# {} is replaced with the display name of the Telegram user.
|
||||
displayname_template: "{} (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.
|
||||
#
|
||||
@@ -40,19 +55,18 @@ bridge:
|
||||
- fullName
|
||||
- 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
|
||||
|
||||
# Bridge management command configuration
|
||||
# Bridge command configuration
|
||||
commands:
|
||||
# The prefix for all management commands.
|
||||
# Can be removed to disable management commands in rooms with more than two users.
|
||||
# The prefix for commands.
|
||||
# This is only required in rooms with users more than two users.
|
||||
prefix: "!tg"
|
||||
|
||||
# Whether or not to allow management commands in rooms with more than two users.
|
||||
# Non-management commands are allowed in portal rooms even if this is false.
|
||||
allow_in_groups: false
|
||||
|
||||
# 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
|
||||
|
||||
# Whitelist of user IDs that are allowed to use this bridge. Leave empty to disable.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
src/index.js
|
||||
1
mautrix_telegram/__init__.py
Normal file
1
mautrix_telegram/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .config import Config
|
||||
38
mautrix_telegram/__main__.py
Normal file
38
mautrix_telegram/__main__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# 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
|
||||
from . import Config
|
||||
|
||||
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)
|
||||
109
mautrix_telegram/config.py
Normal file
109
mautrix_telegram/config.py
Normal 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_{}").format(".+")
|
||||
alias_format = self.get("bridge.alias_template", "telegram_{}").format(".+")
|
||||
|
||||
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
|
||||
}
|
||||
4638
package-lock.json
generated
4638
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
36
package.json
36
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
17
requirements.txt
Normal file
17
requirements.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
aiohttp==2.3.7
|
||||
async-timeout==2.0.0
|
||||
certifi==2017.11.5
|
||||
chardet==3.0.4
|
||||
idna==2.6
|
||||
matrix-client==0.0.6
|
||||
multidict==3.3.2
|
||||
pkg-resources==0.0.0
|
||||
pyaes==1.6.1
|
||||
pyasn1==0.4.2
|
||||
requests==2.18.4
|
||||
rsa==3.4.2
|
||||
ruamel.yaml==0.15.35
|
||||
SQLAlchemy==1.2.1
|
||||
Telethon==0.16.1.3
|
||||
urllib3==1.22
|
||||
yarl==0.17.0
|
||||
28
setup.py
Normal file
28
setup.py
Normal 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
src/app.js
728
src/app.js
@@ -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
src/commands.js
425
src/commands.js
@@ -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** <_phone_> - Request an authentication code.<br/>
|
||||
**logout** - Log out from Telegram.<br/>
|
||||
**search** [_-r|--remote_] <_query_> - Search your contacts or the Telegram servers for users.<br/>
|
||||
**create** <_group/channel_> [_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** <_id_> - Open a private chat with the given Telegram user ID.
|
||||
|
||||
_**Debug commands**: commands to help in debugging the bridge. Disabled by default._<br/>
|
||||
**api** <_method_> <_args_> - 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
src/formatter.js
360
src/formatter.js
@@ -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
src/index.js
67
src/index.js
@@ -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()
|
||||
@@ -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
src/portal.js
902
src/portal.js
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user