Handle Telegram membership events

This commit is contained in:
Tulir Asokan
2017-11-30 20:30:21 +02:00
parent 06a1b0a79e
commit 639ed0e4a8
5 changed files with 239 additions and 52 deletions
+5 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {