Skip to content

Telegram Channel Developer Guide

Ed Mozley edited this page Oct 4, 2026 · 3 revisions

Telegram channel - Developer Guide

Shipped in 2.10.0 Β· Contributed by Andrew Turbay (@turbay-a) in PR #159 Β· User-facing page: Telegram channel

This page explains how the Telegram channel works underneath, file by file, with the real code. It also records what changed when the PR was merged, and why. Several of those changes fix bugs that only show up with real data, and a later change could easily walk straight back into them. If you are about to touch contact matching, threading, dedupe or attachments, read The traps first.


Thank you, Andrew

PR #159 was a big, careful piece of work: a complete Telegram provider, a way to work out who a chat is, bot replies in the customer's own language, sending attachments over three providers (not just the new one), the whole chat reply box moved onto translations, thirteen translations of every new string, and four genuine bug fixes in code that already existed. The design of the provider, the language handling and the attachment pipeline is his and is kept almost exactly as written.

What changed at merge time is listed in What changed at merge time, so it is clear which parts are whose. Most of it concerns one function - deciding who a chat belongs to - where the first version could, in one realistic case, move a real person's every ticket to somebody else and delete their account. That was proven on a throwaway copy before anything was changed; the evidence is in How it was tested.


Where to find things

You want to… Go to
Set up a bot Tickets β†’ Settings β†’ Messaging β†’ Add channel β†’ provider Telegram
Register the webhook The Connect button in that dialog (after Save)
Reply, or send a file The chat reply box on the ticket (Send, Attach)
See who a chat was matched to, and why The ticket's Audit window
See the bot's credit System β†’ Contributors

Files

πŸ”Œ provider Β· πŸ“₯ inbound Β· πŸ“€ outbound Β· πŸ—„οΈ schema Β· πŸ–₯️ UI Β· 🌐 strings Β· ❓ help Β· πŸ§ͺ Feature Bingo

🎨 File What it does
πŸ”Œ includes/messaging/TelegramProvider.php New. Webhook check, parsing, send, contact request, media download/upload, setWebhook(), connection test
πŸ”Œ includes/messaging/MessagingProvider.php sendMedia() contract (new), httpMultipart() and outboundFilename() helpers (merge time)
πŸ”Œ includes/messaging/messaging.php Provider registered; Telegram identifier rule; channelHasServiceWindow() (merge time); signed media URLs; locale mapping
πŸ“₯ includes/messaging/ingest.php Who a chat is (messagingTelegram* functions, rebuilt at merge); per-bot threading
πŸ“€ api/messaging/send_attachment.php New. Analyst sends a file
πŸ“€ api/messaging/media.php New. Public, signed, one-hour link to one outbound file (for Twilio)
πŸ“€ includes/messaging/MetaCloudProvider.php, TwilioProvider.php sendMedia() for WhatsApp
πŸ–₯️ api/messaging/telegram_connect.php New at merge. The Connect button
πŸ–₯️ api/messaging/save_channel.php Telegram provider; secret token required and validated
πŸ–₯️ api/messaging/test_channel.php Self-test with a chat-id-shaped sender; ngrok interstitial detection
πŸ–₯️ api/messaging/send_message.php, api/tickets/get_ticket_thread.php Use channelHasServiceWindow()
πŸ–₯️ tickets/settings/index.php Provider fields, Generate, Connect
πŸ–₯️ assets/js/inbox.js Reply box translated; Attach for Telegram and WhatsApp
πŸ—„οΈ database/freeitsm.sql, includes/db_verify_schema.php, api/system/db_verify.php messaging_identity_links; its two foreign keys; the Telegram ticket origin
πŸ—„οΈ includes/db_verify_indexes.php Generated - see trap 9
🌐 includes/i18n.php I18n::tFor() - translate for a locale other than the session's
🌐 lang/{14 locales}/tickets.php channel_composer.*, telegram_bot.*, channel settings strings
❓ tickets/help.php WhatsApp & Telegram channels section
πŸ§ͺ includes/feature_bingo/cards/tickets-channels.php telegram_channel, telegram_receiving, channel_attachments
✏️ includes/contributors.php The credit

Not touched: the webhook entry point api/messaging/webhook.php. Telegram slots into its existing flow - authenticate, parse, ingest - like every other provider.


1. How one message flows

Telegram ──POST──▢ api/messaging/webhook.php?channel=N
                     β”‚  load channel, build provider
                     β”‚  verifyWebhook()          ← secret token header
                     β”‚  parseInbound()           ← Update JSON β†’ normalised message
                     β–Ό
                   ingestInboundMessage()        (includes/messaging/ingest.php)
                     β”‚  dedupe on provider_msg_id
                     β”‚  messagingTelegramResolveIdentity()   ← who is this?
                     β”‚  bare phone share? stop here
                     β”‚  findOpenChannelTicket(…, channelId)  ← thread or new ticket
                     β–Ό
                   ticket + emails row (+ attachments)

