Skip to content

Instantly share code, notes, and snippets.

@cofob
Last active May 15, 2025 15:57
Show Gist options
  • Save cofob/0272a1af75cbd4e6dc93fc3757a65176 to your computer and use it in GitHub Desktop.
Save cofob/0272a1af75cbd4e6dc93fc3757a65176 to your computer and use it in GitHub Desktop.
Voice to text bot hosted on Cloudflare Workers and using DataCrunch API for Whisper X @yobaniy_ty_v_rot_bot

Telegram Voice Transcription Bot

A Cloudflare Workers-based Telegram bot that automatically transcribes voice messages and video notes using WhisperX AI.

Features

  • Transcribes voice messages and audio notes to text
  • Supports speaker diarization for video notes (identifies different speakers)
  • Secure authentication system with admin permissions
  • Selective chat approval (only works in chats approved by admins)
  • Intelligent message splitting for long transcriptions
  • Secure proxy system for handling Telegram audio files
  • Fully serverless implementation on Cloudflare Workers with D1 database

Requirements

  • Cloudflare account with Workers and D1 access
  • Telegram Bot API token
  • DataCrunch API key for WhisperX

Setup

1. Create a Telegram Bot

  1. Contact @BotFather on Telegram
  2. Create a new bot with /newbot command
  3. Save the API token provided by BotFather

2. Set Up Cloudflare Workers & D1

  1. Install Wrangler:

    npm install -g wrangler
    
  2. Authenticate with Cloudflare:

    wrangler login
    
  3. Create a new Worker project:

    mkdir voice-transcription-bot
    cd voice-transcription-bot
    wrangler init
    
  4. Create a D1 database:

    wrangler d1 create voice-transcription-bot
    
  5. Create the necessary tables:

     -- Create admins table
     CREATE TABLE admins (
       user_id TEXT PRIMARY KEY,
       added_at INTEGER NOT NULL
     );
    
     -- Create authorized_chats table
     CREATE TABLE authorized_chats (
       chat_id TEXT PRIMARY KEY,
       added_by TEXT NOT NULL,
       added_at INTEGER NOT NULL,
       FOREIGN KEY (added_by) REFERENCES admins(user_id)
     );

3. Configuration

Create a wrangler.toml file with the following content:

name = "voice-transcription-bot"
main = "src/index.js"
compatibility_date = "2023-08-01"

[[d1_databases]]
binding = "DB"
database_name = "voice-transcription-bot"
database_id = "your-database-id-from-wrangler-output"

[vars]
TELEGRAM_BOT_TOKEN = "your-telegram-bot-token"
WEBHOOK_SECRET_TOKEN = "generate-a-random-secret-here"
ADMIN_SECRET_KEY = "generate-a-random-admin-secret-here"
FILE_PROXY_SECRET = "generate-a-random-file-proxy-secret-here"
DATACRUNCH_API_KEY = "your-datacrunch-api-key"

Replace all placeholder values with your actual tokens and secrets.

4. Deployment

  1. Add the code from this repository to src/index.js

  2. Deploy the worker:

    wrangler deploy
    
  3. Set up the webhook for your Telegram bot:

    curl -F "url=https://your-worker-name.workers.dev/your-webhook-secret-token" https://api.telegram.org/botYOUR_BOT_TOKEN/setWebhook
    

Usage

User Guide

  1. Add the bot to a group chat or start a private chat with it
  2. Ask an admin to approve your chat using the /approve command
  3. Send voice messages or video notes to the bot
  4. The bot will automatically transcribe the audio and reply with the text

Admin Commands

  • /start - Initialize the bot and become the first admin (if no admins exist)
  • /help - Show help information
  • /approve [chat_id] - Approve the current chat (or specified chat) for transcription
  • /revoke [chat_id] - Revoke access from the current chat (or specified chat)
  • /makeadmin <user_id> <secret_key> - Make another user an admin
  • /listchats - List all approved chats

User Commands

  • /start - Get introduction message
  • /help - Display help information

How It Works

  1. When a voice message or video note is sent to an approved chat, the bot processes it
  2. The bot displays a "Processing..." message that updates with the status
  3. The audio is securely proxied to prevent direct access to Telegram's servers
  4. The audio is sent to the WhisperX API for transcription
  5. For video notes, speaker diarization is used to identify different speakers
  6. The transcription is formatted and sent back as a reply
  7. For long transcriptions, the bot intelligently splits the message at natural boundaries

Advanced Customization

Adjusting Message Length Limits

The default maximum message length is set to 2000 characters. You can adjust this value by modifying the MAX_LENGTH constant in the code.

Changing Transcription Settings

You can modify the transcription parameters by editing the transcribeAudio function. For example, you can enable translation by uncommenting the translate: true line.

Troubleshooting

Common Issues

  1. Bot doesn't respond: Verify that your webhook is set up correctly and the secret token matches
  2. Permission errors: Make sure you're an admin of the bot (use /start as the first user)
  3. Transcription fails: Check your DataCrunch API key and ensure the service is available

Error Logging

The bot logs important errors with an [ERROR] prefix. Check your Cloudflare Workers logs for troubleshooting.

License

This project is licensed under the MIT License.

Credits

Bot created by @cofob

// 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 });
}
}
};
-- Create admins table
CREATE TABLE admins (
user_id TEXT PRIMARY KEY,
added_at INTEGER NOT NULL
);
-- Create authorized_chats table
CREATE TABLE authorized_chats (
chat_id TEXT PRIMARY KEY,
added_by TEXT NOT NULL,
added_at INTEGER NOT NULL,
FOREIGN KEY (added_by) REFERENCES admins(user_id)
);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment