|
// Telegram Voice Transcription Bot |
|
// Hosted on Cloudflare Workers with D1 Database |
|
|
|
// API Constants |
|
const TELEGRAM_API = "https://api.telegram.org/bot"; |
|
const TELEGRAM_FILE_API = "https://api.telegram.org/file/bot"; |
|
const WHISPER_API = "https://fin-02.inference.datacrunch.io/v1/raw/whisperx/predict"; |
|
|
|
// Function to intelligently split text at natural boundaries |
|
function smartSplit(text, maxLength) { |
|
const chunks = []; |
|
|
|
while (text.length > 0) { |
|
if (text.length <= maxLength) { |
|
chunks.push(text); |
|
break; |
|
} |
|
|
|
// Try to find a good splitting point, preferring sentence endings |
|
let splitIndex = text.lastIndexOf(". ", maxLength); |
|
|
|
// If no sentence ending found, try other punctuation |
|
if (splitIndex === -1) splitIndex = text.lastIndexOf("! ", maxLength); |
|
if (splitIndex === -1) splitIndex = text.lastIndexOf("? ", maxLength); |
|
if (splitIndex === -1) splitIndex = text.lastIndexOf("\n", maxLength); |
|
|
|
// If no punctuation found, try splitting at space |
|
if (splitIndex === -1) splitIndex = text.lastIndexOf(" ", maxLength); |
|
|
|
// If all else fails, just split at maxLength |
|
if (splitIndex === -1) splitIndex = maxLength; |
|
else splitIndex += 1; // Include the punctuation or space in the current chunk |
|
|
|
chunks.push(text.substring(0, splitIndex).trim()); |
|
text = text.substring(splitIndex).trim(); |
|
} |
|
|
|
return chunks; |
|
} |
|
|
|
// Function to send large messages as multiple chunks if needed |
|
async function sendSplitMessages(bot, chat_id, text, options = {}) { |
|
const MAX_LENGTH = 2000; // Telegram limit is 4096, but using 2000 as requested |
|
|
|
// If the text is short enough, send it as is |
|
if (text.length <= MAX_LENGTH) { |
|
return await bot.sendMessage(chat_id, text, options); |
|
} |
|
|
|
// Extract header (if any) - assuming header is everything before the first double newline |
|
let header = ""; |
|
let content = text; |
|
|
|
if (text.includes("\n\n")) { |
|
const parts = text.split("\n\n", 2); |
|
header = parts[0]; |
|
content = text.substring(header.length + 2); // +2 for the newlines |
|
} |
|
|
|
// Split the content into chunks |
|
const chunks = smartSplit(content, MAX_LENGTH - header.length - 30); // 30 chars for part indicators |
|
|
|
// Send each chunk as a separate message |
|
const total = chunks.length; |
|
let responses = []; |
|
|
|
for (let i = 0; i < chunks.length; i++) { |
|
// For the first chunk, include the original header |
|
// For subsequent chunks, create a continuation header |
|
let chunkHeader = i === 0 ? header : "📝 *Transcription (continued):*"; |
|
|
|
// Add part indicator if multiple parts |
|
let partIndicator = total > 1 ? ` (Part ${i+1}/${total})` : ""; |
|
|
|
// Combine header and content |
|
let messageText = `${chunkHeader}${partIndicator}\n\n${chunks[i]}`; |
|
|
|
const response = await bot.sendMessage(chat_id, messageText, { |
|
...options, |
|
// Always reply to original message for each chunk |
|
reply_to_message_id: options.reply_to_message_id |
|
}); |
|
|
|
responses.push(response); |
|
|
|
// Small delay between messages to avoid rate limiting |
|
if (i < chunks.length - 1) { |
|
await new Promise(resolve => setTimeout(resolve, 500)); |
|
} |
|
} |
|
|
|
return responses[0]; // Return the first response for compatibility |
|
} |
|
|
|
// File proxy security - simple encryption/decryption for secure file IDs |
|
class FileProxy { |
|
static encrypt(fileId, secret) { |
|
// Create the payload with file ID, timestamp, and secret |
|
const payload = `${fileId}:${Date.now()}:${secret}`; |
|
|
|
// Use btoa() for Base64 encoding in Cloudflare Workers (not Buffer) |
|
const base64 = btoa(payload); |
|
|
|
// Make the Base64 URL-safe |
|
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); |
|
} |
|
|
|
static decrypt(secureId, secret) { |
|
try { |
|
// Replace URL-safe characters back to base64 standard |
|
const base64 = secureId.replace(/-/g, '+').replace(/_/g, '/'); |
|
|
|
// Use atob() for Base64 decoding in Cloudflare Workers (not Buffer) |
|
const decoded = atob(base64); |
|
|
|
// Extract parts - fileId:timestamp:secret |
|
const parts = decoded.split(':'); |
|
if (parts.length !== 3) { |
|
throw new Error('Invalid secure file ID format'); |
|
} |
|
|
|
const [fileId, timestamp, providedSecret] = parts; |
|
|
|
// Verify the secret |
|
if (providedSecret !== secret) { |
|
throw new Error('Invalid secure file ID signature'); |
|
} |
|
|
|
// Check timestamp - make sure it's not older than 1 hour |
|
const fileTimestamp = parseInt(timestamp, 10); |
|
const now = Date.now(); |
|
if (now - fileTimestamp > 60 * 60 * 1000) { // 1 hour in milliseconds |
|
throw new Error('Secure file ID has expired'); |
|
} |
|
|
|
return fileId; |
|
} catch (error) { |
|
console.error(`[ERROR] Failed to decrypt secure file ID: ${error.message}`); |
|
throw new Error('Invalid or expired secure file ID'); |
|
} |
|
} |
|
} |
|
|
|
// TelegramBot Class |
|
class TelegramBot { |
|
constructor(token) { |
|
this.token = token; |
|
this.apiUrl = TELEGRAM_API + token; |
|
this.fileApiUrl = TELEGRAM_FILE_API + token; |
|
} |
|
|
|
async sendMessage(chat_id, text, options = {}) { |
|
const url = `${this.apiUrl}/sendMessage`; |
|
const payload = { |
|
chat_id, |
|
text, |
|
...options |
|
}; |
|
|
|
try { |
|
const response = await fetch(url, { |
|
method: 'POST', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify(payload) |
|
}); |
|
|
|
const result = await response.json(); |
|
|
|
if (!result.ok) { |
|
console.error(`[ERROR] Failed to send message: ${JSON.stringify(result)}`); |
|
} |
|
|
|
return result; |
|
} catch (error) { |
|
console.error(`[ERROR] Exception in sendMessage: ${error.message}`); |
|
throw error; |
|
} |
|
} |
|
|
|
async deleteMessage(chat_id, message_id) { |
|
const url = `${this.apiUrl}/deleteMessage`; |
|
|
|
try { |
|
const response = await fetch(url, { |
|
method: 'POST', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify({ chat_id, message_id }) |
|
}); |
|
|
|
const result = await response.json(); |
|
|
|
if (!result.ok) { |
|
console.error(`[ERROR] Failed to delete message: ${JSON.stringify(result)}`); |
|
} |
|
|
|
return result; |
|
} catch (error) { |
|
console.error(`[ERROR] Exception in deleteMessage: ${error.message}`); |
|
throw error; |
|
} |
|
} |
|
|
|
async editMessageText(text, options = {}) { |
|
const url = `${this.apiUrl}/editMessageText`; |
|
const payload = { |
|
text, |
|
...options |
|
}; |
|
|
|
try { |
|
const response = await fetch(url, { |
|
method: 'POST', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify(payload) |
|
}); |
|
|
|
const result = await response.json(); |
|
|
|
if (!result.ok) { |
|
console.error(`[ERROR] Failed to edit message: ${JSON.stringify(result)}`); |
|
} |
|
|
|
return result; |
|
} catch (error) { |
|
console.error(`[ERROR] Exception in editMessageText: ${error.message}`); |
|
throw error; |
|
} |
|
} |
|
|
|
async getFile(file_id) { |
|
const url = `${this.apiUrl}/getFile`; |
|
|
|
try { |
|
const response = await fetch(url, { |
|
method: 'POST', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify({ file_id }) |
|
}); |
|
|
|
const result = await response.json(); |
|
|
|
if (!result.ok) { |
|
console.error(`[ERROR] Failed to get file: ${JSON.stringify(result)}`); |
|
throw new Error(`Failed to get file: ${JSON.stringify(result)}`); |
|
} |
|
|
|
return result.result; |
|
} catch (error) { |
|
console.error(`[ERROR] Exception in getFile: ${error.message}`); |
|
throw error; |
|
} |
|
} |
|
} |
|
|
|
// Database Helper Functions |
|
async function isUserAdmin(user_id, env) { |
|
try { |
|
const stmt = env.DB.prepare("SELECT user_id FROM admins WHERE user_id = ?"); |
|
const result = await stmt.bind(user_id.toString()).first(); |
|
|
|
return !!result; |
|
} catch (error) { |
|
console.error(`[ERROR] Database error in isUserAdmin: ${error.message}`); |
|
throw error; |
|
} |
|
} |
|
|
|
async function hasAnyAdmins(env) { |
|
try { |
|
const stmt = env.DB.prepare("SELECT COUNT(*) as count FROM admins"); |
|
const result = await stmt.first(); |
|
|
|
return result && result.count > 0; |
|
} catch (error) { |
|
console.error(`[ERROR] Database error in hasAnyAdmins: ${error.message}`); |
|
throw error; |
|
} |
|
} |
|
|
|
async function makeUserAdmin(user_id, env) { |
|
try { |
|
const stmt = env.DB.prepare( |
|
"INSERT INTO admins (user_id, added_at) VALUES (?, ?)" |
|
); |
|
|
|
const result = await stmt.bind( |
|
user_id.toString(), |
|
Math.floor(Date.now() / 1000) |
|
).run(); |
|
|
|
return result; |
|
} catch (error) { |
|
console.error(`[ERROR] Database error in makeUserAdmin: ${error.message}`); |
|
throw error; |
|
} |
|
} |
|
|
|
async function isChatApproved(chat_id, env) { |
|
try { |
|
const stmt = env.DB.prepare("SELECT chat_id FROM authorized_chats WHERE chat_id = ?"); |
|
const result = await stmt.bind(chat_id.toString()).first(); |
|
|
|
return !!result; |
|
} catch (error) { |
|
console.error(`[ERROR] Database error in isChatApproved: ${error.message}`); |
|
throw error; |
|
} |
|
} |
|
|
|
async function approveChat(chat_id, user_id, env) { |
|
try { |
|
const stmt = env.DB.prepare( |
|
"INSERT INTO authorized_chats (chat_id, added_by, added_at) VALUES (?, ?, ?)" |
|
); |
|
|
|
const result = await stmt.bind( |
|
chat_id.toString(), |
|
user_id.toString(), |
|
Math.floor(Date.now() / 1000) |
|
).run(); |
|
|
|
return result; |
|
} catch (error) { |
|
console.error(`[ERROR] Database error in approveChat: ${error.message}`); |
|
throw error; |
|
} |
|
} |
|
|
|
async function revokeChat(chat_id, env) { |
|
try { |
|
const stmt = env.DB.prepare("DELETE FROM authorized_chats WHERE chat_id = ?"); |
|
const result = await stmt.bind(chat_id.toString()).run(); |
|
|
|
return result; |
|
} catch (error) { |
|
console.error(`[ERROR] Database error in revokeChat: ${error.message}`); |
|
throw error; |
|
} |
|
} |
|
|
|
// Command Handlers |
|
async function handleStart(bot, chat_id, user_id, env) { |
|
// Check if we have any admins yet - if not, make this user the first admin |
|
const anyAdmins = await hasAnyAdmins(env); |
|
if (!anyAdmins) { |
|
await makeUserAdmin(user_id, env); |
|
await bot.sendMessage(chat_id, "🎉 You are now the first admin of this bot!"); |
|
} |
|
|
|
await bot.sendMessage(chat_id, |
|
"👋 Welcome to Voice Transcription Bot!\n\n" + |
|
"I can transcribe voice messages and video notes to text using Whisper X.\n\n" + |
|
"To use me in a group, an admin must approve your group first." |
|
); |
|
} |
|
|
|
async function handleHelp(bot, chat_id) { |
|
await bot.sendMessage(chat_id, |
|
"📖 *Voice Transcription Bot Help*\n\n" + |
|
"I can automatically transcribe voice messages and video notes to text using AI.\n\n" + |
|
"*Usage:*\n" + |
|
"- Send a voice message or video note in an approved chat\n" + |
|
"- I'll process it and reply with the transcription\n" + |
|
"- Video notes support speaker diarization (identifying different speakers)\n\n" + |
|
"*Admin commands:*\n" + |
|
"/approve [chat_id] - Approve current chat (or specified chat) for transcription\n" + |
|
"/revoke [chat_id] - Revoke access from current chat (or specified chat)\n" + |
|
"/makeadmin <user_id> <secret_key> - Make another user an admin\n" + |
|
"/listchats - List all approved chats\n\n" + |
|
"Note: Messages from unauthorized chats are ignored.\n\n" + |
|
"Bot was brought to you by @cofob.", |
|
{ parse_mode: "Markdown" } |
|
); |
|
} |
|
|
|
async function handleApprove(bot, chat_id, user_id, env, message) { |
|
// Check if user is admin |
|
const isAdmin = await isUserAdmin(user_id, env); |
|
|
|
if (!isAdmin) { |
|
await bot.sendMessage(chat_id, "⛔ You need to be an admin to use this command."); |
|
return; |
|
} |
|
|
|
// Check if a chat ID was provided as a parameter |
|
const parts = message.text.split(' '); |
|
let targetChatId = chat_id; |
|
let isRemoteApproval = false; |
|
|
|
if (parts.length > 1) { |
|
// A parameter was provided, try to use it as the chat ID |
|
targetChatId = parts[1]; |
|
isRemoteApproval = true; |
|
} |
|
|
|
// Check if chat is already approved |
|
const isApproved = await isChatApproved(targetChatId, env); |
|
|
|
if (isApproved) { |
|
const approvalMessage = isRemoteApproval |
|
? `✅ Chat ${targetChatId} is already approved for transcription.` |
|
: "✅ This chat is already approved for transcription."; |
|
await bot.sendMessage(chat_id, approvalMessage); |
|
return; |
|
} |
|
|
|
// Approve chat |
|
await approveChat(targetChatId, user_id, env); |
|
|
|
const successMessage = isRemoteApproval |
|
? `✅ Chat ${targetChatId} has been approved for voice transcription!` |
|
: "✅ This chat has been approved for voice transcription!"; |
|
await bot.sendMessage(chat_id, successMessage); |
|
} |
|
|
|
async function handleRevoke(bot, chat_id, user_id, env, message) { |
|
// Check if user is admin |
|
const isAdmin = await isUserAdmin(user_id, env); |
|
|
|
if (!isAdmin) { |
|
await bot.sendMessage(chat_id, "⛔ You need to be an admin to use this command."); |
|
return; |
|
} |
|
|
|
// Check if a chat ID was provided as a parameter |
|
const parts = message.text.split(' '); |
|
let targetChatId = chat_id; |
|
let isRemoteRevocation = false; |
|
|
|
if (parts.length > 1) { |
|
// A parameter was provided, try to use it as the chat ID |
|
targetChatId = parts[1]; |
|
isRemoteRevocation = true; |
|
} |
|
|
|
// Check if chat is approved |
|
const isApproved = await isChatApproved(targetChatId, env); |
|
|
|
if (!isApproved) { |
|
const revocationMessage = isRemoteRevocation |
|
? `❌ Chat ${targetChatId} is not approved for transcription.` |
|
: "❌ This chat is not approved for transcription."; |
|
await bot.sendMessage(chat_id, revocationMessage); |
|
return; |
|
} |
|
|
|
// Revoke approval |
|
await revokeChat(targetChatId, env); |
|
|
|
const successMessage = isRemoteRevocation |
|
? `❌ Transcription service has been revoked from chat ${targetChatId}.` |
|
: "❌ Transcription service has been revoked from this chat."; |
|
await bot.sendMessage(chat_id, successMessage); |
|
} |
|
|
|
async function handleMakeAdmin(bot, chat_id, message, user_id, env) { |
|
// Check if the requester is an admin |
|
const isAdmin = await isUserAdmin(user_id, env); |
|
|
|
if (!isAdmin) { |
|
await bot.sendMessage(chat_id, "⛔ You need to be an admin to use this command."); |
|
return; |
|
} |
|
|
|
// Check command format |
|
const parts = message.text.split(' '); |
|
|
|
if (parts.length !== 3) { |
|
await bot.sendMessage(chat_id, "⚠️ Usage: /makeadmin <user_id> <secret_key>"); |
|
return; |
|
} |
|
|
|
const new_user_id = parts[1]; |
|
const secret_key = parts[2]; |
|
|
|
// Verify secret key against environment variable |
|
if (secret_key !== env.ADMIN_SECRET_KEY) { |
|
await bot.sendMessage(chat_id, "⛔ Invalid secret key."); |
|
return; |
|
} |
|
|
|
// Check if user is already an admin |
|
const isNewUserAdmin = await isUserAdmin(new_user_id, env); |
|
|
|
if (isNewUserAdmin) { |
|
await bot.sendMessage(chat_id, "ℹ️ This user is already an admin."); |
|
return; |
|
} |
|
|
|
// Make user an admin |
|
await makeUserAdmin(new_user_id, env); |
|
await bot.sendMessage(chat_id, `✅ User ${new_user_id} has been made an admin.`); |
|
} |
|
|
|
async function handleListChats(bot, chat_id, user_id, env) { |
|
// Check if user is admin |
|
const isAdmin = await isUserAdmin(user_id, env); |
|
|
|
if (!isAdmin) { |
|
await bot.sendMessage(chat_id, "⛔ You need to be an admin to use this command."); |
|
return; |
|
} |
|
|
|
// Get all authorized chats |
|
const stmt = env.DB.prepare(` |
|
SELECT ac.chat_id, ac.added_at, a.user_id as added_by |
|
FROM authorized_chats ac |
|
JOIN admins a ON ac.added_by = a.user_id |
|
ORDER BY ac.added_at DESC |
|
`); |
|
|
|
const chats = await stmt.all(); |
|
|
|
if (!chats.results || chats.results.length === 0) { |
|
await bot.sendMessage(chat_id, "ℹ️ No chats have been approved yet."); |
|
return; |
|
} |
|
|
|
let message = "📋 *Approved Chats:*\n\n"; |
|
|
|
chats.results.forEach((chat, index) => { |
|
const date = new Date(chat.added_at * 1000).toISOString().split('T')[0]; |
|
message += `${index + 1}. Chat ID: \`${chat.chat_id}\`\n Added by: \`${chat.added_by}\`\n Date: ${date}\n\n`; |
|
}); |
|
|
|
await bot.sendMessage(chat_id, message, { parse_mode: "Markdown" }); |
|
} |
|
|
|
// Voice and Video Note Processing Functions |
|
async function processVoiceMessage(bot, message, env, ctx, requestUrl) { |
|
const chat_id = message.chat.id; |
|
const user_id = message.from.id; |
|
const message_id = message.message_id; |
|
|
|
// Determine if this is a voice message or video note |
|
const isVideoNote = !!message.video_note; |
|
const mediaType = isVideoNote ? "video note" : "voice message"; |
|
const fileId = isVideoNote ? message.video_note.file_id : message.voice.file_id; |
|
|
|
// Check if chat is approved |
|
const isApproved = await isChatApproved(chat_id, env); |
|
|
|
if (!isApproved) { |
|
return; |
|
} |
|
|
|
let statusMsgId = null; |
|
|
|
try { |
|
// Notify users that processing is starting |
|
const statusMsg = await bot.sendMessage(chat_id, "🔄 Processing voice message...", { |
|
reply_to_message_id: message_id // Reply to the original message with the status |
|
}); |
|
|
|
if (!statusMsg || !statusMsg.result || !statusMsg.result.message_id) { |
|
console.error(`[ERROR] Failed to send status message or get message ID`); |
|
throw new Error("Failed to initialize processing status"); |
|
} |
|
|
|
statusMsgId = statusMsg.result.message_id; |
|
|
|
// Update status message |
|
await updateStatusMessage(bot, chat_id, statusMsgId, `🔄 Retrieving ${isVideoNote ? 'video' : 'audio'} file...`); |
|
|
|
// Create a secure proxy URL for the audio file |
|
const secureFileId = FileProxy.encrypt(fileId, env.FILE_PROXY_SECRET); |
|
|
|
// Extract base URL from the request URL |
|
const baseUrl = new URL(requestUrl); |
|
baseUrl.pathname = ''; // Clear the path |
|
|
|
// Create the proxy URL with appropriate file extension |
|
const fileExtension = isVideoNote ? ".mp4" : ".ogg"; |
|
const proxyUrl = `${baseUrl.origin}/voice/${secureFileId}${fileExtension}`; |
|
|
|
// Update status message |
|
await updateStatusMessage(bot, chat_id, statusMsgId, "🔄 Sending to transcription service..."); |
|
|
|
// Create a timeout promise to prevent hanging indefinitely |
|
const timeoutPromise = new Promise((_, reject) => { |
|
setTimeout(() => reject(new Error("Transcription request timed out after 60 seconds")), 60000); |
|
}); |
|
|
|
// Try up to 2 retries for transient errors |
|
let transcription = null; |
|
let lastError = null; |
|
const maxRetries = 2; |
|
|
|
for (let attempt = 0; attempt <= maxRetries; attempt++) { |
|
try { |
|
if (attempt > 0) { |
|
await updateStatusMessage(bot, chat_id, statusMsgId, `🔄 Retry attempt ${attempt}/${maxRetries}...`); |
|
} |
|
|
|
// Race the transcription request against the timeout |
|
transcription = await Promise.race([ |
|
transcribeAudio(proxyUrl, env.DATACRUNCH_API_KEY, isVideoNote), |
|
timeoutPromise |
|
]); |
|
|
|
// If we got here, we succeeded |
|
break; |
|
} catch (error) { |
|
lastError = error; |
|
console.error(`[ERROR] Attempt ${attempt} failed: ${error.message}`); |
|
|
|
// Don't retry if it's not a 5xx error or if it's the last attempt |
|
if (!error.message.includes("500") || attempt === maxRetries) { |
|
throw error; |
|
} |
|
|
|
// Wait before retrying (exponential backoff) |
|
await new Promise(resolve => setTimeout(resolve, 2000 * Math.pow(2, attempt))); |
|
} |
|
} |
|
|
|
// If we got here without a transcription but also without throwing, something went wrong |
|
if (!transcription) { |
|
throw lastError || new Error("Failed to get transcription after retries"); |
|
} |
|
|
|
// Format the transcription |
|
const formattedText = formatTranscription(transcription, isVideoNote); |
|
|
|
// Delete the status message since we're going to send a new reply instead |
|
await bot.deleteMessage(chat_id, statusMsgId); |
|
|
|
// Send the transcription as a reply to the original message |
|
await sendSplitMessages(bot, chat_id, formattedText, { |
|
parse_mode: "Markdown", |
|
reply_to_message_id: message_id // Always reply to the original message |
|
}); |
|
|
|
} catch (error) { |
|
console.error(`[ERROR] Error processing voice message: ${error.message}`); |
|
console.error(`[ERROR] Stack trace: ${error.stack}`); |
|
|
|
let errorMessage = "❌ Failed to process voice message."; |
|
|
|
// Provide more specific error messages based on where the error occurred |
|
if (error.message.includes("Could not retrieve file information")) { |
|
errorMessage = "❌ Could not access the voice message file. Please try again."; |
|
} else if (error.message.includes("timed out")) { |
|
errorMessage = "❌ Transcription request timed out. The audio might be too long or the service is busy."; |
|
} else if (error.message.includes("DataCrunch API error")) { |
|
errorMessage = "❌ Error from transcription service. The service may be experiencing issues or the audio format is unsupported."; |
|
|
|
// If it's a 500 error, add more specific information |
|
if (error.message.includes("500")) { |
|
errorMessage += " (Server error)"; |
|
} |
|
} |
|
|
|
// If we have a status message ID, try to edit it with the error |
|
if (statusMsgId) { |
|
try { |
|
await updateStatusMessage(bot, chat_id, statusMsgId, `${errorMessage}\n\nTechnical details: ${error.message}`); |
|
} catch (editError) { |
|
console.error(`[ERROR] Failed to update error message: ${editError.message}`); |
|
// If editing fails, try to send a new message as a reply |
|
try { |
|
await bot.sendMessage(chat_id, `${errorMessage}\n\nTechnical details: ${error.message}`, { |
|
reply_to_message_id: message_id |
|
}); |
|
} catch (sendError) { |
|
console.error(`[ERROR] Also failed to send new error message: ${sendError.message}`); |
|
} |
|
} |
|
} else { |
|
// If we don't have a status message ID, send a new error message as a reply |
|
try { |
|
await bot.sendMessage(chat_id, `${errorMessage}\n\nTechnical details: ${error.message}`, { |
|
reply_to_message_id: message_id |
|
}); |
|
} catch (sendError) { |
|
console.error(`[ERROR] Failed to send error message: ${sendError.message}`); |
|
} |
|
} |
|
} |
|
} |
|
|
|
// Helper function to safely update status messages |
|
async function updateStatusMessage(bot, chat_id, message_id, text) { |
|
if (!message_id) { |
|
return; |
|
} |
|
|
|
try { |
|
await bot.editMessageText(text, { |
|
chat_id, |
|
message_id |
|
}); |
|
} catch (error) { |
|
console.error(`[ERROR] Failed to update status message: ${error.message}`); |
|
// We'll continue even if the update fails |
|
} |
|
} |
|
|
|
async function transcribeAudio(audio_url, api_key, isVideoNote = false) { |
|
try { |
|
// Validate the URL format |
|
if (!audio_url || !audio_url.startsWith('https://')) { |
|
throw new Error('Invalid audio URL format'); |
|
} |
|
|
|
// Prepare request body, use diarization for video notes |
|
const requestBody = JSON.stringify({ |
|
audio_input: audio_url, |
|
//translate: true, |
|
processing_type: isVideoNote ? "diarize" : "align", // Use diarize for video notes |
|
output: "raw" |
|
}); |
|
|
|
const response = await fetch(WHISPER_API, { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
'Authorization': `Bearer ${api_key}` |
|
}, |
|
body: requestBody |
|
}); |
|
|
|
// Get the response data as text first for logging |
|
const responseText = await response.text(); |
|
|
|
if (!response.ok) { |
|
console.error(`[ERROR] DataCrunch API error response: ${responseText}`); |
|
throw new Error(`DataCrunch API error: ${response.status} - ${responseText}`); |
|
} |
|
|
|
// Parse the text response as JSON |
|
let responseData; |
|
try { |
|
responseData = JSON.parse(responseText); |
|
} catch (parseError) { |
|
console.error(`[ERROR] Failed to parse API response as JSON: ${responseText.substring(0, 200)}`); |
|
throw new Error(`Failed to parse transcription result: ${parseError.message}`); |
|
} |
|
|
|
return responseData; |
|
} catch (error) { |
|
console.error(`[ERROR] Exception in transcribeAudio: ${error.message}`); |
|
throw error; |
|
} |
|
} |
|
|
|
function formatTranscription(transcription, isVideoNote = false) { |
|
// Extract the transcribed text from the response |
|
if (!transcription || !transcription.segments || transcription.segments.length === 0) { |
|
return "📝 *Transcription:*\n\n_No text detected_"; |
|
} |
|
|
|
// For video notes with diarization |
|
if (isVideoNote && transcription.segments[0].speaker !== undefined) { |
|
// Group segments by speaker |
|
const speakerSegments = {}; |
|
|
|
transcription.segments.forEach(segment => { |
|
const speaker = `SPEAKER_${segment.speaker}`; |
|
if (!speakerSegments[speaker]) { |
|
speakerSegments[speaker] = []; |
|
} |
|
speakerSegments[speaker].push(segment.text.trim()); |
|
}); |
|
|
|
// Format the output with speaker labels |
|
let formattedText = "📹 *Video Transcription (with speaker diarization):*\n\n"; |
|
|
|
Object.keys(speakerSegments).forEach(speaker => { |
|
const speakerText = speakerSegments[speaker].join(' ').replace(/\s+/g, ' '); |
|
formattedText += `*${speaker}:* ${speakerText}\n\n`; |
|
}); |
|
|
|
return formattedText; |
|
} |
|
|
|
// For regular voice messages |
|
const text = transcription.segments |
|
.map(segment => segment.text.trim()) |
|
.join(' ') |
|
.replace(/\s+/g, ' '); |
|
|
|
return `📝 *Transcription:*\n\n${text}`; |
|
} |
|
|
|
// Main worker handler |
|
export default { |
|
async fetch(request, env, ctx) { |
|
const url = new URL(request.url); |
|
const pathParts = url.pathname.split('/'); |
|
|
|
// Check if this is a voice file proxy request |
|
if (pathParts.length >= 3 && pathParts[1] === 'voice' && (pathParts[2].endsWith('.ogg') || pathParts[2].endsWith('.mp4'))) { |
|
return this.handleVoiceFileProxy(request, env, pathParts[2]); |
|
} |
|
|
|
// Validate the secret webhook path |
|
const secretToken = pathParts[pathParts.length - 1]; |
|
|
|
// If the secret token doesn't match, return a generic 404 error |
|
// This helps hide the existence of the webhook endpoint |
|
if (secretToken !== env.WEBHOOK_SECRET_TOKEN) { |
|
return new Response('Not Found', { status: 404 }); |
|
} |
|
|
|
// Only accept POST requests for the webhook |
|
if (request.method !== 'POST') { |
|
return new Response('Only POST requests are accepted', { status: 405 }); |
|
} |
|
|
|
// Parse the request body |
|
let update; |
|
try { |
|
update = await request.json(); |
|
} catch (error) { |
|
console.error(`[ERROR] Failed to parse request body: ${error.message}`); |
|
return new Response('Invalid JSON', { status: 400 }); |
|
} |
|
|
|
// Initialize the bot |
|
const bot = new TelegramBot(env.TELEGRAM_BOT_TOKEN); |
|
|
|
// Check if we have a message to process |
|
if (!update.message) { |
|
return new Response('OK', { status: 200 }); |
|
} |
|
|
|
const message = update.message; |
|
const chat_id = message.chat.id; |
|
const user_id = message.from.id; |
|
|
|
// Handle commands |
|
if (message.entities && message.entities.some(entity => entity.type === 'bot_command')) { |
|
const command = message.text.split(' ')[0].toLowerCase(); |
|
|
|
try { |
|
switch (command) { |
|
case '/start': |
|
await handleStart(bot, chat_id, user_id, env); |
|
break; |
|
case '/help': |
|
await handleHelp(bot, chat_id); |
|
break; |
|
case '/approve': |
|
await handleApprove(bot, chat_id, user_id, env, message); |
|
break; |
|
case '/revoke': |
|
await handleRevoke(bot, chat_id, user_id, env, message); |
|
break; |
|
case '/makeadmin': |
|
await handleMakeAdmin(bot, chat_id, message, user_id, env); |
|
break; |
|
case '/listchats': |
|
await handleListChats(bot, chat_id, user_id, env); |
|
break; |
|
default: |
|
break; |
|
} |
|
} catch (error) { |
|
console.error(`[ERROR] Error handling command ${command}: ${error.message}`); |
|
console.error(`[ERROR] Stack trace: ${error.stack}`); |
|
|
|
try { |
|
await bot.sendMessage(chat_id, `❌ Error executing command: ${error.message}`); |
|
} catch (sendError) { |
|
console.error(`[ERROR] Failed to send error message: ${sendError.message}`); |
|
} |
|
} |
|
|
|
return new Response('OK', { status: 200 }); |
|
} |
|
|
|
// Handle voice messages and video notes |
|
if (message.voice || message.video_note) { |
|
// Process in the background |
|
ctx.waitUntil(processVoiceMessage(bot, message, env, ctx, request.url) |
|
.catch(error => { |
|
console.error(`[ERROR] Unhandled error in voice/video processing: ${error.message}`); |
|
console.error(`[ERROR] Stack trace: ${error.stack}`); |
|
}) |
|
); |
|
|
|
// Return immediately to acknowledge receipt |
|
return new Response('OK', { status: 200 }); |
|
} |
|
|
|
// For all other message types, just acknowledge |
|
return new Response('OK', { status: 200 }); |
|
}, |
|
|
|
async handleVoiceFileProxy(request, env, secureFileId) { |
|
try { |
|
// Extract file extension and clean secureFileId |
|
let fileExtension = ".ogg"; // Default |
|
if (secureFileId.endsWith(".mp4")) { |
|
fileExtension = ".mp4"; |
|
secureFileId = secureFileId.replace(/\.mp4$/, ''); |
|
} else if (secureFileId.endsWith(".ogg")) { |
|
secureFileId = secureFileId.replace(/\.ogg$/, ''); |
|
} |
|
|
|
// Decrypt and validate the secure file ID |
|
const fileId = FileProxy.decrypt(secureFileId, env.FILE_PROXY_SECRET); |
|
|
|
// Initialize the bot |
|
const bot = new TelegramBot(env.TELEGRAM_BOT_TOKEN); |
|
|
|
// Get file info from Telegram |
|
const fileInfo = await bot.getFile(fileId); |
|
|
|
if (!fileInfo || !fileInfo.file_path) { |
|
console.error(`[ERROR] Could not retrieve file information for file ID: ${fileId}`); |
|
return new Response('File not found', { status: 404 }); |
|
} |
|
|
|
// Get the file URL from Telegram |
|
const fileUrl = `${bot.fileApiUrl}/${fileInfo.file_path}`; |
|
|
|
// Fetch the file from Telegram |
|
const response = await fetch(fileUrl); |
|
|
|
if (!response.ok) { |
|
console.error(`[ERROR] Failed to fetch file from Telegram: ${response.status}`); |
|
return new Response('Failed to fetch file from Telegram', { status: response.status }); |
|
} |
|
|
|
// Determine content type based on file extension |
|
let contentType = 'audio/ogg'; |
|
if (fileExtension === '.mp4') { |
|
contentType = 'video/mp4'; |
|
} |
|
|
|
// Get content type from response headers if available |
|
const respContentType = response.headers.get('content-type'); |
|
if (respContentType) { |
|
contentType = respContentType; |
|
} |
|
|
|
// Stream the file back to the client |
|
return new Response(response.body, { |
|
headers: { |
|
'Content-Type': contentType, |
|
'Cache-Control': 'public, max-age=3600' // Cache for 1 hour |
|
} |
|
}); |
|
} catch (error) { |
|
console.error(`[ERROR] Error in voice file proxy: ${error.message}`); |
|
return new Response(`Error: ${error.message}`, { status: 400 }); |
|
} |
|
} |
|
}; |