Handle Telegram membership events
This commit is contained in:
@@ -34,6 +34,8 @@ A Telegram chat will be created once the bridge is stable enough.
|
||||
* [ ] Message redactions
|
||||
* [ ] Presence (currently always shown as online on Telegram)
|
||||
* [ ] Typing notifications (may not be possible)
|
||||
* [ ] Membership actions (invite, kick, join, leave)
|
||||
* [ ] Pinning messages
|
||||
* [ ] Power level
|
||||
* Telegram → Matrix
|
||||
* [x] Plaintext messages
|
||||
@@ -46,16 +48,17 @@ A Telegram chat will be created once the bridge is stable enough.
|
||||
* [x] Locations
|
||||
* [x] Presence
|
||||
* [x] Typing notifications
|
||||
* [ ] Pinning messages
|
||||
* [ ] Message edits
|
||||
* [ ] Message deletions
|
||||
* [ ] Admin status
|
||||
* [x] Initial group/channel name/description
|
||||
* [x] Membership actions (invite, kick, join, leave)
|
||||
* [ ] Group/channel name/description changes
|
||||
* Initiating chats
|
||||
* [x] Automatic portal creation for groups/channels at startup
|
||||
* [ ] Automatic portal creation for groups/channels when receiving invite/message/etc
|
||||
* [x] Automatic portal creation for groups/channels when receiving invite/message
|
||||
* [x] Private chat creation by inviting Telegram user to new room
|
||||
* [ ] Inviting Telegram users to group/channel portals
|
||||
* [ ] Joining public channels/supergroups using room aliases
|
||||
* [x] Searching for Telegram users using management commands
|
||||
* Misc
|
||||
|
||||
+98
-27
@@ -20,6 +20,7 @@ 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")
|
||||
|
||||
/**
|
||||
@@ -40,6 +41,12 @@ class MautrixTelegram {
|
||||
* @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
|
||||
@@ -82,10 +89,10 @@ class MautrixTelegram {
|
||||
domain: config.homeserver.domain,
|
||||
registration: config.appservice.registration,
|
||||
controller: {
|
||||
onUserQuery(user) {
|
||||
onUserQuery(/*user*/) {
|
||||
return {}
|
||||
},
|
||||
async onEvent(request, context) {
|
||||
async onEvent(request/*, context*/) {
|
||||
try {
|
||||
await self.handleMatrixEvent(request.getData())
|
||||
} catch (err) {
|
||||
@@ -159,15 +166,31 @@ class MautrixTelegram {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a {@link Portal} by Telegram peer.
|
||||
* 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.
|
||||
*
|
||||
* @param {TelegramPeer} peer The TelegramPeer object whose portal to get.
|
||||
* @returns {Portal} The Portal object.
|
||||
* 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
|
||||
@@ -180,8 +203,7 @@ class MautrixTelegram {
|
||||
if (peer.type === "user") {
|
||||
query.receiverID = peer.receiverID
|
||||
}
|
||||
const entries = await this.bridge.getRoomStore()
|
||||
.select(query)
|
||||
const entries = await this.bridge.getRoomStore().select(query)
|
||||
|
||||
// Handle possible db query race conditions
|
||||
portal = this.portalsByPeerID.get(peer.id)
|
||||
@@ -223,16 +245,15 @@ class MautrixTelegram {
|
||||
// FIXME this is probably useless
|
||||
for (const [_, portalByPeer] of this.portalsByPeerID) {
|
||||
if (portalByPeer.roomID === id) {
|
||||
this.portalsByRoomID.set(id, portal)
|
||||
this.portalsByRoomID.set(id, portalByPeer)
|
||||
return portalByPeer
|
||||
}
|
||||
}
|
||||
|
||||
const entries = await this.bridge.getRoomStore()
|
||||
.select({
|
||||
type: "portal",
|
||||
roomID: id,
|
||||
})
|
||||
const entries = await this.bridge.getRoomStore().select({
|
||||
type: "portal",
|
||||
roomID: id,
|
||||
})
|
||||
|
||||
// Handle possible db query race conditions
|
||||
portal = this.portalsByRoomID.get(id)
|
||||
@@ -262,7 +283,7 @@ class MautrixTelegram {
|
||||
*/
|
||||
async getTelegramUser(id, { createIfNotFound = true } = {}) {
|
||||
// TODO remove this after bugs are fixed
|
||||
if (isNaN(parseInt(id))) {
|
||||
if (isNaN(parseInt(id, 10))) {
|
||||
const err = new Error("Fatal: non-int Telegram user ID")
|
||||
console.error(err.stack)
|
||||
throw err
|
||||
@@ -272,11 +293,10 @@ class MautrixTelegram {
|
||||
return user
|
||||
}
|
||||
|
||||
const entries = await this.bridge.getUserStore()
|
||||
.select({
|
||||
type: "remote",
|
||||
id,
|
||||
})
|
||||
const entries = await this.bridge.getUserStore().select({
|
||||
type: "remote",
|
||||
id,
|
||||
})
|
||||
|
||||
// Handle possible db query race conditions
|
||||
if (this.telegramUsersByID.has(id)) {
|
||||
@@ -294,6 +314,55 @@ class MautrixTelegram {
|
||||
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) {
|
||||
console.log("Searching for Matrix user by Telegram ID", id)
|
||||
let user = this.matrixUsersByTelegramID.get(id)
|
||||
if (user) {
|
||||
console.log("Found in cache", user.userID)
|
||||
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) {
|
||||
console.log("Found in MXID cache", userByMXID.userID)
|
||||
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)) {
|
||||
console.log("Found in cache after race", user.userID)
|
||||
return this.matrixUsersByTelegramID.get(id)
|
||||
}
|
||||
|
||||
if (entries.length) {
|
||||
console.log("Found in db", user.userID)
|
||||
user = MatrixUser.fromEntry(this, entries[0])
|
||||
} else {
|
||||
console.log("Not found :(")
|
||||
return undefined
|
||||
}
|
||||
this.matrixUsersByID.set(user.userID, user)
|
||||
this.matrixUsersByTelegramID.set(id, user)
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a {@link MatrixUser} by ID.
|
||||
*
|
||||
@@ -309,11 +378,10 @@ class MautrixTelegram {
|
||||
return user
|
||||
}
|
||||
|
||||
const entries = this.bridge.getUserStore()
|
||||
.select({
|
||||
type: "matrix",
|
||||
id,
|
||||
})
|
||||
const entries = this.bridge.getUserStore().select({
|
||||
type: "matrix",
|
||||
id,
|
||||
})
|
||||
|
||||
// Handle possible db query race conditions
|
||||
if (this.matrixUsersByID.has(id)) {
|
||||
@@ -328,6 +396,9 @@ class MautrixTelegram {
|
||||
return undefined
|
||||
}
|
||||
this.matrixUsersByID.set(id, user)
|
||||
if (user.telegramUserID) {
|
||||
this.matrixUsersByID.set(user.telegramUserID, user)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
@@ -385,7 +456,6 @@ class MautrixTelegram {
|
||||
* @param {MatrixEvent} evt The invite event.
|
||||
*/
|
||||
async handleInvite(sender, evt) {
|
||||
console.log(evt)
|
||||
const asBotID = this.bridge.getBot().getUserId()
|
||||
if (evt.state_key === asBotID) {
|
||||
// Accept all AS bot invites.
|
||||
@@ -399,16 +469,17 @@ class MautrixTelegram {
|
||||
}
|
||||
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)
|
||||
console.log(capture)
|
||||
if (!capture) {
|
||||
return
|
||||
}
|
||||
|
||||
const telegramID = +capture[1]
|
||||
console.log(telegramID)
|
||||
if (!telegramID || isNaN(telegramID)) {
|
||||
return
|
||||
}
|
||||
|
||||
+57
-10
@@ -32,9 +32,16 @@ class MatrixUser {
|
||||
this.commandStatus = undefined
|
||||
this.puppetData = undefined
|
||||
this.contacts = []
|
||||
this.chats = []
|
||||
this._telegramPuppet = undefined
|
||||
}
|
||||
|
||||
get telegramUserID() {
|
||||
return this._telegramPuppet
|
||||
? this._telegramPuppet.userID || undefined
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a database entry into a MatrixUser.
|
||||
*
|
||||
@@ -50,7 +57,8 @@ class MatrixUser {
|
||||
const user = new MatrixUser(app, entry.id)
|
||||
user.phoneNumber = entry.data.phoneNumber
|
||||
user.phoneCodeHash = entry.data.phoneCodeHash
|
||||
user.contactIDs = entry.data.contactIDs
|
||||
user.setContactIDs(entry.data.contactIDs)
|
||||
user.setChatIDs(entry.data.chatIDs)
|
||||
if (entry.data.puppet) {
|
||||
user.puppetData = entry.data.puppet
|
||||
// Create the telegram puppet instance
|
||||
@@ -71,10 +79,12 @@ class MatrixUser {
|
||||
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,
|
||||
},
|
||||
}
|
||||
@@ -102,18 +112,37 @@ class MatrixUser {
|
||||
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.
|
||||
*/
|
||||
set contactIDs(list) {
|
||||
// FIXME This is somewhat dangerous
|
||||
setTimeout(async () => {
|
||||
if (list) {
|
||||
this.contacts = await Promise.all(list.map(id => this.app.getTelegramUser(id)))
|
||||
}
|
||||
}, 0)
|
||||
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)))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,16 +168,17 @@ class MatrixUser {
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize the dialogs (groups, channels) of this user.
|
||||
* 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 syncDialogs({ createRooms = true } = {}) {
|
||||
async syncChats({ createRooms = true } = {}) {
|
||||
const dialogs = await this.telegramPuppet.client("messages.getDialogs", {})
|
||||
let changed = false
|
||||
this.chats = []
|
||||
for (const dialog of dialogs.chats) {
|
||||
if (dialog._ === "chatForbidden" || dialog.deactivated) {
|
||||
continue
|
||||
@@ -158,6 +188,7 @@ class MatrixUser {
|
||||
if (await portal.updateInfo(this.telegramPuppet, dialog)) {
|
||||
changed = true
|
||||
}
|
||||
this.chats.push(portal)
|
||||
if (createRooms) {
|
||||
try {
|
||||
await portal.createMatrixRoom(this.telegramPuppet, {
|
||||
@@ -169,9 +200,25 @@ class MatrixUser {
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.save()
|
||||
return changed
|
||||
}
|
||||
|
||||
async join(portal) {
|
||||
if (!this.chats.includes(portal.id)) {
|
||||
this.chats.push(portal.id)
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
|
||||
async leave(portal) {
|
||||
const chatIDIndex = this.chats.indexOf(portal.id)
|
||||
if (chatIDIndex > -1) {
|
||||
this.chats.splice(chatIDIndex, 1)
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for contacts of this user.
|
||||
*
|
||||
|
||||
+67
-10
@@ -141,6 +141,38 @@ class Portal {
|
||||
typer.intent.sendTyping(this.roomID, true/*, 5500*/)
|
||||
}
|
||||
|
||||
async handleTelegramServiceMessage(evt) {
|
||||
let matrixUser, telegramUser
|
||||
switch (evt.action._) {
|
||||
case "messageActionChatCreate":
|
||||
await this.createMatrixRoom(evt.source, { invite: [evt.source.matrixUser.userID] })
|
||||
break
|
||||
case "messageActionChatDeleteUser":
|
||||
matrixUser = await this.app.getMatrixUserByTelegramID(evt.action.user_id)
|
||||
if (matrixUser) {
|
||||
matrixUser.leave(this)
|
||||
this.kick(matrixUser.userID, "Left Telegram chat")
|
||||
}
|
||||
telegramUser = await this.app.getTelegramUser(evt.action.user_id)
|
||||
telegramUser.intent.leave(this.roomID)
|
||||
break
|
||||
case "messageActionChatAddUser":
|
||||
for (const userID of evt.action.users) {
|
||||
matrixUser = await this.app.getMatrixUserByTelegramID(userID)
|
||||
if (matrixUser) {
|
||||
matrixUser.join(this)
|
||||
this.invite(matrixUser.userID)
|
||||
}
|
||||
telegramUser = await this.app.getTelegramUser(userID)
|
||||
telegramUser.intent.join(this.roomID)
|
||||
}
|
||||
break
|
||||
default:
|
||||
console.log("Unhandled service message of type", evt.action._)
|
||||
console.log(evt.action)
|
||||
}
|
||||
}
|
||||
|
||||
async handleTelegramMessage(evt) {
|
||||
if (!this.isMatrixRoomCreated()) {
|
||||
try {
|
||||
@@ -156,7 +188,7 @@ class Portal {
|
||||
const sender = await this.app.getTelegramUser(evt.from)
|
||||
await sender.intent.sendTyping(this.roomID, false)
|
||||
|
||||
if (evt.text.length > 0) {
|
||||
if (evt.text && evt.text.length > 0) {
|
||||
if (evt.entities) {
|
||||
evt.html = formatter.telegramToMatrix(evt.text, evt.entities)
|
||||
sender.sendHTML(this.roomID, evt.html)
|
||||
@@ -218,16 +250,43 @@ class Portal {
|
||||
return !!this.roomID
|
||||
}
|
||||
|
||||
async getMainIntent() {
|
||||
return this.peer.type === "user"
|
||||
? (await this.app.getTelegramUser(this.peer.id)).intent
|
||||
: this.app.botIntent
|
||||
}
|
||||
|
||||
async invite(users) {
|
||||
const intent = await this.getMainIntent()
|
||||
// TODO check membership before inviting?
|
||||
if (Array.isArray(users)) {
|
||||
for (const userID of users) {
|
||||
if (typeof userID === "string") {
|
||||
intent.invite(this.roomID, userID)
|
||||
}
|
||||
}
|
||||
} else if (typeof users === "string") {
|
||||
intent.invite(this.roomID, users)
|
||||
}
|
||||
}
|
||||
|
||||
async kick(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 createMatrixRoom(telegramPOV, { invite = [], inviteEvenIfNotCreated = true } = {}) {
|
||||
if (this.roomID) {
|
||||
if (invite && inviteEvenIfNotCreated) {
|
||||
const intent = this.peer.type === "user"
|
||||
? (await this.app.getTelegramUser(this.peer.id)).intent
|
||||
: this.app.botIntent
|
||||
for (const userID of invite) {
|
||||
// TODO check membership before inviting?
|
||||
intent.invite(this.roomID, userID)
|
||||
}
|
||||
await this.invite(invite)
|
||||
}
|
||||
return {
|
||||
created: false,
|
||||
@@ -235,9 +294,7 @@ class Portal {
|
||||
}
|
||||
}
|
||||
if (this.creatingMatrixRoom) {
|
||||
console.log("Ongoing room creation detected!")
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
console.log("Ongoing room creation waited for,", this.roomID)
|
||||
return {
|
||||
created: false,
|
||||
roomID: this.roomID,
|
||||
|
||||
+12
-3
@@ -319,6 +319,15 @@ class TelegramPuppet {
|
||||
|
||||
console.log(update)
|
||||
portal = await this.app.getPortalByPeer(to)
|
||||
|
||||
if (update._ === "messageService") {
|
||||
await portal.handleTelegramServiceMessage({
|
||||
from,
|
||||
to,
|
||||
action: update.action,
|
||||
})
|
||||
return
|
||||
}
|
||||
await portal.handleTelegramMessage({
|
||||
from,
|
||||
to,
|
||||
@@ -334,8 +343,8 @@ class TelegramPuppet {
|
||||
geo: update.media && update.media._ === "messageMediaGeo"
|
||||
? update.media.geo
|
||||
: undefined,
|
||||
caption: update.media ?
|
||||
update.media.caption
|
||||
caption: update.media
|
||||
? update.media.caption
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
@@ -391,7 +400,7 @@ class TelegramPuppet {
|
||||
}
|
||||
try {
|
||||
console.log("Updating dialogs...")
|
||||
const changed = await this.matrixUser.syncDialogs()
|
||||
const changed = await this.matrixUser.syncChats()
|
||||
if (!changed) {
|
||||
console.log("Dialogs were up-to-date")
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user