Skip to content

Commit 5d7f0a7

Browse files
authored
Merge pull request chigwell#218 from liagha/feat/date-chip
feat(messages): tappable date chips via format_date
2 parents 98fb1e7 + 7731dc3 commit 5d7f0a7

3 files changed

Lines changed: 324 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ The server currently includes 80+ MCP tools grouped into these areas:
4545

4646
- **Accounts:** list configured accounts and route tool calls by account label.
4747
- **Chats and groups:** list chats, inspect metadata, create groups/channels, join or leave chats, invite or remove users, manage admins, bans, default permissions, slow mode, topics, invite links, common chats, read receipts, and message links.
48-
- **Messages:** send, schedule, edit, delete, forward, pin, unpin, mark read, reply, search, inspect context, create polls, manage reactions, inspect inline buttons, and press inline callbacks. `send_message`, `reply_to_message`, and `edit_message` support classic formatting (`parse_mode='md'`/`'html'`) and server-side rich formatting (`parse_mode='rich'`/`'rich_markdown'`/`'rich_html'` — full Markdown/HTML with tables, headings, formulas, and collapsible sections). Rich modes require Telegram Premium on the account; Premium is re-checked on every call, and without it nothing is sent — the tool returns a structured `telegram_premium_required` result so the agent can reformat with classic modes and retry.
48+
- **Messages:** send, schedule, edit, delete, forward, pin, unpin, mark read, reply, search, inspect context, create polls, manage reactions, inspect inline buttons, and press inline callbacks. `send_message`, `reply_to_message`, and `edit_message` support classic formatting (`parse_mode='md'`/`'html'`) and server-side rich formatting (`parse_mode='rich'`/`'rich_markdown'`/`'rich_html'` — full Markdown/HTML with tables, headings, formulas, and collapsible sections). Rich modes require Telegram Premium on the account; Premium is re-checked on every call, and without it nothing is sent — the tool returns a structured `telegram_premium_required` result so the agent can reformat with classic modes and retry. `send_message`, `reply_to_message`, and `edit_message` also accept `format_date` to render a date as a tappable chip.
4949
- **Contacts:** list, search, add, delete, block, unblock, import, export, inspect direct chats, find recent contact interactions, and remember contacts by the names you actually use (see below).
5050

5151
### Remembered contacts
@@ -107,6 +107,16 @@ or `edit_message`, and insert `<tg-emoji emoji-id="ID">EMOJI</tg-emoji>` using i
107107
the entry above becomes `<tg-emoji emoji-id="5368324170671202286">🍷</tg-emoji>`.
108108
Telegram's account restrictions still apply to sending custom emoji.
109109

110+
### Tappable dates and times
111+
112+
Passing `format_date` to `send_message`, `reply_to_message`, or `edit_message`
113+
renders a tappable date/time chip — the same entity Telegram's apps attach when
114+
you type a recognizable date. Give the date text exactly as it appears in the
115+
message: `'13/09'`, `'13/09/2026'`, or `'13/09 17:00'`. The chip opens
116+
copy-date / add-to-calendar / reminder actions. Plain-text messages only —
117+
omit `parse_mode`. For example, `send_message(chat_id, "Lunch 13/09 13:00",
118+
format_date="13/09 13:00")` sends a message whose date opens that menu.
119+
110120
### Incoming Event Feed (callback mode, Claude Code only)
111121

112122
By default, an agent waits for replies by calling `wait_for_settled_message`, which blocks up to the MCP tool timeout and must be re-called — that works everywhere (Codex, Cursor, etc.) and is unchanged.

‎telegram_mcp/tools/messages.py‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,56 @@ async def _edit_rich(cl, entity, message_id: int, text: str, parse_mode: str):
478478
)
479479

480480

