feat(dispatcher): Conversation class
This commit is contained in:
parent
2f8b3472d1
commit
b9b2b9f6ba
2 changed files with 517 additions and 0 deletions
516
packages/dispatcher/src/conversation.ts
Normal file
516
packages/dispatcher/src/conversation.ts
Normal file
|
@ -0,0 +1,516 @@
|
||||||
|
import { Dispatcher } from './dispatcher'
|
||||||
|
import {
|
||||||
|
FormattedString,
|
||||||
|
InputMediaLike,
|
||||||
|
InputPeerLike,
|
||||||
|
MaybeAsync,
|
||||||
|
Message,
|
||||||
|
MtCuteArgumentError,
|
||||||
|
TelegramClient,
|
||||||
|
TimeoutError,
|
||||||
|
tl,
|
||||||
|
} from '@mtcute/client'
|
||||||
|
import { AsyncLock, getMarkedPeerId } from '@mtcute/core'
|
||||||
|
import {
|
||||||
|
ControllablePromise,
|
||||||
|
createControllablePromise,
|
||||||
|
} from '@mtcute/core/src/utils/controllable-promise'
|
||||||
|
import { HistoryReadUpdate } from './updates'
|
||||||
|
|
||||||
|
interface OneWayLinkedListItem<T> {
|
||||||
|
v: T
|
||||||
|
n?: OneWayLinkedListItem<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
class Queue<T> {
|
||||||
|
first?: OneWayLinkedListItem<T>
|
||||||
|
last?: OneWayLinkedListItem<T>
|
||||||
|
|
||||||
|
length = 0
|
||||||
|
|
||||||
|
constructor (readonly limit = 0) {
|
||||||
|
}
|
||||||
|
|
||||||
|
push(item: T): void {
|
||||||
|
const it: OneWayLinkedListItem<T> = { v: item }
|
||||||
|
if (!this.first) {
|
||||||
|
this.first = this.last = it
|
||||||
|
} else {
|
||||||
|
this.last!.n = it
|
||||||
|
this.last = it
|
||||||
|
}
|
||||||
|
|
||||||
|
this.length += 1
|
||||||
|
|
||||||
|
if (this.limit) {
|
||||||
|
while (this.first && this.length > this.limit) {
|
||||||
|
this.first = this.first.n
|
||||||
|
this.length -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
empty(): boolean {
|
||||||
|
return this.first === undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
peek(): T | undefined {
|
||||||
|
return this.first?.v
|
||||||
|
}
|
||||||
|
|
||||||
|
pop(): T | undefined {
|
||||||
|
if (!this.first) return undefined
|
||||||
|
|
||||||
|
const it = this.first
|
||||||
|
this.first = this.first.n
|
||||||
|
if (!this.first) this.last = undefined
|
||||||
|
|
||||||
|
this.length -= 1
|
||||||
|
return it.v
|
||||||
|
}
|
||||||
|
|
||||||
|
removeBy(pred: (it: T) => boolean): void {
|
||||||
|
if (!this.first) return
|
||||||
|
|
||||||
|
let prev: OneWayLinkedListItem<T> | undefined = undefined
|
||||||
|
let it = this.first
|
||||||
|
while (it && !pred(it.v)) {
|
||||||
|
if (!it.n) return
|
||||||
|
|
||||||
|
prev = it
|
||||||
|
it = it.n
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!it) return
|
||||||
|
|
||||||
|
if (prev) {
|
||||||
|
prev.n = it.n
|
||||||
|
} else {
|
||||||
|
this.first = it.n
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.first) this.last = undefined
|
||||||
|
|
||||||
|
this.length -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.first = this.last = undefined
|
||||||
|
this.length = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueuedHandler<T> {
|
||||||
|
promise: ControllablePromise<T>
|
||||||
|
check?: (update: T) => MaybeAsync<boolean>
|
||||||
|
timeout?: NodeJS.Timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Conversation {
|
||||||
|
private _inputPeer: tl.TypeInputPeer
|
||||||
|
private _chatId: number
|
||||||
|
private _client: TelegramClient
|
||||||
|
private _started = false
|
||||||
|
|
||||||
|
private _lastMessage?: number
|
||||||
|
private _lastReceivedMessage?: number
|
||||||
|
|
||||||
|
private _queuedNewMessage = new Queue<QueuedHandler<Message>>()
|
||||||
|
private _pendingNewMessages = new Queue<Message>()
|
||||||
|
private _lock = new AsyncLock()
|
||||||
|
|
||||||
|
private _pendingEditMessage: Record<number, QueuedHandler<Message>> = {}
|
||||||
|
private _recentEdits = new Queue<Message>(10)
|
||||||
|
|
||||||
|
private _pendingRead: Record<number, QueuedHandler<void>> = {}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly dispatcher: Dispatcher<any, any>,
|
||||||
|
readonly chat: InputPeerLike
|
||||||
|
) {
|
||||||
|
this._onNewMessage = this._onNewMessage.bind(this)
|
||||||
|
this._onEditMessage = this._onEditMessage.bind(this)
|
||||||
|
this._onHistoryRead = this._onHistoryRead.bind(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
get inputPeer(): tl.TypeInputPeer {
|
||||||
|
if (!this._started) {
|
||||||
|
throw new MtCuteArgumentError("Conversation hasn't started yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._inputPeer
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this._started) return
|
||||||
|
|
||||||
|
const client = this.dispatcher['_client']
|
||||||
|
if (!client) {
|
||||||
|
throw new MtCuteArgumentError(
|
||||||
|
'Dispatcher is not bound to a client!'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this._client = client
|
||||||
|
this._started = true
|
||||||
|
this._inputPeer = await client.resolvePeer(this.chat)
|
||||||
|
this._chatId = getMarkedPeerId(this._inputPeer)
|
||||||
|
|
||||||
|
this.dispatcher.on('new_message', this._onNewMessage)
|
||||||
|
this.dispatcher.on('edit_message', this._onEditMessage)
|
||||||
|
this.dispatcher.on('history_read', this._onHistoryRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (!this._started) return
|
||||||
|
|
||||||
|
this.dispatcher.off('new_message', this._onNewMessage)
|
||||||
|
this.dispatcher.off('edit_message', this._onEditMessage)
|
||||||
|
this.dispatcher.off('history_read', this._onHistoryRead)
|
||||||
|
|
||||||
|
// reset pending status
|
||||||
|
this._queuedNewMessage.clear()
|
||||||
|
this._pendingNewMessages.clear()
|
||||||
|
this._pendingEditMessage = {}
|
||||||
|
this._recentEdits.clear()
|
||||||
|
this._pendingRead = {}
|
||||||
|
|
||||||
|
this._started = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a text message to this conversation.
|
||||||
|
*
|
||||||
|
* @param text Text of the message
|
||||||
|
* @param params
|
||||||
|
*/
|
||||||
|
async sendText(
|
||||||
|
text: string | FormattedString,
|
||||||
|
params?: Parameters<TelegramClient['sendText']>[2]
|
||||||
|
): ReturnType<TelegramClient['sendText']> {
|
||||||
|
if (!this._started) {
|
||||||
|
throw new MtCuteArgumentError("Conversation hasn't started yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await this._client.sendText(this._inputPeer, text, params)
|
||||||
|
this._lastMessage = res.id
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a media to this conversation.
|
||||||
|
*
|
||||||
|
* @param media Media to send
|
||||||
|
* @param params
|
||||||
|
*/
|
||||||
|
async sendMedia(
|
||||||
|
media: InputMediaLike | string,
|
||||||
|
params?: Parameters<TelegramClient['sendMedia']>[2]
|
||||||
|
): ReturnType<TelegramClient['sendMedia']> {
|
||||||
|
if (!this._started) {
|
||||||
|
throw new MtCuteArgumentError("Conversation hasn't started yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await this._client.sendMedia(this._inputPeer, media, params)
|
||||||
|
this._lastMessage = res.id
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a media group to this conversation.
|
||||||
|
*
|
||||||
|
* @param medias Medias to send
|
||||||
|
* @param params
|
||||||
|
*/
|
||||||
|
async sendMediaGroup(
|
||||||
|
medias: (InputMediaLike | string)[],
|
||||||
|
params?: Parameters<TelegramClient['sendMediaGroup']>[2]
|
||||||
|
): ReturnType<TelegramClient['sendMediaGroup']> {
|
||||||
|
if (!this._started) {
|
||||||
|
throw new MtCuteArgumentError("Conversation hasn't started yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await this._client.sendMediaGroup(
|
||||||
|
this._inputPeer,
|
||||||
|
medias,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
this._lastMessage = res[res.length - 1].id
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the conversation as read up to a certain point.
|
||||||
|
*
|
||||||
|
* By default, reads until the last message.
|
||||||
|
* You can pass `null` to read the entire conversation,
|
||||||
|
* or pass message ID to read up until that ID.
|
||||||
|
*/
|
||||||
|
markRead(message?: number | null, clearMentions = true): Promise<void> {
|
||||||
|
if (!this._started) {
|
||||||
|
throw new MtCuteArgumentError("Conversation hasn't started yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message === null) {
|
||||||
|
message = 0
|
||||||
|
} else if (message === undefined) {
|
||||||
|
message = this._lastMessage ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._client.readHistory(this._inputPeer, message, clearMentions)
|
||||||
|
}
|
||||||
|
|
||||||
|
async with<T>(handler: () => MaybeAsync<T>): Promise<T> {
|
||||||
|
await this.start()
|
||||||
|
|
||||||
|
let err: unknown
|
||||||
|
let res: T
|
||||||
|
try {
|
||||||
|
res = await handler()
|
||||||
|
} catch (e) {
|
||||||
|
err = e
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stop()
|
||||||
|
|
||||||
|
if (err) throw err
|
||||||
|
|
||||||
|
return res!
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a new message in the conversation
|
||||||
|
*
|
||||||
|
* @param filter Filter for the handler. You can use any filter you can use for dispatcher
|
||||||
|
* @param timeout Timeout for the handler in ms. Pass `null` to disable. When the timeout is reached, `TimeoutError` is thrown.
|
||||||
|
*/
|
||||||
|
waitForNewMessage(
|
||||||
|
filter?: (msg: Message) => MaybeAsync<boolean>,
|
||||||
|
timeout: number | null = 15000
|
||||||
|
): Promise<Message> {
|
||||||
|
if (!this._started) {
|
||||||
|
throw new MtCuteArgumentError("Conversation hasn't started yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = createControllablePromise<Message>()
|
||||||
|
|
||||||
|
let timer: NodeJS.Timeout | undefined = undefined
|
||||||
|
if (timeout !== null) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
promise.reject(new TimeoutError())
|
||||||
|
this._queuedNewMessage.removeBy((it) => it.promise === promise)
|
||||||
|
}, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
this._queuedNewMessage.push({
|
||||||
|
promise,
|
||||||
|
check: filter,
|
||||||
|
timeout: timer,
|
||||||
|
})
|
||||||
|
|
||||||
|
this._processPendingNewMessages()
|
||||||
|
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForResponse(
|
||||||
|
filter?: (msg: Message) => MaybeAsync<boolean>,
|
||||||
|
params?: {
|
||||||
|
message?: number
|
||||||
|
timeout?: number | null
|
||||||
|
}
|
||||||
|
): Promise<Message> {
|
||||||
|
const msgId = params?.message ?? this._lastMessage ?? 0
|
||||||
|
|
||||||
|
const pred = filter
|
||||||
|
? (msg: Message) => (msg.id > msgId ? filter(msg) : false)
|
||||||
|
: (msg: Message) => msg.id > msgId
|
||||||
|
|
||||||
|
return this.waitForNewMessage(pred, params?.timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForReply(
|
||||||
|
filter?: (msg: Message) => MaybeAsync<boolean>,
|
||||||
|
params?: {
|
||||||
|
message?: number
|
||||||
|
timeout?: number | null
|
||||||
|
}
|
||||||
|
): Promise<Message> {
|
||||||
|
const msgId = params?.message ?? this._lastMessage
|
||||||
|
if (!msgId)
|
||||||
|
throw new MtCuteArgumentError(
|
||||||
|
'Provide message for which to wait for reply for'
|
||||||
|
)
|
||||||
|
|
||||||
|
const pred = filter
|
||||||
|
? (msg: Message) =>
|
||||||
|
msg.replyToMessageId === msgId ? filter(msg) : false
|
||||||
|
: (msg: Message) => msg.replyToMessageId === msgId
|
||||||
|
|
||||||
|
return this.waitForNewMessage(pred, params?.timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a message to be edited in the conversation.
|
||||||
|
* By defaults wait for the last message sent by the other party
|
||||||
|
* (at the moment) to be edited.
|
||||||
|
*
|
||||||
|
* Returns the edited message.
|
||||||
|
*
|
||||||
|
* @param filter Filter for the handler. You can use any filter you can use for dispatcher
|
||||||
|
* @param params
|
||||||
|
*/
|
||||||
|
async waitForEdit(
|
||||||
|
filter?: (msg: Message) => MaybeAsync<boolean>,
|
||||||
|
params?: {
|
||||||
|
message?: number
|
||||||
|
timeout?: number | null
|
||||||
|
}
|
||||||
|
): Promise<Message> {
|
||||||
|
if (!this._started) {
|
||||||
|
throw new MtCuteArgumentError("Conversation hasn't started yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgId = params?.message ?? this._lastReceivedMessage
|
||||||
|
if (!msgId) {
|
||||||
|
throw new MtCuteArgumentError(
|
||||||
|
'Provide message for which to wait for edit for'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = createControllablePromise<Message>()
|
||||||
|
|
||||||
|
let timer: NodeJS.Timeout | undefined = undefined
|
||||||
|
if (params?.timeout !== null) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
promise.reject(new TimeoutError())
|
||||||
|
delete this._pendingEditMessage[msgId]
|
||||||
|
}, params?.timeout ?? 15000)
|
||||||
|
}
|
||||||
|
|
||||||
|
this._pendingEditMessage[msgId] = {
|
||||||
|
promise,
|
||||||
|
check: filter,
|
||||||
|
timeout: timer,
|
||||||
|
}
|
||||||
|
|
||||||
|
this._processRecentEdits()
|
||||||
|
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForRead(message?: number, timeout: number | null = 15000): Promise<void> {
|
||||||
|
if (!this._started) {
|
||||||
|
throw new MtCuteArgumentError("Conversation hasn't started yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgId = message ?? this._lastMessage
|
||||||
|
if (!msgId)
|
||||||
|
throw new MtCuteArgumentError(
|
||||||
|
'Provide message for which to wait for reply for'
|
||||||
|
)
|
||||||
|
|
||||||
|
// check if the message is already read
|
||||||
|
const dialog = await this._client.getPeerDialogs(this._inputPeer)
|
||||||
|
if (dialog.lastRead >= msgId) return
|
||||||
|
|
||||||
|
const promise = createControllablePromise<void>()
|
||||||
|
|
||||||
|
let timer: NodeJS.Timeout | undefined = undefined
|
||||||
|
if (timeout !== null) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
promise.reject(new TimeoutError())
|
||||||
|
delete this._pendingRead[msgId]
|
||||||
|
}, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
this._pendingRead[msgId] = {
|
||||||
|
promise,
|
||||||
|
timeout: timer,
|
||||||
|
}
|
||||||
|
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private _onNewMessage(msg: Message) {
|
||||||
|
if (msg.chat.id !== this._chatId) return
|
||||||
|
|
||||||
|
if (this._queuedNewMessage.empty()) {
|
||||||
|
this._pendingNewMessages.push(msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const it = this._queuedNewMessage.peek()!
|
||||||
|
|
||||||
|
// order does matter for new messages
|
||||||
|
this._lock.acquire().then(async () => {
|
||||||
|
try {
|
||||||
|
if (!it.check || (await it.check(msg))) {
|
||||||
|
if (it.timeout) clearTimeout(it.timeout)
|
||||||
|
it.promise.resolve(msg)
|
||||||
|
this._queuedNewMessage.pop()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this._client['_emitError'](e)
|
||||||
|
}
|
||||||
|
|
||||||
|
this._lastMessage = this._lastReceivedMessage = msg.id
|
||||||
|
|
||||||
|
// this func will *never* error, so no need to use .then
|
||||||
|
this._lock.release()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onEditMessage(msg: Message, fromRecent = false) {
|
||||||
|
if (msg.chat.id !== this._chatId) return
|
||||||
|
|
||||||
|
const it = this._pendingEditMessage[msg.id]
|
||||||
|
if (!it && !fromRecent) {
|
||||||
|
this._recentEdits.push(msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (!it.check || (await it.check(msg))) {
|
||||||
|
if (it.timeout) clearTimeout(it.timeout)
|
||||||
|
it.promise.resolve(msg)
|
||||||
|
delete this._pendingEditMessage[msg.id]
|
||||||
|
}
|
||||||
|
})().catch((e) => this._client['_emitError'](e))
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onHistoryRead(upd: HistoryReadUpdate) {
|
||||||
|
if (upd.chatId !== this._chatId) return
|
||||||
|
|
||||||
|
const lastRead = upd.maxReadId
|
||||||
|
|
||||||
|
Object.keys(this._pendingRead).forEach((msgId: any) => {
|
||||||
|
if (parseInt(msgId) <= lastRead) {
|
||||||
|
const it = this._pendingRead[msgId]
|
||||||
|
if (it.timeout) clearTimeout(it.timeout)
|
||||||
|
it.promise.resolve()
|
||||||
|
delete this._pendingRead[msgId]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private _processPendingNewMessages() {
|
||||||
|
if (this._pendingNewMessages.empty()) return
|
||||||
|
|
||||||
|
let it
|
||||||
|
while ((it = this._pendingNewMessages.pop())) {
|
||||||
|
this._onNewMessage(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _processRecentEdits() {
|
||||||
|
if (this._recentEdits.empty()) return
|
||||||
|
|
||||||
|
let it = this._recentEdits.first
|
||||||
|
do {
|
||||||
|
if (!it) break
|
||||||
|
this._onEditMessage(it.v, true)
|
||||||
|
} while ((it = it.n))
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,5 +6,6 @@ export * from './propagation'
|
||||||
export * from './updates'
|
export * from './updates'
|
||||||
export * from './wizard'
|
export * from './wizard'
|
||||||
export * from './callback-data-builder'
|
export * from './callback-data-builder'
|
||||||
|
export * from './conversation'
|
||||||
|
|
||||||
export { UpdateState, IStateStorage } from './state'
|
export { UpdateState, IStateStorage } from './state'
|
||||||
|
|
Loading…
Reference in a new issue