UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

1,491 lines (1,350 loc) 49.6 kB
/** * @todo add Bot API * * @requires telegram * @requires path * @requires fs-extra * @requires events * @requires messenger * * @module telegram/client * @copyright Lasse Marburg 2023 * @license MIT */ const { TelegramClient, Api } = require( "telegram") const { StoreSession } = require("telegram/sessions") const { NewMessage, NewMessageEvent } = require("telegram/events") const { CustomFile } = require("telegram/client/uploads") const { TotalList } = require("telegram/Helpers") const { Dialog } = require("telegram/tl/custom/dialog") const { Button } = require("telegram/tl/custom/button") const errors = require("telegram/errors") const path = require("path") const fs = require("fs-extra") const events = require("events") const { Agent } = require("../messenger") /** * Connect to telegram account using telegram (gramjs) node module * * GramJS Session files are stored in respective game data folder in 'telegram_sessions' * * @param {object} config.settings - telegram account settings * @param {string} config.settings.phone_number - telegram account phone number */ class Account extends Agent { constructor(config, coll, plugin, game) { super(["message", "update", "button"], config, coll, plugin, game, "telegram.id") /** @type {TelegramClient} */ this.client this.session_files = path.join(this.game.data, '/telegram_sessions') /** @type {number} */ this.connection_state this.autoconnect = true } /** * Create gramjs instance for this account and try to connect to telegram */ async setup() { this.log("Setup Telegram client account") this.log(this.settings) if(!this.plugin.settings || !this.plugin.settings.api_id || !this.plugin.settings.api_hash) { this.setConnected(false) throw new adaptor.InvalidError("Can not connect account. Telegram plugin api id and/or api hash missing") } if(this.settings.phone_number) { try { this.session = new StoreSession(path.join(this.session_files, this.settings.phone_number)) this.client = new TelegramClient(this.session, this.plugin.settings.api_id, this.plugin.settings.api_hash, { connectionRetries: 5, }) } catch (error) { this.apiErrorHandling(`Could not setup account with phone number ${this.settings.phone_number}.`, error) } this.client.setLogLevel("warn") this.client.addEventHandler(this.newMessage.bind(this), new NewMessage({})) this.client.addEventHandler(this.newEvent.bind(this)) } else { this.setConnected(false) throw new adaptor.InvalidError("Can not connect account: Phone number missing") } } /** * Update telegram property, name and or phone_number * * If phone number changed, recreate telethon client. Client will try to connect to account. * * @param {Object} data.settings - account connection settings * @param {Object} data.name - account name * @param {Object} data.telegram - account telegram user information */ async update({settings, name, telegram}, origin) { this.name = name this.telegram = telegram if(settings.phone_number != this.settings.phone_number) { this.settings = settings await this.setup(settings) return this.connect({settings, name, telegram}, origin) } this.settings = settings } /** * Try to connect client. * * If successful, store account telegram user data to item document * * If sign in failed: send code request to telegram and respond with a plea for login code. * * @returns {Promise} create client response data if created successful. Undefined if not */ async connect(data, origin) { if(this.settings.phone_number) { if(!typeof this.client.connect === "function") { throw new adaptor.AdaptorError("Can not connect. Telegram client was not properly initiated") } try { await this.client.connect() if(await this.client.isUserAuthorized()) { this.setConnected(true) this.log.info("Telegram account connected") this.log.info(await this.setMe()) return {"connect":"successful"} } else { this.setConnected(false) if(origin) { this.log.info(`Ask for login code due to connect request by ${origin.user.login}`) return {prompt: await this.askForCode()} } else { throw new adaptor.ConnectionError(`Could not sign in to telegram account ${this.settings.phone_number}. Click connect to obtain auth code.`) } } } catch (error) { this.apiErrorHandling(`Could not connect account with phone number ${this.settings.phone_number}.`, error) } } else { this.setConnected(false) throw new adaptor.InvalidError("Could not connect to telegram account: Phone number missing") } } /** * Disconnect client using GramJS disconnect method * * @returns {object} - connected false if disconnect was successful */ async disconnect() { if(!this.client || !typeof this.client.disconnect === "function") { throw new adaptor.AdaptorError("Can not disconnect. Telegram client was not properly initiated") } await this.client.disconnect() if(this.client.disconnected) { this.setConnected(false) this.log.info("Disconnected") return {"connected":false} } else { this.log.warn("Could not disconnect") } } /** * Send new login code via Telegram * * @returns {Promise<object>} - Information about UI window asking the user for the login code */ async askForCode() { await this.client.sendCode({apiId: this.plugin.settings.api_id, apiHash:this.plugin.settings.api_hash}, this.settings.phone_number) return { message:`You should have received a login code for your telegram account ${this.name} (${this.settings.phone_number}). Enter the code to authenticate.`, schema:{type:"object",required:['code'],properties:{code:{type:"string","minLength":1}},"additionalProperties":false}, method:"post", callback:this.signIn.bind(this) } } /** * Use login code to sign in to telegram account * * @param {string} id - item id, since its forwarded by plugin item API topic (not used) * @param {Object} data.code - login code as forwarded from frontend due to plea * @returns {Promise<object>} signIn: "successful" or throws error */ async signIn(id, data) { if(!data.hasOwnProperty("code")) { throw new adaptor.InvalidError('Can not sign in. Login code missing') } this.log.info(`sign in with code ${data.code}`) await this.client.start({ phoneNumber: this.settings.phone_number, phoneCode: () => {return data.code}, onError: (err) => {this.setConnected(false); throw new Error(err)} }) this.client.session.save() this.log.info("telegram account signed in") this.log.info(await this.setMe()) this.setConnected(true) return {signIn: "successful"} } apiErrorHandling(msg, error) { this.setConnected(false) if(error instanceof errors.RPCError) { throw new adaptor.ConnectionError(msg, {cause: error}) } throw error } /** * Store telegram user data to account item document * * @returns {Promise<UserEssentials>} - Telegram account user data (first_name, phone, ...) */ async setMe() { let user = await this.client.getMe() let user_data = this.getUserEssentials(user) this.telegram = user_data let user_update = Object.assign({}, user_data) if(user_update.id) { user_update.id = this.game.db.toLong(user_update.id) } await this.document.set({telegram:user_update}) return user_data } /** * Forward relevant incoming events as chat item events and store changes to the respective chat item. * * - Updates in chat polls like recent votes * - Updates in chats like users joined or left the group * - Bot Callbacks like button click events (Bot only) * * @param {Api.TypeUpdate} event * @returns */ async newEvent(event) { // this.log.trace(event) switch (event.className) { case "UpdateBotCallbackQuery": this.botCallback(event) // Bot only break case "UpdateMessagePoll": this.pollUpdate(event) break case "UpdateUserStatus": this.log.trace(`User ${event.userId} is ${event.status.className.replace("UserStatus", "")}`) break case "UpdateNewMessage": if(event.message.className == "MessageService") { let from_id = this.getPeerId(event.message.fromId) let peer_id = this.getPeerId(event.message.peerId) let chat_item try { chat_item = await this.game.topics.chats.queryItem({"telegram.id":this.game.db.toLong(peer_id)}) } catch (error) { if(error instanceof adaptor.NotFoundError) { this.log(`Update event for unknown chat ${peer_id}.`) return } else { this.log.error(error) } } let dispatchChatUpdate = (topic, payload) => { if(!chat_item.hasOwnProperty("event")) { chat_item.event = new events.EventEmitter() } payload.from_id = this.game.db.toLong(from_id) payload.chat_id = this.game.db.toLong(peer_id) chat_item.event.emit(topic, payload) } if(event.message.action.className == "MessageActionChatEditTitle") { await this.editChatDocument(peer_id,"set",{title:event.message.action.title}) this.log.info(`${from_id} changed title for group chat ${chat_item.name} (${chat_item.telegram.id}) to "${event.message.action.title}"`) dispatchChatUpdate("titleChanged", { title: event.message.action.title }) } else if(event.message.action.className == "MessageActionChatAddUser") { let user_ids = event.message.action.users.map(user_id => this.game.db.toLong(user_id.value)) this.log.info(`${from_id} added ${event.message.action.users.length} member(s) to group chat ${chat_item.name} (${chat_item.telegram.id}): ${user_ids}`) await this.editChatDocument(peer_id,"add",{members:user_ids}) dispatchChatUpdate("membersAdded", { "ids": user_ids, "count": event.message.action.users.length }) } else if(event.message.action.className == "MessageActionChatJoinedByLink") { let user_id = this.game.db.toLong(from_id) this.log.info(`${from_id} joined group chat ${chat_item.name} (${chat_item.telegram.id}). Invited by ${event.message.action.inviterId}`) await this.editChatDocument(peer_id,"add",{members:user_id}) dispatchChatUpdate("memberJoined", { "id": user_id, "inviter_id": this.game.db.toLong(event.message.action.inviterId.value) }) } else if(event.message.action.className == "MessageActionChatDeleteUser") { let user_id = this.game.db.toLong(event.message.action.userId.value) await this.editChatDocument(peer_id,"pull",{members:user_id}) if(from_id == event.message.action.userId.value) { this.log.info(`${from_id} left group chat ${chat_item.name} (${chat_item.telegram.id})`) dispatchChatUpdate("memberLeft", { "id": user_id }) } else { this.log.info(`${from_id} removed ${event.message.action.userId.value} from group chat ${chat_item.name} (${chat_item.telegram.id})`) dispatchChatUpdate("memberRemoved", { "id": user_id, }) } } } break case "UpdateChatParticipants": await this.editChatDocument(event.participants.chatId.value,"set",{member_count:event.participants.participants.length -1,'telegram.participants_count':event.participants.participants.length}) break default: if(event.constructor.name == "UpdateConnectionState") { if(event.state != this.connection_state) { this.connection_state = event.state this.log.trace(`Connection state update: ${event.state}`) } } else { this.log.trace(`Unknown update event ${event.className || event.constructor.name}`) } } } /** * Make an update in a chat document * * @param {string} chat_id - the telegram chat id of the document * @param {string} operator - update operator like "set" or "push" * @param {Object} update - The update document */ async editChatDocument(chat_id, operator, update) { await this.game.topics.chats.edit( {'telegram.id':this.game.db.toLong(chat_id)}, operator, update, {user:{login:"account " + this.name}} ) } /** * Update poll in chat document if it was created with an adaptor:ex telegram account * * Dispatch Event with updated poll data with chat item as event source * * Chat document update and event will only occur if the event source is the account that created the poll. * * If the poll is not anonymous and the creator account voted in the poll, * client will make a separate request to get poll user data to identify what group member voted what option. * * @param {Api.UpdateMessagePoll} event */ async pollUpdate(event) { let chats = await this.game.topics.chats.getMany({'polls':{"$exists":true}}) let chat, poll_name, poll for(let chat_doc of chats) { for(let p in chat_doc.polls) { if(!chat_doc.polls[p].id) { continue } if(chat_doc.polls[p].id == event.pollId.value) { chat = chat_doc.telegram poll = chat_doc.polls[p] poll_name = p break } } } if(!chat) { this.log("Poll Update for unknown poll " + event.pollId.value) return } if(this.telegram && poll.from_id != this.telegram.id) { this.log.trace("Skip poll Update for other account " + poll.from_id + ". Poll " + poll_name) return } this.log.info(`Poll update for ${poll_name} in chat ${chat.id}. ${event.results.totalVoters} total voters.`) let poll_results = event.results.results.map(result => { result.option = result.option.toString() return result }) let recentVoters = [] let account_voted = poll.from_voted if(event.results.recentVoters) { recentVoters = event.results.recentVoters.map(recent_voter => recent_voter.value) account_voted = this.telegram && recentVoters.some(recent_voter => recent_voter == this.telegram.id) } if(event.hasOwnProperty("account_voted")) { account_voted = event.account_voted // If the update is caused by Cast Vote action, account_voted is always true } // Check if this account already voted and if poll is not anonymous. Only then is it possible to get voters user data if(!poll.private && account_voted) { let poll_votes = await this.getPollVotes({peer:this.getPeerTypeId(chat), msg_id:poll.msg_id}) for (let i = 0; i < poll_results.length; i++) { poll_results[i].voter_ids = [] for (let g = 0; g < poll_votes.votes.length; g++) { if(poll_votes.votes[g].option) { poll_votes.votes[g].options = [poll_votes.votes[g].option] } for(let poll_option of poll_votes.votes[g].options) { poll_option = poll_option.toString() if(poll_results[i].option == poll_option) { poll_results[i].voter_ids.push(poll_votes.votes[g].userId.value) let player_update = {} player_update["polls." + poll_name + ".votes"] = i // this.log.debug(`User ${poll_votes.votes[g].userId.value} voted for option ${poll_option} (${i})`) this.game.topics.players.edit({"telegram.id":this.game.db.toLong(poll_votes.votes[g].userId.value)},'add', player_update, {"user":{"login": "account " + this.name}}) } } } } } else { this.log("Can not get poll voters user info. If you need user info, poll can not be anonymous and this account has to cast a vote.") } let results = [] for (let i = 0; i < poll_results.length; i++) { let voters = [] if(poll_results[i].voter_ids) { voters = poll_results[i].voter_ids.map(voter => this.game.db.toLong(voter)) } results.push({ "votes": poll_results[i].voters, "voters": voters, "correct": poll_results[i].correct, "answer": poll.answers[i], "option": poll_results[i].option, "index": i }) } results.sort((a, b) => { return b.votes - a.votes }) for (let i = 0; i < results.length; i++) { results[i]["rank"] = i } let ranking = [...results] results.sort((a, b) => { return a.index - b.index }) let ambiguous = false if(ranking[0].votes == ranking[1].votes) { ambiguous = true } let update = {} recentVoters = recentVoters.map(recent_voter => this.game.db.toLong(recent_voter)) update["polls." + poll_name + ".voters"] = recentVoters update["polls." + poll_name + ".voters_count"] = event.results.totalVoters update["polls." + poll_name + ".results"] = results update["polls." + poll_name + ".ranking"] = ranking update["polls." + poll_name + ".ambiguous"] = ambiguous update["polls." + poll_name + ".from_voted"] = account_voted await this.editChatDocument(chat.id, "set", update) let chat_item = await this.game.topics.chats.queryItem({"telegram.id":this.game.db.toLong(chat.id)}) if(!chat_item.hasOwnProperty("event")) { chat_item.event = new events.EventEmitter() } chat_item.event.emit("pollUpdate", { "name": poll_name, "id": this.game.db.toLong(event.pollId.value), "voters": recentVoters, "voters_count": event.results.totalVoters, "results": results, "ranking": ranking, "ambiguous": ambiguous, "from_voted": account_voted }) let totalVoters = event.results.totalVoters if(account_voted) { totalVoters-=1 } if(chat_item.member_count <= totalVoters) { this.log.info(`All ${chat_item.member_count} group chat members voted in ${poll_name} poll`) chat_item.event.emit("pollComplete", { "name": poll_name, "id": this.game.db.toLong(event.pollId.value), "voters": recentVoters, "voters_count": event.results.totalVoters, "results": results, "ranking": ranking, "ambiguous": ambiguous }) } } /** * On Incoming Message Event Message data is forwarded to the most recent registered routing callback. * If that one does not have a response, message data is forwarded to the next callback etc. * * if there is no routing registered and the account has a default level assigned to it: * - a callback possibility will be set in pending list so default level can get back to this * - default level is initiated * * @param {NewMessageEvent} param - Incoming message object as forwarded by telegram API */ async newMessage({message}) { if(message.out) { return } // this.log.trace(message) let data = { "text":message.message, "id":message.id, "out":message.out, "mentioned":message.mentioned, "media_unread":message.mediaUnread, "post":message.post, "date":new Date(message.date), "from_id":this.getPeerId(message.fromId) || this.getPeerId(message.peerId), "reply_to_msg_id":message.replyToMsgId, "grouped_id":message.groupedId, "peer_type":this.getPeerType(message.peerId), "peer":this.getPeerId(message.peerId) } if(message.media) { data.media = message.media.className.replace("MessageMedia", "").toLowerCase() switch (data.media) { case "document": data.media = this.getDocumentType(message.media.document.mimeType) break case "geo": case "geolive": data.lat = message.media.geo.lat data.long = message.media.geo.long data.accuracy = message.media.geo.accuracyRadius break case "contact": data.first_name = message.media.firstName data.last_name = message.media.lastName data.phone = message.media.phoneNumber data.contact_id = message.media.userId break case "venue": data.title = message.media.title data.address = message.media.address data.provider = message.media.provider data.venue_id = message.media.venueId data.venue_type = message.media.venueType data.lat = message.media.geo.lat data.long = message.media.geo.long break case "dice": data.value = message.media.value data.emoticon = message.media.emoticon break } } this.log(`Incoming ${data.media||"text"} '${data.text}' in ${data.peer_type} ${data.peer} from ${data.from_id}`) /** peer id with recognizable peer type. This is required to match the route id in chats and channels which is also prepended with `-` or `-100` */ data.peer_type_id = this.getPeerTypeId({id: data.peer, type: data.peer_type}) if (this.routing.message[data.peer_type_id]) { for(let response of this.routing.message[data.peer_type_id]) { delete data.peer_type_id let responded = await response.callback(data) if(responded) { log.debug(this.name, "responded, skip any further routings") break } } } else if (this.settings.hasOwnProperty("level") && data.peer_type == "user") { this.log.info("no message routing for " + data.peer_type + " " + data.peer + ": " + data.text) this.pending.message[data.peer] = async react_function => { delete this.pending.message[data.peer] return react_function(data) } delete data.peer_type_id this.initialContact(this.game.db.toLong(data.peer), this.settings.level, this.settings.chat_argument) .then(result => { if(result.ignore) { this.log("ignore message. " + result.player._id + " is already assigned to a session based on " + result.level) this.pending.message[data.peer](data => { return Promise.resolve() }) } else { this.log.info("started default level " + result.level + " with player " + result.player._id) } }) .catch(err => { this.log.error(err) if(this.pending.message.hasOwnProperty(data.peer)) { delete this.pending.message[data.peer] } }) } else { this.log('no routing for incoming message from ' + data.peer_type + ' ' + data.peer + ' and no default level defined: ' + data.text) } } /** * Get the type of document in incoming message * @param {string} mimeType - mimeType as provided in message document * @returns {string} - sticker, voice, video, image, audio or document */ getDocumentType(mimeType) { switch (mimeType) { case "image/webp": return "sticker" case "audio/ogg": return "voice" } let type = mimeType.split('/') switch (type[0]) { case "video": return "video" case "image": return "image" case "audio": return "audio" } return "document" } /** * Get id of peer from gramjs peer object * * @param {Api.PeerChat | Api.PeerUser | Api.PeerChannel} peer * @returns {bigint} - The User, Chat or Channel id value */ getPeerId(peer) { if(!peer) { return } switch (peer.className) { case "PeerUser": return peer.userId.value case "PeerChat": return peer.chatId.value case "PeerChannel": return peer.channelId.value } } /** * Get the type name of a gramjs Peer Object * * @param {Api.PeerChat | Api.PeerUser | Api.PeerChannel} peer * @returns {string} - the peer type in lower case (user, chat or channel) */ getPeerType(peer) { if(!peer) { return } switch (peer.className) { case "PeerUser": return "user" case "PeerChat": return "chat" case "PeerChannel": return "channel" } } /** * Convert chat or channel id to make the peer type recognizable. * * Prepend `-` to chat id * * Prepend `-100` to channel id * * User id is returned as is. * * @param {object} doc - telegram user, chat or channel info object * @param {bigint} doc.id - id property * @param {('user'|'chat'|'channel')} doc.type - the peer type name * @returns {bigint} - id value compatible with peer type id (-xxx for chats and -100xxx for channels) */ getPeerTypeId(doc) { switch (doc.type) { case "user": return doc.id case "chat": return -doc.id case "channel": return BigInt("-100" + doc.id) default: return doc.id } } /** * Send a message via telegram API to a user, chat or channel * * "Typing" will be prepended as requested. Defaults to a logarithmic duration depending on the length of the message. * * Pin the message if requested * * @param {Object} data - parameter for sendMessage method * @param {bigint} data.entity - signed user, chat or channel id * @param {string|number} data.typing - how many seconds to delay the message and show typing icon * @param {boolean} data.pin - wether to pin this message to the top of the chat * @param {number} data.reply_to - reply to a previous message with given id * * @returns {Promise<undefined>} - resolves when message was sent */ async sendMessage(data) { if (typeof data.typing === "undefined" || data.typing == "") { data.typing = Math.log(data.message.length) } await this.showTyping(data.entity, data.typing) if(data.reply_to) { data.replyTo = data.reply_to delete data.reply_to } let message = await this.client.sendMessage(data.entity, data) if(data.pin) { await message.pin({notify:true}) } } /** * Show the "typing" icon on top of the chat for a specific amount of time. * * If timeout value is 0 or less typing will not be shown. * * @param {bigint} id - user, chat or channel id * @param {number} timeout - number of seconds to show "typing" * @returns {Promise<undefined>} - Resolves once typing ends */ showTyping(id, timeout) { if(timeout <= 0) { return } return new Promise((resolve, reject) => { this.log("Show typing for " + parseInt(timeout).toFixed(1) + " seconds") this.client.invoke( new Api.messages.SetTyping({ peer: id, action: new Api.SendMessageTypingAction({}), topMsgId: 43, }) ) .then(result => { setTimeout(() => { return resolve() }, timeout*1000) }) .catch(err => { return reject(err) }) }) } /** * Forward existing message to different chat. Has never been tested * * @param {Object} data - json formatted parameter for telegram forward_messages * @returns {Promise<undefined>} - resolves when message was sent */ async forwardMessage(data) { const result = await this.client.invoke( new Api.messages.ForwardMessages({ fromPeer: data.chat_id, id: [43], randomId: [BigInt("-4156887774564")], toPeer: data.entity, withMyScore: true, dropAuthor: false, dropMediaCaptions: false, noforwards: false, scheduleDate: 43, sendAs: this.telegram.id, }) ) this.log.info(result) } /** * Send image, video or audio file * * Can be formatted as video note or voice message. * * @param {object} data - parameter for sendFile method * @param {bigint} data.entity - signed user, chat or channel id * @param {boolean} data.pin - wether to pin this message to the top of the chat * * @returns {Promise<undefined>} - resolves when file was sent */ async sendFile(data) { let message = await this.client.sendFile(data.entity, { file:data.file, caption:data.caption, voiceNote: data.voice_note, videoNote: data.video_note, forceDocument:data.force_document }) if(data.pin) { await message.pin({notify:true}) } } /** * Send Message with Geo Location based on longitude and latitude * * @param {object} data - parameter for sendMessage with MessageMediaGeo content * @param {bigint} data.entity - signed user, chat or channel id * @param {number} data.longitude - longitude of geo point * @param {number} data.latitude - latitude of geo point * @param {boolean} data.pin - wether to pin this message to the top of the chat * * @returns {Promise<undefined>} - resolves when message was sent */ async sendGeo(data) { let message = await this.client.sendMessage(data.entity, { message: new Api.Message({ id:1, media:new Api.MessageMediaGeo({ geo:new Api.GeoPoint({ long:data.longitude, lat:data.latitude }) }) }) }) if(data.pin) { await message.pin({notify:true}) } } /** * Send a poll or quiz to the group chat * * @param {object} data - parameter for sendMessage with Poll * @param {bigint} data.entity - signed user, chat or channel id * @param {boolean} data.pin - wether to pin this message to the top of the chat * * @returns {Promise<undefined>} - resolves when poll was sent */ async sendPoll(data) { let answers = data.answers.map((answer, i) => { if(answer == data.correct_answer) { data.correctAnswers = [Buffer.from([i])] } return new Api.PollAnswer({text:new Api.TextWithEntities({text:answer, entities:[]}), option:Buffer.from([i])}) }) if(data.quiz && !data.correctAnswers) { throw new adaptor.InvalidError(`Can not send quiz. Correct answer '${data.correct_answer}' does not match any of the available answers.`) } let message = await this.client.sendMessage(data.entity, {file:new Api.InputMediaPoll({ poll:new Api.Poll({ id:data.id || 1234567, question:new Api.TextWithEntities({text:data.question, entities:[]}), answers:answers, multipleChoice: data.multiple_choice, publicVoters: data.public_voters, quiz: data.quiz }), correctAnswers: data.correctAnswers }) }) if(data.pin) { await message.pin({notify:true}) } message.media.poll.id = message.media.poll.id.value return message } /** * Cast vote in an existing poll * * @param {object} data - parameter for sendMessage with Poll * @param {bigint} data.peer - signed user, chat or channel id * * @returns {Promise<Api.Updates} - resolves when vote was sent with poll update data */ async sendVote(data) { let options = data.options.map((option, i) => { return Buffer.from([option]) }) return await this.client.invoke( new Api.messages.SendVote({ peer: data.peer, msgId: data.msg_id, options: options, }) ) } /** * Get Details about poll including user information * * @param {object} data - parameter for sendMessage with Poll * @param {bigint} data.peer - signed user, chat or channel id * * @returns {Promise<Api.messages.GetPollVotes>} - resolves with poll voter data */ async getPollVotes(data) { let result = await this.client.invoke( new Api.messages.GetPollVotes({ peer: data.peer, id: data.msg_id, limit: 100, option: Buffer.from(""), offset: "", }) ) return result } /** * Download media from a message to directory (dir). * * Creates file with auto filename. Full path to file is returned once file is downloaded. * * @param {Object} data - json formatted parameter for telegram downloadMedia message * @param {import("telegram/define").EntityLike} data.chat - peer id. Download media from this user, chat or channel * @param {number} data.message - message id. Download media from this message * @param {string} data.dir - download media to this directory. * * @returns {Promise<string>} - path to downloaded media file */ async downloadMedia(data) { let message = await this.client.getMessages(data.chat,{ids:data.message}) const path = await this.client.downloadMedia(message[0], {outputFile:data.dir}) return path } /** * Create new telegram chat and add users * * @param {Object} data - json formatted parameter for telegram createChat method * @param {array<import("telegram/define").EntityLike} data.users - List of user ids that will be added to the group chat * @param {string} data.title - Chat title * * @returns {Promise<ChatEssentials>} - Essential Chat properties */ async createChat(data) { if(!data.users.length) { throw new adaptor.InvalidError("Can not create chat. Not enough users.") } if(!data.title) { throw new adaptor.InvalidError("Can not create chat. Title missing.") } const { updates } = await this.client.invoke( new Api.messages.CreateChat({ users: data.users, title: data.title, }) ) this.log(`Create Chat ${updates.chats[0].id} "${updates.chats[0].title}" with ${updates.chats[0].participantsCount} participants.`) return this.chatUpdateResponse(updates) } /** * Essential information about Telegram Chat * @typedef {Object} ChatEssentials * @property {bigint} id - chat id * @property {'chat'} type - peer type. Is always: 'chat' * @property {string} title - chat title * @property {number} participants_count - number of participants including account that created the chat * @property {Date} date - Creation date of chat */ /** * Get essential properties from chat create or edit response Data * * @param {*} update - Chat update response Object * @returns {Promise<ChatEssentials>} - Essential chat information properties */ chatUpdateResponse(update) { return { "id":update.chats[0].id.value, "type":"chat", "title":update.chats[0].title, "participants_count":update.chats[0].participantsCount, "date":new Date(update.chats[0].date) } } /** * Add member to Group Chat * * @param {Object} data - parameters for telegram AddChatUser method * @returns {Promise<ChatEssentials>} - Essential Chat properties */ async addUser(data) { const { updates } = await this.client.invoke( new Api.messages.AddChatUser({ chatId: data.chat_id, userId: data.user_id, fwdLimit: data.fwd_limit, }) ) return this.chatUpdateResponse(updates) } /** * Remove member from Group Chat * * @param {Object} data - parameters for telegram DeleteChatUser method * @returns {Promise<ChatEssentials>} - resolves when user was removed */ async removeUser(data) { this.log.info(data) const { updates } = await this.client.invoke( new Api.messages.DeleteChatUser({ chatId: data.chat_id, userId: data.user_id, revokeHistory: data.revoke_history, }) ) return this.chatUpdateResponse(updates) } /** * request the different types of telegram edit group chat functions * * - EditChatAbout * - EditChatPhoto * - EditChatTitle * - EditChatDefaultBannedRights * * @param {string} chat_property - the group chat property to change: about, photo, title, create_link, restrictions or admin * @param {Object} data - json formatted parameter for one of the above telethon edit chat methods * @returns {Promise<ChatEssentials>} - resolves when chat was edited */ async editChat(chat_property, data) { let result try { switch(chat_property) { case "title": result = await this.client.invoke( new Api.messages.EditChatTitle({ chatId: data.chat_id, title: data.title, }) ) return this.chatUpdateResponse(result) case "photo": let filename = data.photo.slice(data.photo.lastIndexOf("/")+1) this.log.info("Filename " + filename) result = await this.client.invoke( new Api.messages.EditChatPhoto({ chatId: data.chat_id, photo: await this.client.uploadFile({ file: new CustomFile( filename, fs.statSync(data.photo).size, data.photo ), workers: 1, }), }) ) return this.chatUpdateResponse(result) case "about": this.log(`Change chat description for ${data.peer}: ${data.about}`) result = await this.client.invoke( new Api.messages.EditChatAbout({ peer: new Api.PeerChat({chatId: data.peer}), about: data.about, }) ) return result case "create_link": result = await this.client.invoke( new Api.messages.ExportChatInvite({ peer: new Api.PeerChat({chatId: data.peer}), legacyRevokePermanent: true, requestNeeded: data.requires_confirmation, expireDate: data.expire_date, usageLimit: data.usage_limit, title: "Invite Link", }) ) this.log(`Created group chat invite link for ${data.peer}: ${result.link}`) return result case "restrictions": result = await this.client.invoke( new Api.messages.EditChatDefaultBannedRights({ peer: new Api.PeerChat({chatId: data.peer}), bannedRights: new Api.ChatBannedRights({ untilDate: undefined, viewMessages: data.view_messages, sendMessages: data.send_messages, sendMedia: data.send_media, sendStickers: data.send_stickers, sendGifs: data.send_gifs, sendGames: data.send_games, sendInline: data.send_inline, sendPolls: data.send_polls, changeInfo: data.change_info, inviteUsers: data.invite_users, pinMessages: data.pin_messages, }), }) ) return result } } catch (error) { if(error instanceof errors.RPCError) { this.log.warn(error) } else { throw error } } } /** * Get telegram user information * * @param {bigint} id - user id * @returns {Promise<UserEssentials>} - user essential properties */ async getUser(id) { let result = await this.client.getEntity(id) return this.getUserEssentials(result) } /** * Get list of all open dialogs for this account * @returns {TotalList<Dialog>} */ async getDialogs() { return await this.client.getDialogs({archived:false}) } /** * Add a contact to telegram account contacts based on phone number. * * @param {Object} data * @param {string} data.phone - player phone number * @param {string} data.firstName - first Name for contact * @param {string} data.lastName - last Name for contact * * @returns {Promise<UserEssentials>} - Essential information about user that was added as contact */ async addContact(data) { if(!data.id && !data.username) { if(data.phone) { const result = await this.client.invoke( new Api.contacts.ResolvePhone({ phone: data.phone, }) ) data.id = result.users[0].id if(result.users[0].firstName) { data.first_name = result.users[0].firstName } if(result.users[0].lastName) { data.last_name = result.users[0].lastName } } else { throw new adaptor.InvalidError("Can not add contact. id, username or phone property required.") } } const result = await this.client.invoke( new Api.contacts.AddContact({ id: data.id || data.username, firstName: data.first_name, lastName: data.last_name || "", phone: data.phone || "", addPhonePrivacyException: true, }) ) return this.getUserEssentials(result.users[0]) } /** * @typedef {Object} UserEssentials * @property {bigint} id - User Telegram ID * @property {string} type - One of the peer types 'user', 'chat' or 'channel' * @property {string} first_name - User first name * @property {string} last_name - User last name * @property {string} phone - User phone number * @property {string} username - User name * @property {boolean} bot - Is user a telegram bot */ /** * Convert telegram (gramjs) user object to js object with only essential user properties * * @param {Api.User} user - Telegram user document * * @returns {UserEssentials} - Essential user, chat or channel information properties */ getUserEssentials(user) { return { id: user.id.value, type: user.className.toLowerCase(), first_name: user.firstName, last_name: user.LastName, phone: user.phone, username: user.username, bot: user.bot } } /** * Disconnect telegram api for this agent */ async close() { await this.disconnect() } command(input) { switch (input[0]) { case "user": if(!isNaN(input[1]) && input[1].charAt(0) != '+') { this.getUser(Number(input[1])) .then((result) => { log.info(this.name, result) }).catch((err) => { log.error(this.name, err) }) } else { this.getUser(input[1]) .then((result) => { log.info(this.name, result) }).catch((err) => { log.error(this.name, err) }) } break case "add_contact": if(input.length == 3) { this.addContact({phone:input[1], firstName:input[2], lastName:""}) } else if (input.length == 2) { this.getUser(Number(input[1])) .then((result) => { this.log.info(result) result["phone"] = "+" + result["phone"] return this.addContact(result) }) .then(result => { log.info(this.name, result) }) .catch((err) => { log.error(this.name, err) }) } break case "get_votes": this.getPollVotes({peer:input[1],msg_id:input[2]}) .then(result => { log.info(this.name, result) }) break default: super.command(input) } } } class Bot extends Account { constructor(...args) { super(...args) this.autoconnect = true } async setup() { this.log("Setup Telegram client bot") this.log(this.settings) if(!this.plugin.settings || !this.plugin.settings.api_id || !this.plugin.settings.api_hash) { this.setConnected(false) throw new adaptor.InvalidError("Can not connect bot. Telegram plugin api id and/or api hash missing") } if(this.settings.token) { try { this.session = new StoreSession(path.join(this.session_files, this.settings.token.split(":")[0])) this.client = new TelegramClient(this.session, this.plugin.settings.api_id, this.plugin.settings.api_hash, { connectionRetries: 5, }) this.client.setLogLevel("warn") await this.client.start({ botAuthToken: this.settings.token }) } catch (error) { this.apiErrorHandling(`Could not start telegram bot with token ${this.settings.token}.`, error) } this.client.addEventHandler(this.newMessage.bind(this), new NewMessage({})) this.client.addEventHandler(this.newEvent.bind(this)) /* if(await this.client.isUserAuthorized()) { this.log.info("Telegram bot connected") this.log.debug(await this.setMe()) this.setConnected(true) } else { this.log.warn(`Could not connect telegram bot with token: ${this.settings.token}. `) this.setConnected(false) } */ } else { this.setConnected(false) throw new adaptor.InvalidError("could not start telegram bot: bot api token missing") } } /** * Update telegram property, name and or token * * If phone number changed, recreate telethon client. Client will try to connect to account. * * @param {Object} data.settings - account connection settings * @param {Object} data.name - account name * @param {Object} data.telegram - account telegram user information */ async update({settings, name, telegram}) { this.name = name this.telegram = telegram if(settings.token != this.settings.token) { this.settings = settings await this.setup() return this.connect() } this.settings = settings } async connect() { if(this.settings.token) { if(!typeof this.client.start === "function") { throw new adaptor.AdaptorError("Can not connect. Telegram client was not properly initiated") } try { await this.client.start({ botAuthToken: this.settings.token }) if(await this.client.isUserAuthorized()) { this.log.info("Telegram bot connected") this.log.debug(await this.setMe()) this.setConnected(true) } else { this.setConnected(false) throw new adaptor.InvalidError(`Could not connect telegram bot with token: ${this.settings.token}.`) } } catch (error) { this.apiErrorHandling(`Could not connect with token ${this.settings.token}.`, error) } } else { this.setConnected(false) throw new adaptor.InvalidError("could not start telegram bot: bot api token missing") } } /** * A button was clicked by a user. Data is forwarded to the most recent registered button routing callback. * If that one does not have a response, data is forwarded to the next callback etc. * * @param {Api.UpdateBotCallbackQuery} event */ async botCallback(event) { let data = { "id":event.msgId, "button":event.data.toString(), "date":adaptor.now(), "from_id":event.userId.value, "peer_type":this.getPeerType(event.peer), "peer":this.getPeerId(event.peer) } this.log(`Button click event '${data.button}' in ${data.peer_type} ${data.peer} from ${data.from_id}`) /** peer id with recognizable peer type. This is required to match the route id in chats and channels which is also prepended with `-` or `-100` */ data.peer_type_id = this.getPeerTypeId({id: data.peer, type: data.peer_type}) if (this.routing.button[data.peer_type_id]) { for(let response of this.routing.button[data.peer_type_id]) { delete data.peer_type_id let responded = await response.callback(data) if(responded) { log.debug(this.name, "responded, skip any further routings") break } } } else { this.log('no routing for incoming button event from ' + data.peer_type + ' ' + data.peer + ': ' + data.button) } } /** * Send Buttons * * @param {object} data - parameter for sendMessage with MessageMediaGeo content * @param {bigint} data.entity - signed user, chat or channel id * @param {string} data.message - text of message that will be send before sending the buttons * @param {array} data.buttons - list of button label texts * * @returns {Promise<undefined>} - resolves when message was sent */ async sendButtons(data) { if (typeof data.typing === "undefined" || data.typing == "") { data.typing = Math.log(data.message.length) } await this.showTyping(data.entity, data.typing) let buttons = [] if(data.type == "inline") { buttons = data.buttons.map(row => { return row.map(b => { return Button.inline(b) }) }) } else if(data.type == "keyboard") { buttons = data.buttons.map(row => { return row.map(b => { return Button.text(b, {resize: true, singleUse: true}) }) }) } await this.client.sendMessage(data.entity, { message:data.message, buttons:buttons}) } async clearButtons(data) { let msg = await this.client.sendMessage(data.entity, {message:"-", buttons:Button.clear()}) await msg.delete({revoke:true}) } } module.exports = { Account: Account, Bot:Bot }