Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 38 additions & 159 deletions CHANGELOG.md

Large diffs are not rendered by default.

40 changes: 38 additions & 2 deletions botsrc/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const STRINGS = {
"help.features": "<b>Возможности</b>",
"help.feat.notify": "• Уведомления о вызовах инструментов",
"help.feat.feed": "• Приём входящих сообщений в чат DeepSeek",
"help.feat.voice": "• Распознавание голосовых (локальный Whisper)",
"help.feat.approval":
"• Подтверждение команд — кнопки «Разрешить / Отклонить»",
"help.feat.questions": "• Вопросы от AI с вариантами ответа",
Expand Down Expand Up @@ -178,7 +179,8 @@ const STRINGS = {
"project.current": "📁 Текущий проект: <code>{dir}</code>",
"project.none": "📁 Проект пока не выбран.",
"project.manual": "⌨️ Ввести путь вручную",
"project.manualHint": "⌨️ Отправь абсолютный путь к папке проекта следующим сообщением.",
"project.manualHint":
"⌨️ Отправь абсолютный путь к папке проекта следующим сообщением.",
"project.cancelled": "Отменено.",

// ===== /sessions и /switch =====
Expand Down Expand Up @@ -215,6 +217,22 @@ const STRINGS = {
"attach.tooBig": "⚠️ Файл слишком большой (макс. {max}MB).",
"attach.unsupported": "⚠️ Пока поддерживаются только фото и документы.",

// ===== Голосовые сообщения (Whisper) =====
"voice.disabled": "🎤 Распознавание голоса выключено в настройках.",
"voice.downloading": "⏳ Скачиваю голосовое…",
"voice.recognizing": "🎧 Распознаю речь…",
"voice.done": "📝 <b>Распознано:</b>\n<blockquote>{text}</blockquote>",
"voice.disclaimer":
"**[Распознано локальным Whisper — в тексте возможны неточности.]**",
"voice.empty": "🤷 Речь не распознана (тишина или неразборчиво).",
"voice.failed": "⚠️ Ошибка распознавания: {err}",
"voice.needExe":
"⚠️ whisper-cli.exe не установлен. Скачайте его в настройках Cookie Code (вкладка Telegram).",
"voice.needModel":
"⚠️ Модель Whisper не скачана. Выберите размер и скачайте модель в настройках Cookie Code.",
"voice.needFfmpeg":
"⚠️ ffmpeg не найден. Установите ffmpeg или укажите путь в настройках Cookie Code.",

// ===== Пагинация =====
"page.prev": "◀️ Назад",
"page.next": "Вперёд ▶️",
Expand Down Expand Up @@ -307,6 +325,7 @@ const STRINGS = {
"help.features": "<b>Features</b>",
"help.feat.notify": "• Notifications about tool calls",
"help.feat.feed": "• Receive incoming messages into DeepSeek chat",
"help.feat.voice": "• Voice message transcription (local Whisper)",
"help.feat.approval": "• Command approval — “Allow / Deny” buttons",
"help.feat.questions": "• AI questions with answer options",
"help.sync": "Settings are synced with the desktop app.",
Expand Down Expand Up @@ -437,7 +456,8 @@ const STRINGS = {
"project.current": "📁 Current project: <code>{dir}</code>",
"project.none": "📁 No project selected yet.",
"project.manual": "⌨️ Enter path manually",
"project.manualHint": "⌨️ Send the absolute path to the project folder as your next message.",
"project.manualHint":
"⌨️ Send the absolute path to the project folder as your next message.",
"project.cancelled": "Cancelled.",

// ===== /sessions and /switch =====
Expand Down Expand Up @@ -474,6 +494,22 @@ const STRINGS = {
"attach.tooBig": "⚠️ File too large (max {max}MB).",
"attach.unsupported": "⚠️ Only photos and documents are supported yet.",

// ===== Voice messages (Whisper) =====
"voice.disabled": "🎤 Voice recognition is disabled in settings.",
"voice.downloading": "⏳ Downloading voice message…",
"voice.recognizing": "🎧 Recognizing speech…",
"voice.done": "📝 <b>Transcribed:</b>\n<blockquote>{text}</blockquote>",
"voice.disclaimer":
"**[Transcribed by local Whisper — the text may contain inaccuracies.]**",
"voice.empty": "🤷 No speech recognized (silence or unintelligible).",
"voice.failed": "⚠️ Recognition error: {err}",
"voice.needExe":
"⚠️ whisper-cli.exe is not installed. Download it in Cookie Code settings (Telegram tab).",
"voice.needModel":
"⚠️ Whisper model is not downloaded. Pick a size and download it in Cookie Code settings.",
"voice.needFfmpeg":
"⚠️ ffmpeg not found. Install ffmpeg or set its path in Cookie Code settings.",

// ===== Pagination =====
"page.prev": "◀️ Back",
"page.next": "Next ▶️",
Expand Down
202 changes: 178 additions & 24 deletions botsrc/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ function _read() {
: [],
allowedUserId: String(s.telegramAllowedUserId || "").trim(),
chatFeed: !!s.telegramChatFeed,
voiceEnabled: !!s.telegramVoiceEnabled,
voiceModel: s.telegramVoiceModel || "light",
whisperLang: s.telegramWhisperLang || "auto",
whisperExePath: String(s.telegramWhisperExePath || "").trim(),
whisperFfmpegPath: String(s.telegramWhisperFfmpegPath || "").trim(),
approvalMode: s.toolApprovalMode || "off",
lang: i18n.normalizeLang(s.telegramLanguage),
};
Expand Down Expand Up @@ -1425,10 +1430,12 @@ async function _cmdNew() {
// Fire-and-forget: не await-им executeJavaScript, т.к. loadURL внутри
// newChat() выгружает контекст и промис не вернётся.
try {
win.webContents.executeJavaScript(
"try { window.electronAPI.newChat(); } catch (e) {} true",
true,
).catch(() => {});
win.webContents
.executeJavaScript(
"try { window.electronAPI.newChat(); } catch (e) {} true",
true,
)
.catch(() => {});
} catch (_) {}

// Небольшая пауза, чтобы окно успело начать навигацию, и сразу отвечаем.
Expand Down Expand Up @@ -1632,18 +1639,27 @@ function _projectView(page) {
slice.forEach((dir, i) => {
const base = dir.split(/[\\/]/).filter(Boolean).pop() || dir;
rows.push([
{ text: "📁 " + base, callback_data: "st_proj_p" + cur + "_" + (offset + i) },
{
text: "📁 " + base,
callback_data: "st_proj_p" + cur + "_" + (offset + i),
},
]);
});

// Навигация ‹/› (если страниц больше одной).
if (pages > 1) {
const nav = [];
if (cur > 1)
nav.push({ text: _t("page.prev"), callback_data: "st_proj_pg_" + (cur - 1) });
nav.push({
text: _t("page.prev"),
callback_data: "st_proj_pg_" + (cur - 1),
});
nav.push({ text: cur + "/" + pages, callback_data: "st_proj_noop" });
if (cur < pages)
nav.push({ text: _t("page.next"), callback_data: "st_proj_pg_" + (cur + 1) });
nav.push({
text: _t("page.next"),
callback_data: "st_proj_pg_" + (cur + 1),
});
rows.push(nav);
}

Expand Down Expand Up @@ -1761,9 +1777,12 @@ async function _cmdSessions(chatId, page, editMessageId) {
const projectDir = _projectDir();
// Проект не выбран: подсказываем, как выбрать его кнопками.
if (!projectDir)
return reply(escapeHtml(_t("diff.noProject")) + "\n" + _t("project.pickHint"), {
replyMarkup: _projectKeyboard(),
});
return reply(
escapeHtml(_t("diff.noProject")) + "\n" + _t("project.pickHint"),
{
replyMarkup: _projectKeyboard(),
},
);
let sessions = [];
try {
const ctx = _activeContext();
Expand Down Expand Up @@ -1866,9 +1885,12 @@ async function _cmdLog(chatId, page, editMessageId) {
const projectDir = _projectDir();
// Проект не выбран: подсказываем выбрать кнопками.
if (!projectDir)
return reply(escapeHtml(_t("diff.noProject")) + "\n" + _t("project.pickHint"), {
replyMarkup: _projectKeyboard(),
});
return reply(
escapeHtml(_t("diff.noProject")) + "\n" + _t("project.pickHint"),
{
replyMarkup: _projectKeyboard(),
},
);
const gitDiff = require("../src/main/git-diff");
const r = await gitDiff.getLog(projectDir, 100);
if (!r.success)
Expand Down Expand Up @@ -1999,6 +2021,7 @@ async function _cmdHelp() {
_t("help.features"),
_t("help.feat.notify"),
_t("help.feat.feed"),
_t("help.feat.voice"),
_t("help.feat.approval"),
_t("help.feat.questions"),
"",
Expand Down Expand Up @@ -2280,6 +2303,87 @@ async function _handleAttachment(chatId, msg) {
} catch (_) {}
}

/** Скачать голосовое из TG, распознать локальным Whisper → в чат. */
async function _handleVoice(chatId, msg) {
const cfg = _read();
const fs = require("fs");
const path = require("path");
const whisper = require("./whisper");

if (!cfg.voiceEnabled) {
await telegramBot.sendMessage(_t("voice.disabled"));
return;
}

const v = msg.voice || msg.audio || {};
const fileId = v.file_id;
if (!fileId) {
await telegramBot.sendMessage(_t("voice.failed", { err: "no file_id" }));
return;
}

await telegramBot.sendMessage(_t("voice.downloading"));
const name = "voice_" + Date.now() + ".oga";
const dest = path.join(_tgTempDir(), name);
const dl = await telegramBot.downloadFile(fileId, dest);
if (!dl.success) {
await telegramBot.sendMessage(
_t("voice.failed", { err: escapeHtml(dl.error) }),
);
return;
}

await telegramBot.sendChatAction("typing");
await telegramBot.sendMessage(_t("voice.recognizing"));

let res;
try {
res = await whisper.transcribe(dest, {
model: cfg.voiceModel,
lang: cfg.whisperLang,
exe: cfg.whisperExePath,
ffmpeg: cfg.whisperFfmpegPath,
});
} catch (err) {
res = { success: false, error: err.message };
} finally {
try {
fs.unlinkSync(dest);
} catch (_) {}
}

if (!res.success) {
// Понятные подсказки для типовых ошибок.
let hint = res.error;
if (res.error === "needExe") hint = _t("voice.needExe");
else if (res.error === "needModel") hint = _t("voice.needModel");
else if (res.error === "needFfmpeg") hint = _t("voice.needFfmpeg");
await telegramBot.sendMessage(_t("voice.failed", { err: hint }));
return;
}

const text = String(res.text || "").trim();
if (!text) {
await telegramBot.sendMessage(_t("voice.empty"));
return;
}

await telegramBot.sendMessage(_t("voice.done", { text: escapeHtml(text) }), {
parseMode: "HTML",
});

if (!cfg.chatFeed) {
_log("info", "voice: chatFeed выключен, текст не отправлен в чат");
return;
}
// Пометка о возможных неточностях распознавания (перед текстом).
const chatText = _t("voice.disclaimer") + "\n" + text;
const sent = await _sendToChat(chatText);
if (!sent.success) {
await telegramBot.sendMessage("⚠ " + (sent.error || "unknown"));
}
}

/** Входящее из TG → в чат DeepSeek (или команда). */
async function _handleIncoming(chatId, text, msg) {
const cfg = _read();
Expand All @@ -2293,6 +2397,12 @@ async function _handleIncoming(chatId, text, msg) {
// Аргументы команды (всё после первого слова).
const cmdArgs = trimmed.split(/\s+/).slice(1).join(" ").trim();

// ---- Голосовые/аудио → локальный Whisper ----
if (msg && (msg.voice || msg.audio)) {
await _handleVoice(chatId, msg);
return;
}

// ---- Вложения (фото/документы) ----
// Подпись (caption) обрабатывается внутри _handleAttachment, поэтому
// после вложения дальше не идём (иначе caption уйдёт вторым сообщением).
Expand Down Expand Up @@ -2398,7 +2508,9 @@ async function _handleIncoming(chatId, text, msg) {
// ---- Ожидание ручного ввода пути к проекту ----
if (_projectWaiting.has(chatId)) {
_projectWaiting.delete(chatId);
const dir = String(raw || "").trim().replace(/^["']|["']$/g, "");
const dir = String(raw || "")
.trim()
.replace(/^["']|["']$/g, "");
if (!dir) {
await telegramBot.sendMessage(_t("project.cancelled"));
return;
Expand Down Expand Up @@ -2604,6 +2716,28 @@ function _techDetail(toolName, args) {
// Максимальная длина результата в уведомлении.
const TECH_RESULT_MAX = 600;

/**
* Убрать служебную XML-обёртку результата инструмента (<path>/<type>/<content>),
* которую возвращают WriteTool/ReadTool. В Telegram она выглядит как мусорный
* псевдо-XML; нам нужен только полезный текст (verb «Created file» или контент).
*/
function _stripResultWrapper(preview) {
let s = String(preview == null ? "" : preview).trim();
if (!s) return "";
// Формат: <path>..</path>\n<type>file</type>\n<content>\n..\n</content>
const contentMatch = s.match(/<content>([\s\S]*?)<\/content>/i);
if (contentMatch) {
s = contentMatch[1].trim();
} else {
// Нет <content>, но есть <path>/<type> — вырезаем их построчно.
s = s
.replace(/<path>[\s\S]*?<\/path>/gi, "")
.replace(/<type>[\s\S]*?<\/type>/gi, "")
.trim();
}
return s;
}

/** Отправить накопленное уведомление (или ничего, если пусто). */
function _techFlush() {
const b = _techBatch;
Expand All @@ -2625,16 +2759,27 @@ function _techFlush() {
let body = "";
if (b.command) body = _techTrunc(b.command);
else if (b.detail && _techTrunc(b.detail) !== b.detail) body = b.detail;
if (body) msg += "\n<blockquote>" + escapeHtml(body) + "</blockquote>";

// Результат выполнения — второй цитатой (если есть и не пустой).
const result = String(b.preview || "").replace(/\n{3,}/g, "\n\n").trim();
if (body)
msg += "\n<blockquote expandable>" + escapeHtml(body) + "</blockquote>";

// Результат выполнения — второй, сворачиваемой цитатой (если есть и не пустой).
// Предварительно убираем служебную XML-обёртку Write/Read (<path>/<type>/<content>).
let result = _stripResultWrapper(b.preview)
.replace(/\n{3,}/g, "\n\n")
.trim();
// write/read возвращают в результате только verb («Created file»/«Updated file»)
// или усечённый текст. Если тело файла есть в аргументах вызова — показываем
// фрагмент содержимого вместо служебного verb.
if (/^(Created|Updated) file$/.test(result)) {
const content = String((b.args && b.args.content) || "");
if (content) result = content;
}
if (result) {
const shown =
result.length > TECH_RESULT_MAX
? result.slice(0, TECH_RESULT_MAX) + "\n…(обрезано)"
: result;
msg += "\n<blockquote>" + escapeHtml(shown) + "</blockquote>";
msg += "\n<blockquote expandable>" + escapeHtml(shown) + "</blockquote>";
}
return telegramBot.sendMessage(msg, { parseMode: "HTML" });
}
Expand All @@ -2657,9 +2802,10 @@ function _techQueue(toolName, args, preview) {
const icon = _techIcon(toolName);
const detail = _techDetail(toolName, args);
const name = String(toolName || "").toLowerCase();
const command = name === "bash" || name === "pwsh"
? String((args && args.command) || "")
: "";
const command =
name === "bash" || name === "pwsh"
? String((args && args.command) || "")
: "";
const result = String(preview || "");

if (
Expand All @@ -2680,6 +2826,7 @@ function _techQueue(toolName, args, preview) {
detail,
command,
preview: result,
args,
count: 1,
};
if (prev) _techFlush();
Expand Down Expand Up @@ -2709,12 +2856,19 @@ async function notifyToolResult(toolName, ok, detail) {
}

const args = detail && typeof detail === "object" ? detail.args : null;
const preview = detail && typeof detail === "object" ? detail.preview : detail;
const preview =
detail && typeof detail === "object" ? detail.preview : detail;

// Ошибка — шлём сразу, отдельным сообщением, с обрезанным текстом ошибки.
if (!ok) {
const icon = _techIcon(toolName);
let msg = "❌ " + icon.emoji + " " + icon.label + " " + _techTrunc(_techDetail(toolName, args));
let msg =
"❌ " +
icon.emoji +
" " +
icon.label +
" " +
_techTrunc(_techDetail(toolName, args));
if (preview)
msg += "\n" + _techTrunc(String(preview).replace(/\n{3,}/g, "\n\n"));
return telegramBot.sendMessage(escapeHtml(msg), { parseMode: "HTML" });
Expand Down
Loading
Loading