adaptorex
Version:
Connect all your live interactive storytelling devices and software
1,176 lines (1,068 loc) • 39.9 kB
JavaScript
/**
* @requires plugin
* @requires client
* @requires logic
*
* @requires file
*
* @module telegram/telegram
* @copyright Lasse Marburg 2023
* @license MIT
*/
const file = require('../../file.js')
const logic = require('../logic/logic.js')
const messenger = require('../messenger.js')
var schema = require('./schema.json')
const path = require('path')
const client = require('./client.js')
/** @typedef {import('./client.js').Account} Account */
/** @typedef {import('../../game.js').Game} Game */
/** @typedef {import('../../session.js').Session} Session */
/**
* interface to telegram API using telethon python library through child process
*
* @class Telegram
* @extends {messenger.Messenger}
*/
class Telegram extends messenger.Messenger {
constructor() {
super(schema, 'telegram.id')
}
/**
* if not exists, create chats collection
* adjust account and bot add functions
* run init
*
* @returns {Promise} - resolves with current schema when ready
*/
setup(config, game) {
return new Promise((resolve, reject) => {
file.mkdir(game.data + '/telegram_sessions')
super.setup(config, game, {accounts: client.Account, bots: client.Bot})
.then(result => {
return this.game.addDataCollection("chats")
})
.then(result => {
this.addDialogTemplate("onMessage")
this.addTemplate("editChat", (payload, action) => {
const title = `telegram ${action.name}`
const regex = new RegExp(`${action.plugin}\.[a-zA-Z]*\.`);
let subtitle = `Edit ${payload.chat} with ${payload.agent?.replace(regex, '')}`
const body = []
if (payload.add_members) {
body.push({text: `add members: ${payload.add_members.join(', ')}`})
}
if (payload.remove_members) {
body.push({text: `remove members: ${payload.remove_members.join(', ')}`})
}
if (payload.title) {
body.push({text: `title: ${payload.title}`})
}
if (payload.photo) {
body.push({text: `photo: ${payload.photo}`})
}
if (payload.permissions) {
body.push({text: `change permissions`})
}
return {title, subtitle, body}
})
this.addTemplate("buttons", (payload, action) => {
const title = `${action.plugin} ${action.name}`;
const regex = new RegExp(`${action.plugin}\.[a-zA-Z]*\.`)
let subtitle = `${payload.type} buttons from ${payload.agent?.replace(regex, '')} to ${payload.to}`;
const body = [{text:payload.message}]
if(payload.buttons && payload.buttons.length) {
for (const [i, row] of payload.buttons.entries()) {
if(row && row.length) {
for(const [b, button] of row.entries()) {
text = `${i+1}.${b+1}. ${button.label}`
if(button.respond) {
text += ` (${button.respond.length} ${button.respond.length == 1 ? "reply" : "replies"})`
}
body.push({text:text, next:button.next})
}
}
}
}
return { title, subtitle, body }
})
return this.load({})
})
.then(result => {
return resolve(this.schema)
})
.catch(error => {
return reject(error)
})
})
}
/**
* quit and rerun init if settings changed
*
* @param {Object} settings - api id and api hash for telegram developer account
*
*/
async update(settings) {
if(settings.api_hash != this.settings.api_hash || settings.api_id != this.settings.api_id) {
this.settings.api_id = settings.api_id // assign separate values to not overwrite settings reference. Only this way values will also change for item.
this.settings.api_hash = settings.api_hash
for(let account in this.accounts.items) {
this.log.info("Reconnecting account " + this.accounts.items[account].name)
await this.accounts.items[account].setup()
await this.accounts.items[account].connect()
}
for(let bot in this.bots.items) {
this.log.info("Reconnecting bot " + this.bots.items[bot].name)
await this.bots.items[bot].setup()
await this.bots.items[bot].connect()
}
}
}
async sendMessage(data, session) {
let msg = new Message(data, session, this.game)
await msg.setup([this.accounts, this.bots])
await msg.sendMessage()
}
async sendFile(data, session) {
let msg = new Message(data, session, this.game)
await msg.setup([this.accounts, this.bots])
await msg.sendFile()
}
async sendGeo(data, session) {
let msg = new Message(data, session, this.game)
await msg.setup([this.accounts, this.bots])
await msg.sendGeo()
}
async sendPoll(data, session) {
let msg = new Message(data, session, this.game)
await msg.setup([this.accounts, this.bots])
await msg.sendPoll()
}
async castVote(data, session) {
let msg = new Message(data, session, this.game)
await msg.setup([this.accounts, this.bots])
await msg.sendVote()
}
async sendQuiz(data, session) {
let msg = new Message(data, session, this.game)
await msg.setup([this.accounts, this.bots])
await msg.sendQuiz()
}
async buttons(data, session) {
let route = "button"
if(data.type == "keyboard") {
route = "message"
data.if = []
for(let row of data.buttons) {
for(let button of row) {
if(button.respond || button.next) {
if(!data.if) {
data.if = []
}
data.if.push({
"equals":button.label,
"case_sensitive":true,
"respond":button.respond,
"next":button.next
})
}
}
}
}
let dialog = new ButtonDialog(data, session, this.game, route)
await dialog.setup([this.bots])
// Do not (re-)send buttons on restart if action is muted
if(session.action.status == "active") {
await dialog.sendButtons(data)
}
return {cancel: dialog.cancel.bind(dialog), mute: dialog.mute.bind(dialog), unmute: dialog.unmute.bind(dialog)}
}
async onMessage(data, session) {
let dialog = new Dialog(data, session, this.game)
await dialog.setup([this.accounts, this.bots])
return {cancel: dialog.cancel.bind(dialog), mute: dialog.mute.bind(dialog), unmute: dialog.unmute.bind(dialog)}
}
async createChat(data, session) {
let chat = new Chat(data, session, this.game)
await chat.setup([this.accounts, this.bots])
await chat.create()
}
async editChat(data, session) {
let chat = new Chat(data, session, this.game)
await chat.setup([this.accounts, this.bots])
await chat.edit()
}
command(input) {
switch(input[0]) {
default:
super.command(input)
break
}
}
}
/**
* temporary session of two members sending 1-n messages/responses back and forth.
*
* requires an agent that is an existing account and a reference to a chat document.
* Referencing a player is possible. The player needs a telegram.id or at least a phone_number property.
*
* after resolving the references in setup:
* - this.chat is the chat document object.
* - this.chat_id is the chat or player telegram id or phone_number
*
*
* @class Conversation
*/
class Conversation extends messenger.Conversation {
constructor(data, session, game, route) {
super(data, session, game, route)
/**
* The telegram client account API
* @type {Account}
*/
this.agent
if (this.hasOwnProperty("chat")) {
this.chat_reference = this.chat
delete this.chat
} else if (this.hasOwnProperty("to")) {
this.chat_reference = this.to
}
if (this.hasOwnProperty("agent")) {
this.agent_reference = this.agent
delete this.agent
}
this.game = game
}
/**
* get telegram chat compatible identifier.
*
* if the identified document has a `telegram_contacts` property it is treated as a telegram user.
* getId will extract as many information as possible from the user and try to add it as a contact as long as that didn't happen before.
*
* Different accounts will all access the same telegram property. Adding the accounts name to `telegram_contacts` will make sure,
* that only accounts that did not have contact with the user before will try to add the user to their contacts.
*
* @param {Object} document - player or chat item document
* @param {string} [reference] - variable reference to player or chat item. Defaults to this.chat_reference
* @returns identifier for telethon chat. undefined if no id could be found
*/
async getId(document, reference) {
if(!reference) {
reference = this.chat_reference
}
let agent_is_bot = this.agent instanceof client.Bot
if (document.hasOwnProperty("telegram")) {
if(document.hasOwnProperty("telegram_contacts")) {
if(!document.telegram_contacts.includes(this.agent.name)) {
let user = {}
if(!document.telegram.phone) {
try {
user = await this.agent.getUser(document.telegram.id)
} catch (error) {
if(!agent_is_bot && error.message.startsWith("Cannot cast") || error.message.startsWith("Could not find")) {
this.session.log.warn(`Could not get user\n${error.message}\nRetry after loading conversations with getDialogs.`)
let dialogs = await this.agent.getDialogs() // Get Open conversations to make sure the new user is recognizable
this.session.log(`Fetched ${dialogs.length} conversations to find unknown user ${document.telegram.id} after getUser failed.`)
user = await this.agent.getUser(document.telegram.id)
} else {
throw(error)
}
}
let user_update = {'telegram':user}
if(user.hasOwnProperty('id')) {
user_update.telegram.id = this.longID(user.id)
}
if(user.first_name && !document.hasOwnProperty("name")) {
user_update["name"] = user.first_name
}
await this.session.variables.set(reference, user_update)
} else {
user = document.telegram
}
if(!user.last_name) {
user["last_name"] = ""
}
if(!agent_is_bot && user.phone) {
let contact
try {
contact = await this.agent.addContact(user)
} catch (error) {
if(error.message.startsWith("Cannot cast") || error.message.startsWith("Could not find")) {
this.session.log.warn(`Could not add contact \n${error.message}\nWill not skip followup actions!`)
let dialogs = await this.agent.getDialogs() // Get Open conversations to make sure the new user is recognizable
this.session.log(`Fetched ${dialogs.length} conversations to find recently added contact ${document.telegram.id} after addContact failed.`)
} else {
throw(error)
}
}
if(contact && contact.hasOwnProperty('id')) {
let contact_update = {'telegram':contact}
if(contact.first_name && !document.hasOwnProperty("name")) {
contact_update["name"] = contact.first_name
}
if(!document.hasOwnProperty("phone_number")) {
contact_update["phone_number"] = '+' + contact.phone
}
contact_update.telegram.id = this.longID(contact.id)
await this.session.variables.set(reference, contact_update)
await this.session.variables.push(reference, {'telegram_contacts':this.agent.name})
this.session.log.info("add telegram contact for " + contact.phone)
} else {
this.session.log.error("Failed to add telegram contact " + document._id + ". Maybe due to user privacy settings.")
this.session.log.error(user)
}
} else if (agent_is_bot && user.first_name) {
await this.session.variables.push(reference, {'telegram_contacts':this.agent.name})
} else {
this.session.log(`Could not add ${document.telegram.id} (document ${document._id} ${document.name||""}) to telegram contacts. Maybe due to user privacy settings.`)
// this.session.log(user)
}
}
}
if(document.telegram.hasOwnProperty("id")) {
return this.agent.getPeerTypeId(document.telegram)
} else {
throw "Document with telegram property is missing telegram id. " + document._id
}
} else if (!agent_is_bot && document.hasOwnProperty("phone_number") && document.hasOwnProperty("name")) {
let data = {phone:document.phone_number}
if(document.hasOwnProperty("name")) {
data["first_name"] = document.name
} else {
data["first_name"] = document._id
}
if(document.hasOwnProperty("last_name")) {
data["last_name"] = document.last_name
} else {
data["last_name"] = ""
}
let result = await this.agent.addContact(data)
let result_update = {'telegram':result}
if(result.hasOwnProperty('id')) {
result_update.telegram.id = this.longID(result.id)
}
await this.session.variables.set(reference, result_update)
this.session.log.info("add telegram contact for " + document.phone_number)
this.session.log.info(result)
return this.agent.getPeerTypeId(result)
} else {
throw new adaptor.NotFoundError("could not resolve chat id for document " + document._id)
}
}
/**
* Convert IDs so they are stored properly to database
*
* mongodb requires to specifically set the data type for 64bit integers.
*
* @param {number|array<number>} id - telegram user or chat id
* @returns {Object|array|number} mongodb Long int64 Object or number (nedb)
*/
longID(id) {
if(Array.isArray(id)) {
let id_list = []
for(let i of id) {
id_list.push(this.game.db.toLong(i))
}
return id_list
}
let long_id = this.game.db.toLong(id)
return long_id
}
sendMessage(content) {
return new Promise((resolve, reject) => {
this.session.variables.review(content.text)
.then(result => {
delete content.text
content["message"] = result + ""
if(content.message == "") {
throw new adaptor.InvalidError(`${this.agent.name} can not send message to ${this.chat_id}. Text can not be empty`)
}
let properties = Object.assign({
entity: this.chat_id
}, content)
return this.agent.sendMessage(properties)
})
.then((result) => {
this.session.log.info(this.agent.name + " sent message to " + this.chat_id + ": " + content.message)
return resolve()
})
.catch((err) => {
return reject(err)
})
})
}
sendFile(content) {
return new Promise((resolve, reject) => {
this.session.variables.review(content.file)
.then(result => {
content.file = this.game.getFilePath(result)
if (content.caption) {
return this.session.variables.review(content.caption)
} else {
return
}
})
.then((result) => {
content["caption"] = result
let properties = Object.assign({
entity: this.chat_id
}, content)
this.session.log("Send File " + content.file + " from " + this.agent.name + " to " + this.chat_id)
return this.agent.sendFile(properties)
})
.then(result => {
return resolve()
})
.catch((err) => {
return reject(err)
})
})
}
async sendGeo(content) {
let lat = await this.session.variables.review(content.latitude)
let lon = await this.session.variables.review(content.longitude)
if(typeof lat === "string") {
lat = parseFloat(lat)
}
if(typeof lon === "string") {
lon = parseFloat(lon)
}
await this.agent.sendGeo({entity: this.chat_id, latitude:lat, longitude:lon})
this.session.log(this.agent.name + " sent Geo Location " + lon + " " + lat + " to " + this.chat_id)
}
async sendPoll(content) {
let question = await this.session.variables.review(content.question)
let answers = await this.session.variables.findAndReplace(content.answers)
if(content.correct_answer) {
content.correct_answer = await this.session.variables.review(content.correct_answer)
}
let poll_id = Math.floor(
Math.random() * (99999 - 10000) + 10000
)
let public_voters = !content.private
let result = await this.agent.sendPoll({entity: this.chat_id, question:question, answers:answers, correct_answer:content.correct_answer,id:poll_id, public_voters:public_voters,multiple_choice:content.multiple_choice,quiz:content.quiz})
if(this.agent.telegram && this.agent.telegram.id) {
content["from_id"] = this.agent.telegram.id
content["from_voted"] = false
}
content["id"] = this.longID(result.media.poll.id)
content["msg_id"] = result.id
content["voters_count"] = 0
content["ambiguous"] = true
let update = {}
update["polls." + content.name] = content
await this.session.variables.set(this.chat_reference, update)
this.session.log(this.agent.name + " sent Poll " + result.id + " to " + this.chat_reference + ": " + content.question)
}
/**
* Cast a vote in one of the polls that where stored in a chat document
*
* Figures out what poll to cast vote in and maps selected option to option index
*
* @param {object} data - sendVote message parameters
*/
async sendVote(data) {
let poll = await this.session.variables.review(data.poll)
if(!this.chat.polls) {
throw new adaptor.InvalidError("Can not cast vote for poll " + poll + ". There is no poll in chat Item " + this.chat_reference)
}
if(!this.chat.polls[poll]) {
throw new adaptor.InvalidError("Can not cast vote for poll " + poll + ". Poll could not be found in chat Item " + this.chat_reference)
}
let poll_data = this.chat.polls[poll]
let options_index = []
for(let option of this.options) {
if(isNaN(option)) {
option = await this.session.variables.review(option)
for(let i = 0; i < poll_data.answers.length; i++) {
if(poll_data.answers[i].includes(option)) {
options_index.push(i)
break
}
}
} else {
options_index.push(option)
}
}
this.session.log.info(this.agent.name + " cast vote for option(s) " + options_index + " in poll " + poll + " msg id " + poll_data.msg_id + " chat id " + this.chat_id)
let result = await this.agent.sendVote({peer: this.chat_id, options: options_index, msg_id: poll_data.msg_id})
if(result.updates) {
if(this.agent.telegram && poll_data.from_id == this.agent.telegram.id) {
result.updates[0].account_voted = true
}
await this.agent.pollUpdate(result.updates[0])
}
}
async sendButtons(content) {
let message = await this.session.variables.review(content.message)
/**
* alternative with row indicator:
*
* let buttons = [[]]
* for(let b of content.buttons) {
* if(typeof b.row === "number") {
* while(b.row >= buttons.length) {
* buttons.push([])
* }
* buttons[b.row].push(await this.session.variables.review(b.label))
* } else {
* buttons[0].push(await this.session.variables.review(b.label))
* }
* }
*
*/
let buttons = []
for(const [i, row] of content.buttons.entries()) {
buttons.push([])
for(const button of row) {
buttons[i].push(await this.session.variables.review(button.label))
}
}
await this.agent.sendButtons({entity: this.chat_id, message:message, buttons:buttons, type:content.type, typing:content.typing})
this.session.log(`${this.agent.name} sent ${content.type} Buttons to ${this.chat_id}: ${buttons}`)
}
}
/**
* respond to incoming messages. Dialog is between 1 adaptor:ex account and 1 group chat or player.
*
* Each incoming message is queued to avoid conflicts and race conditions.
*/
class Dialog extends Conversation {
constructor(data, session, game, route="message") {
super(data, session, game, route)
/** stacks all incoming message data during this conversation */
this.history = []
}
/**
*
* @param {Array<Account>} agents - telegram account items that where added to the plugin
*/
async setup(agents) {
await super.setup(agents)
}
/**
* compare incoming message with conditions.
*
* goto else response if there is no match.
*
* ignore if incoming groupchat message is not from the user specified in "from"
*
* cancel the "no answer" timeout responses if there is any type of response in if or else.
*
* @param {Object} properties - incoming message properties like text, date, from, to, media etc.
* @returns {Promise<boolean>} - resolves true if there was some kind of match. false otherwise.
*/
async incomingMessage(properties) {
this.history.push(properties)
if (this.hasOwnProperty("from") && properties.peer_type == "chat") {
let is_from = this.from_ids.some(is_from_id => is_from_id == properties.from_id)
if(!is_from) {
this.session.log.trace('ignore message from ' + properties.from_id + ' in chat ' + this.chat_id + " expect message from " + properties.from_id + " instead")
return false
}
}
if (this.hasOwnProperty("if")) {
for (let if_condition of this["if"]) {
let condition = new DialogCondition(if_condition, properties, this.session, this.game)
let reaction = await condition.match()
if(reaction) {
await this.respond(reaction, properties)
return true
}
}
}
if (this.hasOwnProperty("else")) {
let reaction = DialogCondition.getReaction(this["else"], this.session)
await this.respond(reaction, properties)
return true
} else {
let content = properties.text
if("media" in properties) {
content = properties.media
}
this.session.log('no match on message "' + content + '" from ' + this.chat_id + " and no else defined")
return false
}
}
/**
* Respond according to reaction object. Maybe:
* - run "next" callback
* - download media
* - respond to message
* - reply to message
*
* message and response details are stored to dialog level variable:
* **match** store what part of the message triggered next cue
* **download** store download path and media type
*
* @param {Object} reaction - object that stores how to react on incoming message
* @param {Object} message - message details
* @returns {Promise} - resolves undefined once all is done
*/
async respond(reaction, message) {
this.cancelDelayedMessages()
if("download" in reaction) {
if(!["photo","image","document","audio","voice","video","sticker"].includes(message.media)) {
this.session.log.warn(`Can not download media. ${message.media} media type does not provide a file.`)
} else {
let directory = await this.session.variables.review(reaction.download.dir)
directory = this.game.getFilePath(directory)
file.mkdir(directory)
let file_path = await this.agent.downloadMedia({message:message.id, chat:this.chat_id, dir:directory})
if(reaction.download.file) {
reaction.download.file = await this.session.variables.review(reaction.download.file)
let new_file_path = path.resolve(directory, reaction.download.file + path.parse(file_path).ext)
file.mv(file_path, new_file_path)
file_path = new_file_path
}
if(reaction.download.variable) {
reaction.download.variable = await this.session.variables.review(reaction.download.variable)
this.session.variables.set(reaction.download.variable, file_path)
}
message["download"] = file_path
}
}
if(message.peer) {
message.peer = this.game.db.toLong(message.peer)
}
if(message.from_id) {
message.from_id = this.game.db.toLong(message.from_id)
}
if("match" in reaction) {
message["match"] = reaction.match
}
await this.session.variables.store(message)
if("respond" in reaction) {
await this.sendMessage({text:reaction.respond})
}
if("reply" in reaction) {
await this.sendMessage({text:reaction.reply, reply_to:message.id})
}
if("next" in reaction) {
this.session.next(reaction.next)
}
}
}
class ButtonDialog extends Dialog {
constructor(data, session, game, route) {
super(data, session, game, route)
}
async setup(agents) {
await super.setup(agents)
if(this.type == "inline") {
this.listenerCallback = this.session.getCallback(properties => {
return new Promise((resolve, reject) => {
this.queue.put(properties, response => {
if(response && response.error) {
return reject(response.error)
}
return resolve(response)
})
})
})
/** queue of unresolved incoming message responses */
this.queue = new adaptor.Queue(this.buttonClick.bind(this))
this.routing = this.agent.on(this.route, { id: this.chat_id, priority: this.priority }, this.listenerCallback)
}
}
/**
* compare incoming button with buttons list.
*
* ignore if incoming groupchat button click is not from the user specified in "from"
*
* cancel the "no answer" timeout responses if there is any type of response in if or else.
*
* @param {Object} properties - incoming message properties like text, date, from, to, media etc.
* @returns {Promise<boolean>} - resolves true if there was some kind of match. false otherwise.
*/
async buttonClick(properties) {
this.history.push(properties)
if (this.hasOwnProperty("from") && properties.peer_type == "chat") {
let is_from = this.from_ids.some(is_from_id => is_from_id == properties.from_id)
if(!is_from) {
this.session.log.trace('ignore message from ' + properties.from_id + ' in chat ' + this.chat_id + " expect message from " + properties.from_id + " instead")
return false
}
}
if (this.hasOwnProperty("buttons")) {
for (let row of this["buttons"]) {
for(let button of row) {
if(button.label == properties.button) {
let reaction = DialogCondition.getReaction(button, this.session)
reaction.match = button.label
await this.respond(reaction, properties)
}
}
}
}
}
async cancel() {
if (this.hasOwnProperty("buttons") && this.type == 'inline') {
this.agent.cancel(this.route, this.chat_id, this.routing)
await this.queue.cancel()
} else {
this.agent.clearButtons({entity: this.chat_id})
}
await super.cancel()
}
}
/**
* allows to find media in message
*
* @param {Object} properties - condition properties
* @class DialogCondition
* @extends {logic.Condition}
*/
class DialogCondition extends logic.Condition {
constructor(properties, value_properties, session, game) {
super(properties, session, game)
this.value = value_properties.text
if (properties.hasOwnProperty("media")) {
if(properties.media == "downloadable") {
this.condition = ["photo","image","document","audio","voice","video","sticker"]
} else if(properties.media == "any") {
this.condition = ["photo","image","document","audio","voice","video","sticker","geo","geolive","contact","poll","webpage","venue","game","dice","invoice"]
} else {
this.condition = properties.media
}
this.survey = this.equals
this.name = "contains media"
this.value = value_properties.media
}
this.properties = properties
}
feedback(result) {
if(result) {
let react = DialogCondition.getReaction(this.properties, this.session)
react["match"] = result
return react
}
return undefined
}
/**
* Find out how to react to the current event.
*
* returns one of these options:
* - what message text to send (as response or reply)
* - to ignore
* - to dispatch next cue (if there is no respond or if the last respond is called based on count
*
* @param {Object} react - stores information about if/how to react that are altered with each call of this function
*
* @returns {Object} - instructions on how to handle current response status.
*/
static getReaction(react, session) {
let return_value = {}
if(react.hasOwnProperty("download")) {
return_value["download"] = react.download
}
if (react.respond && react.respond.length) {
if (!react.hasOwnProperty("count")) {
react["count"] = 0
} else {
react.count++
}
if (react.count >= react.respond.length - 1 && react.hasOwnProperty("next")) {
if (react.next) {
return_value["next"] = react.next
}
}
if (react.count < react.respond.length) {
if (typeof react.respond[react.count] === "string") {
return_value["respond"] = react.respond[react.count]
} else if (react.respond[react.count].mode) {
let mode = react.respond[react.count].mode
return_value[mode] = react.respond[react.count].text
} else {
session.log.error("failed to create message response.")
return ({ type: 'ignore' })
}
return (return_value)
}
session.log("No response left. Ignoring no. " + react.count)
return ({ type: 'ignore' })
} else {
react["count"] = 0
if (react.hasOwnProperty("next")) {
return_value["next"] = react.next
}
return_value["type"] = "ignore"
return (return_value)
}
}
}
/**
* Create and Edit Telegram Chatgroups
*
* @class Chat
* @extends {Conversation}
*/
class Chat extends Conversation {
constructor(data, session, game) {
super(data, session, game)
}
/**
* Remove "-" that might have been prepended to chat_id property
*
* @param {Array<object>} agents
*/
async setup(agents) {
await super.setup(agents)
if(this.chat_id < 0) {
this.chat_id = -this.chat_id
}
}
/**
* get list of telegram ids off chat members
*
* @param {array} members - list of variable references to chat members
* @returns {Promise<array>} - list of telegram ids
*/
async getMembers(members) {
let member_ids = []
for(let member of members) {
let ref = await this.session.variables.getVariableReference(member)
let member_docs = await this.game.db[ref.collection].find(ref.query)
for(let member_doc of member_docs) {
if(member_doc) {
let member_id = await this.getId(member_doc, `${ref.collection}.{'_id':'${member_doc._id}'}`)
if(!member_ids.includes(member_id)) {
member_ids.push(member_id)
}
}
}
}
return member_ids
}
updateMembers(members, chat_id, operation) {
let promises = []
for(let member of members) {
promises.push(
this.session.variables[operation](member, { "chats": chat_id })
)
}
return Promise.all(promises)
}
/**
* create a group chat or channel via the account and when done edit its properties.
*
* get members from variables. Once created the chat will be referenced by `_id` in each member and the agent that created the chat.
*
* If given, create reference to chat in session it originates from.
*
* @param {Object} properties - members, name, etc...
*/
async create() {
let member_ids = await this.getMembers(this.members)
this.name = await this.session.variables.review(this.name)
if(this.title) {
this.title = await this.session.variables.review(this.title)
}
let chat = await this.agent.createChat({
title: this.title || this.name,
users: member_ids
})
this.chat_id = chat.id
chat.id = this.longID(chat.id)
let result = await this.session.variables.create(
"chats",
{
name: this.name,
title: this.title || this.name,
members: this.longID(member_ids),
member_count: chat.participants_count -1,
admin: this.agent.name,
telegram: chat
},
this.reference
)
this.chat_reference = `chats.{_id:'${result.created_id}'}`
await this.session.variables.add(this.agent_reference, { "chats": result.created_id })
await this.updateMembers(this.members, result.created_id, "add")
await this.edit(false)
}
/**
* edit properties of existing chat.
*
* add and remove members and configure user rights.
*
* please note: most of the changes to the chat document is updated through chat updates using the gramjs update event.
* The document updates in this function are redundant except for "remove_members" which does not update properly with the respective event.
*
* @returns {Promise<undefined>}
*/
async edit(change_title=true) {
if (this.hasOwnProperty("add_members")) {
let member_ids = await this.getMembers(this.add_members)
await this.addMembers(member_ids)
await this.session.variables.add(this.chat_reference, { "members": { "$each": this.longID(member_ids) } })
await this.updateMembers(this.add_members, this.chat._id, "add")
this.session.log.info(member_ids.length + " members added to group chat " + this.chat.name)
}
if (this.hasOwnProperty("remove_members")) {
let member_ids = await this.getMembers(this.remove_members)
let response = await this.removeMembers(member_ids)
await this.session.variables.set(this.chat_reference, {member_count:response.participants_count -1,'telegram.participants_count':response.participants_count})
await this.session.variables.pull(this.chat_reference, { "members": { "$in": member_ids } })
await this.updateMembers(this.remove_members, this.chat._id, "pull")
this.session.log.info(member_ids.length + " members removed from group chat " + this.chat.name)
}
if(this.photo) {
this.photo = await this.session.variables.review(this.photo)
this.photo = this.game.getFilePath(this.photo)
await this.agent.editChat("photo", {chat_id: this.chat_id, photo:this.photo})
}
if(this.about) {
this.about = await this.session.variables.review(this.about)
await this.agent.editChat("about", {peer: this.chat_id, about:this.about})
}
if(change_title && this.title) {
this.title = await this.session.variables.review(this.title)
await this.agent.editChat("title", {chat_id: this.chat_id, title:this.title})
await this.session.variables.set(this.chat_reference, {title:this.title})
}
if(this.create_link) {
let result = await this.agent.editChat("create_link", {peer: this.chat_id})
await this.session.variables.set(this.chat_reference, {link:result.link})
}
if(this.permissions) {
let restrictions = {}
for(let permission in this.permissions) {
restrictions[permission] = !this.permissions[permission]
}
await this.agent.editChat("restrictions", Object.assign({peer: this.chat_id}, restrictions))
}
if(this.admin) {
if(this.admin.hasOwnProperty("members")) {
let admin_members = await this.getMembers(this.admin.members)
delete this.admin.members
for(let user of admin_members) {
let admin = Object.assign({entity: this.chat_id, user:user}, this.admin)
await this.agent.editChat("admin", admin)
}
}
}
return
}
/**
* remove list of users from existing chat.
* call removeUser function successively
*
* @param {Array} members - list of telegram compatible user ids
* @returns {Object} infos about chat after latest remove user update
*/
async removeMembers(members) {
let response
for (const u of members) {
response = await this.agent.removeUser({ chat_id: this.chat_id, user_id: u, revoke_history: false })
}
return response
}
/**
* add list of new users to existing chat.
* call addUser function successively
*
* @param {Array} members - list of telegram compatible user ids
* @returns {Object} infos about chat after latest add user update
*/
async addMembers(members) {
let response
for (const u of members) {
response = await this.agent.addUser({ chat_id: this.chat_id, user_id: u, fwd_limit: 42 })
}
return response
}
}
/**
* sends one message from/to conversation members.
*
* @class Message
* @extends {Conversation}
*/
class Message extends Conversation {
constructor(data, session, game) {
super(data, session, game)
}
/**
* Send a text message
*
* @return {Promise} - resolves undefined once message is sent.
*/
async sendMessage() {
if (Array.isArray(this.player)) {
throw new adaptor.InvalidError("multiple players in one conversation not allowed")
} else {
await super.sendMessage({ text: this.text, pin:this.pin, typing:this.typing})
}
}
/**
* Send a file in voice, video or document format.
*
* @return {Promise} - resolves undefined once file is sent.
*/
async sendFile() {
if (Array.isArray(this.player)) {
throw new adaptor.InvalidError("multiple players in one conversation not allowed")
} else {
let content = {
file: this.file,
caption: this.caption
}
if (this.format) {
if (this.format == "document") {
content["force_document"] = true
} else if (this.format == "voice") {
content["voice_note"] = true
} else if (this.format == "video") {
content["video_note"] = true
}
}
if(this.pin) {
content["pin"] = this.pin
}
await super.sendFile(content)
}
}
/**
* Send a geo location widget.
*
* @return {Promise} - resolves undefined once location is sent.
*/
async sendGeo() {
await super.sendGeo({ longitude: this.longitude, latitude:this.latitude})
}
async sendPoll() {
await super.sendPoll({
name:this.name,
question:this.question,
answers:this.answers,
private:this.private,
multiple_choice:this.multiple_choice
})
}
async sendQuiz() {
await super.sendPoll({
name:this.name,
question:this.question,
answers:this.answers,
correct_answer:this.correct_answer,
private:this.private,
multiple_choice:false,
quiz:true
})
}
async sendVote() {
await super.sendVote({
name:this.name,
poll:this.poll,
options:this.options
})
}
async sendTextButtons() {
await super.sendTextButtons({
texts:this.buttons
})
}
}
/** telegram connector class to adaptor */
module.exports.Plugin = Telegram