481+
def _chip_conflict(parse_mode):
482+
"""Return why format_date cannot combine with parse_mode, or None when it can."""
483+
if parse_mode:
484+
return "format_date needs plain-text messages (leave parse_mode unset)."
485+
return None
486+
487+
488+
def _date_entity(message: str, format_date: str):
489+
"""Return (entity, None) marking format_date as a tappable chip, or (None, error)."""
490+
idx = message.find(format_date)
491+
if idx < 0:
492+
return None, f"format_date '{format_date}' was not found in the message text."
493+
tokens = format_date.split()
494+
parts = tokens[0].split("/")
495+
if len(parts) not in (2, 3) or any(not p.isdigit() for p in parts):
496+
return (
497+
None,
498+
f"format_date '{format_date}' must look like 13/09, 13/09/2026 or 13/09 17:00.",
499+
)
500+
clock = None
501+
if len(tokens) == 2:
502+
clock = tokens[1].split(":")
503+
if len(clock) != 2 or any(not p.isdigit() for p in clock):
504+
return (
505+
None,
506+
f"format_date '{format_date}' must look like 13/09, 13/09/2026 or 13/09 17:00.",
507+
)
508+
if len(tokens) > 2:
509+
return (
510+
None,
511+
f"format_date '{format_date}' must look like 13/09, 13/09/2026 or 13/09 17:00.",
512+
)
513+
try:
514+
day, month = int(parts[0]), int(parts[1])
515+
year = int(parts[2]) if len(parts) == 3 else datetime.now().year
516+
hour, minute = (int(clock[0]), int(clock[1])) if clock else (0, 0)
517+
date = datetime(year, month, day, hour, minute).astimezone()
518+
except ValueError:
519+
return None, f"format_date '{format_date}' is not a valid date."
520+
entity = types.MessageEntityFormattedDate(
521+
offset=len(message[:idx].encode("utf-16-le")) // 2,
522+
length=len(format_date.encode("utf-16-le")) // 2,
523+
date=date,
524+
short_date=len(parts) == 2,
525+
long_date=len(parts) == 3,
526+
short_time=clock is not None,
527+
)
528+
return entity, None
529+
530+
481531
@mcp.tool(
482532
annotations=ToolAnnotations(title="Send Message", openWorldHint=True, destructiveHint=True)
483533
)
@@ -487,6 +537,7 @@ async def send_message(
487537
chat_id: Union[int, str],
488538
message: str,
489539
parse_mode: Optional[str] = None,
540+
format_date: Optional[str] = None,
490541
account: str = None,
491542
) -> str:
492543
"""
@@ -495,9 +546,13 @@ async def send_message(
495546
<tg-emoji emoji-id="ID">EMOJI</tg-emoji>, using the returned id and emoji.
496547
HTML-escape the emoji and other literal text. Custom emoji availability is
497548
subject to Telegram's account restrictions.
549+
format_date renders a tappable chip (copy / add-to-calendar / reminder) over
550+
the date text given verbatim: '13/09', '13/09/2026', or '13/09 17:00'.
498551
Args:
499552
chat_id: The ID or username of the chat.
500553
message: The message content to send.
554+
format_date: Exact date text in the message to render as a tappable date chip.
555+
Plain-text messages only — leave parse_mode unset.
501556
parse_mode: Optional formatting mode. Use 'html' for HTML tags (<b>, <i>, <code>, <pre>,
502557
<a href="...">), 'md' or 'markdown' for Markdown (**bold**, __italic__, `code`,
503558
```pre```), or omit for plain text. Use 'rich'/'rich_markdown' for full
@@ -512,7 +567,28 @@ async def send_message(
512567
cl = get_client(account)
513568
entity = await resolve_entity(chat_id, cl)
514569
if parse_mode and parse_mode.lower() in RICH_PARSE_MODES:
570+
conflict = _chip_conflict(format_date)
571+
if conflict:
572+
return conflict
515573
return await _send_rich(cl, entity, message, parse_mode.lower())
574+
if format_date:
575+
conflict = _chip_conflict(parse_mode)
576+
if conflict:
577+
return conflict
578+
chip, chip_error = _date_entity(message, format_date)
579+
if chip_error:
580+
return chip_error
581+
import random
582+
583+
await cl(
584+
functions.messages.SendMessageRequest(
585+
peer=entity,
586+
message=message,
587+
random_id=random.randint(0, 2**62),
588+
entities=[chip],
589+
)
590+
)
591+
return "Message sent successfully."
516592
await cl.send_message(entity, message, parse_mode=parse_mode)
517593
return "Message sent successfully."
518594
except Exception as e:
@@ -1513,17 +1589,22 @@ async def edit_message(
15131589
message_id: int,
15141590
new_text: str,
15151591
parse_mode: Optional[str] = None,
1592+
format_date: Optional[str] = None,
15161593
account: str = None,
15171594
) -> str:
15181595
"""
15191596
Edit a message you sent.
15201597
Reuse custom_emojis from message-reading tools with parse_mode='html' and
15211598
<tg-emoji emoji-id="ID">EMOJI</tg-emoji>, using the returned id and emoji.
15221599
HTML-escape the emoji and other literal text.
1600+
format_date renders a tappable chip (copy / add-to-calendar / reminder) over
1601+
the date text given verbatim: '13/09', '13/09/2026', or '13/09 17:00'.
15231602
Args:
15241603
chat_id: The ID or username of the chat.
15251604
message_id: The ID of the message to edit.
15261605
new_text: The replacement text.
1606+
format_date: Exact date text in the new_text to render as a tappable date chip.
1607+
Plain-text messages only — leave parse_mode unset.
15271608
parse_mode: Optional formatting mode — same values as send_message: 'md'/'markdown',
15281609
'html', or 'rich'/'rich_markdown'/'rich_html' for full server-side formatting
15291610
(tables, headings, formulas; REQUIRES Telegram Premium — without it nothing is
@@ -1535,7 +1616,26 @@ async def edit_message(
15351616
cl = get_client(account)
15361617
entity = await resolve_entity(chat_id, cl)
15371618
if parse_mode and parse_mode.lower() in RICH_PARSE_MODES:
1619+
conflict = _chip_conflict(format_date)
1620+
if conflict:
1621+
return conflict
15381622
return await _edit_rich(cl, entity, message_id, new_text, parse_mode.lower())
1623+
if format_date:
1624+
conflict = _chip_conflict(parse_mode)
1625+
if conflict:
1626+
return conflict
1627+
chip, chip_error = _date_entity(new_text, format_date)
1628+
if chip_error:
1629+
return chip_error
1630+
await cl(
1631+
functions.messages.EditMessageRequest(
1632+
peer=entity,
1633+
id=message_id,
1634+
message=new_text,
1635+
entities=[chip],
1636+
)
1637+
)
1638+
return f"Message {message_id} edited."
15391639
# Only pass parse_mode when the caller set it: Telethon treats an explicit
15401640
# None as "disable parsing", while omitting the argument uses its default
15411641
# parser. Passing None unconditionally would turn previously formatted
@@ -1767,17 +1867,22 @@ async def reply_to_message(
17671867
message_id: int,
17681868
text: str,
17691869
parse_mode: Optional[str] = None,
1870+
format_date: Optional[str] = None,
17701871
account: str = None,
17711872
) -> str:
17721873
"""
17731874
Reply to a specific message in a chat.
17741875
Reuse custom_emojis from message-reading tools with parse_mode='html' and
17751876
<tg-emoji emoji-id="ID">EMOJI</tg-emoji>, using the returned id and emoji.
17761877
HTML-escape the emoji and other literal text.
1878+
format_date renders a tappable chip (copy / add-to-calendar / reminder) over
1879+
the date text given verbatim: '13/09', '13/09/2026', or '13/09 17:00'.
17771880
Args:
17781881
chat_id: The chat ID or username.
17791882
message_id: The message ID to reply to.
17801883
text: The reply text.
1884+
format_date: Exact date text in the reply to render as a tappable date chip.
1885+
Plain-text messages only — leave parse_mode unset.
17811886
parse_mode: Optional formatting mode — same values as send_message: 'md'/'markdown',
17821887
'html', or 'rich'/'rich_markdown'/'rich_html' for full server-side formatting
17831888
(tables, headings, formulas; REQUIRES Telegram Premium — without it nothing is
@@ -1787,7 +1892,29 @@ async def reply_to_message(
17871892
cl = get_client(account)
17881893
entity = await resolve_entity(chat_id, cl)
17891894
if parse_mode and parse_mode.lower() in RICH_PARSE_MODES:
1895+
conflict = _chip_conflict(format_date)
1896+
if conflict:
1897+
return conflict
17901898
return await _send_rich(cl, entity, text, parse_mode.lower(), reply_to=message_id)
1899+
if format_date:
1900+
conflict = _chip_conflict(parse_mode)
1901+
if conflict:
1902+
return conflict
1903+
chip, chip_error = _date_entity(text, format_date)
1904+
if chip_error:
1905+
return chip_error
1906+
import random
1907+
1908+
await cl(
1909+
functions.messages.SendMessageRequest(
1910+
peer=entity,
1911+
message=text,
1912+
random_id=random.randint(0, 2**62),
1913+
reply_to=types.InputReplyToMessage(reply_to_msg_id=message_id),
1914+
entities=[chip],
1915+
)
1916+
)
1917+
return f"Replied to message {message_id} in chat {chat_id}."
17911918
await cl.send_message(entity, text, reply_to=message_id, parse_mode=parse_mode)
17921919
return f"Replied to message {message_id} in chat {chat_id}."
17931920
except Exception as e:

0 commit comments

Comments
 (0)