Everything Telegram-specific is in the provider and in the identity functions. The rest is the shared channel pipeline that WhatsApp, web chat and Slack already use.


2. Authenticating the webhook

When the bot is connected, FreeITSM gives Telegram a secret token. Telegram sends it back on every delivery in the X-Telegram-Bot-Api-Secret-Token header:

// TelegramProvider::verifyWebhook()
$presented = $headers['x-telegram-bot-api-secret-token'] ?? '';
$expected  = (string) ($this->channel['verify_token'] ?? '');
if ($expected === '' || $presented === '') {
    return false;              // never set up β†’ refuse, like Meta without an app secret
}
return hash_equals($expected, $presented);
  • Constant-time comparison (hash_equals), so the secret can't be guessed one character at a time from response timings.
  • No secret means no entry. A half-configured channel refuses everything rather than accepting unauthenticated messages.
  • The secret lives in messaging_channels.verify_token, the column Meta uses for its own verify token. It is encrypted at rest by save_channel.php and decrypted by loadMessagingChannel().

save_channel.php now requires a secret for Telegram, and checks it against Telegram's own character rule, so a bad one is caught where the admin can fix it rather than coming back later as a Telegram error:

if ($provider === 'telegram') {
    if ($verifyPlain === '') {
        throw new Exception('A secret token is required for Telegram - type one or click Generate.');
    }
    if (!preg_match('/^[A-Za-z0-9_-]{1,256}$/', $verifyPlain)) {
        throw new Exception('The secret token may only use letters, numbers, - and _ (Telegram\'s rule), up to 256 characters.');
    }
    ...
}

3. Parsing a message

parseInbound() turns Telegram's Update object into the normalised message every channel uses (documented at the top of MessagingProvider.php). The Telegram-specific parts:

Field Value Why
from the chat id, e.g. "987654321" It is what sendMessage() needs to reply. It is not a phone number - see normaliseChannelIdentifier()'s Telegram branch, which keeps a leading - for group chats instead of stripping it like a phone number.
provider_msg_id tg:<channel id>:<chat id>:<message id> The dedupe key - see below
language_code e.g. "uk" The person's app language, for the bot's replies
contact ['phone' => '+…'] Only for the sender's own contact card

Only the sender's own contact card is identity

$contact = $message['contact'] ?? null;
if (is_array($contact) && !empty($contact['phone_number'])
    && isset($contact['user_id'], $from['id'])
    && (int) $contact['user_id'] === (int) $from['id']) {
    $entry['contact'] = ['phone' => '+' . ltrim((string) $contact['phone_number'], '+')];
} elseif (is_array($contact) && $entry['body'] === '') {
    // Somebody ELSE's contact card ... kept as text
    $entry['body'] = '[Contact card: ' . $who . ', +' . $num . ']';
}

Telegram lets anyone forward someone else's saved contact card. If that counted, anyone could claim to be anyone by forwarding their card. The PR got this exactly right: a card counts only when its user_id is the sender's own id, which is what the Share phone number button produces.

Merge-time addition: a forwarded card used to arrive as an empty message and be stored as [empty message], losing what the customer actually sent. It is now kept as text, e.g. [Contact card: Bob Real, +447700900002]. It is still never used for matching.

The dedupe key carries the bot

Providers retry webhooks, so ingest.php skips a provider_msg_id it has already stored. Telegram message ids are only unique within one bot's chat with one person:

// TelegramProvider::parseInbound()
'provider_msg_id' => 'tg:' . (int) ($this->channel['id'] ?? 0) . ':' . $chatId . ':' . $messageId,

πŸ”΄ The PR used 'tg:' . $chatId . ':' . $messageId. That looks unique, but a private chat id is the person's Telegram user id, the same on every bot, and each bot numbers its own chat from 1. So the first message a person sent to a second bot had exactly the same key as the first one they sent to the first bot, and was silently dropped as a duplicate. Found by testing two bots against each other; see trap 4.


4. Threading: the conversation is (bot, chat)

findOpenChannelTicket() decides whether a message joins an open ticket or opens a new one:

// includes/messaging/ingest.php
$byBot = ($channelType === 'telegram' && $channelId !== null);

$sql = "SELECT t.id FROM tickets t JOIN emails e ON e.ticket_id = t.id
         WHERE e.channel = ? AND e.from_address = ?
           " . ($byBot ? "AND e.channel_id = ?" : "") . "
           AND t.deleted_datetime IS NULL AND t.closed_datetime IS NULL
         ORDER BY t.updated_datetime DESC LIMIT 1";

