wizardloop / broadcastmanager
High-performance Telegram broadcast manager for MadelineProto
Requires
- php: ^8.2
- amphp/amp: ^3.0
- danog/madelineproto: ^8.4
README
High-Performance Telegram Broadcast Manager for MadelineProto. Manage Telegram broadcasts efficiently: send text, media, albums, inline buttons, pin/unpin messages, delete previous broadcasts, edit the last broadcast, schedule broadcasts, run self-destruct deletion jobs, and track live progress.
Requirements
- PHP
8.2or newer danog/madelineproto^8.4amphp/amp^3.0
Installation
composer require wizardloop/broadcastmanager
Include Composer autoload:
require 'vendor/autoload.php';
Create the manager with your MadelineProto API instance:
use BroadcastTool\BroadcastManager; $manager = new BroadcastManager($api);
Optional custom data directory:
BroadcastManager::setDataDir(__DIR__ . '/data'); // default: __DIR__ . '/../data'
Features
- Concurrent broadcasts with safe concurrency clamping.
- Live progress message updates.
- Pause, resume, and cancel by broadcast id.
- Text, media, albums, entities, and inline buttons.
- Optional pinning of the last message sent to each peer.
- Delete last broadcast or all saved broadcast messages.
- Edit the last saved broadcast message for each peer.
- Scheduled broadcasts persisted to disk.
- Self-destruct broadcasts with automatic deletion after
0to48hours. - Per-broadcast metadata in
data/broadcasts/{broadcastId}.json. - FLOOD_WAIT retry handling and hard-fail handling.
- Internal error log at
data/broadcast-errors.log.
Basic Broadcast
$users = ['123456789', '987654321']; $messages = [ [ 'message' => '<b>Hello subscribers</b>', 'parse_mode' => 'HTML', ], ]; $broadcastId = $manager->broadcastWithProgress( allUsers: $users, messages: $messages, chatId: $adminChatId, pin: false, concurrency: 10 );
broadcastWithProgress() returns a string id. Use it with progress(), pause(), resume(), and cancel().
$progress = $manager->progress($broadcastId);
Concurrency is clamped internally:
- Minimum:
1 - Maximum:
30 - Default:
10 - Recommended examples:
10
Live status messages are updated at most once every 5 seconds and once again when the operation finishes.
Message Payloads
Broadcast messages are passed directly to MadelineProto send methods with peer added internally.
Text message
$messages = [ [ 'message' => '<b>Hello</b>', 'parse_mode' => 'HTML', ], ];
Inline buttons
$messages = [ [ 'message' => 'Click a button below:', 'buttons' => [ [['text' => 'Visit Website', 'url' => 'https://example.com']], [['text' => 'Start', 'callback_data' => 'start_action']], ], ], ];
buttons is converted to reply_markup internally for sending.
Media message
$messages = [ [ 'message' => 'Photo caption', 'media' => [ '_' => 'inputMediaUploadedPhoto', 'file' => '/path/to/photo.jpg', ], 'parse_mode' => 'HTML', ], ];
When a message has media, BroadcastManager uses messages->sendMedia(). Otherwise it uses messages->sendMessage().
Album message
If a message contains albumFile, BroadcastManager reads that JSON file and sends it with messages->sendMultiMedia() in chunks of 10.
$messages = [ [ 'albumFile' => __DIR__ . '/album.json', ], ];
Example album.json item:
[
{
"media": {
"type": "photo",
"botApiFileId": "AgACAgQAAxkBA..."
},
"caption": "Album caption",
"entities": []
}
]
Supported album media types are mapped to inputMediaPhoto and inputMediaDocument.
Progress
$progress = $manager->progress($broadcastId); if ($progress !== null) { echo "Progress: {$progress['progressPercent']}%\n"; echo "Success: {$progress['success']}\n"; echo "Failed: {$progress['failed']}\n"; echo "Pending: {$progress['pending']}\n"; }
progress() returns array|null:
[
'processed' => 0,
'success' => 0,
'failed' => 0,
'pending' => 0,
'flood' => 0,
'progressPercent' => 0.0,
'breakdown' => [
'sent' => 0,
'deleted' => 0,
'unpin' => 0,
'edited' => 0,
'unchanged' => 0,
'scheduled' => 0,
],
'edited' => 0,
'unchanged' => 0,
'scheduled' => 0,
'selfDestruct' => null,
'type' => 'send',
'total' => 0,
'elapsed' => 0.0,
'tps' => 0.0,
'done' => false,
'paused' => false,
'cancel' => false,
'startedAt' => null,
]
Control Running Operations
$manager->pause($broadcastId); $manager->resume($broadcastId); $manager->cancel($broadcastId);
cancel() only marks the operation as cancelled. It does not clear in-flight Telegram requests.
Check operation state:
$manager->isActive($broadcastId); $manager->isPaused($broadcastId); $manager->isCancelled($broadcastId);
Pause, resume, cancel, and progress work with send/edit/delete/unpin operations that have a live state id.
Pin And Unpin
Pin the last message sent to each peer:
$broadcastId = $manager->broadcastWithProgress( allUsers: $users, messages: $messages, chatId: $adminChatId, pin: true, concurrency: 10 );
Unpin all messages for all peers:
$unpinId = $manager->unpinAllMessagesForAll($users, $adminChatId, 10);
Delete Broadcast Messages
Delete the last saved broadcast message for each peer using data/{peer}/lastBroadcast.txt:
$deleteId = $manager->deleteLastBroadcastForAll($users, $adminChatId, 10);
Delete the last message from a specific broadcast using data/broadcasts/{broadcastId}.json:
$deleteId = $manager->deleteLastBroadcastForAll( allUsers: $users, chatId: $adminChatId, concurrency: 10, broadcastId: $broadcastId );
If you pass an empty allUsers array with broadcastId, peers are loaded from that broadcast metadata:
$deleteId = $manager->deleteLastBroadcastForAll( allUsers: [], chatId: $adminChatId, concurrency: 10, broadcastId: $broadcastId );
When broadcastId is provided, the legacy lastBroadcast.txt file is not used and is not removed. This prevents a newer broadcast from being affected by an older delete request.
Delete all saved broadcast messages for each peer using data/{peer}/messages.txt:
$deleteAllId = $manager->deleteAllBroadcastsForAll($users, $adminChatId, 10);
Check whether legacy saved message files exist:
$hasLast = $manager->hasLastBroadcast(); $hasAll = $manager->hasAllBroadcast();
Edit Last Broadcast
editLastBroadcastForAll() reads each peer's data/{peer}/lastBroadcast.txt and edits that message id by default.
$editId = $manager->editLastBroadcastForAll( allUsers: $users, newText: '<b>Updated text</b>', chatId: $adminChatId, buttons: null, media: null, concurrency: 10, parseMode: 'HTML' );
Edit the last message from a specific broadcast using data/broadcasts/{broadcastId}.json:
$editId = $manager->editLastBroadcastForAll( allUsers: $users, newText: '<b>Updated text for a specific broadcast</b>', chatId: $adminChatId, buttons: null, media: null, concurrency: 10, parseMode: 'HTML', broadcastId: $broadcastId );
If you pass an empty allUsers array with broadcastId, peers are loaded from that broadcast metadata:
$editId = $manager->editLastBroadcastForAll( allUsers: [], newText: '<b>Updated text for the stored broadcast peers</b>', chatId: $adminChatId, buttons: null, media: null, concurrency: 10, parseMode: 'HTML', broadcastId: $broadcastId );
With buttons:
$editId = $manager->editLastBroadcastForAll( allUsers: $users, newText: 'Updated with a button', chatId: $adminChatId, buttons: [ [['text' => 'Open', 'url' => 'https://example.com']], ], media: null, concurrency: 10, parseMode: 'HTML' );
Edit counters include:
editedunchangedfailedflood
MESSAGE_NOT_MODIFIED is counted as unchanged, not failed.
Scheduled Broadcasts
Scheduled broadcasts are saved in data/scheduled-broadcasts.json.
$scheduleId = $manager->scheduleBroadcastForAll( allUsers: $users, messages: [ ['message' => 'Scheduled hello'], ], scheduledAt: time() + 3600, chatId: $adminChatId, pin: false, concurrency: 10, selfDestructHours: null );
If scheduledAt is in the future, the broadcast is saved and not sent yet.
If scheduledAt <= time(), it is marked running, executed immediately, and then marked done, cancelled, or failed.
Run due scheduled broadcasts periodically:
$results = $manager->runDueScheduledBroadcasts();
List scheduled broadcasts:
$scheduled = $manager->listScheduledBroadcasts();
Cancel a scheduled broadcast that has not started:
$cancelled = $manager->cancelScheduledBroadcast($scheduleId);
cancelScheduledBroadcast() returns false once the job is already running, done, cancelled, or failed.
Self-Destruct Broadcasts
Pass selfDestructHours as the sixth argument to broadcastWithProgress().
$broadcastId = $manager->broadcastWithProgress( allUsers: $users, messages: [ ['message' => 'This message will be removed later.'], ], chatId: $adminChatId, pin: false, concurrency: 10, selfDestructHours: 6 );
Rules:
nullmeans no automatic deletion.0means delete immediately after the broadcast finishes.1through48means delete after that many hours.
Invalid values below 0 or above 48 throw InvalidArgumentException.
Run due self-destruct jobs periodically:
$results = $manager->runDueSelfDestructJobs();
List self-destruct jobs:
$jobs = $manager->listSelfDestructJobs();
Cancel a self-destruct job that has not started:
$cancelled = $manager->cancelSelfDestructJob($jobId);
Self-destruct deletes by data/broadcasts/{broadcastId}.json, not by lastBroadcast.txt. If a newer broadcast was sent later, the self-destruct job still deletes only the messages from its own broadcast id.
If a broadcast is cancelled midway, the self-destruct job is created only for messages that were actually sent and saved in metadata.
Periodic Runners
Scheduled broadcasts and self-destruct jobs are durable, but they run only when you call the runners. Call them from your bot loop, event handler, or internal cron-style task:
$manager->runDueScheduledBroadcasts(); $manager->runDueSelfDestructJobs();
Filter Peers
$filterSub = $manager->filterPeers($users, 'users'); $targets = $filterSub['targets']; // array $failed = $filterSub['failed']; // int $total = $filterSub['total']; // int
Supported filter types:
usersgroupschannelsall
Last Broadcast Data
lastBroadcastData() returns the latest saved status text from data/LastBrodDATA.txt, or false if it does not exist.
$lastData = $manager->lastBroadcastData();
Data Files
Legacy files are still written for backward compatibility:
data/{peer}/lastBroadcast.txtdata/{peer}/messages.txtdata/LastBrodDATA.txt
New files:
data/broadcasts/{broadcastId}.jsondata/scheduled-broadcasts.jsondata/self-destruct-jobs.jsondata/broadcast-errors.log
Example broadcast metadata:
{
"id": "2b9a24c5ef2cbb10",
"type": "send",
"createdAt": 1710000000,
"status": "done",
"total": 100,
"sent": 90,
"failed": 10,
"peers": {
"12345": {
"lastMessageId": 111,
"messageIds": [111, 112],
"status": "sent"
}
},
"selfDestruct": {
"enabled": true,
"hours": 6,
"deleteAt": 1710021600,
"deleteJobId": "selfdestruct_..."
}
}
Public API Reference
public function __construct(API $api); public function broadcastWithProgress( array $allUsers, array $messages, $chatId = null, bool $pin = false, int $concurrency = 10, ?int $selfDestructHours = null ): string; public function editLastBroadcastForAll( array $allUsers, string $newText, $chatId = null, ?array $buttons = null, $media = null, int $concurrency = 10, string $parseMode = 'HTML', ?string $broadcastId = null ): string; public function scheduleBroadcastForAll( array $allUsers, array $messages, int $scheduledAt, $chatId = null, bool $pin = false, int $concurrency = 10, ?int $selfDestructHours = null ): string; public function runDueScheduledBroadcasts(): array; public function cancelScheduledBroadcast(string $scheduleId): bool; public function listScheduledBroadcasts(): array; public function deleteLastBroadcastForAll( array $allUsers, $chatId = null, int $concurrency = 10, ?string $broadcastId = null ): string; public function deleteAllBroadcastsForAll(array $allUsers, $chatId = null, int $concurrency = 10): string; public function unpinAllMessagesForAll(array $allUsers, $chatId = null, int $concurrency = 10): string; public function runDueSelfDestructJobs(): array; public function cancelSelfDestructJob(string $jobId): bool; public function listSelfDestructJobs(): array; public function pause(string $id): void; public function resume(string $id): void; public function cancel(string $id): void; public function isPaused(string $id): bool; public function isCancelled(string $id): bool; public function isActive(?string $id = null): bool; public function hasLastBroadcast(): bool; public function hasAllBroadcast(): bool; public function progress(?string $id = null): ?array; public function lastBroadcastData(): string|false; public function filterPeers(array $allUsers, string $filterType = 'users'): array; public static function setDataDir(string $path): void;
Error Handling
Hard-fail Telegram RPC errors are counted as failed and are not retried. FLOOD_WAIT errors increase the job attempt count, set a future retry time, and are retried up to three attempts.
Internal logging is written to:
data/broadcast-errors.log
Log write failures are ignored so they do not crash the bot.
Changelog
See CHANGELOG.md for updates.
License
GNU AGPL-3.0 - see LICENSE.