adaptorex
Version:
Connect all your live interactive storytelling devices and software
1,491 lines (1,350 loc) • 49.6 kB
JavaScript
/**
* @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
}