Start Python rewrite

Initial setup, config parsing and registration generation is included.
This commit is contained in:
Tulir Asokan
2018-01-16 23:31:48 +02:00
parent e08db039bf
commit ad6a9ebae3
22 changed files with 274 additions and 8901 deletions

View File

@@ -8,5 +8,5 @@ charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{yaml,yml}]
[*.{yaml,yml,py}]
indent_style = space

View File

@@ -1,174 +0,0 @@
{
"env": {
"node": true,
"es6": true
},
"extends": "airbnb-base",
"parserOptions": {
"sourceType": "module",
"ecmaVersion": 8
},
"plugins": [
"import"
],
"rules": {
"indent": [
"error",
"tab",
{
"FunctionDeclaration": {
"parameters": 2,
"body": 1
},
"FunctionExpression": {
"parameters": 2,
"body": 1
},
"VariableDeclarator": 2,
"CallExpression": {
"arguments": 2
},
"MemberExpression": "off",
"ImportDeclaration": "first"
}
],
"object-curly-newline": [
"error",
{
"consistent": true
}
],
"one-var": [
"error",
{
"initialized": "never",
"uninitialized": "always"
}
],
"one-var-declaration-per-line": [
"error",
"initializations"
],
"quotes": [
"error",
"double"
],
"semi": [
"error",
"never"
],
"comma-dangle": [
"error",
"always-multiline"
],
"max-len": [
"warn",
120
],
"no-unused-vars": [
"error",
{
"vars": "all",
"args": "after-used",
"varsIgnorePattern": "_"
}
],
"space-before-function-paren": [
"error",
{
"anonymous": "never",
"named": "never",
"asyncArrow": "always"
}
],
"func-style": [
"warn",
"declaration",
{
"allowArrowFunctions": true
}
],
"id-length": [
"warn",
{
"max": 25,
"exceptions": [
"i",
"x",
"y",
"$",
"_"
]
}
],
"import/order": [
"warn",
{
"groups": [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index"
],
"newlines-between": "never"
}
],
"arrow-body-style": [
"error",
"as-needed"
],
"complexity": [
"warn",
11
],
"new-cap": [
"warn",
{
"newIsCap": true,
"capIsNew": true,
"capIsNewExceptions": ["MTProto"]
}
],
"no-empty": [
"error",
{
"allowEmptyCatch": true
}
],
"no-cond-assign": [
"error",
"except-parens"
],
"function-paren-newline": "off",
"no-labels": "off",
"no-control-regex": "off",
"no-void": "off",
"func-names": "off",
"no-continue": "off",
"default-case": "off",
"no-plusplus": "off",
"no-use-before-define": "off",
"no-restricted-syntax": "off",
"no-return-assign": "off",
"no-param-reassign": "off",
"arrow-parens": "off",
"no-nested-ternary": "off",
"no-new": "off",
"no-tabs": "off",
"no-prototype-builtins": "off",
"no-console": "off",
"class-methods-use-this": "off",
"prefer-destructuring": "off",
"camelcase": "off",
"spaced-comment": "off",
"no-bitwise": "off",
"no-case-declarations": "off",
"no-template-curly-in-string": "off",
"no-await-in-loop": "off",
"no-restricted-globals": "off",
"no-fallthrough": "off",
"no-underscore-dangle": "off"
}
}

9
.gitignore vendored
View File

@@ -1,6 +1,11 @@
node_modules/
.idea/
jsdoc/
.venv
pip-selfcheck.json
*.pyc
__pycache__
config.yaml
registration.yaml
*.db
*.session

View File

@@ -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

View File

@@ -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.

View File

@@ -1 +0,0 @@
src/index.js

View File

@@ -0,0 +1 @@
from .config import Config

View 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
View File

@@ -0,0 +1,109 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import ruamel.yaml
import random
import string
yaml = ruamel.yaml.YAML()
class DictWithRecursion:
def __init__(self, data={}):
self._data = data
def _recursive_get(self, data, key, default_value):
if '.' in key:
key, next_key = key.split('.', 1)
next_data = data.get(key, {})
return self._recursive_get(next_data, next_key, default_value)
return data.get(key, default_value)
def get(self, key, default_value, allow_recursion=True):
if allow_recursion and '.' in key:
return self._recursive_get(self._data, key, default_value)
return self._data.get(key, default_value)
def __getitem__(self, key):
return self.get(key, None)
def _recursive_set(self, data, key, value):
if '.' in key:
key, next_key = key.split('.', 1)
if key not in data:
data[key] = {}
next_data = data.get(key, {})
self._recursive_set(next_data, next_key, value)
return
data[key] = value
def set(self, key, value, allow_recursion=True):
if allow_recursion and '.' in key:
self._recursive_set(self._data, key, value)
return
self._data[key] = value
def __setitem__(self, key, value):
self.set(key, value)
class Config(DictWithRecursion):
def __init__(self, path, registration_path):
super().__init__()
self.path = path
self.registration_path = registration_path
self._registration = None
def load(self):
with open(self.path, 'r') as stream:
self._data = yaml.load(stream)
def save(self):
with open(self.path, 'w') as stream:
yaml.dump(self._data, stream)
if self._registration and self.registration_path:
with open(self.registration_path, 'w') as stream:
yaml.dump(self._registration, stream)
def _new_token(self):
return "".join(random.choices(string.ascii_lowercase + string.digits, k=64))
def generate_registration(self):
homeserver = self["homeserver.domain"]
username_format = self.get("bridge.username_template", "telegram_{}").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

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +0,0 @@
{
"name": "mautrix-telegram",
"version": "0.1.0",
"description": "A Matrix-Telegram puppeting bridge",
"author": "Tulir Asokan <tulir@maunium.net>",
"license": "GPL-3.0",
"main": "src/index.js",
"repository": {
"type": "git",
"url": "https://github.com/tulir/mautrix-telegram.git"
},
"dependencies": {
"chalk": "^2.3.0",
"colors": "1.1.x",
"commander": "2.12.x",
"escape-html": "1.0.x",
"file-type": "7.4.x",
"marked": "0.3.x",
"matrix-appservice-bridge": "1.x.x",
"matrix-js-sdk": "0.x.x",
"md5": "2.2.x",
"sanitize-html": "1.16.x",
"string-similarity": "1.2.x",
"telegram-mtproto": "3.2.7",
"yamljs": "0.3.x"
},
"devDependencies": {
"eslint": "4.15.x",
"eslint-config-airbnb-base": "12.1.x",
"eslint-plugin-import": "2.8.x",
"jsdoc": "3.5.x"
},
"scripts": {
"gen-jsdoc": "./node_modules/.bin/jsdoc src/ --recurse --package package.json --readme README.md --destination jsdoc"
}
}

17
requirements.txt Normal file
View 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
View File

@@ -0,0 +1,28 @@
import setuptools
setuptools.setup(
name="mautrix_telegram",
version="0.1.0",
url="https://github.com/tulir/mautrix-telegram",
author="Tulir Asokan",
author_email="tulir@maunium.net",
description="A Matrix-Telegram puppeting bridge.",
long_description=open("README.md").read(),
packages=setuptools.find_packages(),
install_requires=["telethon", "matrix-client", "sqlalchemy"],
classifiers=[
"Development Status :: 2 - Pre-Alpha",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.6",
],
entry_points="""
[console_scripts]
mautrix-telegram=mautrix_telegram.__main__:main
""",
)

View File

@@ -1,728 +0,0 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge
// Copyright (C) 2017 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
const { Bridge } = require("matrix-appservice-bridge")
const escapeHTML = require("escape-html")
const sanitizeHTML = require("sanitize-html")
const marked = require("marked")
const commands = require("./commands")
const MatrixUser = require("./matrix-user")
const TelegramUser = require("./telegram-user")
const TelegramPeer = require("./telegram-peer")
const Portal = require("./portal")
const chalk = require("chalk")
/**
* The base class for the bridge.
*/
class MautrixTelegram {
/**
* Create a MautrixTelegram instance with the given config data.
*
* @param config The data from the config file.
*/
constructor(config) {
/**
* The app config.
* @type {Object}
*/
this.config = config
/**
* A special-cased {@link TelegramUser} that is used to send broadcasts to a channel.
* @type {TelegramUser}
*/
this.channelTelegramSender = new TelegramUser(this, -1)
/**
* MXID -> {@link MatrixUser} cache.
* @private
* @type {Map<string, MatrixUser>}
*/
this.matrixUsersByID = new Map()
/**
* Telegram ID -> {@link MatrixUser} cache.
* @priavte
* @type {Map<number, MatrixUser>}
*/
this.matrixUsersByTelegramID = new Map()
/**
* Telegram ID -> {@link TelegramUser} cache.
* @private
* @type {Map<number, TelegramUser>}
*/
this.telegramUsersByID = new Map()
/**
* Telegram peer ID -> {@link Portal} cache.
* @private
* @type {Map<number, Portal>}
*/
this.portalsByPeerID = new Map()
/**
* Matrix room ID -> {@link Portal} cache.
* @private
* @type {Map<string, Portal>}
*/
this.portalsByRoomID = new Map()
/**
* List of management rooms.
* @type {string[]}
*/
this.managementRooms = []
/**
* A regular expression that matches MXIDs of Telegram user bridged by this bridge.
* @type {RegExp}
*/
this.usernameRegex = new RegExp(
`^@${
this.config.bridge.username_template.replace("${ID}", "([0-9]+)")
}:${
this.config.homeserver.domain
}$`)
const self = this
/**
* The matrix-appservice-bridge Bridge instance.
* @private
* @type {Bridge}
*/
this.bridge = new Bridge({
homeserverUrl: config.homeserver.address,
domain: config.homeserver.domain,
registration: config.appservice.registration,
controller: {
onUserQuery(/*user*/) {
return {}
},
onLog: msg => self.debug("blue", msg),
async onEvent(request/*, context*/) {
try {
await self.handleMatrixEvent(request.getData())
} catch (err) {
console.error("Matrix event handling failed:", err)
console.error(err.stack)
}
},
},
})
}
debug(color, ...message) {
if (this.config.appservice.debug) {
if (!chalk[color]) {
message.unshift(`[Invalid color: ${color}]`)
color = "bgRed"
}
console.log(chalk[color](...message))
}
}
debugErr(color, ...message) {
if (this.config.appservice.debug) {
if (!chalk[color]) {
message.unshift(`[Invalid color: ${color}]`)
color = "bgRed"
}
console.error(chalk[color](...message))
}
}
info(...message) {
console.log(...message)
}
warn(...message) {
console.error(chalk.yellow(...message))
}
/**
* Start the bridge.
*/
async run() {
this.info("Appservice listening on port %s", this.config.appservice.port)
await this.bridge.run(this.config.appservice.port, {})
// Load all Matrix users to cache
const userEntries = await this.bridge.getUserStore().select({ type: "matrix" })
for (const entry of userEntries) {
const user = MatrixUser.fromEntry(this, entry)
this.matrixUsersByID.set(entry.id, user)
if (user.telegramUserID) {
this.matrixUsersByTelegramID.set(user.telegramUserID, user)
}
}
}
/**
* The {@link MatrixClient} object for the appservice bot.
*/
get bot() {
return this.bridge.getBot()
}
/**
* The {@link Intent} object for the appservice bot.
*/
get botIntent() {
return this.bridge.getIntent()
}
/**
* Get the {@link Intent} for the Telegram user with the given ID.
*
* This does not care if a {@link TelegramUser} object for the user ID exists.
* It simply returns an intent for a Matrix puppet user with the correct MXID.
*
* @param {number} id The ID of the Telegram user.
* @returns {Intent} The Matrix puppet intent for the given Telegram user.
*/
getIntentForTelegramUser(id) {
if (id === -1) {
return this.botIntent
}
return this.bridge.getIntentFromLocalpart(this.getUsernameForTelegramUser(id))
}
/**
* Get the Matrix username localpart for the Telegram user with the given ID.
*
* @param {number} id The ID of the Telegram user.
* @returns {string} The Matrix username localpart for the given Telegram user.
*/
getUsernameForTelegramUser(id) {
return this.config.bridge.username_template.replace("${ID}", id)
}
/**
* Get the full Matrix ID ({@code @localpart:homeserver.tld}) for the Telegram user with the given ID.
*
* @param {number} id The ID of the Telegram user.
* @returns {string} The full Matrix ID for the given Telegram user.
*/
getMXIDForTelegramUser(id) {
return `@${this.getUsernameForTelegramUser(id)}:${this.config.homeserver.domain}`
}
/**
* Get the matrix.to link for the Matrix puppet of the Telegram user with the given ID.
*
* @param {number} id The ID of the Telegram user.
* @returns {string} A matrix.to link that points to the Matrix puppet of the given user.
*/
getMatrixToLinkForTelegramUser(id) {
return `https://matrix.to/#/${this.getMXIDForTelegramUser(id)}`
}
/**
* Get a {@link Portal} by Telegram peer or peer ID.
*
* This will either get the room from the room cache or the bridge room database.
* If the room is not found, a new {@link Portal} object is created.
*
* You may set the {@code opts.createIfNotFound} parameter to change whether or not to create the Portal
* automatically. However, if the peer is just the ID, a new room will not be created in any case.
*
* @param {TelegramPeer|number} peer The TelegramPeer object OR the ID of the peer whose portal to get.
* If only a peer ID is given, it is assumed that the peer is a chat or a
* channel. Searching for user peers requires the receiver ID, thus here it
* requires the full TelegramPeer object.
* @param {object} [opts] Additional options.
* @param {boolean} opts.createIfNotFound Whether or not to create the room if it is not found
* @returns {Portal} The Portal object.
*/
async getPortalByPeer(peer, { createIfNotFound = true } = {}) {
if (typeof peer === "number") {
peer = {
id: peer,
}
createIfNotFound = false
} else if (!(peer instanceof TelegramPeer)) {
throw new Error("Invalid argument: peer is not a number or a TelegramPeer.")
}
let portal = this.portalsByPeerID.get(peer.id)
if (portal) {
return portal
}
const query = {
type: "portal",
id: peer.id,
}
if (peer.type === "user") {
query.receiverID = peer.receiverID
}
const entries = await this.bridge.getRoomStore().select(query)
// Handle possible db query race conditions
portal = this.portalsByPeerID.get(peer.id)
if (portal) {
return portal
}
if (entries.length) {
portal = Portal.fromEntry(this, entries[0])
} else if (createIfNotFound) {
portal = new Portal(this, undefined, peer)
} else {
return undefined
}
this.portalsByPeerID.set(peer.id, portal)
if (portal.roomID) {
this.portalsByRoomID.set(portal.roomID, portal)
}
return portal
}
/**
* Get a {@link Portal} by Matrix room ID.
*
* This will either get the room from the room cache or the bridge room database.
* If the room is not found, this function WILL NOT create a new room,
* but rather just return {@code undefined}.
*
* @param {string} id The Matrix room ID of the portal to get.
* @returns {Portal} The Portal object.
*/
async getPortalByRoomID(id) {
let portal = this.portalsByRoomID.get(id)
if (portal) {
return portal
}
// Check if we have it stored in the by-peer map
// FIXME this is probably useless
for (const [_, portalByPeer] of this.portalsByPeerID) {
if (portalByPeer.roomID === id) {
this.portalsByRoomID.set(id, portalByPeer)
return portalByPeer
}
}
const entries = await this.bridge.getRoomStore().select({
type: "portal",
roomID: id,
})
// Handle possible db query race conditions
portal = this.portalsByRoomID.get(id)
if (portal) {
return portal
}
if (entries.length) {
portal = Portal.fromEntry(this, entries[0])
} else {
// Don't create portals based on room ID
return undefined
}
this.portalsByPeerID.set(portal.id, portal)
this.portalsByRoomID.set(id, portal)
return portal
}
/**
* Get a {@link TelegramUser} by ID.
*
* This will either get the user from the user cache or the bridge user database.
* If the user is not found, a new {@link TelegramUser} instance is created.
*
* @param {number} id The internal Telegram ID of the user to get.
* @returns {TelegramUser} The TelegramUser object.
*/
async getTelegramUser(id, { createIfNotFound = true } = {}) {
if (id === -1) {
return this.channelTelegramSender
}
// TODO remove this after bugs are fixed
if (isNaN(parseInt(id, 10))) {
const err = new Error("Fatal: non-int Telegram user ID")
console.error(err.stack)
throw err
}
let user = this.telegramUsersByID.get(id)
if (user) {
return user
}
const entries = await this.bridge.getUserStore().select({
type: "remote",
id,
})
// Handle possible db query race conditions
if (this.telegramUsersByID.has(id)) {
return this.telegramUsersByID.get(id)
}
if (entries.length) {
user = TelegramUser.fromEntry(this, entries[0])
} else if (createIfNotFound) {
user = new TelegramUser(this, id)
} else {
return undefined
}
this.telegramUsersByID.set(id, user)
return user
}
/**
* Get a {@link MatrixUser} by Telegram user ID.
*
* This will either get the user from the user cache or the bridge user database.
*
* @param {number} id The Telegram user ID of the Matrix user to get.
* @returns {MatrixUser} The MatrixUser object.
*/
async getMatrixUserByTelegramID(id) {
let user = this.matrixUsersByTelegramID.get(id)
if (user) {
return user
}
// Check if we have the user stored in the by- map
// FIXME this should be made useless by making sure we always add to the second map when appropriate
for (const [_, userByMXID] of this.matrixUsersByID) {
if (userByMXID.telegramUserID === id) {
this.matrixUsersByTelegramID.set(id, userByMXID)
return userByMXID
}
}
const entries = this.bridge.getUserStore().select({
type: "matrix",
telegramID: id,
})
// Handle possible db query race conditions
if (this.matrixUsersByTelegramID.has(id)) {
return this.matrixUsersByTelegramID.get(id)
}
if (entries.length) {
user = MatrixUser.fromEntry(this, entries[0])
} else {
return undefined
}
this.matrixUsersByID.set(user.userID, user)
this.matrixUsersByTelegramID.set(id, user)
return user
}
/**
* Get a {@link MatrixUser} by ID.
*
* This will either get the user from the user cache or the bridge user database.
* If the user is not found, a new {@link MatrixUser} instance is created.
*
* @param {string} id The MXID of the Matrix user to get.
* @returns {MatrixUser} The MatrixUser object.
*/
async getMatrixUser(id, { createIfNotFound = true } = {}) {
let user = this.matrixUsersByID.get(id)
if (user) {
return user
}
const entries = this.bridge.getUserStore().select({
type: "matrix",
id,
})
// Handle possible db query race conditions
if (this.matrixUsersByID.has(id)) {
return this.matrixUsersByID.get(id)
}
if (entries.length) {
user = MatrixUser.fromEntry(this, entries[0])
} else if (createIfNotFound) {
user = new MatrixUser(this, id)
} else {
return undefined
}
this.matrixUsersByID.set(id, user)
if (user.telegramUserID) {
this.matrixUsersByID.set(user.telegramUserID, user)
}
return user
}
/**
* Save a user to the bridge user database.
*
* @param {MatrixUser|TelegramUser} user The user object to save.
*/
putUser(user) {
const entry = user.toEntry()
return this.bridge.getUserStore()
.upsert({
type: entry.type,
id: entry.id,
}, entry)
}
/**
* Save a room to the bridge room database.
*
* @param {Room} room The Room object to save.
*/
putRoom(room) {
const entry = room.toEntry()
return this.bridge.getRoomStore()
.upsert({
type: entry.type,
id: entry.id,
}, entry)
}
/**
* Get the members in the given room.
*
* @param {string} roomID The ID of the room to search.
* @param {Intent} [intent] The Intent object to use when reading the room state.
* Uses {@link #botIntent} by default.
* @returns {string[]} The list of MXIDs who are in the room.
*/
async getRoomMembers(roomID, intent = this.botIntent) {
const roomState = await intent.roomState(roomID)
const members = []
for (const event of roomState) {
if (event.type === "m.room.member" && event.membership === "join") {
members.push(event.user_id)
}
}
return members
}
async getRoomTitle(roomID, intent = this.botIntent) {
const roomState = await intent.roomState(roomID)
for (const event of roomState) {
if (event.type === "m.room.name") {
return event.content.name
}
}
return undefined
}
async handlePart(sender, evt) {
// TODO handle kicking real Matrix users who have logged in with Telegram?
const capture = this.usernameRegex.exec(evt.state_key)
if (!capture) {
return
}
const telegramID = +capture[1]
if (!telegramID || isNaN(telegramID)) {
return
}
const user = await this.getTelegramUser(telegramID)
const portal = await this.getPortalByRoomID(evt.room_id)
if (!portal) {
return
}
await portal.kickTelegram(sender.telegramPuppet, user)
}
/**
* Handle an invite to a Matrix room.
*
* @param {MatrixUser} sender The user who sent this invite.
* @param {MatrixEvent} evt The invite event.
*/
async handleInvite(sender, evt) {
const asBotID = this.bridge.getBot().getUserId()
if (evt.state_key === asBotID) {
// Accept all AS bot invites.
try {
await this.botIntent.join(evt.room_id)
} catch (err) {
console.error(`Failed to join room ${evt.room_id}:`, err)
if (err instanceof Error) {
console.error(err.stack)
}
}
return
}
if (evt.sender === asBotID || evt.sender === evt.state_key) {
return
}
// Check if the invited user is a Telegram user.
const capture = this.usernameRegex.exec(evt.state_key)
if (!capture) {
return
}
const telegramID = +capture[1]
if (!telegramID || isNaN(telegramID)) {
return
}
const intent = this.getIntentForTelegramUser(telegramID)
try {
await intent.join(evt.room_id)
const members = await this.getRoomMembers(evt.room_id, intent)
const user = await this.getTelegramUser(telegramID)
if (members.length < 2) {
console.warn(`No members in room ${evt.room_id}`)
await intent.leave(evt.room_id)
} else if (members.length === 2) {
const peer = user.toPeer(sender.telegramPuppet)
const portal = await this.getPortalByPeer(peer)
if (portal.roomID) {
await intent.sendMessage(evt.room_id, {
msgtype: "m.notice",
body: "You already have a private chat room with me!\nI'll re-invite you to that room.",
})
try {
await intent.invite(portal.roomID, sender.userID)
} catch (_) {}
await intent.leave(evt.room_id)
} else {
portal.roomID = evt.room_id
await portal.save()
await intent.sendMessage(portal.roomID, {
msgtype: "m.notice",
body: "Portal to Telegram private chat created.",
})
await user.updateInfo(sender.telegramPuppet, undefined, { updateAvatar: true })
}
} else if (!members.includes(asBotID)) {
await intent.sendMessage(evt.room_id, {
msgtype: "m.notice",
body: "Inviting additional Telegram users to private chats or non-portal rooms is not supported.",
})
await intent.leave(evt.room_id)
} else {
const portal = await this.getPortalByRoomID(evt.room_id)
if (portal) {
await portal.inviteTelegram(sender.telegramPuppet, user)
}
}
} catch (err) {
console.error(`Failed to process invite to room ${evt.room_id} for Telegram user ${telegramID}: ${err}`)
if (err instanceof Error) {
console.error(err.stack)
}
}
}
/**
* Handle a single received Matrix event.
*
* @param {MatrixEvent} evt The Matrix event that occurred.
*/
async handleMatrixEvent(evt) {
const user = await this.getMatrixUser(evt.sender)
if (!user.whitelisted) {
return
}
const asBotID = this.bridge.getBot().getUserId()
if (evt.type === "m.room.member") {
if (evt.content.membership === "invite") {
await this.handleInvite(user, evt)
return
} else if (evt.content.membership === "leave") {
await this.handlePart(user, evt)
return
}
}
if (evt.sender === asBotID || evt.type !== "m.room.message" || !evt.content) {
// Ignore own messages and non-message events.
return
}
const cmdprefix = this.config.bridge.commands.prefix
const hasCommandPrefix = cmdprefix && evt.content.body.startsWith(`${cmdprefix} `)
const portal = await this.getPortalByRoomID(evt.room_id)
if (portal && !hasCommandPrefix) {
portal.handleMatrixEvent(user, evt)
return
}
let isManagement = this.managementRooms.includes(evt.room_id) || hasCommandPrefix
if (!isManagement) {
const roomMembers = await this.getRoomMembers(evt.room_id)
if (roomMembers.length === 2 && roomMembers.includes(asBotID)) {
this.managementRooms.push(evt.room_id)
isManagement = true
}
}
if (isManagement) {
const prefixLength = cmdprefix.length + 1
if (cmdprefix && evt.content.body.startsWith(`${cmdprefix} `)) {
evt.content.body = evt.content.body.substr(prefixLength)
}
const args = evt.content.body.split(" ")
const command = args.shift()
const replyFunc = (reply, { allowHTML = false, markdown = true } = {}) => {
reply = reply.replace("$cmdprefix", cmdprefix || "")
if (!markdown && !allowHTML) {
reply = escapeHTML(reply)
}
if (markdown) {
reply = marked(reply, {
sanitize: !allowHTML,
})
}
this.botIntent.sendMessage(
evt.room_id, {
body: sanitizeHTML(reply),
formatted_body: reply,
msgtype: "m.notice",
format: "org.matrix.custom.html",
})
}
commands.run(user, command, args, replyFunc, {
app: this,
evt,
roomID: evt.room_id,
isManagement,
isPortal: !!portal,
})
}
}
/**
* Check whether the given user ID is allowed to use this bridge.
*
* @param {string} userID The full Matrix ID to check (@user:homeserver.tld)
* @returns {boolean} Whether or not the user should be allowed to use the bridge.
*/
checkWhitelist(userID) {
if (!this.config.bridge.whitelist || this.config.bridge.whitelist.length === 0) {
return true
}
userID = userID.toLowerCase()
const userIDCapture = /@.+:(.+)/.exec(userID)
const homeserver = userIDCapture && userIDCapture.length > 1 ? userIDCapture[1] : undefined
for (let whitelisted of this.config.bridge.whitelist) {
whitelisted = whitelisted.toLowerCase()
if (whitelisted === userID || (homeserver && whitelisted === homeserver)) {
return true
}
}
return false
}
}
module.exports = MautrixTelegram

View File

