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