adaptorex
Version:
Connect all your live interactive storytelling devices and software
1,653 lines (1,521 loc) • 51.9 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]) {
f