πŸ”΄ Before the merge it matched on the sender only. With one bot per company (an MSP's natural setup), a person's message to company B's bot was appended to their open ticket from company A's bot - another company's ticket. Now Telegram matches on the bot as well.

WhatsApp is deliberately left as it was. Its sender-only rule predates this change and has the same shape if one person messages two of your WhatsApp numbers, but changing it was not part of merging Telegram - see Known gaps.


5. Who is this chat? (rebuilt at merge time)

This is the part to read carefully.

The problem it solves

WhatsApp tells us a sender's phone number, which is also something an analyst recognises. Telegram tells us a chat number that means nothing. Andrew's idea, which is kept:

  1. The first message from a chat gets a placeholder requester immediately. The ticket is never held up.
  2. The bot asks, once, for the person's phone number through Telegram's native Share phone number button. Telegram vouches for that number.
  3. If the number belongs to a known person, the chat is linked to them.

The link is stored in messaging_identity_links, so every later message from that chat resolves straight to the right person.

What the first version did, and what happened

The first version's merge step, simplified:

// PR #159, messagingTelegramLinkPhone() - DO NOT bring this back
$matchedId = messagingFindUserByPhone($conn, $phone);          // first match, any company
if ($matchedId !== null) {
    UPDATE messaging_identity_links SET user_id = $matchedId …;
    UPDATE tickets SET user_id = $matchedId WHERE user_id = $placeholderUserId;   // ← every ticket
    if (no tickets left) DELETE FROM users WHERE id = $placeholderUserId;          // ← delete
} else {
    UPDATE users SET phone = $phone WHERE id = $placeholderUserId;                 // ← overwrite
}

$placeholderUserId was whoever the chat was currently linked to. On the first share that really is the placeholder. After a match, though, it is a real person. Replaying it on a throwaway copy:

Step What happened on the PR's code
1. Alice's Telegram chat says hello placeholder created, ticket 1 βœ…
2. She shares her number matched to Alice Real, ticket 1 moves to her, placeholder deleted βœ…
3. (Alice also has a ticket she raised by email)
4. She taps Share again, same number $placeholderUserId = Alice β†’ "no match" branch β†’ Alice's office phone overwritten, and a note saying "New Telegram contact confirmed - no existing profile matched it"
5. Her chat shares a number on Bob's record (a new phone, a family phone) all of Alice's tickets - the email one included - moved to Bob, and Alice's account deleted

Bob could then see Alice's tickets in the portal, and nothing recorded it. That is the bug the rebuild exists to make impossible.

The rules now

Each rule is one of the failures above, turned into a guarantee.

Rule 1 - only the chat's own placeholder is ever merged away. A placeholder is recognised by its synthetic address, exactly as getOrCreateChannelUser() creates it:

function messagingTelegramPlaceholderEmail(string $chatId): string
{
    return ltrim($chatId, '+') . '@telegram.local';   // MUST match getOrCreateChannelUser()
}

function messagingTelegramIsPlaceholder(PDO $conn, int $userId, string $chatId): bool { … }
// messagingTelegramLinkPhone()
if (!messagingTelegramIsPlaceholder($conn, $linkedUserId, $chatId)) {
    // already linked to a real person: change NOBODY
    messagingTelegramAudit($conn, $openTicketId, 'Telegram phone shared', null,
        $phone . ' - this chat is already linked to ' . messagingUserLabel($conn, $linkedUserId) . '; nothing was changed.');
    …
    return [$linkedUserId, ''];
}

In plain English: once a chat belongs to someone, a phone share is just noted. No tickets move, no account is deleted, no field is written.

Rule 2 - only tickets raised through this bot move. The placeholder is keyed on the chat id, which every bot shares, so "every ticket the placeholder has" could include another company's ticket:

$sel = $conn->prepare(
    "SELECT DISTINCT t.id FROM tickets t
       JOIN emails e ON e.ticket_id = t.id
      WHERE t.user_id = ? AND e.channel = 'telegram' AND e.channel_id = ? AND e.direction = 'Inbound'"
);
$sel->execute([$placeholderId, $channelId]);
$moved = …;
UPDATE tickets SET user_id = ? WHERE user_id = ? AND id IN (…$moved…)

Rule 3 - the placeholder is deleted only when nothing at all points at it, and only if it is still the placeholder. Inside the same transaction:

$left = $conn->prepare("SELECT (SELECT COUNT(*) FROM tickets WHERE user_id = ?)
                             + (SELECT COUNT(*) FROM messaging_identity_links WHERE user_id = ?)");
…
if ((int) $left->fetchColumn() === 0 && messagingTelegramIsPlaceholder($conn, $placeholderId, $chatId)) {
    $conn->prepare("DELETE FROM users WHERE id = ? AND email = ?")
         ->execute([$placeholderId, messagingTelegramPlaceholderEmail($chatId)]);
}

The AND email = ? on the DELETE is belt and braces: even a future bug that passes the wrong id cannot delete a real person.

Rule 4 - match only within the bot's company, and only an unambiguous number.

function messagingFindUsersByPhone(PDO $conn, string $phone, ?int $tenantId): array
{
    $needle = preg_replace('/\D+/', '', $phone);
    if ($needle === '' || strlen($needle) < 7) return [];
    $sql = "SELECT id, phone, mobile FROM users
             WHERE ((phone IS NOT NULL AND phone <> '') OR (mobile IS NOT NULL AND mobile <> ''))
               AND (is_active = 1 OR is_active IS NULL)
               AND (email IS NULL OR email NOT LIKE '%@telegram.local')";
    if (isMultiTenant($conn)) {
        if ($tenantId === null) return [];                       // triage: never guess a company
        $sql .= " AND COALESCE(tenant_id, ?) = ?";               // no company = Default, as everywhere
        $params = [getDefaultTenantId($conn), $tenantId];
    }
    … collect EVERY matching id …
}

and in the caller:

$tenantId   = resolveTicketTenantForChannel($conn, $channelId, $chatId);
$candidates = messagingFindUsersByPhone($conn, $phone, $tenantId);
if (count($candidates) === 1) { … merge … }
// 0 or 2+ β†’ keep the placeholder
Situation Before Now
Number belongs to someone in another company matched them not a candidate
Shared switchboard number on several records first one found won nobody - "2 people in this company have this number"
Multi-company channel with no company (triage) matched anyone matches nobody
Another chat's placeholder has the number could merge into it placeholders are never candidates
An inactive (left) person has the number could match skipped

Rule 5 - the link is per bot. messaging_identity_links is keyed (channel_id, external_id), not (channel_type, external_id). A match made through company A's bot does not decide who that person is on company B's bot. See Schema.

Rule 6 - never write to a real person's record. In the no-match branch, only the placeholder - our own record - gets the number, only in an empty mobile (a Telegram number is a mobile; the office phone is left alone):

$conn->prepare("UPDATE users SET mobile = ? WHERE id = ? AND email = ? AND (mobile IS NULL OR mobile = '')")
     ->execute([$phone, $placeholderId, messagingTelegramPlaceholderEmail($chatId)]);

The decision, as a picture

flowchart TD
    A["Phone number shared<br/>(sender's own card)"] --> B{"Is the chat linked to<br/>its own placeholder?"}
    B -- "No: a real person" --> N1["Change nobody.<br/>Audit: already linked to X"]
    B -- "Yes" --> C{"People with this number<br/>in the bot's company?"}
    C -- "exactly 1" --> M["Link chat β†’ them<br/>Move THIS bot's tickets<br/>Delete placeholder if unused<br/>Audit each moved ticket"]
    C -- "0" --> K0["Keep placeholder<br/>(number in its empty mobile)<br/>Audit: no match"]
    C -- "2 or more" --> K2["Keep placeholder<br/>Audit: N people share it"]
    N1 --> R["Neutral thanks to the chat"]
    M --> R
    K0 --> R
    K2 --> R
Loading

The audit trail, not the message text

The first version recorded outcomes by appending text to emails.body_content of the ticket's most recent message - which was the customer's own words, or just as easily an analyst's reply - and by putting πŸ†• New contact β€” in front of the ticket subject. Both changed data that means something else: the subject goes out in emails and is searched, and an outbound message's body is a record of what was sent.

Outcomes now go to ticket_audit as automation:

function messagingTelegramAudit(PDO $conn, int $ticketId, string $field, ?string $old, string $new): void
{
    $conn->prepare("INSERT INTO ticket_audit (ticket_id, analyst_id, field_name, old_value, new_value, created_datetime)
                    VALUES (?, NULL, ?, ?, ?, UTC_TIMESTAMP())")
         ->execute([$ticketId, $field, $old !== null ? mb_substr($old, 0, 500) : null, mb_substr($new, 0, 500)]);
}

analyst_id NULL is the established "the system did this" marker (see the ticket_audit comment in freeitsm.sql). It shows in the ticket's Audit window like any other change:

field_name old_value new_value
Requester Telegram chat 111 Alice Real <alice@example.com> - matched by the phone number they shared (+447700900001)
Telegram phone shared +442079460000 - 2 people in this company have this number, so it was not matched to any of them.

ticket_notes was considered and rejected: its analyst_id is NOT NULL with a foreign key, and the webhook has no analyst.

What the chat is told

Every outcome sends the same neutral reply (tickets.telegram_bot.ack_new). The PR had a separate "Thanks β€” found your account", which let anyone holding a phone find out whether that number belongs to someone in this install. That key was removed from all 14 language files.

A phone tap on its own adds nothing to the ticket

// ingestInboundMessage()
if ($contactOnly && trim((string) ($msg['body'] ?? '')) === '' && !$hasMedia) {
    return ['status' => 'contact_linked', 'ticket_id' => null];
}

πŸ”΄ The PR tested $body === '', but a few lines earlier an empty $body has already become '[empty message]'. So the check never fired: every phone tap added an [empty message] entry to the ticket and bumped it as a customer reply. It now tests the text as it arrived.

Two messages at once

Two messages from a brand-new chat can arrive together, and both find no link. The unique key lets only one INSERT win. The loser re-reads the winner's row instead of failing, and does not ask for the phone number a second time:

} catch (Exception $e) {
    $find->execute([$channelId, $chatId]);
    $row = $find->fetch(PDO::FETCH_ASSOC);
    if ($row) { $isNewPlaceholder = false; $userId = (int) $row['user_id']; }
    …
}

6. The bot speaks the customer's language

The webhook has no session, so t() (which uses the session's locale) is the wrong tool. Andrew added I18n::tFor():

// includes/i18n.php
public static function tFor($locale, $key, $params = []) {
    …
    $locale = array_key_exists($locale, self::SUPPORTED_LOCALES) ? $locale : self::FALLBACK_LOCALE;
    $value = self::resolve($namespace, $path, $locale);
    if ($value === null && $locale !== self::FALLBACK_LOCALE) {
        $value = self::resolve($namespace, $path, self::FALLBACK_LOCALE);   // English fallback per key
    }
    …
}

messagingNormaliseLocale() maps Telegram's tag onto an installed locale: pt/pt-BR… β†’ pt-BR, the old generic no β†’ nb, en-US β†’ en, anything unknown β†’ en.

The chat's locale is stored on its link row. A message that reports a language updates it (people change their app language); one that doesn't (some clients omit it) keeps the stored one.

Use tFor() for anything sent to someone who is not the signed-in user - a customer, an external system. Using t() in a webhook silently gives everyone English.


7. Connect instead of a curl command

The PR's settings dialog built this for the admin to copy and run:

curl "https://api.telegram.org/bot<THE BOT TOKEN>/setWebhook?url=…&secret_token=…"

That put the bot token into the browser, the clipboard and the admin's shell history, and asked an IT manager to run a terminal command. It was replaced by a Connect button. api/messaging/telegram_connect.php loads the saved channel and calls:

// TelegramProvider::setWebhook()
if (stripos($url, 'https://') !== 0) {
    throw new Exception('Telegram only delivers to an https:// address. …');
}
$this->httpRequest(self::API_BASE . $token . '/setWebhook', [
    'method' => 'POST', 'headers' => ['Content-Type: application/json'],
    'body'   => json_encode([
        'url'             => $url,
        'secret_token'    => $secret,
        'allowed_updates' => self::TELEGRAM_UPDATE_TYPES,   // ['message', 'edited_message', 'callback_query']
    ]),
]);

πŸ”΄ allowed_updates must list everything parseInbound() reads. Until 3.1.0 it said ['message', 'edited_message'], and that was right until ratings in the chat added a button. A button press is a callback_query; Telegram never sends a type the bot didn't ask for, and stores nothing to replay later. The customer's button just shimmered, and nothing failed anywhere. Telegram also keeps the list a bot was registered with, so changing the constant fixes new connections only. testConnection() therefore calls getWebhookInfo and tells the admin to press Connect when the registered list is missing a type. tests/messaging-teams-mattermost.php checks the constant includes callback_query.

  • The token never leaves the server.
  • telegram_connect.php has the same gate as saving a channel: the Tickets module, Cap::TICKETS_MESSAGING, and analystCanAccessChannel() for a company-pinned bot, answering "not found" rather than "not yours".
  • Connect uses the saved token and secret. If the admin has typed a new one without saving, the dialog says save first instead of quietly connecting with the old values.
  • drop_pending_updates is deliberately not set, so messages sent while the bot was unregistered still arrive.

8. Sending attachments

The contract

// MessagingProvider
public function sendMedia(string $to, string $filePath, string $mimeType,
                          string $caption = '', string $publicUrl = '', string $filename = ''): string

The three providers disagree about how they want a file, so the method takes every shape and each provider uses what it needs:

Provider Uses How
Telegram $filePath Multipart upload: sendPhoto for images (rendered inline), sendDocument for everything else
WhatsApp via Meta $filePath Upload to /{phone}/media for an id, then send a message referencing it (image/video/audio/document; no caption on audio, which Meta rejects)
WhatsApp via Twilio $publicUrl Twilio never takes an upload; it fetches the file from a URL you give it

Slack and web chat inherit the base method, which throws "Sending attachments is not supported for this channel yet". The reply box only shows Attach for Telegram and WhatsApp, so analysts never meet that.

Merge time: the multipart cURL code the PR wrote twice is now one helper, httpMultipart(), so the SSL setting, timeout and error wording can't drift between providers. $filename is new too: on disk a file has our random name (3f9a…c2.pdf, from uploadStoreFile()), which is right for the server but meaningless to a customer. outboundFilename() sends the original name instead, and Meta now also sets filename on a document, so WhatsApp no longer shows Untitled.

send_attachment.php - order matters

check access + find the conversation + reply window
   β”‚
   β–Ό
INSERT emails row (Outbound)                    ← first, because the folder is keyed on its id
   β”‚
   β–Ό
uploadStoreFile($_FILES['file'], tickets/attachments/{id/1000}/{id}, …)
   β”‚      ext whitelist + content check + OUR name + folder guard
   β–Ό
INSERT email_attachments row
   β”‚
   β–Ό
provider->sendMedia(path, …, signed URL, original name)
   β”‚
   └── any exception after the INSERT ──▢ delete both rows + the file

Twilio needs a URL that already points at the file, so the record and the file must exist before sending. A refused file or a refused send rolls everything back: a failed attachment never sits in the thread looking as if it went out.

πŸ”΄ The PR validated the upload into sys_get_temp_dir() and then rename()d it. Two side effects: uploadStoreFile() dropped its .htaccess/web.config guard into the OS temp folder, and the real folder was made with a bare mkdir(), so it never got the guard. Storing straight into the final folder through uploadStoreFile() fixes both. Never write a file for a ticket without uploadStoreFile() or uploadStoreBytes() - the reasons, including a proven remote-code-execution hole, are in Security review response (2026-08).

media.php - the signed link Twilio fetches

GET /api/messaging/media.php?id=<email_attachments.id>&exp=<unix time>&token=<hmac>
// messaging.php
function messagingOutboundMediaToken(int $attachmentId, int $expires): string
{
    $signingKey = hash_hmac('sha256', 'freeitsm/messaging-outbound-media/v1', getEncryptionKey(), true);
    return hash_hmac('sha256', $attachmentId . ':' . $expires, $signingKey);
}
Defence Detail
Bound to one file and one expiry the HMAC covers id:exp; change either and it fails (403)
One hour expired β†’ 410, even with the right token
Outbound chat files only the query requires direction = 'Outbound' AND channel <> 'email', so an incoming attachment or an email attachment is unreachable even with a valid token for it (404)
Its own key (merge time) derived from the encryption key with a versioned label, rather than reusing the AES key directly. One key, one job; bumping the label invalidates old links.
No key, no crash (merge time) getEncryptionKey() throws when the key file is missing; on a public endpoint that is now a 404, not a fatal that prints the key's path
Safe headers (merge time) attachmentSendHeaders(), the helper every attachment download uses: type and inline-or-download from the extension via a fixed table (never the stored content_type), nosniff, header-safe name. The PR set these by hand from the stored type.
Path check realpath() must sit inside tickets/attachments/ plus a separator, so a sibling folder that merely starts with "attachments" fails

Twilio decides the media type from that Content-Type, which is why the fixed table matters: it covers images, PDF, audio, video and Office formats with honest types.


9. Schema

CREATE TABLE IF NOT EXISTS `messaging_identity_links` (
    `id`               INT NOT NULL AUTO_INCREMENT,
    `channel_id`       INT NOT NULL,                 -- the bot (merge time)
    `channel_type`     VARCHAR(20) NOT NULL,
    `external_id`      VARCHAR(190) NOT NULL,        -- the chat id
    `user_id`          INT NOT NULL,
    `phone`            VARCHAR(40) NULL,             -- what they shared, whatever it matched
    `locale`           VARCHAR(10) NULL,
    `linked_datetime`  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uq_messaging_identity_link` (`channel_id`,`external_id`),
    KEY `ix_messaging_identity_links_user` (`user_id`),
    CONSTRAINT `fk_messaging_identity_links_channel` FOREIGN KEY (`channel_id`) REFERENCES `messaging_channels` (`id`) ON DELETE CASCADE,
    CONSTRAINT `fk_messaging_identity_links_user`    FOREIGN KEY (`user_id`)    REFERENCES `users` (`id`)              ON DELETE CASCADE
);
  • Per bot - the PR's unique key was (channel_type, external_id), one identity per chat across every bot.
  • CASCADE both ways - deleting the person or the bot removes the link. A dangling link would resolve a chat to an id that no longer exists (compare ssoClearDanglingLink()).
  • The table was new in this release, so its definition could simply be corrected. No migration was needed.
  • The PR's comment said the first message was held back until the phone number arrived. That described an earlier version of the code, not what it does, so it was rewritten.

The Telegram ticket origin. getChannelOriginId() looks an origin up by name (ucfirst('telegram')). The PR seeded none, so every Telegram ticket had no origin and vanished from any report grouped by origin. db_verify.php now seeds it beside WhatsApp, Web chat and Slack (display_order 53).


10. One list for the reply window

// includes/messaging/messaging.php
function channelHasServiceWindow(string $channelType): bool
{
    return !in_array($channelType, ['webchat', 'slack', 'telegram'], true);
}

The list used to be written out by hand in send_message.php and get_ticket_thread.php, with a comment begging the two to agree. The PR correctly added Telegram to both, and a third copy in send_attachment.php. If the composer and the API ever disagree, the composer greys out a reply the API would accept, or offers one it will refuse. All three now call this one function. A new channel is added here and nowhere else.


The traps

1. "Whoever the chat is linked to" is not the placeholder

After the first match it is a real person. Any code that moves tickets from, deletes, or writes to the linked user must first ask messagingTelegramIsPlaceholder(). This one mistake caused the data loss in section 5.

2. Never move "all of a user's tickets"

Scope a move to the tickets this channel raised (emails.channel_id). A placeholder is shared across bots, and a real person has tickets from email, the portal and every other channel.

3. Phone matching needs a company and an unambiguous answer

Never match across companies. Never take "the first" of several. Zero or two-plus candidates means no match.

4. Anything keyed on the chat id must also carry the bot

A private chat id is the person's Telegram user id, identical on every bot. That bit three things in the PR: the dedupe key (messages dropped), threading (cross-company tickets) and the identity link. If you add a fourth thing keyed on the chat - a per-chat setting, a rate limit - key it on (channel_id, chat id).

5. Don't write system notes into message bodies or subjects

They are records of what was said and sent. Use ticket_audit with analyst_id NULL.

6. Don't tell a stranger what you found

Bot replies must not depend on whether an account exists.

7. Use tFor() for text to someone without a session

t() in a webhook is always the install's default language.

8. Files go through uploadStoreFile() into their final folder

Not into the OS temp folder, and never a hand-made mkdir(). Serve them with attachmentSendHeaders().

9. db_verify_indexes.php is generated

The PR edited it by hand, putting the two new indexes in a different place from where the generator puts them, so the Schema drift check would have failed on GitHub. After changing an index in freeitsm.sql, run php scripts/gen_db_verify_indexes.php and commit both files. On Windows, --check also complains about line endings, so compare with diff --strip-trailing-cr before believing it.

10. A new channel has a checklist

It needs a ticket origin seeded in db_verify.php, an entry in channelHasServiceWindow() if it has no reply window, its own branch in normaliseChannelIdentifier() if its sender isn't a phone number, a help section, and a Feature Bingo card. The PR missed the origin; a later channel will be tempted to as well.

11. A new table must not lose messages before Database Verification

Found on Ed's own install the day it merged: his database hadn't run Database Verification, so messaging_identity_links didn't exist, every Telegram message threw "Table … doesn't exist" inside ingest, and the message was lost. Every upgraded install spends a while in that state. messagingTelegramResolveIdentity() now asks messagingIdentityLinksReady() first and, if the table is missing, falls back to a plain placeholder requester - a ticket without phone matching, never no ticket. Anything new that ingest reads needs the same probe (compare ssoJitColumnsReady()).

12. A step that needs a saved record must appear the moment it is saved

Connect only exists on a saved channel, and the dialog used to close on Save - so on a new bot Connect never appeared, Telegram was never told where to deliver (getWebhookInfo showed an empty url), and messages went nowhere without a single error. Saving a Telegram channel now reopens it with "Saved. Now click Connect". When a setup has a "then do this" step, check it is reachable from the screen the admin is actually left on.


What changed at merge time

Area Change Why
Contact matching Rebuilt - rules 1-6 in section 5 Proven data loss: a real person's tickets moved and their account deleted
Identity link Per bot; FK to messaging_channels One company's match decided another's
Dedupe key Includes the channel id A person's first message to a second bot was dropped
Threading Telegram matches on (bot, chat) Messages threaded into another company's ticket
Outcomes ticket_audit, not message text or subject It edited the customer's (or an analyst's) message, and the subject
Bot reply One neutral text; ack_matched removed "Found your account" revealed who is registered
Phone tap No [empty message] entry The empty-body check could never fire
Forwarded contact card Kept as text It was stored as [empty message]
Setup Connect button + setWebhook(); secret required and validated The curl command exposed the bot token
Reply window channelHasServiceWindow() The list was in three places
Uploads httpMultipart() shared; real file names Duplicate cURL code; recipients saw random names
send_attachment.php Stores straight into the final folder via uploadStoreFile() Guard file in the OS temp folder; final folder unguarded
media.php attachmentSendHeaders(), derived signing key, 404 without a key Hand-set headers from the stored type; key reuse; possible fatal
Origin Telegram origin seeded Telegram tickets had none
Indexes Regenerated Schema drift check failed
Write-up Help, three Feature Bingo cards, Contributors, this page Not in the PR

None of this takes away from the contribution. The matching bug only appears on the second phone share, after a successful match - a path nobody walks while building the feature - and the cross-bot bugs need two bots to exist at once.


How it was tested

Ed's development database holds real data, so all of this ran on a throwaway copy: a git worktree of the branch served by WAMP, a new database built from freeitsm.sql, and a real Database Verification run, so the origin and foreign keys came from the upgrade path. Messages were posted to the real webhook, signed with the bot's secret header exactly as Telegram sends them.

First, the PR's own code, to prove the problem before changing anything: steps 1-5 of the table in section 5 - Alice deleted, her email ticket moved to Bob.

Then the merged code:

Area Cases Result
Webhook unsigned request; another bot's secret 403 both βœ…
The Alice/Bob replay match; same number again; Bob's number Alice kept with both tickets; Bob untouched; Alice's phone field untouched; one audit row each βœ…
Phone taps three shares no [empty message] rows βœ…
Ambiguous switchboard number on two records (a third in national form) nobody matched; audit says why; national form not treated as equal βœ…
No match unknown number placeholder kept, number in its mobile βœ…
Forwarded card someone else's card not used for identity; stored as [Contact card: …] βœ…
Multi-company number belongs to a Default-company person, shared on Beta's bot not matched βœ…
Per bot a chat linked on bot 1 messages bot 2 separate identity and ticket βœ…
Dedupe message #1 to bot 1 and #1 to bot 2; a real retry both kept; retry dropped βœ…
Scoped move placeholder with a ticket on each bot, matched on bot 2 only bot 2's ticket moved; placeholder kept for bot 1 βœ…
Origin new Telegram ticket Telegram βœ…
Self-test simulate ticket created and removed; no person or link left behind βœ…
Save no secret; bad characters; no token; valid; rename keeps secrets refused Γ—3, saved, kept βœ…
Connect http address; https with a fake token (reached Telegram, refused); unsaved; no session; no permission each correct βœ…
Attach .php; PHP code named .png; real PNG with a fake token (reached Telegram, refused); no permission refused Γ—2; rolled back - row, attachment and file counts identical before and after; refused βœ…
Signed link valid; expired; tampered expiry; token for another id; incoming attachment with its own valid token; old-scheme token 200 as image/png with nosniff although the stored type was text/html; 410; 403; 403; 404; 403 βœ…
Browser (headless Chrome) settings dialog: Connect shown, curl box gone, save first after typing, Telegram's answer shown; ticket reply box: title, Attach, Send βœ…, no script errors
Existing suites security-findings with live checks 193/193, web-exposure-guard 12/12, config-not-load-bearing 13/13, sso-dangling-link 12/12, outbound-email-mime 26/26, db-verify-indexes 32/32, schema drift up to date, i18n gate OK, Feature Bingo 592 cards / 0 malformed pass

Not tested here: a round trip with a real bot and a real phone (the token was fake, so every outbound call reached Telegram and was refused - which proves the request, not the delivery), and WhatsApp attachments against live Meta or Twilio accounts.


Known gaps

  • National vs international numbers. 07700 900123 and 447700900123 don't match, because converting needs the country. A missed match costs an analyst a click; a wrong one hands over someone's tickets. A per-install default country could make this safe later.
  • No screen for a chat's link. You can see the outcome in the Audit window, but not view or undo a link from the UI. The Unlink built for SSO in Tickets β†’ Users is the obvious model.
  • WhatsApp threads by sender across numbers. One person messaging two of your WhatsApp numbers threads into one ticket, whichever company each number belongs to. This existed before Telegram and was left unchanged; the Telegram fix in section 4 shows the shape of a fix.
  • Edited messages are ignored. An edit has the same message id, so dedupe drops it. That is fine for a helpdesk, but it means an edit never reaches the ticket.
  • Group chats work, but the whole group is one requester, because the chat id is the conversation.
  • Strings owed. The Connect strings are English only so far; the other 13 locales fall back to English for them.

Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally