Telegram HIL Channel
Telegram's Bot API is one of the simplest and most developer-friendly HIL channels.
InlineKeyboardMarkup with callback_data carries the
correlation key directly. No 24-hour window restrictions — bots can message users
at any time after they have started the bot.
How Telegram Inline Keyboards Work
When you send a message with an InlineKeyboardMarkup, Telegram renders
buttons beneath the message. When a user taps a button, Telegram sends a
callback_query update to your webhook. The callback_query.data
field contains whatever string you put in callback_data on the button —
returned verbatim, up to 64 bytes.
A GUID is 36 bytes. Adding a short prefix (e.g., A|) costs 2 bytes.
A prefixed GUID is 38 bytes — comfortably within the 64-byte limit.
Building the Message
private object BuildTelegramMessage(
IReadOnlyList<ResolvedHilField> fields,
string resId,
long chatId)
{
var lines = new List<string> { "📋 *Approval Required*\n" };
foreach (var field in fields)
{
if (field.DisplayMode == HilDisplayMode.Concealed) continue;
string val = field.DisplayMode == HilDisplayMode.ReadableMasked
? "••••••••"
: EscapeMarkdown(field.CurrentValue?.ToString() ?? "—");
string editMark = field.InputMode != HilInputMode.Locked ? " ✏️" : "";
lines.Add($"*{EscapeMarkdown(field.Label)}*{editMark}: {val}");
if (!string.IsNullOrEmpty(field.Description))
lines.Add($"_{EscapeMarkdown(field.Description)}_");
}
return new {
chat_id = chatId,
text = string.Join("\n", lines),
parse_mode = "MarkdownV2",
reply_markup = new {
inline_keyboard = new[] {
new object[] {
new { text = "Approve ✅", callback_data = $"A|{resId}" },
new { text = "Reject ❌", callback_data = $"R|{resId}" }
}
}
}
};
}
Sending the Message
// POST to https://api.telegram.org/bot{token}/sendMessage
await _httpClient.PostAsJsonAsync(
$"https://api.telegram.org/bot{settings.BotToken}/sendMessage",
message,
cancellationToken);
return NodeExecutionResult.Suspend("waiting");
Webhook Handler
Telegram sends Update objects to your webhook. A button tap produces an
update with a callback_query. After processing, always call
answerCallbackQuery to dismiss the loading spinner on the button.
[HttpPost("webhooks/telegram/{secret}")]
public async Task<IActionResult> Handle(
string secret,
[FromBody] TelegramUpdate update)
{
// 1. Validate the path secret matches your configured value
if (secret != _settings.WebhookPathSecret)
return Unauthorized();
var query = update.CallbackQuery;
if (query?.Data == null || query.Data.Length < 38) return Ok();
// 2. Parse "A|{guid}" or "R|{guid}"
var action = query.Data[0]; // 'A' or 'R'
var resId = query.Data[2..]; // substring after "A|" or "R|"
// 3. Immediately answer the callback to dismiss the spinner
_ = Task.Run(() => AnswerCallbackQueryAsync(query.Id));
// 4. Build response and continue workflow
var responseData = new Dictionary<string, object>
{
["approved"] = action == 'A',
["respondedBy"] = query.From.Username ?? query.From.Id.ToString()
};
_ = Task.Run(() => _continuationOrchestrator.ContinueAsync(resId, responseData));
return Ok();
}
private async Task AnswerCallbackQueryAsync(string callbackQueryId)
{
await _httpClient.PostAsJsonAsync(
$"https://api.telegram.org/bot{_settings.BotToken}/answerCallbackQuery",
new { callback_query_id = callbackQueryId, text = "Response recorded ✅" });
}
Securing the Webhook Endpoint
Telegram doesn't sign webhook payloads with an HMAC — instead, use a secret path segment in your webhook URL that only Telegram knows:
// Register the webhook with a secret in the URL path
POST https://api.telegram.org/bot{token}/setWebhook
{
"url": "https://your-domain/webhooks/telegram/{randomSecret}",
"allowed_updates": ["callback_query", "message"]
}
Telegram also supports an official secret_token header parameter
(added in Bot API 6.1) — use this if your infrastructure supports it, as it's
cleaner than a path segment.
Handling Editable Fields
For RequiredFromHuman fields, Telegram supports a simple
force-reply approach: send a message asking for the value with
ForceReply enabled. Track the expected field in a session table keyed
by chat_id. When the user replies, the inbound message handler reads the
session and collects the value.
// Send a prompt for a required field
new {
chat_id = chatId,
text = $"Please enter a value for: *{field.Label}*",
parse_mode = "MarkdownV2",
reply_markup = new { force_reply = true, selective = true }
}
Required Configuration
| Config Field | Value |
|---|---|
BotToken | @{secret:TelegramBotToken} |
WebhookPathSecret | @{secret:TelegramWebhookSecret} |
RecipientChatId | @{input:telegramChatId} |