feat: configurable call timeout & username normalization

This commit is contained in:
Joan
2026-01-26 18:07:22 +01:00
parent 442c08888d
commit b062b15797
6 changed files with 8772 additions and 1021 deletions

6
.gitignore vendored
View File

@@ -11,11 +11,7 @@ data/*.lock
data/*.signal data/*.signal
# Runtime Data # Runtime Data
data/*.madeline data/
data/*.madeline.*
data/*.mp3
data/*.wav
data/*.log
redis_data/ redis_data/
# Dependencies (if installed locally) # Dependencies (if installed locally)

View File

@@ -7,9 +7,10 @@ A dockerized application that converts text to audio (using Piper Neural TTS) an
* **High Quality TTS**: Uses [Piper](https://github.com/rhasspy/piper) with `es_ES-sharvard-medium` model for human-like speech. * **High Quality TTS**: Uses [Piper](https://github.com/rhasspy/piper) with `es_ES-sharvard-medium` model for human-like speech.
* **Web Dashboard**: Manage queue, view history, and test voices via browser. * **Web Dashboard**: Manage queue, view history, and test voices via browser.
* **Web-based Login**: Log in to your Telegram account directly from the UI (supports 2FA). * **Web-based Login**: Log in to your Telegram account directly from the UI (supports 2FA).
* **Web-based Configuration**: Set `API_ID` and `API_HASH` without touching config files. * **Web-based Configuration**: Set `API_ID`, `API_HASH`, and `Call Timeout` via the UI.
* **Flexible Inputs**: Auto-handles usernames with or without `@` prefix.
* **Real-time Queue**: Redis-backed queue with history and error reporting. * **Real-time Queue**: Redis-backed queue with history and error reporting.
* **Smart Dialing**: Waits for the user to answer before playing audio. * **Smart Dialing**: Configurable timeout (default 15s), detects "Rejected" vs "No Answer", and watches for user hang-ups during playback.
* **Legacy API Support**: Compatible with simple GET requests. * **Legacy API Support**: Compatible with simple GET requests.
## Prerequisites ## Prerequisites
@@ -39,26 +40,28 @@ A dockerized application that converts text to audio (using Piper Neural TTS) an
### 1. Initial Setup ### 1. Initial Setup
1. Open your browser and navigate to `http://localhost:8777`. 1. Open your browser and navigate to `http://localhost:8777`.
2. You will be prompted to enter your **Telegram API ID** and **API Hash**. 2. You will be prompted to enter your **Telegram API ID**, **API Hash**, **Phone Number**, and **Call Timeout**.
* Get these from [my.telegram.org](https://my.telegram.org). * Get API keys from [my.telegram.org](https://my.telegram.org).
* **Call Timeout**: Seconds to wait for the user to answer before cancelling (default 15s).
3. Click **Save Configuration**. The worker will restart. 3. Click **Save Configuration**. The worker will restart.
### 2. Login ### 2. Login
1. After configuration, the dashboard will show a **Login Now** button. 1. After configuration, the dashboard will verify calls.
2. Enter your **Phone Number** (international format, e.g., `+34...`). 2. Follow the simple 2-step process (Config -> Login) if not auto-logged in.
3. Scan the **QR Code** using your Telegram Mobile App (Settings > Devices > Link Desktop Device). 3. Enter the **Login Code** sent to your Telegram app.
* *Note: If the QR code expires or fails, check `docker logs telemovris_app` for details.* 4. If you have 2FA enabled, you will be prompted for your password.
### 3. Sending Messages ### 3. Sending Messages
* **Via Dashboard**: Use the "Queue New Task" form. * **Via Dashboard**: Use the "Queue New Task" form.
* Username can be `@username` or just `username`.
* **Via Legacy API**: * **Via Legacy API**:
``` ```
GET http://localhost:8777/sendmessage?user=@username&message=Hello+World&audio=yes GET http://localhost:8777/sendmessage?user=username&message=Hello+World&audio=yes
``` ```
## Development ## Development
* **Reset Config**: If you need to change accounts or keys, click the "Reset Config" button in the dashboard (when disconnected). * **Reset Config**: If you need to change accounts, keys, or timeout settings, click the "Reset Config" button in the dashboard.
* **Logs**: Check worker logs for debugging: * **Logs**: Check worker logs for debugging:
```bash ```bash
docker logs -f telemovris_app docker logs -f telemovris_app

File diff suppressed because it is too large Load Diff

View File

@@ -32,11 +32,50 @@
<label>App api_hash</label> <label>App api_hash</label>
<input type="text" name="api_hash" class="form-control" placeholder="abcdef123456..." required> <input type="text" name="api_hash" class="form-control" placeholder="abcdef123456..." required>
</div> </div>
<div class="mb-3">
<label>Phone Number (with +)</label>
<input type="text" name="phone" class="form-control" placeholder="+1234567890" required>
</div>
<div class="mb-3">
<label>Call Timeout (seconds)</label>
<input type="number" name="call_timeout" class="form-control" value="15" required>
</div>
<button type="submit" class="btn btn-warning">Save Configuration</button> <button type="submit" class="btn btn-warning">Save Configuration</button>
</form> </form>
</div> </div>
</div> </div>
<!-- Login Modal/Form -->
<div id="loginForm" class="card mt-4" style="display:none;">
<div class="card-header bg-primary text-white">Login Required</div>
<div class="card-body">
<div id="loginStep1">
<p>Logging in with phone number from config...</p>
<div class="spinner-border text-primary" role="status"></div>
</div>
<div id="loginStep2" style="display:none;">
<div class="mb-3">
<label>Enter Login Code</label>
<input type="text" id="loginCode" class="form-control" placeholder="12345">
</div>
<div class="d-flex gap-2">
<button onclick="submitLoginCode()" class="btn btn-primary">Verify Code</button>
<button onclick="startLogin()" class="btn btn-outline-secondary">Resend Code (Retry)</button>
</div>
</div>
<div id="loginStep3" style="display:none;">
<div class="mb-3">
<label>Two-Factor Password</label>
<input type="password" id="loginPassword" class="form-control">
</div>
<button onclick="submitLoginPassword()" class="btn btn-primary">Submit Password</button>
</div>
<div id="loginMsg" class="mt-2 text-danger"></div>
</div>
</div>
<div id="mainDashboard" class="card mt-4" style="display:none;"> <div id="mainDashboard" class="card mt-4" style="display:none;">
<div class="card-header">Queue New Task</div> <div class="card-header">Queue New Task</div>
<div class="card-body"> <div class="card-body">
@@ -111,9 +150,15 @@
const data = await res.json(); const data = await res.json();
const banner = document.getElementById('statusBanner'); const banner = document.getElementById('statusBanner');
const loginForm = document.getElementById('loginForm');
const mainDashboard = document.getElementById('mainDashboard'); const mainDashboard = document.getElementById('mainDashboard');
const setupForm = document.getElementById('setupForm'); const setupForm = document.getElementById('setupForm');
// If user is currently logging in (modal visible), don't disrupt them unless we detect a hard state change like logged_in
if (loginForm.style.display === 'block' && data.status !== 'logged_in') {
return;
}
if (data.status === 'logged_in') { if (data.status === 'logged_in') {
const name = data.me.first_name || 'User'; const name = data.me.first_name || 'User';
banner.className = 'alert alert-success d-flex justify-content-between align-items-center'; banner.className = 'alert alert-success d-flex justify-content-between align-items-center';
@@ -126,30 +171,95 @@
`; `;
mainDashboard.style.display = 'block'; mainDashboard.style.display = 'block';
setupForm.style.display = 'none'; setupForm.style.display = 'none';
loginForm.style.display = 'none'; // Close modal if success
} else if (data.status === 'waiting_for_config') { } else if (data.status === 'waiting_for_config') {
banner.className = 'alert alert-danger d-flex justify-content-between align-items-center'; banner.className = 'alert alert-danger d-flex justify-content-between align-items-center';
banner.innerText = 'Configuration Missing'; banner.innerText = 'Configuration Missing';
mainDashboard.style.display = 'none'; mainDashboard.style.display = 'none';
setupForm.style.display = 'block'; setupForm.style.display = 'block';
loginForm.style.display = 'none';
} else { } else {
banner.className = 'alert alert-warning d-flex justify-content-between align-items-center'; banner.className = 'alert alert-warning d-flex justify-content-between align-items-center';
banner.innerHTML = ` banner.innerHTML = `
<span><strong>Disconnected:</strong> Worker is not logged in.</span> <span><strong>Disconnected:</strong> Worker is not logged in.</span>
<div> <div>
<a href="/login" class="btn btn-sm btn-dark">Login Now</a> <button onclick="startLogin()" class="btn btn-sm btn-primary">Login Now</button>
<button onclick="doReset()" class="btn btn-sm btn-outline-danger ms-2">Reset Config</button> <button onclick="doReset()" class="btn btn-sm btn-outline-danger ms-2">Reset Config</button>
</div> </div>
`; `;
mainDashboard.style.display = 'block'; mainDashboard.style.display = 'block';
setupForm.style.display = 'none'; setupForm.style.display = 'none';
// Don't auto-hide login form here, let the 'if' above handle it or user close it?
// Actually if we are here, it means we are disconnected.
// If the user hasn't opened the modal (handled above), ensure it's hidden (default state)
if (loginForm.style.display !== 'block') {
loginForm.style.display = 'none';
}
} }
} catch(e) { } catch(e) {
console.error("Status check failed", e); console.error("Status check failed", e);
} }
} }
async function startLogin() {
document.getElementById('statusBanner').style.display = 'none';
document.getElementById('mainDashboard').style.display = 'none';
document.getElementById('loginForm').style.display = 'block';
document.getElementById('loginStep1').style.display = 'block';
try {
// Empty body triggers use of config phone
const res = await fetch('/login', { method: 'POST' });
const json = await res.json();
if(json.status === 'code_requested') {
document.getElementById('loginStep1').style.display = 'none';
document.getElementById('loginStep2').style.display = 'block';
} else if (json.status === 'error') {
document.getElementById('loginMsg').innerText = json.message;
}
} catch(e) {
document.getElementById('loginMsg').innerText = e.message;
}
}
async function submitLoginCode() {
const code = document.getElementById('loginCode').value;
const res = await fetch('/login', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'code=' + encodeURIComponent(code)
});
const json = await res.json();
if (json.status === 'success') {
location.reload();
} else if (json.message && json.message.includes('password')) {
document.getElementById('loginStep2').style.display = 'none';
document.getElementById('loginStep3').style.display = 'block';
} else {
document.getElementById('loginMsg').innerText = json.message || 'Error';
}
}
async function submitLoginPassword() {
const password = document.getElementById('loginPassword').value;
const res = await fetch('/login', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'password=' + encodeURIComponent(password)
});
const json = await res.json();
if (json.status === 'success') {
location.reload();
} else {
document.getElementById('loginMsg').innerText = json.message || 'Error';
}
}
async function doReset() { async function doReset() {
if(!confirm("Are you sure? This will delete the API ID/Hash and require setup again.")) return; if(!confirm("Are you sure? This will delete the API ID/Hash and require setup again.")) return;
try { try {

View File

@@ -19,6 +19,13 @@ if ($method === 'POST' && $uri === '/api/queue') {
exit; exit;
} }
// Normalize Username (Add @ if missing and not numeric/phone)
$username = trim($data['username']);
if (!empty($username) && !is_numeric($username) && $username[0] !== '+' && $username[0] !== '@') {
$username = '@' . $username;
}
$data['username'] = $username;
$jobId = uniqid('job_'); $jobId = uniqid('job_');
$data['id'] = $jobId; $data['id'] = $jobId;
$data['status'] = 'queued'; $data['status'] = 'queued';
@@ -31,7 +38,7 @@ if ($method === 'POST' && $uri === '/api/queue') {
// Push to history list (for display) // Push to history list (for display)
$redis->lpush('job_history', $jobId); $redis->lpush('job_history', $jobId);
echo json_encode(['job_id' => $jobId, 'status' => 'queued']); echo json_encode(['job_id' => $jobId, 'status' => 'queued', 'normalized_username' => $username]);
exit; exit;
} }
@@ -107,6 +114,12 @@ if ($method === 'GET' && $uri === '/sendmessage') {
exit; exit;
} }
// Normalize Username (Add @ if missing and not numeric/phone)
$user = trim($user);
if (!empty($user) && !is_numeric($user) && $user[0] !== '+' && $user[0] !== '@') {
$user = '@' . $user;
}
$audio = 0; $audio = 0;
if ($audioParam && strtolower($audioParam) === 'yes') { if ($audioParam && strtolower($audioParam) === 'yes') {
$audio = 1; $audio = 1;
@@ -144,15 +157,17 @@ if ($method === 'POST' && $uri === '/api/logout') {
// API: Save Config // API: Save Config
if ($method === 'POST' && $uri === '/api/config') { if ($method === 'POST' && $uri === '/api/config') {
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
if (!isset($data['api_id'], $data['api_hash'])) { if (!isset($data['api_id'], $data['api_hash'], $data['phone'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['error' => 'Missing api_id or api_hash']); echo json_encode(['error' => 'Missing api_id, api_hash, or phone']);
exit; exit;
} }
file_put_contents('/app/data/config.json', json_encode([ file_put_contents('/app/data/config.json', json_encode([
'api_id' => trim($data['api_id']), 'api_id' => trim($data['api_id']),
'api_hash' => trim($data['api_hash']) 'api_hash' => trim($data['api_hash']),
'phone' => trim($data['phone']),
'call_timeout' => isset($data['call_timeout']) ? (int)$data['call_timeout'] : 15
])); ]));
// Force worker restart to pick up new config // Force worker restart to pick up new config
@@ -193,20 +208,27 @@ if ($method === 'POST' && $uri === '/login') {
// We will use a dedicated session API wrapper or just basic calls if possible. // We will use a dedicated session API wrapper or just basic calls if possible.
// For simplicity, we try to Instantiate MP and check state. // For simplicity, we try to Instantiate MP and check state.
// Load Config
if (!file_exists('/app/data/config.json')) {
echo json_encode(['status' => 'error', 'message' => 'Config missing']);
exit;
}
$config = json_decode(file_get_contents('/app/data/config.json'), true);
$settings = new \danog\MadelineProto\Settings(); $settings = new \danog\MadelineProto\Settings();
$settings->setAppInfo((new \danog\MadelineProto\Settings\AppInfo()) $settings->setAppInfo((new \danog\MadelineProto\Settings\AppInfo())
->setApiId((int)getenv('API_ID')) ->setApiId((int)$config['api_id'])
->setApiHash(getenv('API_HASH')) ->setApiHash($config['api_hash'])
); );
$MadelineProto = new API('/app/data/session.madeline', $settings); $MadelineProto = new API('/app/data/session.madeline', $settings);
$phone = $_POST['phone'] ?? null; $phone = $_POST['phone'] ?? $config['phone'] ?? null;
$code = $_POST['code'] ?? null; $code = $_POST['code'] ?? null;
$password = $_POST['password'] ?? null; $password = $_POST['password'] ?? null;
try { try {
if ($phone) { if ($phone && !$code && !$password) {
// Create lock file to pause worker // Create lock file to pause worker
touch('/app/data/login.lock'); touch('/app/data/login.lock');
@@ -227,11 +249,10 @@ if ($method === 'POST' && $uri === '/login') {
echo json_encode(['status' => 'error', 'message' => 'Invalid Request']); echo json_encode(['status' => 'error', 'message' => 'Invalid Request']);
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Don't remove lock on error immediately, user might retry code? // Remove lock on error so worker can resume or user can retry
// Actually if phoneLogin failed, we should probably remove it? if (file_exists('/app/data/login.lock')) {
// For now, let's keep it simple. If it's a fatal error or user gives up, they might need to restart. unlink('/app/data/login.lock');
// Or we can provide a 'reset' button. }
// Let's just catch and return error.
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
} }
exit; exit;

View File

@@ -13,6 +13,10 @@ use danog\MadelineProto\Settings\AppInfo;
echo "Worker Initializing...\n"; echo "Worker Initializing...\n";
// Cleanup stale locks
if (file_exists('/app/data/login.lock')) unlink('/app/data/login.lock');
if (file_exists('/app/data/logout.signal')) unlink('/app/data/logout.signal');
$redis->del('telegram:status'); $redis->del('telegram:status');
$redis->del('telegram:me'); $redis->del('telegram:me');
@@ -29,6 +33,8 @@ while (true) {
sleep(2); sleep(2);
} }
$redis->set('telegram:status', 'starting');
$settings = new Settings(); $settings = new Settings();
$settings->setAppInfo((new AppInfo()) $settings->setAppInfo((new AppInfo())
->setApiId((int)$config['api_id']) ->setApiId((int)$config['api_id'])
@@ -38,6 +44,14 @@ $settings->setAppInfo((new AppInfo())
$MadelineProto = null; $MadelineProto = null;
while (true) { while (true) {
// Check for logout/reset signal
if (file_exists('/app/data/logout.signal')) {
echo "Worker received logout signal. Logging out...\n";
unlink('/app/data/logout.signal');
// We might not have an active instance to logout(), so just exit
exit(0);
}
// Check for login lock (Web UI is performing login) // Check for login lock (Web UI is performing login)
if (file_exists('/app/data/login.lock')) { if (file_exists('/app/data/login.lock')) {
echo "Worker detected login in progress. Sleeping...\n"; echo "Worker detected login in progress. Sleeping...\n";
@@ -67,9 +81,14 @@ while (true) {
// Passive Wait Loop // Passive Wait Loop
$waited = 0; $waited = 0;
while ($waited < 60) { while ($waited < 60) {
if (file_exists('/app/data/logout.signal')) {
echo "Worker received logout signal during wait. Logging out...\n";
unlink('/app/data/logout.signal');
exit(0);
}
if (file_exists('/app/data/login.lock')) { if (file_exists('/app/data/login.lock')) {
echo "Worker detected login activity! Retrying...\n"; echo "Worker detected login activity! Retrying...\n";
break; // Break inner loop to restart main loop (which handles lock) break; // Break inner loop
} }
sleep(1); sleep(1);
$waited++; $waited++;
@@ -82,9 +101,14 @@ while (true) {
$MadelineProto = null; $MadelineProto = null;
unset($MadelineProto); unset($MadelineProto);
// Passive Wait Loop // Passive Wait Loop Wait Loop
$waited = 0; $waited = 0;
while ($waited < 60) { while ($waited < 60) {
if (file_exists('/app/data/logout.signal')) {
echo "Worker received logout signal during wait. Logging out...\n";
unlink('/app/data/logout.signal');
exit(0);
}
if (file_exists('/app/data/login.lock')) { if (file_exists('/app/data/login.lock')) {
echo "Worker detected login activity! Retrying...\n"; echo "Worker detected login activity! Retrying...\n";
break; break;
@@ -148,40 +172,69 @@ while (true) {
// Request Call (Synchronous return of VoIP controller) // Request Call (Synchronous return of VoIP controller)
$call = $MadelineProto->requestCall($task['username']); $call = $MadelineProto->requestCall($task['username']);
echo "Ringing...\n"; $timeoutSeconds = isset($config['call_timeout']) ? (int)$config['call_timeout'] : 15;
$timeoutLoops = $timeoutSeconds * 2; // 0.5s per loop
echo "Ringing (Timeout: {$timeoutSeconds}s)...\n";
$answered = false; $answered = false;
for ($i = 0; $i < 60; $i++) { $rejected = false;
for ($i = 0; $i < $timeoutLoops; $i++) {
try { try {
// Check for answer $state = $call->getCallState();
// Note: STATE_ESTABLISHED is typically 3 or 4. We use the constant.
if ($call->getCallState() === \danog\MadelineProto\VoIP::STATE_ESTABLISHED) { // Check for answer (RUNNING or CONFIRMED)
if ($state === \danog\MadelineProto\VoIP\CallState::RUNNING || $state === \danog\MadelineProto\VoIP\CallState::CONFIRMED) {
$answered = true; $answered = true;
echo "Call Answered!\n"; echo "Call Answered! (State: " . $state->name . ")\n";
break;
}
if ($state === \danog\MadelineProto\VoIP\CallState::ENDED) {
echo "Call Rejected or Ended prematurely.\n";
$rejected = true;
break; break;
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Ignore state check errors temporarily // echo "State check error: " . $e->getMessage() . "\n";
} }
sleep(1); usleep(500000); // 0.5s
} }
if (!$answered) { if ($rejected) {
echo "No answer after 60s. Cancelling.\n"; $redis->hset("job:{$task['id']}", 'status', 'rejected');
} elseif (!$answered) {
echo "No answer after {$timeoutSeconds}s. Cancelling.\n";
$call->discard(); $call->discard();
$redis->hset("job:{$task['id']}", 'status', 'no_answer'); $redis->hset("job:{$task['id']}", 'status', 'no_answer');
continue; continue;
} } else {
// Answered case
// Play Audio AFTER answer // Play Audio AFTER answer
$call->play(new \danog\MadelineProto\LocalFile($audioFile)); $call->play(new \danog\MadelineProto\LocalFile($audioFile));
// Wait for playback to complete (now accurate since we just started) // Wait for playback to complete, checking for hangup
$duration = TTS::getDuration($audioFile); $duration = TTS::getDuration($audioFile);
echo "Audio duration: {$duration}s. Sleeping...\n"; echo "Audio duration: {$duration}s. Playing...\n";
sleep((int)ceil($duration) + 2); // Buffer of 2s
$startTime = microtime(true);
$waitTime = $duration + 2; // Buffer
while ((microtime(true) - $startTime) < $waitTime) {
try {
$state = $call->getCallState();
if ($state === \danog\MadelineProto\VoIP\CallState::ENDED) {
echo "User hung up during playback.\n";
break;
}
} catch (\Throwable $e) {
// Ignore state check errors during playback
}
usleep(500000); // 0.5s
}
$call->discard(); $call->discard();
} }
}
$redis->hset("job:{$task['id']}", 'status', 'completed'); $redis->hset("job:{$task['id']}", 'status', 'completed');