UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

1,653 lines (1,521 loc) 51.9 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]) { f