@@ -1,425 +0,0 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge
// Copyright (C) 2017 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
const makePasswordHash = require("telegram-mtproto").plugins.makePasswordHash
const Portal = require("./portal")
const commands = {}
/**
* Module containing all management commands.
*
* @module commands
*/
/**
* Run management command.
*
* @param {string} sender The MXID of the user who sent the command.
* @param {string} command The command itself.
* @param {Array<string>} args A list of arguments.
* @param {function} reply A function that is called to reply to the command.
* @param {object} extra Extra information that the handlers may find useful.
* @param {MautrixTelegram} extra.app The app main class instance.
* @param {MatrixEvent} extra.evt The event that caused this call.
* @param {string} extra.roomID The ID of the Matrix room the command was sent to.
* @param {boolean} extra.isManagement Whether or not the Matrix room is a management room.
* @param {boolean} extra.isPortal Whether or not the Matrix room is a portal to a Telegram chat.
*/
function run(sender, command, args, reply, extra) {
const commandFunc = this.commands[command]
if (!commandFunc) {
if (sender.commandStatus) {
if (command === "cancel") {
reply(`${sender.commandStatus.action} cancelled.`)
sender.commandStatus = undefined
return undefined
}
args.unshift(command)
return sender.commandStatus.next(sender, args, reply, extra)
}
reply("Unknown command. Try `$cmdprefix help` for help.")
return undefined
}
try {
return commandFunc(sender, args, reply, extra)
} catch (err) {
reply(`Error running command: ${err}.`)
if (err instanceof Error) {
reply(["```", err.stack, "```"].join(""))
console.error(err.stack)
}
}
return undefined
}
commands.cancel = () => "Nothing to cancel."
commands.help = (sender, args, reply, { isManagement, isPortal }) => {
let replyMsg = ""
if (isManagement) {
replyMsg += "This is a management room: prefixing commands with `$cmdprefix` is not required.\n"
} else if (isPortal) {
replyMsg += "**This is a portal room**: you must always prefix commands with `$cmdprefix`.\n" +
"Management commands will not be sent to Telegram.\n"
} else {
replyMsg += "**This is not a management room**: you must prefix commands with `$cmdprefix`.\n"
}
replyMsg += `
_**Generic bridge commands**: commands for using the bridge that aren't related to Telegram._<br/>
**help** - Show this help message.<br/>
**cancel** - Cancel an ongoing action (such as login).<br/>
**setManagement** - Mark the room as a management room.<br/>
**unsetManagement** - Undo management room marking.
_**Telegram actions**: commands for using the bridge to interact with Telegram._<br/>
**login** &lt;_phone_&gt; - Request an authentication code.<br/>
**logout** - Log out from Telegram.<br/>
**search** [_-r|--remote_] &lt;_query_&gt; - Search your contacts or the Telegram servers for users.<br/>
**create** &lt;_group/channel_&gt; [_room ID_] - Create a Telegram chat of the given type for a Matrix room.
If the room ID is not specified, a chat for the current room is created.<br/>
**upgrade** - Upgrade a normal Telegram group to a supergroup.
_**Temporary commands**: commands that will be replaced with more Matrix-y actions later._<br/>
**pm** &lt;_id_&gt; - Open a private chat with the given Telegram user ID.
_**Debug commands**: commands to help in debugging the bridge. Disabled by default._<br/>
**api** &lt;_method_&gt; &lt;_args_&gt; - Call a Telegram API method. Args is always a single JSON object.
`
reply(replyMsg, { allowHTML: true })
}
commands.setManagement = (sender, _, reply, { app, roomID, isPortal }) => {
if (isPortal) {
reply("You may not mark portal rooms as management rooms.")
return
}
app.managementRooms.push(roomID)
reply("Room marked as management room. You can now run commands without the `$cmdprefix` prefix.")
}
commands.unsetManagement = (sender, _, reply, { app, roomID }) => {
app.managementRooms.splice(app.managementRooms.indexOf(roomID), 1)
reply("Room unmarked as management room. You must now include the `$cmdprefix` prefix when running commands.")
}
/////////////////////////////
// Authentication handlers //
/////////////////////////////
/**
* Two-factor authentication handler.
*/
commands.enterPassword = async (sender, args, reply, { isManagement }) => {
if (!isManagement) {
reply("Logging in is considered a confidential action, and thus is only allowed in management rooms.")
return
} else if (args.length === 0) {
reply("**Usage:** `$cmdprefix <password> [salt]`")
return
}
let salt
if (!sender.commandStatus || !sender.commandStatus.salt) {
if (args.length > 1) {
salt = args[1]
} else {
reply("No password salt found. Did you enter your phone code already?")
return
}
} else {
salt = sender.commandStatus.salt
}
const hash = makePasswordHash(salt, args[0])
try {
await sender.checkPassword(hash)
reply(`Logged in successfully as ${sender.telegramPuppet.getDisplayName()}.`)
sender.commandStatus = undefined
} catch (err) {
reply(`**Login failed:** ${err}`)
if (err instanceof Error) {
reply(["```", err.stack, "```"].join(""))
console.error(err.stack)
}
}
}
/*
* Login code send handler.
*/
commands.enterCode = async (sender, args, reply, { isManagement }) => {
if (!isManagement) {
reply("Logging in is considered a confidential action, and thus is only allowed in management rooms.")
return
} else if (args.length === 0) {
reply("**Usage:** `$cmdprefix <authentication code>`")
return
}
try {
const data = await sender.signInToTelegram(args[0])
if (data.status === "ok") {
reply(`Logged in successfully as ${sender.telegramPuppet.getDisplayName()}.`)
sender.commandStatus = undefined
} else if (data.status === "need-password") {
reply(`You have two-factor authentication enabled. Password hint: ${data.hint}
Enter your password using \`$cmdprefix <password>\``)
sender.commandStatus = {
action: "Two-factor authentication",
next: commands.enterPassword,
salt: data.salt,
}
} else {
reply(`Unexpected sign in response, status=${data.status}`)
}
} catch (err) {
reply(`**Login failed:** ${err}`)
if (err instanceof Error) {
reply(["```", err.stack, "```"].join(""))
console.error(err.stack)
}
}
}
/*
* Login code request handler.
*/
commands.login = async (sender, args, reply, { isManagement }) => {
if (!isManagement) {
reply("Logging in is considered a confidential action, and thus is only allowed in management rooms.")
return
} else if (args.length === 0) {
reply("**Usage:** `$cmdprefix login <phone number>`")
return
}
try {
/*const data = */
await sender.sendTelegramCode(args[0])
reply(`Login code sent to ${args[0]}.\nEnter the code using \`$cmdprefix <code>\``)
sender.commandStatus = {
action: "Phone code authentication",
next: commands.enterCode,
}
} catch (err) {
reply(`**Failed to send code:** ${err}`)
if (err instanceof Error) {
reply(["```", err.stack, "```"].join(""))
console.error(err.stack)
}
}
}
commands.register = async (sender, args, reply) => {
reply("Registration has not yet been implemented. Please use the official apps for now.")
}
commands.logout = async (sender, args, reply) => {
try {
sender.logOutFromTelegram()
reply("Logged out successfully.")
} catch (err) {
reply(`**Failed to log out:** ${err}`)
if (err instanceof Error) {
reply(["```", err.stack, "```"].join(""))
console.error(err.stack)
}
}
}
//////////////////////////////
// General command handlers //
//////////////////////////////
commands.create = async (sender, args, reply, { app, roomID }) => {
if (args.length < 1 || (args[0] !== "group" && args[0] !== "channel")) {
reply("**Usage:** `$cmdprefix create <group/channel>`")
return
} else if (!sender._telegramPuppet) {
reply("This command requires you to be logged in.")
return
} else if (args[0] === "channel") {
reply("Creating channels is not yet supported.")
return
}
if (args.length > 1) {
roomID = args[1]
}
// TODO make sure that the AS bot is in the room.
const title = await app.getRoomTitle(roomID)
if (!title) {
reply("Please set a room name before creating a Telegram chat.")
return
}
let portal = await app.getPortalByRoomID(roomID)
if (portal) {
reply("This is already a portal room.")
return
}
portal = new Portal(app, roomID)
try {
await portal.createTelegramChat(sender.telegramPuppet, title)
reply(`Telegram chat created. ID: ${portal.id}`)
if (app.managementRooms.includes(roomID)) {
app.managementRooms.splice(app.managementRooms.indexOf(roomID), 1)
}
} catch (err) {
reply(`Failed to create Telegram chat: ${err}`)
}
}
commands.upgrade = async (sender, args, reply, { app, roomID }) => {
if (!sender._telegramPuppet) {
reply("This command requires you to be logged in.")
return
}
const portal = await app.getPortalByRoomID(roomID)
if (!portal) {
reply("This is not a portal room.")
return
}
await portal.upgradeTelegramChat(sender.telegramPuppet)
}
commands.search = async (sender, args, reply, { app }) => {
if (args.length < 1) {
reply("**Usage:** `$cmdprefix search [-r|--remote] <query>`")
return
} else if (!sender._telegramPuppet) {
reply("This command requires you to be logged in.")
return
}
const msg = []
if (args[0] !== "-r" && args[0] !== "--remote") {
const contactResults = await sender.searchContacts(args.join(" "))
if (contactResults.length > 0) {
msg.push("**Following results found from local contacts:**")
msg.push("")
for (const { match, contact } of contactResults) {
msg.push(`- <a href="${
app.getMatrixToLinkForTelegramUser(contact.id)}">${contact.getDisplayName()}</a>: ${contact.id} (${match}% match)`)
}
msg.push("")
msg.push("To force searching from Telegram servers, add `-r` before the search query.")
reply(msg.join("\n"), { allowHTML: true })
return
}
} else {
args.shift()
msg.push("-r flag found: forcing remote search")
msg.push("")
}
const query = args.join(" ")
if (query.length < 5) {
reply("Failed to search server: Query is too short.")
return
}
const telegramResults = await sender.searchTelegram(query)
if (telegramResults.length > 0) {
msg.push("**Following results received from Telegram server:**")
for (const user of telegramResults) {
msg.push(`- <a href="${
app.getMatrixToLinkForTelegramUser(user.id)}">${user.getDisplayName()}</a>: ${user.id}`)
}
} else {
msg.push("**No users found.**")
}
reply(msg.join("\n"), { allowHTML: true })
}
commands.pm = async (sender, args, reply, { app }) => {
if (args.length < 1) {
reply("**Usage:** `$cmdprefix pm <id>`")
return
} else if (!sender._telegramPuppet) {
reply("This command requires you to be logged in.")
return
}
const user = await app.getTelegramUser(+args[0], { createIfNotFound: false })
if (!user) {
reply("User info not saved. Try searching for the user first?")
return
}
const peer = user.toPeer(sender.telegramPuppet)
const userInfo = await peer.getInfo(sender.telegramPuppet)
await user.updateInfo(sender.telegramPuppet, userInfo)
const portal = await app.getPortalByPeer(peer)
await portal.createMatrixRoom(sender.telegramPuppet, {
invite: [sender.userID],
})
}
////////////////////////////
// Debug command handlers //
////////////////////////////
commands.api = async (sender, args, reply, { app }) => {
if (!app.config.bridge.commands.allow_direct_api_calls) {
reply("Direct API calls are forbidden on this mautrix-telegram instance.")
return
}
const apiMethod = args.shift()
let apiArgs
try {
apiArgs = JSON.parse(args.join(" "))
} catch (err) {
reply("Invalid API method parameters. Usage: $cmdprefix api <method> <json data>")
return
}
try {
reply(`Calling ${apiMethod} with the following arguments:\n${JSON.stringify(apiArgs, "", " ")}`)
const response = await sender.telegramPuppet.client(apiMethod, apiArgs)
reply(`API call successful. Response:
<pre><code class="language-json">
${JSON.stringify(response, "", " ")}
</code></pre>`, { allowHTML: true })
} catch (err) {
reply(`API call errored. Response:\n${JSON.stringify(err, "", " ")}`)
}
}
function timeout(promise, ms = 2500) {
return new Promise((resolve, reject) => {
promise.then(resolve, reject)
setTimeout(() => reject(new Error("API call response not received")), ms)
})
}
commands.ping = async (sender, args, reply) => {
try {
await timeout(sender.telegramPuppet.client("contacts.getContacts", {}))
reply("Connection seems OK.")
} catch (err) {
reply(`Not connected: ${err}`)
}
}
module.exports = {
commands,
run,
}

View File

