feat: make DAVE Telegram chat conversational
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 8s
Build / test-and-image (pull_request) Successful in 52s

This commit is contained in:
2026-07-07 18:46:44 +02:00
parent c0fb9d1e66
commit a7f3f36d2b
10 changed files with 244 additions and 35 deletions

View File

@@ -111,6 +111,25 @@ export class TelegramAlerter {
}
}
async sendConversation(message, opts = {}) {
const messages = Array.isArray(message)
? message.flatMap(item => this._conversationChunks(item, opts.maxChunkChars))
: this._conversationChunks(message, opts.maxChunkChars);
if (!messages.length) return { ok: false };
let last = { ok: false };
for (let i = 0; i < messages.length; i++) {
last = await this.sendMessage(messages[i], {
...opts,
parseMode: null,
replyToMessageId: i === 0 ? opts.replyToMessageId : undefined,
replyMarkup: i === messages.length - 1 ? opts.replyMarkup : undefined,
});
if (!last.ok) return last;
if (i < messages.length - 1) await sleep(opts.delayMs ?? 350);
}
return last;
}
/**
* Split text into chunks of at most maxLen. Prefer breaking at newlines to avoid
* splitting mid-Markdown.
@@ -131,6 +150,39 @@ export class TelegramAlerter {
return chunks;
}
_conversationChunks(text, maxLen = 900) {
const cleaned = stripVisibleMarkdown(String(text || '').trim());
if (!cleaned) return [];
const limit = Math.max(300, Math.min(1800, Number(maxLen) || 900));
const paragraphs = cleaned.split(/\n{2,}/).map(part => part.trim()).filter(Boolean);
const chunks = [];
let current = '';
const flush = () => {
if (current.trim()) chunks.push(current.trim());
current = '';
};
for (const paragraph of paragraphs) {
if (paragraph.length > limit) {
flush();
for (const sentence of splitSentences(paragraph)) {
if ((current.length + sentence.length + 1) > limit) flush();
if (sentence.length > limit) {
chunks.push(...this._chunkText(sentence, limit).map(part => part.trim()).filter(Boolean));
} else {
current = current ? `${current} ${sentence}` : sentence;
}
}
flush();
continue;
}
if ((current.length + paragraph.length + 2) > limit) flush();
current = current ? `${current}\n\n${paragraph}` : paragraph;
}
flush();
return chunks;
}
// Backward-compatible alias
async sendAlert(message) {
const result = await this.sendMessage(message);
@@ -529,12 +581,7 @@ export class TelegramAlerter {
try {
const response = await handler(args, msg.message_id, msg);
if (response) {
const text = typeof response === 'string' ? response : response.text;
const parseMode = typeof response === 'object' && Object.hasOwn(response, 'parseMode')
? response.parseMode
: undefined;
const replyMarkup = typeof response === 'object' ? response.replyMarkup : null;
await this.sendMessage(text, { chatId: replyChatId, replyToMessageId: msg.message_id, ...(parseMode !== undefined ? { parseMode } : {}), ...(replyMarkup ? { replyMarkup } : {}) });
await this._sendHandlerResponse(response, { chatId: replyChatId, replyToMessageId: msg.message_id });
}
} catch (err) {
console.error(`[Telegram] Command ${command} error:`, err.message);
@@ -555,16 +602,10 @@ export class TelegramAlerter {
try {
const response = await handler(text, msg);
if (!response) return;
const responseText = typeof response === 'string' ? response : response.text;
const responseParseMode = typeof response === 'object' && Object.hasOwn(response, 'parseMode')
? response.parseMode
: parseMode;
const replyMarkup = typeof response === 'object' ? response.replyMarkup : null;
await this.sendMessage(responseText, {
await this._sendHandlerResponse(response, {
chatId: replyChatId,
replyToMessageId: msg.message_id,
parseMode: responseParseMode,
...(replyMarkup ? { replyMarkup } : {}),
parseMode,
});
} catch (err) {
console.error('[Telegram] AI chat error:', err.message);
@@ -587,14 +628,10 @@ export class TelegramAlerter {
try {
const response = await this._callbackHandler(query.data, query);
if (!response) return;
const text = typeof response === 'string' ? response : response.text;
const parseMode = typeof response === 'object' && Object.hasOwn(response, 'parseMode') ? response.parseMode : null;
const replyMarkup = typeof response === 'object' ? response.replyMarkup : null;
await this.sendMessage(text, {
await this._sendHandlerResponse(response, {
chatId,
replyToMessageId: query.message?.message_id,
parseMode,
...(replyMarkup ? { replyMarkup } : {}),
parseMode: null,
});
} catch (error) {
console.error('[Telegram] Callback error:', error.message);
@@ -611,6 +648,31 @@ export class TelegramAlerter {
return () => clearInterval(interval);
}
async _sendHandlerResponse(response, defaults = {}) {
const text = typeof response === 'string' ? response : response.text;
const messages = typeof response === 'object' ? response.messages : null;
const parseMode = typeof response === 'object' && Object.hasOwn(response, 'parseMode')
? response.parseMode
: defaults.parseMode;
const replyMarkup = typeof response === 'object' ? response.replyMarkup : null;
const common = {
chatId: defaults.chatId,
replyToMessageId: defaults.replyToMessageId,
...(replyMarkup ? { replyMarkup } : {}),
};
if (typeof response === 'object' && response.conversation) {
return this.sendConversation(messages || text, {
...common,
maxChunkChars: response.maxChunkChars,
delayMs: response.delayMs,
});
}
return this.sendMessage(text, {
...common,
...(parseMode !== undefined ? { parseMode } : {}),
});
}
async _initializeBotCommands() {
await this._loadBotIdentity();
@@ -928,3 +990,24 @@ function parseJSON(text) {
return null;
}
}
function stripVisibleMarkdown(text) {
return String(text || '')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/__([^_]+)__/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/^#{1,6}\s+/gm, '')
.replace(/^\s*[-*]\s+/gm, '- ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function splitSentences(text) {
const normalized = String(text || '').replace(/\s+/g, ' ').trim();
if (!normalized) return [];
return normalized.match(/[^.!?\n]+[.!?]+(?:\s+|$)|[^.!?\n]+$/g)?.map(item => item.trim()).filter(Boolean) || [normalized];
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}