@soffits/dsh-deltachat-bot 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +185 -2
- package/bin/dsh-deltachat.js +140 -0
- package/cordis.patch.yml +9 -0
- package/icon.svg +14 -0
- package/index.js +4 -0
- package/locale/en.json +6 -0
- package/package.json +67 -4
- package/src/app.js +653 -0
- package/src/commands.js +454 -0
- package/src/config.js +63 -0
- package/src/constants.js +50 -0
- package/src/controls.js +214 -0
- package/src/core.js +236 -0
- package/src/helpers.js +180 -0
- package/src/identity.js +134 -0
- package/src/inbound.js +100 -0
- package/src/models.js +191 -0
- package/src/operations.js +183 -0
- package/src/outbound.js +79 -0
- package/src/prompts.js +179 -0
- package/src/reactions.js +125 -0
- package/src/sessions.js +388 -0
- package/src/spool.js +172 -0
- package/src/state.js +162 -0
- package/src/text.js +175 -0
- package/src/titles.js +146 -0
- package/src/turns.js +197 -0
- package/src/voice.js +126 -0
- package/tools.js +60 -0
package/src/outbound.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Sending into chats: text made plain and split, files, notices, in order per chat. */
|
|
2
|
+
import { stat } from 'node:fs/promises'
|
|
3
|
+
import { basename, isAbsolute } from 'node:path'
|
|
4
|
+
import { MAX_FILE_BYTES } from './constants.js'
|
|
5
|
+
import { messageData, viewTypeOf } from './core.js'
|
|
6
|
+
import { splitText, toPlainText } from './text.js'
|
|
7
|
+
|
|
8
|
+
/** An error that a retry will not fix. */
|
|
9
|
+
export function finalError(message) {
|
|
10
|
+
return Object.assign(new Error(message), { retryable: false })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create the sender.
|
|
15
|
+
* @param deps - `{ requireRuntime, chatState, reportError, messageOf }`.
|
|
16
|
+
* @returns `{ queueOut, sendText, checkFiles, sendFiles, notice }`.
|
|
17
|
+
*/
|
|
18
|
+
export function createOutbound({ requireRuntime, chatState, reportError, messageOf }) {
|
|
19
|
+
/** Run output for a chat in order. */
|
|
20
|
+
function queueOut(chatId, task) {
|
|
21
|
+
const chat = chatState(chatId)
|
|
22
|
+
const run = chat.out.catch(() => {}).then(task)
|
|
23
|
+
chat.out = run.catch((error) => reportError('send', error))
|
|
24
|
+
return run
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------- outbound
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Send text (Markdown is made plain) into a chat.
|
|
31
|
+
* @param chatId - chat id.
|
|
32
|
+
* @param text - Markdown text.
|
|
33
|
+
* @param options - `{ quote, plain }`: message id to quote on the first part;
|
|
34
|
+
* `plain` skips the Markdown conversion.
|
|
35
|
+
* @returns the sent message ids.
|
|
36
|
+
*/
|
|
37
|
+
async function sendText(chatId, text, { quote, plain = false } = {}) {
|
|
38
|
+
const { dc, accountId } = requireRuntime()
|
|
39
|
+
const ids = []
|
|
40
|
+
for (const [index, part] of splitText(plain ? text : toPlainText(text)).entries()) {
|
|
41
|
+
ids.push(await dc.rpc.sendMsg(accountId, chatId, messageData({ text: part, ...(index === 0 && Number.isSafeInteger(quote) ? { quotedMessageId: quote } : {}) })))
|
|
42
|
+
}
|
|
43
|
+
return ids
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check files before anything is sent.
|
|
48
|
+
* @param paths - absolute paths.
|
|
49
|
+
* @returns `{ path, name, size }` entries; throws on the first bad one.
|
|
50
|
+
*/
|
|
51
|
+
async function checkFiles(paths) {
|
|
52
|
+
const files = []
|
|
53
|
+
for (const path of paths) {
|
|
54
|
+
if (!isAbsolute(path)) throw finalError(`File paths must be absolute: ${path}`)
|
|
55
|
+
const info = await stat(path).catch((error) => { throw finalError(`${path}: ${messageOf(error)}`) })
|
|
56
|
+
if (!info.isFile()) throw finalError(`${path} is not a file`)
|
|
57
|
+
if (info.size > MAX_FILE_BYTES) throw finalError(`${path} is larger than ${MAX_FILE_BYTES / 1024 / 1024} MB`)
|
|
58
|
+
files.push({ path, name: basename(path), size: info.size })
|
|
59
|
+
}
|
|
60
|
+
return files
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Send checked files; the core copies each into its blob directory. */
|
|
64
|
+
async function sendFiles(chatId, files) {
|
|
65
|
+
const { dc, accountId } = requireRuntime()
|
|
66
|
+
const ids = []
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
ids.push(await dc.rpc.sendMsg(accountId, chatId, messageData({ file: file.path, filename: file.name, viewtype: viewTypeOf(file.path) })))
|
|
69
|
+
}
|
|
70
|
+
return ids
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A short bot notice (already plain text) into a chat. */
|
|
74
|
+
function notice(chatId, text) {
|
|
75
|
+
return queueOut(chatId, () => sendText(chatId, text, { plain: true }))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { queueOut, sendText, checkFiles, sendFiles, notice }
|
|
79
|
+
}
|
package/src/prompts.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval requests and agent questions for turns started from Delta Chat.
|
|
3
|
+
* Delta Chat has no buttons, so a question lists its options as numbers and
|
|
4
|
+
* the next message answers it; an approval waits for /allow or /deny.
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse a reply to a question with options.
|
|
10
|
+
* @param text - the reply.
|
|
11
|
+
* @param question - `{ options, multiSelect }`.
|
|
12
|
+
* @returns `{ selected, custom }`: option labels picked by number, or the
|
|
13
|
+
* whole reply as a free answer.
|
|
14
|
+
*/
|
|
15
|
+
export function parseAnswer(text, question) {
|
|
16
|
+
const options = Array.isArray(question?.options) ? question.options : []
|
|
17
|
+
const trimmed = String(text ?? '').trim()
|
|
18
|
+
if (options.length > 0 && /^\d+(?:[\s,]+\d+)*$/u.test(trimmed)) {
|
|
19
|
+
const numbers = trimmed.split(/[\s,]+/u).map(Number)
|
|
20
|
+
if (numbers.every((number) => number >= 1 && number <= options.length)) {
|
|
21
|
+
const picked = [...new Set(numbers)].map((number) => options[number - 1].label)
|
|
22
|
+
return { selected: question.multiSelect === true ? picked : picked.slice(0, 1), custom: undefined }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { selected: [], custom: trimmed }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Text of one question.
|
|
30
|
+
* @param question - user question.
|
|
31
|
+
* @param index - its position.
|
|
32
|
+
* @param total - questions in the batch.
|
|
33
|
+
*/
|
|
34
|
+
export function questionText(question, index, total) {
|
|
35
|
+
const lines = []
|
|
36
|
+
if (question.header) lines.push(question.header)
|
|
37
|
+
lines.push(`❓ ${question.question}`)
|
|
38
|
+
if (question.detail) lines.push('', String(question.detail).slice(0, 3000))
|
|
39
|
+
const options = Array.isArray(question.options) ? question.options : []
|
|
40
|
+
if (options.length > 0) {
|
|
41
|
+
lines.push('')
|
|
42
|
+
for (const [number, option] of options.entries()) {
|
|
43
|
+
lines.push(`${number + 1}. ${option.label}${option.description ? ` — ${option.description}` : ''}`)
|
|
44
|
+
}
|
|
45
|
+
lines.push('', question.multiSelect === true
|
|
46
|
+
? 'Reply with one or more numbers (like "1 3"), or write your own answer.'
|
|
47
|
+
: 'Reply with a number, or write your own answer.')
|
|
48
|
+
} else {
|
|
49
|
+
lines.push('', 'Reply with your answer.')
|
|
50
|
+
}
|
|
51
|
+
if (total > 1) lines.push(`(Question ${index + 1} of ${total}; /stop cancels.)`)
|
|
52
|
+
return lines.join('\n')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Create the prompt broker.
|
|
57
|
+
* @param options - `{ chatOfAgent, send, warn }`: `chatOfAgent(agent)` gives
|
|
58
|
+
* the chat that should answer for a turn, or undefined to leave it to DSH.
|
|
59
|
+
* @returns `{ attach, consumeReply, settleApproval, cancelChat, cancelAll, pending }`.
|
|
60
|
+
*/
|
|
61
|
+
export function createPrompts({ chatOfAgent, send, warn }) {
|
|
62
|
+
const attached = new WeakSet()
|
|
63
|
+
/** Pending question batches by chat id. */
|
|
64
|
+
const questions = new Map()
|
|
65
|
+
/** Pending approvals by chat id (a list, oldest first). */
|
|
66
|
+
const approvals = new Map()
|
|
67
|
+
|
|
68
|
+
function finishQuestion(chatId, batch, error) {
|
|
69
|
+
if (questions.get(chatId) !== batch) return
|
|
70
|
+
questions.delete(chatId)
|
|
71
|
+
batch.signal?.removeEventListener('abort', batch.onAbort)
|
|
72
|
+
if (error === undefined) batch.resolve({ answers: batch.answers })
|
|
73
|
+
else batch.reject(error)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function askQuestions(chatId, req, next) {
|
|
77
|
+
const list = Array.isArray(req.questions) ? req.questions : []
|
|
78
|
+
if (list.length === 0 || questions.has(chatId)) return next()
|
|
79
|
+
const signal = req.signal
|
|
80
|
+
if (signal?.aborted) return next()
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const batch = { id: randomUUID(), questions: list, index: 0, answers: [], resolve, reject, signal, onAbort: undefined }
|
|
83
|
+
batch.onAbort = () => {
|
|
84
|
+
finishQuestion(chatId, batch, signal.reason ?? new Error('question cancelled'))
|
|
85
|
+
void send(chatId, '⏹ Question cancelled.')
|
|
86
|
+
}
|
|
87
|
+
signal?.addEventListener('abort', batch.onAbort, { once: true })
|
|
88
|
+
questions.set(chatId, batch)
|
|
89
|
+
Promise.resolve(send(chatId, questionText(list[0], 0, list.length))).catch((error) => {
|
|
90
|
+
warn(`question not sent: ${error?.message ?? error}`)
|
|
91
|
+
finishQuestion(chatId, batch, error)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function askApproval(chatId, req) {
|
|
97
|
+
if (req.signal?.aborted) return 'cancelled'
|
|
98
|
+
const reason = req.displayReason?.en ?? req.reason ?? 'The tool asked for approval.'
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
const entry = { resolve, toolName: req.toolName, signal: req.signal, onAbort: undefined }
|
|
101
|
+
entry.onAbort = () => settle(chatId, entry, 'cancelled')
|
|
102
|
+
req.signal?.addEventListener('abort', entry.onAbort, { once: true })
|
|
103
|
+
const list = approvals.get(chatId) ?? []
|
|
104
|
+
list.push(entry)
|
|
105
|
+
approvals.set(chatId, list)
|
|
106
|
+
Promise.resolve(send(chatId, `🔐 Approval needed for ${req.toolName}:\n${reason}\n\nReply /allow or /deny.`)).catch(() => settle(chatId, entry, 'unavailable'))
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function settle(chatId, entry, outcome) {
|
|
111
|
+
const list = approvals.get(chatId) ?? []
|
|
112
|
+
const index = list.indexOf(entry)
|
|
113
|
+
if (index === -1) return false
|
|
114
|
+
list.splice(index, 1)
|
|
115
|
+
if (list.length === 0) approvals.delete(chatId)
|
|
116
|
+
entry.signal?.removeEventListener('abort', entry.onAbort)
|
|
117
|
+
entry.resolve(outcome)
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
/** Answer approval and question requests of an agent while its turn belongs to a chat. */
|
|
123
|
+
attach(agent) {
|
|
124
|
+
if (attached.has(agent) || typeof agent?.ctx?.on !== 'function') return
|
|
125
|
+
attached.add(agent)
|
|
126
|
+
agent.ctx.on('approval/request', (req, next) => {
|
|
127
|
+
const chatId = chatOfAgent(agent)
|
|
128
|
+
if (chatId === undefined) return next()
|
|
129
|
+
return askApproval(chatId, req)
|
|
130
|
+
}, { prepend: true })
|
|
131
|
+
agent.ctx.on('user-questions/request', (req, next) => {
|
|
132
|
+
const chatId = chatOfAgent(agent)
|
|
133
|
+
if (chatId === undefined) return next()
|
|
134
|
+
return askQuestions(chatId, req, next)
|
|
135
|
+
}, { prepend: true })
|
|
136
|
+
},
|
|
137
|
+
/**
|
|
138
|
+
* Take a message as the answer to the chat's open question.
|
|
139
|
+
* @returns true when the message answered a question.
|
|
140
|
+
*/
|
|
141
|
+
consumeReply(chatId, text) {
|
|
142
|
+
const batch = questions.get(chatId)
|
|
143
|
+
if (batch === undefined || text.length === 0 || text.startsWith('/')) return false
|
|
144
|
+
const question = batch.questions[batch.index]
|
|
145
|
+
const { selected, custom } = parseAnswer(text, question)
|
|
146
|
+
batch.answers.push({ id: question.id, selected, ...(custom ? { custom } : {}) })
|
|
147
|
+
batch.index += 1
|
|
148
|
+
if (batch.index < batch.questions.length) {
|
|
149
|
+
void send(chatId, questionText(batch.questions[batch.index], batch.index, batch.questions.length))
|
|
150
|
+
} else {
|
|
151
|
+
finishQuestion(chatId, batch)
|
|
152
|
+
}
|
|
153
|
+
return true
|
|
154
|
+
},
|
|
155
|
+
/**
|
|
156
|
+
* Resolve the oldest approval of a chat.
|
|
157
|
+
* @returns the tool name, or undefined when none was waiting.
|
|
158
|
+
*/
|
|
159
|
+
settleApproval(chatId, allowed) {
|
|
160
|
+
const entry = approvals.get(chatId)?.[0]
|
|
161
|
+
if (entry === undefined) return undefined
|
|
162
|
+
settle(chatId, entry, allowed ? 'allowed-once' : 'rejected')
|
|
163
|
+
return entry.toolName
|
|
164
|
+
},
|
|
165
|
+
/** Cancel what a chat was asked (on /stop). */
|
|
166
|
+
cancelChat(chatId) {
|
|
167
|
+
const batch = questions.get(chatId)
|
|
168
|
+
if (batch !== undefined) finishQuestion(chatId, batch, new Error('Question cancelled by /stop'))
|
|
169
|
+
for (const entry of [...(approvals.get(chatId) ?? [])]) settle(chatId, entry, 'cancelled')
|
|
170
|
+
},
|
|
171
|
+
cancelAll() {
|
|
172
|
+
for (const chatId of [...questions.keys(), ...approvals.keys()]) this.cancelChat(chatId)
|
|
173
|
+
},
|
|
174
|
+
/** @returns what a chat is being asked: `{ question, approvals }`. */
|
|
175
|
+
pending(chatId) {
|
|
176
|
+
return { question: questions.has(chatId), approvals: approvals.get(chatId)?.length ?? 0 }
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
}
|
package/src/reactions.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working signs: Delta Chat has no typing indicator, so a message the bot
|
|
3
|
+
* works on carries a reaction from the bot (⏳ for a new or queued turn, 👀
|
|
4
|
+
* when it went into the running turn) until the turn that took it has ended
|
|
5
|
+
* and its answer was sent. Reacted messages are kept in `state.json`, so a
|
|
6
|
+
* sign left by a turn that a Host restart cut off is removed at the next start.
|
|
7
|
+
*/
|
|
8
|
+
import { REQUEST_PREFIX, STEER_REACTION } from './constants.js'
|
|
9
|
+
import { inInbox } from './sessions.js'
|
|
10
|
+
|
|
11
|
+
/** Reaction on a message that starts a turn or waits for one. */
|
|
12
|
+
export const WORKING_REACTION = '⏳'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create the working-sign tracker.
|
|
16
|
+
* @param deps - `{ ctx, getStore, getRuntime, reportError }`.
|
|
17
|
+
* @returns `{ emojiFor, mark, admitted, attach, finishTurn, sweep, resume, release }`.
|
|
18
|
+
*/
|
|
19
|
+
export function createReactions({ ctx, getStore, getRuntime, reportError }) {
|
|
20
|
+
/** Reacted messages by message id: `{ chatId, admitted, turn }`. */
|
|
21
|
+
const tracked = new Map()
|
|
22
|
+
let loaded = false
|
|
23
|
+
|
|
24
|
+
/** Take over the messages a previous run left reacted. */
|
|
25
|
+
function load() {
|
|
26
|
+
if (loaded) return
|
|
27
|
+
const store = getStore()
|
|
28
|
+
if (store === undefined) return
|
|
29
|
+
loaded = true
|
|
30
|
+
for (const [msgId, chatId] of Object.entries(store.reactions())) {
|
|
31
|
+
if (!tracked.has(Number(msgId))) tracked.set(Number(msgId), { chatId, admitted: true, turn: undefined })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @returns the live agent of a chat's session, or undefined. */
|
|
36
|
+
function agentOf(chatId) {
|
|
37
|
+
const sessionId = getStore()?.chat(chatId)?.sessionId
|
|
38
|
+
return typeof sessionId === 'string' ? ctx.get('agents')?.get(sessionId) : undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The sign for a message about to go to a session.
|
|
43
|
+
* @param running - whether the session runs a turn.
|
|
44
|
+
* @param steer - whether a message during a turn goes into it.
|
|
45
|
+
*/
|
|
46
|
+
function emojiFor(running, steer) {
|
|
47
|
+
return running && steer ? STEER_REACTION : WORKING_REACTION
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** React to a message and remember it. */
|
|
51
|
+
async function mark(chatId, msgId, emoji) {
|
|
52
|
+
load()
|
|
53
|
+
const runtime = getRuntime()
|
|
54
|
+
if (runtime === undefined) return
|
|
55
|
+
tracked.set(msgId, { chatId, admitted: false, turn: undefined })
|
|
56
|
+
await getStore()?.noteReaction(msgId, chatId)
|
|
57
|
+
await runtime.dc.rpc.sendReaction(runtime.accountId, msgId, [emoji]).catch((error) => reportError(`reaction on ${msgId}`, error))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The message reached the session, so a sweep may judge it. */
|
|
61
|
+
function admitted(msgId) {
|
|
62
|
+
const entry = tracked.get(msgId)
|
|
63
|
+
if (entry !== undefined) entry.admitted = true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The message is part of a turn now.
|
|
68
|
+
* @param msgId - Delta Chat message id.
|
|
69
|
+
* @param turn - key of the turn (session id and turn number).
|
|
70
|
+
*/
|
|
71
|
+
function attach(msgId, turn) {
|
|
72
|
+
const entry = tracked.get(msgId)
|
|
73
|
+
if (entry !== undefined) entry.turn = turn
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Remove the bot's reaction from messages. */
|
|
77
|
+
async function release(msgIds) {
|
|
78
|
+
const runtime = getRuntime()
|
|
79
|
+
if (msgIds.length === 0 || runtime === undefined) return
|
|
80
|
+
for (const msgId of msgIds) tracked.delete(msgId)
|
|
81
|
+
await getStore()?.dropReactions(msgIds)
|
|
82
|
+
for (const msgId of msgIds) {
|
|
83
|
+
await runtime.dc.rpc.sendReaction(runtime.accountId, msgId, []).catch((error) => reportError(`reaction on ${msgId}`, error))
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Messages of a chat that will get no turn: admitted, not taken into a
|
|
89
|
+
* turn, and no longer waiting in the agent's inbox (dropped by /stop or
|
|
90
|
+
* /clear, or a duplicate).
|
|
91
|
+
*/
|
|
92
|
+
function dropped(chatId, { ignoreTurn = false } = {}) {
|
|
93
|
+
const agent = agentOf(chatId)
|
|
94
|
+
return [...tracked].filter(([msgId, entry]) => entry.chatId === chatId && entry.admitted
|
|
95
|
+
&& (ignoreTurn || entry.turn === undefined) && !inInbox(agent, `${REQUEST_PREFIX}${chatId}:${msgId}`)).map(([msgId]) => msgId)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A turn ended and its answer went out: clear its messages (whatever chat
|
|
100
|
+
* the session is bound to now) and the chat's dropped ones.
|
|
101
|
+
*/
|
|
102
|
+
async function finishTurn(chatId, turn) {
|
|
103
|
+
load()
|
|
104
|
+
const own = [...tracked].filter(([, entry]) => entry.turn === turn).map(([msgId]) => msgId)
|
|
105
|
+
await release([...own, ...dropped(chatId)])
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Clear the signs of a chat's dropped messages. */
|
|
109
|
+
async function sweep(chatId) {
|
|
110
|
+
load()
|
|
111
|
+
await release(dropped(chatId))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Clear signs whose session runs no turn, e.g. after a restart. */
|
|
115
|
+
async function resume() {
|
|
116
|
+
load()
|
|
117
|
+
const idle = new Set([...tracked.values()].map((entry) => entry.chatId).filter((chatId) => {
|
|
118
|
+
const agent = agentOf(chatId)
|
|
119
|
+
return agent === undefined || agent.status === undefined || agent.status === 'idle'
|
|
120
|
+
}))
|
|
121
|
+
for (const chatId of idle) await release(dropped(chatId, { ignoreTurn: true }))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { emojiFor, mark, admitted, attach, finishTurn, sweep, resume, release }
|
|
125
|
+
}
|