@@ -1,360 +0,0 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge
// Copyright (C) 2017 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
/**
* Utility functions to convert between Telegram and Matrix (HTML) formatting.
* <br><br>
* <b>WARNING: This module contains headache-causing regular expressions and other duct tape.</b>
*
* @module formatter
*/
String.prototype.insert = function(at, str) {
return this.slice(0, at) + str + this.slice(at)
}
/**
* Add a simple HTML tag to the given tag list.
*
* @param {Object[]} tags The tag list.
* @param {Object} entity The Telegram format entity.
* @param {number} entity.offset The index where the format entity starts.
* @param {number} entity.length The length of the format entity.
* @param {string} tag The HTML tag to add.
* @param {number} priority The tag priority to use when sorting tags at the same index.
* @private
*/
function addSimpleTag(tags, entity, tag, priority = 0) {
tags.push([entity.offset, `<${tag}>`, -priority])
tags.push([entity.offset + entity.length, `</${tag}>`, priority])
}
/**
* Add a HTML tag to the given tag list.
*
* @param {Object[]} tags The tag list.
* @param {Object} entity The Telegram format entity.
* @param {number} entity.offset The index where the format entity starts.
* @param {number} entity.length The length of the format entity.
* @param {string} tag The HTML tag to add.
* @param {string} attrs The HTML attributes to add to the tag.
* @param {number} priority The tag priority to use when sorting tags at the same index.
* @private
*/
function addTag(tags, entity, tag, attrs, priority = 0) {
tags.push([entity.offset, `<${tag} ${attrs}>`, -priority])
tags.push([entity.offset + entity.length, `</${tag}>`, priority])
}
/**
* Convert a Telegram entity-formatted message to a Matrix HTML-formatted message.
* <br><br>
* <b>WARNING: I am not responsible for possible severe headaches caused by reading any part of this function.</b>
*
* @param {string} message The plaintext message.
* @param {Array} entities The Telegram formatting entities.
* @param {MautrixTelegram} app The app main class instance to use when reformatting mentions.
*/
function telegramToMatrix(message, entities, app) {
const tags = []
// Decreasing priority counter used to ensure that formattings right next to eachother don't flip like this:
// *bold*_italic_ --> <strong>bold<em></strong>italic</em>
let pc = 9001
// Convert Telegram formatting entities into a weird custom indexed HTML tag format thingy.
for (const entity of entities) {
let url, tag, mxid
switch (entity._) {
case "messageEntityBold":
tag = tag || "strong"
case "messageEntityItalic":
tag = tag || "em"
case "messageEntityCode":
tag = tag || "code"
addSimpleTag(tags, entity, tag, --pc)
break
case "messageEntityPre":
pc--
addSimpleTag(tags, entity, "pre", pc)
addTag(tags, entity, "code", `class="language-${entity.language}"`, pc + 1)
break
case "messageEntityBotCommand":
// TODO bridge bot commands differently?
message = `${message.substr(0, entity.offset)}!${message.substr(entity.offset + 1)}`
case "messageEntityHashtag":
addTag(tags, entity, "font", "color=\"blue\"", --pc)
break
case "messageEntityMentionName":
let user = app.matrixUsersByTelegramID.get(entity.user_id)
if (!user) {
// TODO this loop step should be made useless
for (const userByMXID of app.matrixUsersByID.values()) {
if (userByMXID.telegramUserID === entity.user_id) {
user = userByMXID
app.matrixUsersByTelegramID.set(userByMXID.telegramUserID, userByMXID)
break
}
}
}
mxid = user ?
user.userID :
app.getMXIDForTelegramUser(entity.user_id)
case "messageEntityMention":
if (!mxid) {
const username = message.substr(entity.offset + 1, entity.length - 1)
for (const userByMXID of app.matrixUsersByID.values()) {
if (userByMXID._telegramPuppet && userByMXID._telegramPuppet.data.username === username) {
mxid = userByMXID.userID
break
}
}
if (!mxid) {
for (const userByID of app.telegramUsersByID.values()) {
if (userByID.username === username) {
mxid = userByID.mxid
break
}
}
}
}
if (!mxid) {
continue
}
addTag(tags, entity, "a", `href="https://matrix.to/#/${mxid}"`)
break
case "messageEntityEmail":
url = url || `mailto:${message.substr(entity.offset, entity.length)}`
case "messageEntityUrl":
url = url || message.substr(entity.offset, entity.length)
case "messageEntityTextUrl":
url = url || entity.url
addTag(tags, entity, "a", `href="${url}"`, --pc)
break
}
}
// Sort tags in a mysterious way (it seems to work, don't touch it!).
//
// The important thing is that the tags are sorted last to first,
// so when replacing by index, the index doesn't need to be adapted.
tags.sort(([aIndex, , aPriority], [bIndex, , bPriority]) => bIndex - aIndex || aPriority - bPriority)
// Insert tags into message
for (const [index, replacement] of tags) {
message = message.insert(index, replacement)
}
message = message.replace(/\n/g, "<br/>\n")
return message
}
// Formatting that is converted back to text
const linebreaks = /<br(.*?)>(\n)?/g
const paragraphs = /<p>([^]*?)<\/p>/g
const headers = /<h([0-6])>([^]*?)<\/h[0-6]>/g
const unorderedLists = /<ul>([^]*?)<\/ul>/g
const orderedLists = /<ol>([^]*?)<\/ol>/g
const listEntries = /<li>([^]*?)<\/li>/g
const blockquotes = /<blockquote>([^]*?)<\/blockquote>/g
// Formatting that is brutally murdered
const strikedText = /<del>([^]*?)<\/del>/g
const underlinedText = /<u>([^]*?)<\/u>/g
// Formatting that is converted to Telegram entity formatting
const boldText = /<(strong)>()([^]*?)<\/strong>/g
const italicText = /<(em)>()([^]*?)<\/em>/g
const codeblocks = /<(pre><code)>()([^]*?)<\/code><\/pre>/g
const codeblocksWithSyntaxHighlight = /<(pre><code class)="language-(.*?)">([^]*?)<\/code><\/pre>/g
const inlineCode = /<(code)>()(.*?)<\/code>/g
const emailAddresses = /<a href="(mailto):(.*?)">([^]*?)<\/a>/g
const mentions = /<a href="https:\/\/(matrix\.to)\/#\/(@.+?)">(.*?)<\/a>/g
const hyperlinks = /<(a href)="(.*?)">([^]*?)<\/a>/g
const commands = /(\s|^)!([^\s]+)/g
const REGEX_CAPTURE_GROUP_COUNT = 3
RegExp.any = function(...regexes) {
let components = []
for (const regex of regexes) {
if (regex instanceof RegExp) {
components = components.concat(regex._components || regex.source)
}
}
return new RegExp(`(?:${components.join(")|(?:")})`)
}
const regexMonster = RegExp.any(boldText, italicText, codeblocks,
codeblocksWithSyntaxHighlight, inlineCode, emailAddresses,
mentions, hyperlinks)
const NUMBER_OF_REGEXES_EATEN_BY_MONSTER = 8
function regexMonsterMatchParser(match) {
match.pop() // Remove full string
const index = match.pop()
let identifier, arg, text
for (let i = 0; i < NUMBER_OF_REGEXES_EATEN_BY_MONSTER; i++) {
if (match[i * REGEX_CAPTURE_GROUP_COUNT]) {
identifier = match[i * REGEX_CAPTURE_GROUP_COUNT]
arg = match[(i * REGEX_CAPTURE_GROUP_COUNT) + 1]
text = match[(i * REGEX_CAPTURE_GROUP_COUNT) + 2]
}
}
return { index, identifier, arg, text }
}
function regexMonsterHandler(identifier, arg, text, index, app) {
let entity, entityClass, argField
switch (identifier) {
case "strong":
entityClass = "Bold"
break
case "em":
entityClass = "Italic"
break
case "pre><code":
case "pre><code class":
argField = "language"
entityClass = "Pre"
break
case "code":
entityClass = "Code"
break
case "mailto":
entityClass = "email"
// Force text to be the email address
text = arg
break
case "a href":
if (arg === text) {
entityClass = "Url"
} else {
entityClass = "TextUrl"
argField = "url"
}
case "matrix.to":
if (app) {
const match = app.usernameRegex.exec(arg)
if (!match || match.length < 2) {
break
}
const userID = match[1]
const user = app.telegramUsersByID.get(+userID)
if (!user) {
break
}
if (user.username) {
entityClass = "Mention"
text = `@${user.username}`
} else {
text = user.getDisplayName()
entity = {
_: "inputMessageEntityMentionName",
offset: index,
length: text.length,
user_id: {
_: "inputUser",
user_id: user.id,
},
}
}
}
break
}
if (!entity && entityClass) {
entity = {
_: `messageEntity${entityClass}`,
offset: index,
length: text.length,
}
if (argField) {
entity[argField] = arg
}
}
return { replacement: text, entity }
}
/**
* Convert a Matrix HTML-formatted message to a Telegram entity-formatted message.
*
* @param {string} message The HTML-formatted message.
* @returns {{message: string, entities: Array}} The Telegram entity-formatted message.
*/
function matrixToTelegram(message, isHTML, app) {
const entities = []
message = message.replace(commands, (_, prefix, command, index) => {
entities.push({
_: "messageEntityBotCommand",
offset: index + prefix.length,
length: command.length + 1,
})
return `${prefix}/${command}`
})
if (!isHTML) {
return { message, entities }
}
// First replace all the things that don't get converted into Telegram entities
message = message.replace(linebreaks, "\n")
message = message.replace(paragraphs, "$1\n")
message = message.replace(headers, (_, count, text) => `${"#".repeat(count)} ${text}`)
message = message.replace(unorderedLists, (_, list) => list.replace(listEntries, "- $1"))
message = message.replace(orderedLists, (_, list) => {
let n = 0
return list.replace(listEntries, (fullMatch, text) => `${++n}. ${text}`)
})
message = message.replace(blockquotes, (_, quote) => quote
.split("\n")
.map(line => line.trim())
.filter(line => line.length > 0)
.map(line => `> ${line}`)
.join("\n"))
// Just remove these, they have no textual or Telegramical representation.
message = message.replace(strikedText, (_, text) => text)
message = message.replace(underlinedText, (_, text) => text)
message = message.trim()
const regexMonsterReplacer = (match, ...args) => {
const { index, identifier, arg, text } = regexMonsterMatchParser(args)
if (!identifier) {
// This shouldn't happen
console.warn(`Warning: Match found but parsing failed for match "${match}"`)
return match
}
const { replacement, entity } = regexMonsterHandler(identifier, arg, text, index, app)
if (entity) {
entities.push(entity)
}
return replacement || text
}
// We replace matches iteratively to make sure the indexes of matches are correct.
let oldMessage = message
message = message.replace(regexMonster, regexMonsterReplacer)
while (oldMessage !== message) {
oldMessage = message
message = message.replace(regexMonster, regexMonsterReplacer)
}
return { message, entities }
}
module.exports = { telegramToMatrix, matrixToTelegram }

View File

@@ -1,67 +0,0 @@
#!/usr/bin/env node
// mautrix-telegram - A Matrix-Telegram puppeting bridge
// Copyright (C) 2017 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
const { AppServiceRegistration } = require("matrix-appservice-bridge")
const program = require("commander")
const YAML = require("yamljs")
const fs = require("fs")
const MautrixTelegram = require("./app")
const pkg = require("../package.json")
program
.version(pkg.version)
.option("-c, --config <path>", "the file to load the config from. defaults to ./config.yaml")
.option("-g, --generate-registration", "generate a registration based on the config")
.option("-r, --registration <path>", "the file to save the registration to. defaults to ./registration.yaml")
.parse(process.argv)
// commander doesn't seem to set default values automatically.
program.registration = program.registration || "./registration.yaml"
program.config = program.config || "./config.yaml"
const config = YAML.load(program.config)
if (program.generateRegistration) {
const registration = {
id: config.appservice.id,
hs_token: AppServiceRegistration.generateToken(),
as_token: AppServiceRegistration.generateToken(),
namespaces: {
users: [{
exclusive: true,
regex: `@${config.bridge.username_template.replace("${ID}", ".+")}:${config.homeserver.domain}`,
}],
aliases: [{
exclusive: true,
regex: `#${config.bridge.alias_template.replace("${NAME}", ".+")}:${config.homeserver.domain}`,
}],
rooms: [],
},
url: `${config.appservice.protocol}://${config.appservice.hostname}:${config.appservice.port}`,
sender_localpart: config.bridge.bot_username,
rate_limited: false,
}
fs.writeFileSync(program.registration, YAML.stringify(registration, 10))
config.appservice.registration = program.registration
fs.writeFileSync(program.config, YAML.stringify(config, 10))
console.log("Registration generated and saved to", program.registration)
process.exit()
}
const app = new MautrixTelegram(config)
app.run()

View File

@@ -1,402 +0,0 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge
// Copyright (C) 2017 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
const md5 = require("md5")
const TelegramPuppet = require("./telegram-puppet")
const TelegramPeer = require("./telegram-peer")
const strSim = require("string-similarity")
const chalk = require("chalk")
/**
* MatrixUser represents a Matrix user who probably wants to control their
* Telegram account from Matrix.
*/
class MatrixUser {
constructor(app, userID) {
this.app = app
this.userID = userID
this.whitelisted = app.checkWhitelist(userID)
this.phoneNumber = undefined
this.phoneCodeHash = undefined
this.commandStatus = undefined
this.puppetData = undefined
this.contacts = []
this.chats = []
this._telegramPuppet = undefined
}
/**
* Get the user ID of the Telegram user this Matrix user controls.
*
* @returns {number|undefined} The Telegram user ID, or undefined if not logged in.
*/
get telegramUserID() {
return this._telegramPuppet
? this._telegramPuppet.userID || undefined
: undefined
}
/**
* Convert a database entry into a MatrixUser.
*
* @param {MautrixTelegram} app The app main class instance.
* @param {Object} entry The database entry.
* @returns {MatrixUser} The loaded MatrixUser.
*/
static fromEntry(app, entry) {
if (entry.type !== "matrix") {
throw new Error("MatrixUser can only be created from entry type \"matrix\"")
}
const user = new MatrixUser(app, entry.id)
user.phoneNumber = entry.data.phoneNumber
user.phoneCodeHash = entry.data.phoneCodeHash
user.setContactIDs(entry.data.contactIDs)
user.setChatIDs(entry.data.chatIDs)
if (entry.data.puppet) {
user.puppetData = entry.data.puppet
// Create the telegram puppet instance
user.telegramPuppet
}
return user
}
/**
* Convert this MatrixUser into a database entry.
*
* @returns {Object} A user store database entry.
*/
toEntry() {
if (this._telegramPuppet) {
this.puppetData = this._telegramPuppet.toSubentry()
}
return {
type: "matrix",
id: this.userID,
telegramID: this.telegramUserID,
data: {
phoneNumber: this.phoneNumber,
phoneCodeHash: this.phoneCodeHash,
contactIDs: this.contactIDs,
chatIDs: this.chatIDs,
puppet: this.puppetData,
},
}
}
/**
* Get the telegram puppet this Matrix user controls.
* If one doesn't exist, it'll be created based on the {@link #puppetData} field.
*
* @returns {TelegramPuppet} The Telegram account controller.
*/
get telegramPuppet() {
if (!this._telegramPuppet) {
this._telegramPuppet = TelegramPuppet.fromSubentry(this.app, this, this.puppetData || {})
}
return this._telegramPuppet
}
/**
* Get the IDs of all the Telegram contacts of this user.
*
* @returns {number[]} A list of Telegram user IDs.
*/
get contactIDs() {
return this.contacts.map(contact => contact.id)
}
/**
* Get the IDs of all the Telegram chats this user is in.
*
* @returns {number[]} A list of Telegram chat IDs.
*/
get chatIDs() {
return this.chats.map(chat => chat.id)
}
/**
* Update the contacts of this user based on a list of Telegram user IDs.
*
* @param {number[]} list The list of Telegram user IDs.
*/
async setContactIDs(list) {
if (!list) {
return
}
this.contacts = await Promise.all(list.map(id => this.app.getTelegramUser(id)))
}
/**
* Update the chats of this user based on a list of Telegram chat IDs.
*
* @param {number[]} list The list of Telegram chat IDs.
*/
async setChatIDs(list) {
if (!list) {
return
}
this.chats = await Promise.all(list.map(id => this.app.getPortalByPeer(id)))
}
/**
* Synchronize the contacts of this user.
*
* @returns {boolean} Whether or not anything changed.
*/
async syncContacts() {
const contacts = await this.telegramPuppet.client("contacts.getContacts", {
hash: md5(this.contactIDs.join(",")),
})
if (contacts._ === "contacts.contactsNotModified") {
return false
}
for (const [index, contact] of Object.entries(contacts.users)) {
const telegramUser = await this.app.getTelegramUser(contact.id)
await telegramUser.updateInfo(this.telegramPuppet, contact, true)
contacts.users[index] = telegramUser
}
this.contacts = contacts.users
await this.save()
return true
}
/**
* Synchronize the chats (groups, channels) of this user.
*
* @param {object} [opts] Additional options.
* @param {boolean} opts.createRooms Whether or not portal rooms should be automatically created.
* Defaults to {@code true}
* @returns {boolean} Whether or not anything changed.
*/
async syncChats({ createRooms = true } = {}) {
const dialogs = await this.telegramPuppet.client("messages.getDialogs", {})
let changed = false
for (const user of dialogs.users) {
this.app.debug("cyan", "Syncing data for", this.telegramPuppet.userID, JSON.stringify(user, "", " "))
if (!user.self) {
continue
}
// Automatically create Saved Messages room
const peer = new TelegramPeer("user", user.id, {
receiverID: user.id,
accessHash: user.access_hash,
})
const portal = await this.app.getPortalByPeer(peer)
if (createRooms) {
try {
await portal.createMatrixRoom(this.telegramPuppet, {
invite: [this.userID],
})
} catch (err) {
console.error(err)
console.error(err.stack)
}
}
}
this.chats = []
for (const dialog of dialogs.chats) {
if (dialog._ === "chatForbidden" || dialog._ === "channelForbidden" || dialog.deactivated || dialog.left) {
continue
}
this.app.debug("cyan", "Syncing data for ", this.telegramPuppet.userID, JSON.stringify(dialog, "", " "))
const peer = new TelegramPeer(dialog._, dialog.id, {
accessHash: dialog.access_hash,
})
const portal = await this.app.getPortalByPeer(peer)
this.chats.push(portal)
if (createRooms) {
if (peer.type === "channel") {
portal.accessHashes.set(this.telegramPuppet.userID, dialog.access_hash)
}
try {
await portal.createMatrixRoom(this.telegramPuppet, {
invite: [this.userID],
})
} catch (err) {
console.error(`Failed to create a room for ${dialog._} ${dialog.id}`)
console.error(err)
continue
}
}
if (await portal.updateInfo(this.telegramPuppet, dialog)) {
changed = true
}
}
await this.save()
return changed
}
/**
* Add a {@link Portal} to the chat list of this user.
*
* This should only be used for non-private chat portals.
*
* @param {Portal} portal The portal to add.
*/
async join(portal) {
if (!this.chats.includes(portal.id)) {
this.chats.push(portal.id)
await this.save()
}
}
/**
* Remove a {@link Portal} from the chat list of this user.
*
* This should only be used for non-private chat portals.
*
* @param {Portal} portal The portal to remove.
*/
async leave(portal) {
const chatIDIndex = this.chats.indexOf(portal.id)
if (chatIDIndex > -1) {
this.chats.splice(chatIDIndex, 1)
await this.save()
}
}
/**
* Search for contacts of this user.
*
* @param {string} query The search query.
* @param {object} [opts] Additional options.
* @param {number} opts.maxResults The maximum number of results to show.
* @param {number} opts.minSimilarity The minimum query similarity, below which results should be ignored.
* @returns {Object[]} The search results.
*/
async searchContacts(query, { maxResults = 5, minSimilarity = 0.45 } = {}) {
const results = []
for (const contact of this.contacts) {
let displaynameSimilarity = 0
let usernameSimilarity = 0
let numberSimilarity = 0
if (contact.firstName || contact.lastName) {
displaynameSimilarity = strSim.compareTwoStrings(query, contact.getFirstAndLastName())
}
if (contact.username) {
usernameSimilarity = strSim.compareTwoStrings(query, contact.username)
}
if (contact.phoneNumber) {
numberSimilarity = strSim.compareTwoStrings(query, contact.phoneNumber)
}
const similarity = Math.max(displaynameSimilarity, usernameSimilarity, numberSimilarity)
if (similarity >= minSimilarity) {
results.push({
similarity,
match: Math.round(similarity * 1000) / 10,
contact,
})
}
}
return results
.sort((a, b) => b.similarity - a.similarity)
.slice(0, maxResults)
}
/**
* Search for non-contact Telegram users from the point of view of this user.
* @param {string} query The search query.
* @param {object} [opts] Additional options.
* @param {number} opts.maxResults The maximum number of results to show.
* @returns {Object[]} The search results.
*/
async searchTelegram(query, { maxResults = 5 } = {}) {
const results = await this.telegramPuppet.client("contacts.search", {
q: query,
limit: maxResults,
})
const resultUsers = []
for (const userInfo of results.users) {
const user = await this.app.getTelegramUser(userInfo.id)
user.updateInfo(this.telegramPuppet, userInfo)
resultUsers.push(user)
}
return resultUsers
}
/**
* Request a Telegarm phone code for logging in (or registering)
*
* @param {string} phoneNumber The phone number.
* @returns {Object} The code send result as returned by {@link TelegramPuppet#sendCode()}.
*/
async sendTelegramCode(phoneNumber) {
if (this._telegramPuppet && this._telegramPuppet.userID) {
throw new Error("You are already logged in. Please log out before logging in again.")
}
switch (this.telegramPuppet.checkPhone(phoneNumber)) {
case "unregistered":
throw new Error("That number has not been registered. Please register it first.")
case "invalid":
throw new Error("Invalid phone number.")
}
const result = await this.telegramPuppet.sendCode(phoneNumber)
this.phoneNumber = phoneNumber
this.phoneCodeHash = result.phone_code_hash
await this.save()
return result
}
/**
* Log out from Telegram.
*/
async logOutFromTelegram() {
this.telegramPuppet.logOut()
// TODO kick user from all portals
this._telegramPuppet = undefined
this.puppetData = undefined
await this.save()
}
/**
* Sign in to Telegram with a phone code sent using {@link #sendTelegramCode()}.
*
* @param {number} phoneCode The phone code.
* @returns {Object} The sign in result as returned by {@link TelegramPuppet#signIn()}.
*/
async signInToTelegram(phoneCode) {
if (!this.phoneNumber) throw new Error("Phone number not set")
if (!this.phoneCodeHash) throw new Error("Phone code not sent")
const result = await this.telegramPuppet.signIn(this.phoneNumber, this.phoneCodeHash, phoneCode)
this.phoneCodeHash = undefined
await this.save()
return result
}
/**
* Finish signing in to Telegram using the two-factor auth password.
*
* @param {string} password_hash The salted hash of the password.
* @returns {Object} The sign in result as returned by {@link TelegramPuppet#checkPassword()}
*/
async checkPassword(password_hash) {
const result = await this.telegramPuppet.checkPassword(password_hash)
await this.save()
return result
}
/**
* Save this MatrixUser to the database.
*/
save() {
return this.app.putUser(this)
}
}
module.exports = MatrixUser

View File

@@ -1,902 +0,0 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge
// Copyright (C) 2017 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
const md5 = require("md5")
const TelegramPeer = require("./telegram-peer")
const formatter = require("./formatter")
/**
* Portal represents a portal from a Matrix room to a Telegram chat.
*/
class Portal {
constructor(app, roomID, peer) {
this.app = app
this.type = "portal"
this.roomID = roomID
this.peer = peer
this.accessHashes = new Map()
// deduplicate duplication caused by telegram-mtproto bugs
this.lastMessageIDs = new Map()
// deduplicate duplication caused by multiple users
this.messageHashes = []
}
/**
* Get the peer ID of this portal.
*
* @returns {number} The ID of the peer of the Telegram side of this portal.
*/
get id() {
return this.peer.id
}
/**
* Get the receiver ID of this portal. Only applicable for private chat portals.
*
* @returns {number} The ID of the receiving user of this portal.
*/
get receiverID() {
return this.peer.receiverID
}
/**
* Convert a database entry into a Portal.
*
* @param {MautrixTelegram} app The app main class instance.
* @param {Object} entry The database entry.
* @returns {Portal} The loaded Portal.
*/
static fromEntry(app, entry) {
if (entry.type !== "portal") {
throw new Error("MatrixUser can only be created from entry type \"portal\"")
}
const portal = new Portal(app, entry.roomID || entry.data.roomID, TelegramPeer.fromSubentry(entry.data.peer))
portal.photo = entry.data.photo
portal.avatarURL = entry.data.avatarURL
if (portal.peer.type === "channel") {
portal.accessHashes = new Map(entry.data.accessHashes)
}
return portal
}
/**
* Synchronize the user list of this portal.
*
* @param {TelegramPuppet} telegramPOV The Telegram account whose point of view the data is/should be fetched from.
* @param {UserFull[]} [users] The list of {@link https://tjhorner.com/tl-schema/type/UserFull user info}
* objects.
* @returns {boolean} Whether or not syncing was successful. It can only be unsuccessful if the
* user list was not provided and an access hash was not found for the given
* Telegram user.
*/
async syncTelegramUsers(telegramPOV, users) {
if (!users) {
if (!await this.loadAccessHash(telegramPOV)) {
return false
}
const data = await this.peer.getInfo(telegramPOV)
users = data.users
}
for (const userData of users) {
const user = await this.app.getTelegramUser(userData.id)
// We don't want to update avatars here, as it would likely cause a flood error
await user.updateInfo(telegramPOV, userData, { updateAvatar: false })
await user.intent.join(this.roomID)
}
return true
}
/**
* Copy a photo from Telegram to Matrix.
*
* @param {TelegramPuppet} telegramPOV The Telegram account whose point of view the image should be downloaded from.
* @param {TelegramUser} sender The user who sent the photo.
* @param {Photo} photo The Telegram {@link https://tjhorner.com/tl-schema/type/Photo Photo} object.
* @returns {Object} The uploaded Matrix photo object.
*/
async copyTelegramPhoto(telegramPOV, sender, photo) {
const size = photo.sizes.slice(-1)[0]
const uploaded = await this.copyTelegramFile(telegramPOV, sender, size.location, photo.id)
uploaded.info.h = size.h
uploaded.info.w = size.w
uploaded.info.size = size.size
uploaded.info.orientation = 0
return uploaded
}
/**
* Copy a file from Telegram to Matrix.
*
* @param {TelegramPuppet} telegramPOV The Telegram account whose point of view the file should be downloaded from.
* @param {TelegramUser} sender The user who sent the file.
* @param {FileLocation} location The Telegram {@link https://tjhorner.com/tl-schema/type/FileLocation
* FileLocation}.
* @returns {Object} The uploaded Matrix file object.
*/
async copyTelegramFile(telegramPOV, sender, location, id) {
id = id || location.id
const file = await telegramPOV.getFile(location)
const uploaded = await sender.intent.getClient().uploadContent({
stream: file.buffer,
name: `${id}.${file.extension}`,
type: file.mimetype,
}, { rawResponse: false })
uploaded.matrixtype = file.matrixtype
uploaded.info = {
mimetype: file.mimetype,
size: location.size,
}
return uploaded
}
/**
* Update the avatar of this portal to the given photo.
*
* @param {TelegramPuppet} telegramPOV The Telegram account whose point of view the avatar should be downloaded
* from, if necessary.
* @param {ChatPhoto} photo The Telegram {@link https://tjhorner.com/tl-schema/type/ChatPhoto ChatPhoto}
* object.
* @returns {boolean} Whether or not the photo was updated.
*/
async updateAvatar(telegramPOV, photo) {
if (!photo || this.peer.type === "user") {
return false
}
if (this.photo && this.avatarURL &&
this.photo.dc_id === photo.dc_id &&
this.photo.volume_id === photo.volume_id &&
this.photo.local_id === photo.local_id) {
return false
}
const file = await telegramPOV.getFile(photo)
const name = `${photo.volume_id}_${photo.local_id}.${file.extension}`
const uploaded = await this.app.botIntent.getClient().uploadContent({
stream: file.buffer,
name,
type: file.mimetype,
}, { rawResponse: false })
this.avatarURL = uploaded.content_uri
this.photo = {
dc_id: photo.dc_id,
volume_id: photo.volume_id,
local_id: photo.local_id,
}
await this.app.botIntent.setRoomAvatar(this.roomID, this.avatarURL)
return true
}
/**
* Load the access hash for the given puppet.
*
* @param {TelegramPuppet} telegramPOV The puppet whose access hash to load.
* @returns {boolean} As specified by {@link TelegramPeer#loadAccessHash(app, telegramPOV)}.
*/
loadAccessHash(telegramPOV) {
return this.peer.loadAccessHash(this.app, telegramPOV, { portal: this })
}
/**
* Handle a Telegram typing event.
*
* @param {Object} evt The custom event object.
* @param {number} evt.from The ID of the Telegram user who is typing.
* @param {TelegramPeer} evt.to The peer where the user is typing.
* @param {TelegramPuppet} evt.source The source where this event was captured.
*/
async handleTelegramTyping(evt) {
if (!this.isMatrixRoomCreated()) {
return
}
const typer = await this.app.getTelegramUser(evt.from)
// The Intent API currently doesn't allow you to set the
// typing timeout. Once it does, we should set it to ~5.5s
// as Telegram resends typing notifications every 5 seconds.
typer.intent.sendTyping(this.roomID, true/*, 5500*/)
}
/**
* Add a Telegram user to this room.
*
* This makes the Matrix puppet of that Telegram user join this room. If the Telegram user is also a puppet
* controlled by a Matrix user, that Matrix user is invited as well.
*
* @param {number} userID The Telegram ID of the user to add.
*/
async addUser(userID) {
const matrixUser = await this.app.getMatrixUserByTelegramID(userID)
if (matrixUser) {
matrixUser.join(this)
this.inviteMatrix(matrixUser.userID)
}
const telegramUser = await this.app.getTelegramUser(userID)
await telegramUser.intent.join(this.roomID)
}
/**
* Remove a Telegram user from this room.
*
* This makes the Matrix puppet of the given Telegram user leave this room. If the Telegram user is also a puppet
* controlled by a Matrix user, that Matrix user is kicked with the message "Left Telegram chat".
*
* @param {number} userID The Telegram ID of the user to remove.
*/
async deleteUser(userID) {
const matrixUser = await this.app.getMatrixUserByTelegramID(userID)
if (matrixUser) {
matrixUser.leave(this)
this.kickMatrix(matrixUser.userID, "Left Telegram chat")
}
const telegramUser = await this.app.getTelegramUser(userID)
telegramUser.intent.leave(this.roomID)
}
/**
* Handle a Telegram service message event.
*
* @param {Object} evt The custom event object.
* @param {number} evt.from The ID of the Telegram user who caused the service message.
* @param {TelegramPeer} evt.to The peer to which the message was sent.
* @param {TelegramPuppet} evt.source The source where this event was captured.
* @param {MessageAction} evt.action The Telegram {@link https://tjhorner.com/tl-schema/type/MessageAction
* MessageAction} object.
*/
async handleTelegramServiceMessage(evt) {
if (!this.isMatrixRoomCreated()) {
if (evt.action._ === "messageActionChatDeleteUser") {
// We don't care about user deletions on chats without portals
return
}
this.app.debug("magenta", "Service message received, creating room for", evt.to.id)
await this.createMatrixRoom(evt.source, { invite: [evt.source.matrixUser.userID] })
return
}
if (evt.id) {
const last = this.lastMessageIDs.get(evt.source.userID)
if (last && evt.id <= last) {
this.app.debug(`Received old/duplicate message with ID ${evt.id} (latest ID: ${last})`)
return
}
}
this.lastMessageIDs.set(evt.source.userID, evt.id)
switch (evt.action._) {
case "messageActionChatCreate":
// Portal gets created at beginning if it doesn't exist
// Falls through to invite everyone in initial user list
case "messageActionChatAddUser":
for (const userID of evt.action.users) {
await this.addUser(userID)
}
break
case "messageActionChatJoinedByLink":
await this.addUser(evt.from)
break
case "messageActionChannelCreate":
// Portal gets created at beginning if it doesn't exist
// Channels don't send initial user lists 3:<
break
case "messageActionChatMigrateTo":
this.peer.id = evt.action.channel_id
this.peer.type = "channel"
const accessHash = await this.peer.fetchAccessHashFromServer(evt.source)
if (!accessHash) {
console.error("Failed to fetch access hash for mirgrated channel!")
break
}
this.accessHashes.set(evt.source.userID, accessHash)
await this.save()
const sender = await this.app.getTelegramUser(evt.from)
await sender.sendEmote(this.roomID, "upgraded this group to a supergroup.")
break
case "messageActionChatDeleteUser":
await this.deleteUser(evt.action.user_id)
break
case "messageActionChatEditPhoto":
const sizes = evt.action.photo.sizes
let largestSize = sizes[0]
let largestSizePixels = largestSize.w * largestSize.h
for (const size of sizes) {
const pixels = size.w * size.h
if (pixels > largestSizePixels) {
largestSizePixels = pixels
largestSize = size
}
}
// TODO once permissions are synced, make the avatar change event come from the user who changed the avatar
await this.updateAvatar(evt.source, largestSize.location)
break
case "messageActionChatEditTitle":
this.peer.title = evt.action.title
await this.save()
const intent = await this.getMainIntent()
await intent.setRoomName(this.roomID, this.peer.title)
break
default:
this.app.warn("Unhandled service message of type", evt.action._, "from", evt.from)
this.app.warn(JSON.stringify(evt.action, "", " "))
}
}
/**
* Context: Matrix user X is logged into mautrix-telegram and has a private chat portal room with Telegram user Y.
* X sends message to Y from another Telegram client.
*
* Problem: We can't control X's Matrix account. We also can't make sure that X's Telegram account's Matrix puppet
* is always in private chat portal rooms, since X could create a private chat portal by inviting Y's
* puppet without giving it, the only AS-controllable user in the room, any power.
*
* Solution: When encountering an error caused by the above situation, this function is called.
* This function first tries to invite X's Matrix puppet to the room.
* If that fails, text messages are sent through the other user as notices and other messages are dropped.
*
* @param {Object} evt The custom event object (see #handleTelegramMessage(evt))
* @param {TelegramUser} sender The Telegram user object of the sender.
* @returns {boolean} Whether or not the puppet for the sender was successfully invited.
*/
async tryFixPrivateChatForOutgoingMessage(evt, sender) {
try {
const intent = await this.getMainIntent()
await intent.invite(this.roomID, sender.mxid)
return true
} catch (_) {
const receiver = await this.app.getTelegramUser(evt.to.id, { createIfNotFound: false })
if (receiver) {
if (evt.text) {
receiver.sendNotice(this.roomID, `[Your message from another client] ${evt.text}`)
}
}
}
return false
}
/**
* @typedef PortalMessage A portal message event.
*
* @property {string} [text]
* @property {string} [caption]
*
* @property {number} from
* @property {number} [fwdFrom]
*
* @property {Object} to
* @property {number} to.id
*
* @property {Object} [geo]
* @property {number} geo.lat
* @property {number} geo.long
*
* @property {Object} [document]
* @property {number} document.id
* @property {Object} [photo]
* @property {number} photo.id
*/
/**
* Get a deduplication hash of the given event. The hash is formed of the text or caption, source, forward source
* and target. For documents and photos, the file ID is included and for locations the longitude and latitude are
* included.
*
* @param {PortalMessage} evt The event.
* @returns {string} An md5 hash of the data.
*/
hash(evt) {
let base = (evt.text || evt.caption) + evt.from + evt.fwdFrom + evt.to.id
if (evt.geo) {
base += evt.geo.lat
base += evt.geo.long
} else if (evt.document) {
base += evt.document.id
} else if (evt.photo) {
base += evt.photo.id
}
return md5(base)
}
/**
* Hash the given event and check if it has been recently handled.
*
* @param {PortalMessage} evt The event.
* @returns {boolean} Whether or not the event has been recently handled.
*/
deduplicate(evt) {
const hashed = this.hash(evt)
if (this.messageHashes.includes(hashed)) {
return true
}
this.messageHashes.unshift(hashed)
if (this.messageHashes.length > 20) {
this.messageHashes.length = 20
}
return false
}
/**
* Handle a Telegram service message event.
*
* @param {Object} evt The custom event object.
* @param {number} evt.from The ID of the Telegram user who sent the message.
* @param {number} evt.fwdFrom The ID of the Telegram user who originally sent the message.
* @param {TelegramPeer} evt.to The peer to which the message was sent.
* @param {TelegramPuppet} evt.source The source where this event was captured.
* @param {string} evt.text The text in the message.
* @param {string} [evt.caption] The image/file caption.
* @param {MessageEntity[]} [evt.entities] The Telegram {@link https://tjhorner.com/tl-schema/type/MessageEntity
* formatting entities} in the message.
* @param {messageMediaPhoto} [evt.photo] The Telegram {@link https://tjhorner.com/tl-schema/constructor/messageMediaPhoto Photo} attached to the message.
* @param {messageMediaDocument} [evt.document] The Telegram {@link https://tjhorner.com/tl-schema/constructor/messageMediaDocument Document} attached to the message.
* @param {messageMediaGeo} [evt.geo] The Telegram {@link https://tjhorner.com/tl-schema/constructor/messageMediaGeo Location} attached to the message.
*/
async handleTelegramMessage(evt) {
if (!this.isMatrixRoomCreated()) {
try {
const result = await this.createMatrixRoom(evt.source, { invite: [evt.source.matrixUser.userID] })
if (!result.roomID) {
return
}
} catch (err) {
console.error("Error creating room:", err)
console.error(err.stack)
return
}
}
if (this.deduplicate(evt)) {
return
}
const sender = await this.app.getTelegramUser(evt.from)
try {
await sender.intent.sendTyping(this.roomID, false)
} catch (err) {
if (evt.to.type === "user") {
if (!await this.tryFixPrivateChatForOutgoingMessage(evt, sender)) {
return
}
await sender.intent.sendTyping(this.roomID, false)
} else {
throw err
}
}
// TODO display forwards (evt.fwdFrom)
if (evt.text && evt.text.length > 0) {
if (evt.entities) {
evt.html = formatter.telegramToMatrix(evt.text, evt.entities, this.app)
sender.sendHTML(this.roomID, evt.html)
} else {
sender.sendText(this.roomID, evt.text)
}
}
if (evt.photo) {
const photo = await this.copyTelegramPhoto(evt.source, sender, evt.photo)
photo.name = evt.caption || "Uploaded photo"
sender.sendFile(this.roomID, photo)
} else if (evt.document) {
// TODO handle stickers better
const file = await this.copyTelegramFile(evt.source, sender, evt.document)
if (evt.caption) {
file.name = evt.caption
} else if (file.matrixtype === "m.audio") {
file.name = "Uploaded audio"
} else if (file.matrixtype === "m.video") {
file.name = "Uploaded video"
} else {
file.name = "Uploaded document"
}
sender.sendFile(this.roomID, file)
} else if (evt.geo) {
sender.sendLocation(this.roomID, evt.geo)
}
}
/**
* Handle a Matrix event.
*
* @param {MatrixUser} sender The user who sent the message.
* @param {Object} evt The {@link https://matrix.org/docs/spec/client_server/r0.3.0.html#event-structure
* Matrix event}.
*/
async handleMatrixEvent(sender, evt) {
await this.loadAccessHash(sender.telegramPuppet)
switch (evt.content.msgtype) {
case "m.text":
const { message, entities } = formatter.matrixToTelegram(
evt.content.formatted_body || evt.content.body,
evt.content.format === "org.matrix.custom.html",
this.app)
this.deduplicate({
text: message,
date: Math.round(Date.now() / 1000),
from: sender.telegramPuppet.userID,
fwdFrom: 0,
to: {
id: this.peer.id,
},
})
await sender.telegramPuppet.sendMessage(this.peer, message, entities)
break
case "m.video":
case "m.audio":
case "m.file":
// TODO upload document
//break
case "m.image":
const intent = await this.getMainIntent()
await intent.sendMessage(this.roomID, {
msgtype: "m.notice",
body: "Sending files is not yet supported.",
})
break
case "m.location":
const [, lat, long] = /geo:([-]?[0-9]+\.[0-9]+)+,([-]?[0-9]+\.[0-9]+)/.exec()
this.deduplicate({
text: message,
date: Math.round(Date.now() / 1000),
from: sender.telegramPuppet.userID,
fwdFrom: 0,
to: {
id: this.peer.id,
},
geo: { lat, long },
})
await sender.telegramPuppet.sendMedia(this.peer, {
_: "inputMediaGeoPoint",
geo_point: {
_: "inputGeoPoint",
lat: +lat,
long: +long,
},
})
break
default:
this.app.warn("Unhandled event:", JSON.stringify(evt, "", " "))
}
}
/**
* @returns {boolean} Whether or not a Matrix room has been created for this Portal.
*/
isMatrixRoomCreated() {
return !!this.roomID
}
/**
* Get the primary intent object for this Portal.
*
* For groups and channels, this is always the AS bot intent.
* For private chats, it is the intent of the other user.
*
* @returns {Intent} The primary intent.
*/
async getMainIntent() {
return this.peer.type === "user"
? (await this.app.getTelegramUser(this.peer.id)).intent
: this.app.botIntent
}
async inviteTelegram(telegramPOV, user) {
if (this.peer.type === "chat") {
const updates = await telegramPOV.client("messages.addChatUser", {
chat_id: this.peer.id,
user_id: user.toPeer(telegramPOV).toInputObject(),
fwd_limit: 50,
})
this.app.debug("green", "Chat invite result:", JSON.stringify(updates, "", " "))
} else if (this.peer.type === "channel") {
const updates = await telegramPOV.client("channels.inviteToChannel", {
channel: this.peer.toInputObject(),
users: [user.toPeer(telegramPOV).toInputObject()],
})
this.app.debug("green", "Channel invite result:", JSON.stringify(updates, "", " "))
} else {
throw new Error(`Can't invite user to peer type ${this.peer.type}`)
}
}
async kickTelegram(telegramPOV, user) {
let updates
if (this.peer.type === "chat") {
updates = await telegramPOV.client("messages.deleteChatUser", {
chat_id: this.peer.id,
user_id: user.toPeer(telegramPOV).toInputObject(),
})
} else if (this.peer.type === "channel") {
this.loadAccessHash(telegramPOV)
updates = await telegramPOV.client("channels.kickFromChannel", {
channel: this.peer.toInputObject(),
user_id: user.toPeer(telegramPOV).toInputObject(),
kicked: true,
})
} else {
throw new Error(`Can't invite user to peer type ${this.peer.type}`)
}
await telegramPOV.handleUpdate(updates)
}
/**
* Invite one or more Matrix users to this Portal.
*
* @param {string[]|string} users The MXID or list of MXIDs to invite.
*/
async inviteMatrix(users) {
const intent = await this.getMainIntent()
// TODO check membership before inviting?
if (Array.isArray(users)) {
for (const userID of users) {
if (typeof userID === "string") {
try {
await intent.invite(this.roomID, userID)
} catch (err) {
if (err.httpStatus !== 403) {
console.error(`Failed to invite ${userID} to ${this.roomID}:`)
console.error(err)
}
}
}
}
} else if (typeof users === "string") {
try {
await intent.invite(this.roomID, users)
} catch (err) {
if (err.httpStatus !== 403) {
console.error(`Failed to invite ${users} to ${this.roomID}:`)
console.error(err)
}
}
}
}
/**
* Kick one or more Matrix users from this Portal.
*
* @param {string[]|string} users The MXID or list of MXIDs to kick.
* @param {string} reason The reason for kicking the user(s).
*/
async kickMatrix(users, reason) {
const intent = await this.getMainIntent()
if (Array.isArray(users)) {
for (const userID of users) {
if (typeof userID === "string") {
intent.kick(this.roomID, users, reason)
}
}
} else if (typeof users === "string") {
intent.kick(this.roomID, users, reason)
}
}
async createTelegramChat(telegramPOV, title) {
const members = await this.app.getRoomMembers(this.roomID)
const telegramInviteIDs = []
const asBotID = this.app.bot.getUserId()
for (const member of members) {
if (member === asBotID) {
continue
}
const user = await this.app.getMatrixUser(member)
if (user._telegramPuppet) {
telegramInviteIDs.push(user.telegramPuppet.userID)
}
const match = this.app.usernameRegex.exec(member)
if (!match || match.length < 2) {
continue
}
telegramInviteIDs.push(+match[1])
}
if (telegramInviteIDs.length < 2) {
// TODO once we have the option for a bot, this error will need to be changed.
throw new Error("Not enough users")
}
const telegramInvites = []
for (const userID of telegramInviteIDs) {
const user = await this.app.getTelegramUser(userID, { createIfNotFound: false })
if (!user) {
continue
}
telegramInvites.push(user.toPeer(telegramPOV).toInputObject())
}
const createUpdates = await telegramPOV.client("messages.createChat", {
title,
users: telegramInvites,
})
const chat = createUpdates.chats[0]
this.peer = new TelegramPeer("chat", chat.id, { title })
await this.save()
}
async upgradeTelegramChat(telegramPOV) {
if (this.peer.type !== "chat") {
throw new Error("Can't upgrade non-chat portal.")
}
const updates = await telegramPOV.client("messages.migrateChat", {
chat_id: this.id,
})
await telegramPOV.handleUpdate(updates)
}
/**
* Create a Matrix room for this portal.
*
* @param {TelegramPuppet} telegramPOV
* @param {string|string[] invite
* @param {boolean} inviteEvenIfNotCreated
* @returns {{created: boolean, roomID: string}}
*/
async createMatrixRoom(telegramPOV, { invite = [], inviteEvenIfNotCreated = true } = {}) {
if (this.roomID) {
if (invite && inviteEvenIfNotCreated) {
await this.inviteMatrix(invite)
}
return {
created: false,
roomID: this.roomID,
}
}
if (this.creatingMatrixRoom) {
await new Promise(resolve => setTimeout(resolve, 1000))
return {
created: false,
roomID: this.roomID,
}
}
this.creatingMatrixRoom = true
if (!await this.loadAccessHash(telegramPOV)) {
this.creatingMatrixRoom = false
throw new Error(`Failed to load access hash for ${this.peer.type} ${this.peer.username || this.peer.id}.`)
}
let room, info, users
try {
({ info, users } = await this.peer.getInfo(telegramPOV))
if (this.peer.type === "chat") {
room = await this.app.botIntent.createRoom({
options: {
name: info.title,
topic: info.about,
visibility: "private",
invite,
},
})
} else if (this.peer.type === "channel") {
room = await this.app.botIntent.createRoom({
options: {
name: info.title,
topic: info.about,
visibility: info.username ? "public" : "private",
room_alias_name: info.username
? this.app.config.bridge.alias_template.replace("${NAME}", info.username)
: undefined,
invite,
},
})
} else if (this.peer.type === "user") {
const user = await this.app.getTelegramUser(info.id)
await user.updateInfo(telegramPOV, info, { updateAvatar: true })
room = await user.intent.createRoom({
createAsClient: true,
options: {
name: this.peer.id === this.peer.receiverID
? "Saved Messages (Telegram)"
: undefined, //user.getDisplayName(),
topic: "Telegram private chat",
visibility: "private",
invite,
},
})
} else {
this.creatingMatrixRoom = false
throw new Error(`Unrecognized peer type: ${this.peer.type}`)
}
} catch (err) {
this.creatingMatrixRoom = false
throw err instanceof Error ? err : new Error(err)
}
this.roomID = room.room_id
this.creatingMatrixRoom = false
this.app.portalsByRoomID.set(this.roomID, this)
await this.save()
if (this.peer.type !== "user") {
try {
await this.syncTelegramUsers(telegramPOV, users)
if (info.photo && info.photo.photo_big) {
await this.updateAvatar(telegramPOV, info.photo.photo_big)
}
} catch (err) {
console.error(err)
if (err instanceof Error) {
console.error(err.stack)
}
}
}
return {
created: true,
roomID: this.roomID,
}
}
async updateInfo(telegramPOV, dialog) {
if (!dialog) {
this.app.warn("updateInfo called without dialog data")
const { user } = this.peer.getInfo(telegramPOV)
if (!user) {
throw new Error("Dialog data not given and fetching data failed")
}
dialog = user
}
let changed = false
if (this.peer.type === "channel") {
if (telegramPOV && this.accessHashes.get(telegramPOV.userID) !== dialog.access_hash) {
this.accessHashes.set(telegramPOV.userID, dialog.access_hash)
changed = true
}
}
if (this.peer.type === "user") {
const user = await this.app.getTelegramUser(this.peer.id)
await user.updateInfo(telegramPOV, dialog)
} else if (dialog.photo && dialog.photo.photo_big) {
changed = await this.updateAvatar(telegramPOV, dialog.photo.photo_big) || changed
}
changed = this.peer.updateInfo(dialog) || changed
if (changed) {
this.save()
}
return changed
}
/**
* Convert this Portal into a database entry.
*
* @returns {Object} A room store database entry.
*/
toEntry() {
return {
type: this.type,
id: this.id,
receiverID: this.receiverID,
roomID: this.roomID,
data: {
peer: this.peer.toSubentry(),
photo: this.photo,
avatarURL: this.avatarURL,
accessHashes: this.peer.type === "channel"
? Array.from(this.accessHashes)
: undefined,
},
}
}
/**
* Save this Portal to the database.
*/
save() {
return this.app.putRoom(this)
}
}
module.exports = Portal

View File

@@ -1,275 +0,0 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge
// Copyright (C) 2017 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
/**
* TelegramPeer represents some Telegram entity that can be messaged.
*
* The possible peer types are chat (groups), channel (includes supergroups) and user.
*/
class TelegramPeer {
constructor(type, id, { accessHash, receiverID, username, title } = {}) {
this.type = type
this.id = id
this.accessHash = accessHash
this.receiverID = receiverID
this.username = username
this.title = title
}
/**
* Create a TelegramPeer based on peer data received from Telegram.
*
* @param {Object} peer The data received from Telegram.
* @param {number} sender The user ID of the other person, in case the peer is an user referring to the receiver.
* @param {number} receiverID The user ID of the receiver (in case peer type is {@code user})
* @returns {TelegramPeer}
*/
static fromTelegramData(peer, sender, receiverID) {
switch (peer._) {
case "peerChat":
return new TelegramPeer("chat", peer.chat_id)
case "peerUser":
const args = {
accessHash: peer.access_hash,
receiverID,
}
if (sender === receiverID && peer.user_id !== receiverID) {
return new TelegramPeer("user", peer.user_id, args)
}
return new TelegramPeer("user", sender, args)
case "peerChannel":
return new TelegramPeer("channel", peer.channel_id, {
accessHash: peer.access_hash,
})
default:
throw new Error(`Unrecognized peer type ${peer._}`)
}
}
/**
* Load the access hash for a specific puppeted Telegram user from the channel portal or TelegramUser info.
*
* @param {MautrixTelegram} app The app main class instance.
* @param {TelegramPuppet} telegramPOV The puppeted Telegram user for whom the access hash is needed.
* @param {Portal} [portal] Optional channel {@link Portal} instance to avoid calling {@link app#getPortalByPeer(peer)}.
* Only used if {@link #type} is {@code user}.
* @param {TelegramUser} [user] Optional {@link TelegramUser} instance to avoid calling {@link app#getTelegramUser(id)}.
* Only used if {@link #type} is {@code channel}.
* @returns {boolean} Whether or not the access hash was found and loaded.
*/
async loadAccessHash(app, telegramPOV, { portal, user } = {}) {
if (this.type === "chat") {
return true
} else if (this.type === "user") {
user = user || await app.getTelegramUser(this.id)
if (user.accessHashes.has(telegramPOV.userID)) {
this.accessHash = user.accessHashes.get(telegramPOV.userID)
return true
}
return false
} else if (this.type === "channel") {
portal = portal || await app.getPortalByPeer(this)
if (portal.accessHashes.has(telegramPOV.userID)) {
this.accessHash = portal.accessHashes.get(telegramPOV.userID)
return true
}
return false
}
return false
}
/**
* Update info based on a Telegram dialog.
*
* @param dialog The dialog data sent by Telegram.
* @returns {boolean} Whether or not something was changed.
*/
async updateInfo(dialog) {
let changed = false
if (dialog.username && (this.type === "channel" || this.type === "user")) {
if (this.username !== dialog.username) {
this.username = dialog.username
changed = true
}
}
if (dialog.title && this.title !== dialog.title) {
this.title = dialog.title
changed = true
}
return changed
}
async fetchAccessHashFromServer(telegramPOV) {
const data = await this.getInfoFromDialogs(telegramPOV)
if (!data) {
return undefined
}
this.accessHash = data.access_hash
return this.accessHash
}
async getInfoFromDialogs(telegramPOV) {
const dialogs = await telegramPOV.client("messages.getDialogs", {})
if (this.type === "user") {
for (const user of dialogs.users) {
if (user.id === this.id) {
return user
}
}
} else {
for (const chat of dialogs.chats) {
if (chat.id === this.id) {
return chat
}
}
}
return undefined
}
/**
* Get info about this peer from the Telegram servers.
*
* @param {TelegramPuppet} telegramPOV The Telegram user whose point of view the data should be fetched from.
* @returns {{info: Object, users: Array<Object>}} The info sent by Telegram. For user-type peers, the users array
* is unnecessary.
*/
async getInfo(telegramPOV) {
let info, users
switch (this.type) {
case "user":
info = await telegramPOV.client("users.getFullUser", {
id: this.toInputObject(),
})
users = [info.user]
info = info.user
break
case "chat":
info = await telegramPOV.client("messages.getFullChat", {
chat_id: this.id,
})
users = info.users
info = info.chats[0]
break
case "channel":
info = await telegramPOV.client("channels.getFullChannel", {
channel: this.toInputObject(),
})
info = info.chats[0]
try {
const participants = await telegramPOV.client("channels.getParticipants", {
channel: this.toInputObject(),
filter: { _: "channelParticipantsRecent" },
offset: 0,
limit: 1000,
})
users = participants.users
} catch (err) {
// Getting channel participants apparently requires admin.
// TODO figure out what to do about that ^
users = []
}
break
default:
throw new Error(`Unknown peer type ${this.type}`)
}
return {
info,
users,
}
}
/**
* Create a Telegram InputPeer object based on the data in this TelegramPeer.
*
* @returns {Object} The Telegram InputPeer object.
*/
toInputPeer() {
switch (this.type) {
case "chat":
return {
_: "inputPeerChat",
chat_id: this.id,
}
case "user":
return {
_: "inputPeerUser",
user_id: this.id,
access_hash: this.accessHash,
}
case "channel":
return {
_: "inputPeerChannel",
channel_id: this.id,
access_hash: this.accessHash,
}
default:
throw new Error(`Unrecognized peer type ${this.type}`)
}
}
/**
* Create a Telegram input* object (i.e. inputUser or inputChannel) based on the data in this TelegramPeer.
*
* @returns {Object} The Telegram input* object.
*/
toInputObject() {
switch (this.type) {
case "chat":
throw new Error(`Unsupported type ${this.type}`)
case "user":
return {
_: "inputUser",
user_id: this.id,
access_hash: this.accessHash,
}
case "channel":
return {
_: "inputChannel",
channel_id: this.id,
access_hash: this.accessHash,
}
default:
throw new Error(`Unrecognized type ${this.type}`)
}
}
/**
* Load the data in a database subentry to a new TelegramPeer object.
*
* @param {Object} entry The database subentry.
* @returns {TelegramPeer} The created TelegramPeer object.
*/
static fromSubentry(entry) {
return new TelegramPeer(entry.type, entry.id, entry)
}
/**
* Convert this TelegramPeer into a subentry that can be stored in the database.
*
* @returns {Object} The database-storable subentry.
*/
toSubentry() {
return {
type: this.type,
id: this.id,
username: this.username,
title: this.title,
receiverID: this.receiverID,
}
}
}
module.exports = TelegramPeer

