import { logger } from '../log'
import Rocketchat from './Rocketchat'
import mem from 'mem'
import {
ISocketOptions,
IRespondOptions,
ICallback,
IMessageCallback,
ISubscriptionEvent,
IMessage,
ISubscription,
IMessageReceipt
} from '../../interfaces'
import { RID } from '../api/RocketChat'
const MY_MESSAGES = '__my_messages__'
const TOPIC_MESSAGES = 'stream-room-messages'
export default class BotClient extends Rocketchat {
integrationId: string
lastReadTime: Date = new Date(-8640000000000000)
joinedIds: string[] = []
messages: ISubscription | null = null
constructor ({ allPublic = false, integrationId, cachedMethods = ['channelInfo','privateInfo','getRoomIdByNameOrId', 'getRoomId', 'getRoomName','getRoomNameById','getDirectMessageRoomId' ], ...config }: any) {
super({ ...config, allPublic })
this.integrationId = integrationId
cachedMethods.forEach((name: string) => {
if ((this as any)[name]) {
(this as any)[name] = mem((this as any)[name].bind(this), { maxAge: 60 * 60 * 1000 }).bind(this)
}
})
}
/**
* Initialise socket instance with given options or defaults.
* Proxies the DDP module socket connection. Resolves with socket when open.
* Accepts callback following error-first-pattern.
* Error returned or promise rejected on timeout.
* @example
Use with callback
* import driver from '@rocket.chat/sdk/bot'
* driver.connect({}, (err) => {
* if (err) throw err
* else console.log('connected')
* })
* @example Using promise
* import driver from '@rocket.chat/sdk/bot'
* driver.connect()
* .then(() => console.log('connected'))
* .catch((err) => console.error(err))
*/
async connect (options: ISocketOptions, callback?: ICallback): Promise {
try {
const result = await super.connect(options)
if (callback) {
callback(null, (await this.socket))
}
return result
} catch (error) {
if (callback) {
callback(error, this)
}
return Promise.reject(error)
}
}
async unsubscribeAll () {
delete this.messages
return super.unsubscribeAll()
}
/** Begin subscription to user's "global" message stream. Will only allow one. */
async subscribeToMessages () {
if (!this.messages) {
this.messages = await this.subscribe(TOPIC_MESSAGES, MY_MESSAGES)
}
return this.messages
}
/**
* Add callback for changes in the message stream, subscribing if not already.
* This can be called directly for custom extensions, but for most usage (e.g.
* for bots) the respondToMessages is more useful to only receive messages
* matching configuration.
*
* @param callback Function called with every change in subscriptions.
* - Uses error-first callback pattern
* - Second argument is the changed message
* - Third argument is additional attributes, such as `roomType`
*/
async reactToMessages (callback: IMessageCallback, debug?: string) {
const handler = (e: ISubscriptionEvent) => {
try {
const message: IMessage = e.fields.args[0]
if (!message || !message._id) {
callback(new Error('Message handler fired on event without message or meta data'))
} else {
callback(null, message, {} as any)
}
} catch (err) {
this.logger.error(`[driver] Message handler err: ${err.message}`)
callback(err)
}
}
this.messages = await this.subscribeToMessages()
this.messages.onEvent(handler)
// this.logger.info(`[driver] Added event handler for ${this.messages.name} subscription`)
}
/**
* Applies `reactToMessages` with some filtering of messages based on config.
* If no rooms are joined at this point, it will attempt to join now based on
* environment config, otherwise it might not receive any messages. It doesn't
* matter that this happens asynchronously because joined rooms can change after
* the subscription is set up.
*
* @param callback Function called after filters run on subscription events.
* - Uses error-first callback pattern
* - Second argument is the changed item
* - Third argument is additional attributes, such as `roomType`
* @param options Sets filters for different event/message types.
*/
async respondToMessages (callback: IMessageCallback, options: IRespondOptions = {}) {
const config = { ...this.config, ...options }
// Join configured rooms if they haven't been already, unless listening to all
// public rooms, in which case it doesn't matter
if (!config.allPublic && this.joinedIds.length === 0 && config.rooms && config.rooms.length > 0) {
try {
await this.joinRooms(config.rooms)
} catch (err) {
this.logger.error(`[driver] Failed to join configured rooms (${config.rooms.join(', ')}): ${err.message}`)
}
}
return this.reactToMessages(async (err, message, meta) => {
if (err) {
logger.error(`[driver] Unable to receive: ${err.message}`)
return callback(err) // bubble errors back to adapter
}
if (typeof message === 'undefined' /*|| typeof meta === 'undefined'*/) {
logger.error(`[driver] Message or meta undefined`)
return callback(err)
}
// Ignore bot's own messages
if (message.u && message.u._id === this.userId) return
// Ignore DMs unless configured not to
try {
const room = await this.rooms.info({ rid: message.rid })
const isDM = room.t === 'd'
if (isDM && !config.dm) return
// Ignore Livechat unless configured not to
const isLC = room.t === 'l'
if (isLC && !config.livechat) return
} catch (error) {
console.log(error)
}
// Ignore messages in un-joined public rooms unless configured not to
// if (!config.allPublic && !isDM && !meta.roomParticipant) return
// Set current time for comparison to incoming
let currentReadTime = (message.ts) ? new Date(message.ts.$date) : new Date()
// Ignore edited messages if configured to
if (!config.edited && message.editedAt) return
// Ignore messages in stream that aren't new
if (currentReadTime < this.lastReadTime) return
// At this point, message has passed checks and can be responded to
// const username = (message.u) ? message.u.username : 'unknown'
// this.logger.info(`[driver] Message ${message._id} from ${username}`)
this.lastReadTime = currentReadTime
callback(null, message, meta)
})
}
/** Get ID for a room by name (or ID). */
getRoomId (name: string): Promise < RID > {
return this.getRoomIdByNameOrId(name)
}
/** Join the bot into a room by its name or ID */
async joinRoom ({ rid }: any): Promise < RID > {
const roomId = await this.getRoomId(rid)
const joinedIndex = this.joinedIds.indexOf(rid)
if (joinedIndex !== -1) {
logger.error(`[driver] Join room failed, already joined`)
throw new Error(`[driver] Join room failed, already joined`)
}
await super.joinRoom({ rid: roomId })
this.joinedIds.push(roomId)
return roomId
}
/** Exit a room the bot has joined */
async leaveRoom (room: string): Promise < RID > {
let roomId = await this.getRoomId(room)
let joinedIndex = this.joinedIds.indexOf(room)
if (joinedIndex === -1) {
this.logger.error(`[driver] Leave room failed, bot has not joined ${room}`)
throw new Error(`[driver] Leave room failed, bot has not joined ${room}`)
}
await this.leaveRoom(roomId)
delete this.joinedIds[joinedIndex]
return roomId
}
/** Join a set of rooms by array of names or IDs */
joinRooms (rooms: string[]): Promise < RID[] > {
return Promise.all(rooms.map((rid) => this.joinRoom({ rid })))
}
/**
* Prepare and send string/s to specified room ID.
* @param content Accepts message text string or array of strings.
* @param roomId ID of the target room to use in send.
* @todo Returning one or many gets complicated with type checking not allowing
* use of a property because result may be array, when you know it's not.
* Solution would probably be to always return an array, even for single
* send. This would be a breaking change, should hold until major version.
*/
sendToRoomId (content: IMessage | string | string[], roomId: string): Promise {
if (Array.isArray(content)) {
return Promise.all(content.map((text) => {
return this.sendMessage(text, roomId)
}))
}
return this.sendMessage(content, roomId)
}
/**
* Prepare and send string/s to specified room name (or ID).
* @param content Accepts message text string or array of strings.
* @param room A name (or ID) to resolve as ID to use in send.
*/
sendToRoom (content: IMessage | string | string[], room: string): Promise {
return this.getRoomId(room)
.then((roomId) => this.sendToRoomId(content, roomId))
}
/**
* Prepare and send string/s to a user in a DM.
* @param content Accepts message text string or array of strings.
* @param username Name to create (or get) DM for room ID to use in send.
*/
sendDirectToUser (content: IMessage | string | string[], username: string): Promise {
return this.getDirectMessageRoomId(username)
.then((rid) => this.sendToRoomId(content, rid))
}
/**
* Get ID for a DM room by its recipient's name.
* Will create a DM (with the bot) if it doesn't exist already.
* @todo test why create resolves with object instead of simply ID
*/
getDirectMessageRoomId (username: string): Promise < RID > {
return this.createDirectMessage(username).then((DM: any) => {
return DM._id
})
}
}