View File

@@ -1,580 +0,0 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge
// Copyright (C) 2017 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
const telegram = require("telegram-mtproto")
const { nextRandomInt } = require("telegram-mtproto/lib/bin")
const fileType = require("file-type")
const pkg = require("../package.json")
const TelegramPeer = require("./telegram-peer")
/**
* @module telegram-puppet
*/
/**
* Mapping from Telegram file types to MIME types and extensions.
* @private
*/
function metaFromFileType(type) {
const extension = type.substr("storage.file".length).toLowerCase()
let fileClass, mimetype, matrixtype
switch (type) {
case "storage.fileGif":
case "storage.fileJpeg":
case "storage.filePng":
case "storage.fileWebp":
fileClass = "image"
break
case "storage.fileMov":
mimetype = "quicktime"
case "storage.fileMp4":
fileClass = "video"
break
case "storage.fileMp3":
mimetype = "mpeg"
fileClass = "audio"
break
case "storage.filePartial":
throw new Error("Partial files should be completed before fetching their type.")
case "storage.fileUnknown":
fileClass = "application"
mimetype = "octet-stream"
matrixtype = "m.file"
break
default:
return undefined
}
mimetype = `${fileClass}/${mimetype || extension}`
matrixtype = matrixtype || `m.${fileClass}`
return { mimetype, extension, matrixtype }
}
/**
* Mapping from MIME type to Matrix file type. Used when determining MIME type and extension using magic numbers.
*
* @param {string} mime The MIME type.
* @returns {string} The corresponding Matrix file type.
* @private
*/
function matrixFromMime(mime) {
if (mime.startsWith("audio/")) {
return "m.audio"
} else if (mime.startsWith("video/")) {
return "m.video"
} else if (mime.startsWith("image/")) {
return "m.image"
}
return "m.file"
}
/**
* TelegramPuppet represents a Telegram account being controlled from Matrix.
*/
class TelegramPuppet {
constructor(app, { userID, matrixUser, data, api_hash, api_id, server_config, api_config }) {
this._client = undefined
this.userID = userID
this.matrixUser = matrixUser
this.data = data
this.app = app
this.serverConfig = Object.assign({}, server_config)
this.apiHash = api_hash
this.apiID = api_id
this.pts = 0
this.date = 0
this.lastID = 0
this.puppetStorage = {
get: async (key) => {
let value = this.data[key]
if (typeof value === "string" && value.startsWith("b64:")) {
value = Array.from(Buffer.from(value.substr("b64:".length), "base64"))
}
return value
},
set: async (key, value) => {
if (Array.isArray(value)) {
value = `b64:${Buffer.from(value).toString("base64")}`
}
if (this.data[key] === value) {
return
}
this.data[key] = value
await this.matrixUser.save()
},
remove: async (...keys) => {
keys.forEach((key) => delete this.data[key])
await this.matrixUser.save()
},
clear: async () => {
this.data = {}
await this.matrixUser.save()
},
}
this.apiConfig = Object.assign({}, {
app_version: pkg.version,
lang_code: "en",
api_id,
initConnection: 0x69796de9,
layer: 57,
invokeWithLayer: 0xda9b0d0d,
}, api_config)
if (this.data.dc && this.data[`dc${this.data.dc}_auth_key`]) {
this.listen()
}
}
static fromSubentry(app, matrixUser, data) {
const userID = data.userID
delete data.userID
return new TelegramPuppet(app, Object.assign({
userID,
matrixUser,
data,
}, app.config.telegram))
}
toSubentry() {
return Object.assign({
userID: this.userID,
}, this.data)
}
get client() {
if (!this._client) {
this._client = telegram.MTProto({
api: this.apiConfig,
server: this.serverConfig,
app: { storage: this.puppetStorage },
})
}
return this._client
}
async checkPhone(phone_number) {
try {
const status = this.client("auth.checkPhone", { phone_number })
if (status.phone_registered) {
return "registered"
}
return "unregistered"
} catch (err) {
if (err.message === "PHONE_NUMBER_INVALID") {
return "invalid"
}
throw err
}
}
sendCode(phone_number) {
return this.client("auth.sendCode", {
phone_number,
current_number: true,
api_id: this.apiID,
api_hash: this.apiHash,
})
}
logOut() {
clearInterval(this.loop)
return this.client("auth.logOut")
}
async signIn(phone_number, phone_code_hash, phone_code) {
try {
const result = await
this.client("auth.signIn", {
phone_number, phone_code, phone_code_hash,
})
return this.signInComplete(result)
} catch (err) {
if (err.type !== "SESSION_PASSWORD_NEEDED" && err.message !== "SESSION_PASSWORD_NEEDED") {
console.error("Unknown login error:", JSON.stringify(err, "", " "))
throw err
}
const password = await
this.client("account.getPassword", {})
return {
status: "need-password",
hint: password.hint,
salt: password.current_salt,
}
}
}
async checkPassword(password_hash) {
const result = await this.client("auth.checkPassword", { password_hash })
return this.signInComplete(result)
}
getDisplayName() {
if (this.data.firstName || this.data.lastName) {
return [this.data.firstName, this.data.lastName].filter(s => !!s).join(" ")
} else if (this.data.username) {
return this.data.username
}
return this.data.phone_number
}
signInComplete(data) {
this.userID = data.user.id
this.data.username = data.user.username
this.data.firstName = data.user.first_name
this.data.lastName = data.user.last_name
this.data.phoneNumber = data.user.phone_number
this.matrixUser.save()
this.listen()
return {
status: "ok",
}
}
async sendMessage(peer, message, entities) {
if (!message) {
throw new Error("Invalid parameter: message is undefined.")
}
const payload = {
peer: peer.toInputPeer(),
message,
entities,
random_id: [nextRandomInt(0xFFFFFFFF), nextRandomInt(0xFFFFFFFF)],
}
if (!payload.entities) {
// Everything breaks if we send undefined things :/
delete payload.entities
}
const result = await this.client("messages.sendMessage", payload)
return result
}
async sendMedia(peer, media) {
if (!media) {
throw new Error("Invalid parameter: media is undefined.")
}
const result = await this.client("messages.sendMedia", {
peer: peer.toInputPeer(),
media,
random_id: [nextRandomInt(0xFFFFFFFF), nextRandomInt(0xFFFFFFFF)],
})
// TODO use result? (maybe the ID)
return result
}
async onUpdate(update) {
if (!update) {
this.app.error("Oh noes! Empty update")
return
}
let to, from, portal
switch (update._) {
// Telegram user status handling.
case "updateUserStatus":
const user = await this.app.getTelegramUser(update.user_id)
const presence = update.status._ === "userStatusOnline" ? "online" : "offline"
await user.intent.getClient().setPresence({ presence })
return
//
// Telegram typing event handling
//
case "updateUserTyping":
to = new TelegramPeer("user", update.user_id, { receiverID: this.userID })
/* falls through */
case "updateChatUserTyping":
to = to || new TelegramPeer("chat", update.chat_id)
portal = await this.app.getPortalByPeer(to)
await portal.handleTelegramTyping({
from: update.user_id,
to,
source: this,
})
return
//
// Telegram message handling/parsing.
// The actual handling happens after the switch.
//
case "updateShortMessage":
to = new TelegramPeer("user", update.user_id, { receiverID: this.userID })
from = update.out ? this.userID : update.user_id
break
case "updateShortChatMessage":
to = new TelegramPeer("chat", update.chat_id)
from = update.from_id
break
case "updateNewChannelMessage":
// TODO use message.post_author
from = -1
case "updateNewMessage":
this.pts = update.pts
update = update.message // Message defined at message#90dddc11 in layer 71
from = update.from_id || from
to = TelegramPeer.fromTelegramData(update.to_id, update.from_id, this.userID)
break
case "updateReadMessages":
case "updateReadHistoryOutbox":
case "updateReadHistoryInbox":
case "updateDeleteMessages":
case "updateRestoreMessages":
// TODO we probably want to handle those five updates properly
this.pts = update.pts
return
case "updateDraftMessage":
this.app.debug("yellow", `Message draft received: ${JSON.stringify(update, "", " ")}`)
// Ignore, we can't do anything with drafts.
return
default:
// Unknown update type
this.app.warn(`Update of unknown type ${update._} received: ${JSON.stringify(update, "", " ")}`)
return
}
if (!to) {
// This shouldn't happen
this.app.warn("No target found for update", update)
return
}
if (update._ === "messageService" && update.action._ === "messageActionChannelMigrateFrom") {
return
}
portal = await this.app.getPortalByPeer(to)
if (update._ === "messageService") {
await portal.handleTelegramServiceMessage({
from,
to,
source: this,
action: update.action,
})
return
}
await portal.handleTelegramMessage({
from,
to,
id: update.id,
date: update.date,
fwdFrom: update.fwd_from ? update.fwd_from.from_id : 0,
source: this,
text: update.message,
entities: update.entities,
photo: update.media && update.media._ === "messageMediaPhoto"
? update.media.photo
: undefined,
document: update.media && update.media._ === "messageMediaDocument"
? update.media.document
: undefined,
geo: update.media && update.media._ === "messageMediaGeo"
? update.media.geo
: undefined,
caption: update.media
? update.media.caption
: undefined,
})
}
async receiveUsers(users) {
this.app.debug("green", "Handling received users:", JSON.stringify(users, "", " "))
for (const user of users) {
const telegramUser = await this.app.getTelegramUser(user.id)
await telegramUser.updateInfo(this, user, true)
}
}
async receiveChats(chats) {
this.app.debug("green", "Handling received chats:", JSON.stringify(chats, "", " "))
for (const chat of chats) {
const peer = new TelegramPeer(chat._, chat.id, {
accessHash: chat.access_hash,
})
const portal = await this.app.getPortalByPeer(peer)
await portal.updateInfo(this, chat)
}
}
async handleUpdatesTooLong() {
if (this.pts === 0 || this.date === 0) {
this.app.warn("updatesTooLong received, but we don't have timestamps :(")
return
}
this.app.debug("magenta", "Handling updatesTooLong", this.pts, this.date)
const data = await this.client("updates.getDifference", {
pts: this.pts,
date: this.date,
qts: -1,
})
if (data._ === "updates.differenceEmpty") {
this.date = data.date
return
}
await this.receiveUsers(data.users)
await this.receiveChats(data.chats)
const state = data.state || data.intermediate_state
this.app.debug("cyan", `updates.getDifference -> ${data._}`)
this.app.debug("cyan", "====================================================================================================================================================")
this.app.debug("magenta", `diff.new_messages: ${JSON.stringify(data.new_messages, "", " ")}`)
this.app.debug("cyan", "====================================================================================================================================================")
this.app.debug("magenta", `diff.other_updates: ${JSON.stringify(data.other_updates, "", " ")}`)
this.app.debug("cyan", "====================================================================================================================================================")
this.app.debug("cyan", `Current timestamps: pts=${this.pts}, date=${this.date}, unix=${Date.now() / 1000}`)
this.app.debug("magenta", `diff.state: ${JSON.stringify(state, "", " ")}`)
this.pts = state.pts
this.date = state.date
/*for (const message of data.new_messages) {
await this.onUpdate({
_: "updateNewMessage",
pts: this.pts,
message,
})
}*/
for (const update of data.other_updates) {
await this.onUpdate(update)
}
if (data._ === "updates.differenceSlice") {
//await this.handleUpdatesTooLong()
}
}
async handleUpdate(data) {
if (!data.update || data.update._ !== "updateUserStatus") {
this.app.debug("green", "Raw event for", this.userID, JSON.stringify(data, "", " "))
}
try {
switch (data._) {
case "updateShort":
this.date = data.date
await this.onUpdate(data.update)
break
case "updates":
this.date = data.date
await this.receiveUsers(data.users)
await this.receiveChats(data.chats)
for (const update of data.updates) {
await this.onUpdate(update)
}
break
case "updateShortMessage":
case "updateShortChatMessage":
await this.onUpdate(data)
break
case "updatesTooLong":
await this.handleUpdatesTooLong()
break
default:
this.app.warn("Unrecognized update type:", data._)
}
} catch (err) {
this.app.warn("Error handling update:", err)
}
}
async listen() {
this.client.bus.untypedMessage.observe(data => this.handleUpdate(data.message))
try {
// FIXME updating status crashes or freezes
//console.log("Updating online status...")
//const statusUpdate = await this.client("account.updateStatus", { offline: false })
//console.log(statusUpdate)
this.app.info("Fetching initial state...")
const state = await this.client("updates.getState", {})
this.pts = state.pts
this.date = state.date
this.app.debug("green", "Initial state:", JSON.stringify(state, "", " "))
} catch (err) {
console.error("Error getting initial state:", err)
}
try {
this.app.info("Updating contact list...")
const changed = await this.matrixUser.syncContacts()
if (!changed) {
this.app.info("Contacts were up-to-date")
} else {
this.app.info("Contacts updated")
}
} catch (err) {
console.error("Failed to update contacts:", err)
}
try {
this.app.info("Updating dialogs...")
const changed = await this.matrixUser.syncChats()
if (!changed) {
this.app.info("Dialogs were up-to-date")
} else {
this.app.info("Dialogs updated")
}
} catch (err) {
console.error("Failed to update dialogs:", err)
}
this.loop = setInterval(async () => {
try {
await this.client("updates.getState", {})
} catch (err) {
console.error("Error updating state:", err)
console.error(err.stack)
}
}, 1000)
}
async uploadFile() {
}
async getFile(location) {
if (location.volume_id && location.local_id) {
location = {
_: "inputFileLocation",
volume_id: location.volume_id,
local_id: location.local_id,
secret: location.secret,
}
} else if (location.id && location.access_hash) {
location = {
_: "inputDocumentFileLocation",
id: location.id,
access_hash: location.access_hash,
}
} else {
throw new Error("Unrecognized file location type.")
}
const file = await this.client("upload.getFile", {
location,
offset: 0,
// Max download size: 100mb
limit: 100 * 1024 * 1024,
})
file.buffer = Buffer.from(file.bytes)
if (file.type._ === "storage.filePartial") {
const { mime, ext } = fileType(file.buffer)
file.mimetype = mime
file.extension = ext
file.matrixtype = matrixFromMime(mime)
} else {
const meta = metaFromFileType(file.type._)
if (meta) {
file.mimetype = meta.mimetype
file.extension = meta.extension
file.matrixtype = meta.matrixtype
}
}
return file
}
}
module.exports = TelegramPuppet

View File

@@ -1,256 +0,0 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge
// Copyright (C) 2017 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
const sanitizeHTML = require("sanitize-html")
const TelegramPeer = require("./telegram-peer")
/**
* TelegramUser represents a Telegram user who probably has an
* appservice-managed Matrix account.
*/
class TelegramUser {
constructor(app, id, user) {
this.app = app
this.id = id
this.accessHashes = new Map()
this._intent = undefined
if (user) {
this.updateInfo(undefined, user)
}
}
static fromEntry(app, entry) {
if (entry.type !== "remote") {
throw new Error("TelegramUser can only be created from entry type \"remote\"")
}
const user = new TelegramUser(app, entry.id)
const data = entry.data
user.firstName = data.firstName
user.lastName = data.lastName
user.username = data.username
user.phoneNumber = data.phoneNumber
user.photo = data.photo
user.avatarURL = data.avatarURL
user.accessHashes = new Map(data.accessHashes)
return user
}
toPeer(telegramPOV) {
return new TelegramPeer("user", this.id, {
accessHash: this.accessHashes.get(telegramPOV.userID),
receiverID: telegramPOV.userID,
})
}
toEntry() {
return {
type: "remote",
id: this.id,
data: {
firstName: this.firstName,
lastName: this.lastName,
username: this.username,
phoneNumber: this.phoneNumber,
photo: this.photo,
avatarURL: this.avatarURL,
accessHashes: Array.from(this.accessHashes),
},
}
}
async updateInfo(telegramPOV, user, { updateAvatar = false } = {}) {
if (!user) {
this.app.warn("updateInfo called without user data")
user = await telegramPOV.client("users.getFullUser", {
id: this.toPeer(telegramPOV).toInputObject(),
})
if (!user) {
throw new Error("User data not given and fetching data failed")
}
}
let changed = false
if (user.first_name || user.last_name || user.username) {
if (this.firstName !== user.first_name) {
this.firstName = user.first_name
changed = true
}
if (this.lastName !== user.last_name) {
this.lastName = user.last_name
changed = true
}
if (user.username && this.username !== user.username) {
this.username = user.username
changed = true
}
}
if (user.access_hash && telegramPOV && this.accessHashes.get(telegramPOV.userID) !== user.access_hash) {
this.accessHashes.set(telegramPOV.userID, user.access_hash)
changed = true
}
const userInfo = await this.intent.getProfileInfo(this.mxid, "displayname")
if (userInfo.displayname !== this.getDisplayName()) {
this.intent.setDisplayName(this.app.config.bridge.displayname_template
.replace("${DISPLAYNAME}", this.getDisplayName()))
}
if (updateAvatar && this.updateAvatar(telegramPOV, user)) {
changed = true
}
if (changed) {
this.save()
}
return changed
}
get intent() {
if (!this._intent) {
this._intent = this.app.getIntentForTelegramUser(this.id)
}
return this._intent
}
get mxid() {
return this.intent.client.credentials.userId
}
getFirstAndLastName() {
return [this.firstName, this.lastName].filter(s => !!s).join(" ")
}
getLastAndFirstName() {
return [this.lastName, this.firstName].filter(s => !!s).join(" ")
}
getDisplayName() {
for (const preference of this.app.config.bridge.displayname_preference) {
if (preference === "fullName") {
if (this.firstName || this.lastName) {
return this.getFirstAndLastName()
}
} else if (preference === "fullNameReversed") {
if (this.firstName || this.lastName) {
return this.getLastAndFirstName()
}
} else if (this[preference]) {
return this[preference]
}
}
return this.id
}
save() {
return this.app.putUser(this)
}
sendHTML(roomID, html) {
return this.intent.sendMessage(roomID, {
msgtype: "m.text",
format: "org.matrix.custom.html",
formatted_body: html,
body: sanitizeHTML(html),
})
}
sendNotice(roomID, text) {
return this.intent.sendMessage(roomID, {
msgtype: "m.notice",
body: text,
})
}
sendEmote(roomID, text) {
return this.intent.sendMessage(roomID, {
msgtype: "m.emote",
body: text,
})
}
sendText(roomID, text) {
return this.intent.sendText(roomID, text)
}
sendFile(roomID, file) {
return this.intent.sendMessage(roomID, {
msgtype: file.matrixtype || "m.file",
url: file.content_uri,
body: file.name || "Uploaded file",
info: file.info,
})
}
sendLocation(roomID, { long = 0.0, lat = 0.0, body } = {}) {
if (!body) {
const longChar = long > 0 ? "E" : "W"
const latChar = lat > 0 ? "N" : "S"
const roundedLong = Math.abs(Math.round(long * 100000) / 100000)
const roundedLat = Math.abs(Math.round(lat * 100000) / 100000)
body = `Location: ${roundedLat}° ${latChar}, ${roundedLong}° ${longChar}`
}
return this.intent.sendMessage(roomID, {
msgtype: "m.location",
geo_uri: `geo:${lat},${long}`,
body,
})
}
uploadContent(opts) {
return this.intent.getClient()
.uploadContent({
stream: opts.stream,
name: opts.name,
type: opts.type,
}, {
rawResponse: false,
})
}
async updateAvatar(telegramPOV, user) {
if (!user.photo) {
return false
}
const photo = user.photo.photo_big
if (this.photo && this.avatarURL &&
this.photo.dc_id === photo.dc_id &&
this.photo.volume_id === photo.volume_id &&
this.photo.local_id === photo.local_id) {
return false
}
const file = await telegramPOV.getFile(photo)
const name = `${photo.volume_id}_${photo.local_id}.${file.extension}`
const uploaded = await this.uploadContent({
stream: Buffer.from(file.bytes),
name,
type: file.mimetype,
})
this.avatarURL = uploaded.content_uri
this.photo = {
dc_id: photo.dc_id,
volume_id: photo.volume_id,
local_id: photo.local_id,
}
await this.intent.setAvatarUrl(this.avatarURL)
await this.save()
return true
}
}
module.exports = TelegramUser