Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,4 @@ make gate # test + diff-check
- Reactions: free accounts can't react in Saved Messages or many groups; `react` returns exit 9 PREMIUM_REQUIRED.
- Filter ids 0 and 1 are reserved server-side (`All chats`, `Archive`); user-created folders start at id 2.
- Backfill respects `--max-messages` (default 100k) and `--max-db-size-mb` (default 500); refuses to start if exceeded.
- Default parse mode for outbound writes is `plain` (literal text) since v1.1.0. Pass `--parse-mode html` or `--parse-mode md` to opt into formatting. Earlier versions fell back to Telethon's implicit Markdown.
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.1.0] - 2026-05-09

### Added
- `--parse-mode {plain,html,md}` flag on `send`, `edit-msg`,
`upload-photo`, `upload-voice`, `upload-video`, `upload-document`.
Send formatted messages: `<b>bold</b>`, `<i>italic</i>`, `<a href>`,
`<code>`, `<pre>`, `<spoiler>` for HTML; `**bold**`, `__italic__`,
`` `code` ``, `[text](url)`, `||spoiler||` for Markdown.
- SDK: `Client().messages.send(..., parse_mode="html")` and a new
`Client().messages.edit(...)` method that didn't exist before.

### Changed
- **Default parse mode for outbound messages is now plain text.**
Previously `tg send` invoked Telethon without specifying
`parse_mode`, which fell back to Telethon's implicit Markdown
parser (interpreting `**bold**`, `` `code` ``, etc.). The new
default is explicit `plain` — WYSIWYG. Pass `--parse-mode md`
to opt back into Markdown.

## [1.0.1] - 2026-05-08

Polish release. No code or behavior changes.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ tg show @username --limit 20
# Send a message (--allow-write required for any Telegram write)
tg send @username "hello from tg-cli" --allow-write

# Format messages as HTML or Markdown
tg send @username "<b>hello</b> from <i>tgctl</i>" --parse-mode html --allow-write
tg send @username "**bold** and \`code\`" --parse-mode md --allow-write

# Pipe stdin as the message body
echo "multi-line\nbody" | tg send @username - --allow-write

Expand Down
1 change: 1 addition & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,4 @@ For write commands:
| `--idempotency-key <key>` | Replay-safe: same key + same command returns the cached envelope without re-calling |
| `--fuzzy` | Allow title-substring chat selectors on a write (otherwise rejected to prevent agent fat-fingering) |
| `--confirm <id>` | Required on destructive commands; must equal the resolved chat/user/session id |
| `--parse-mode {plain,html,md}` | Available on `send`, `edit-msg`, and the four `upload-*` (caption). Default `plain` — text is sent literally. `html` allows `<b>`, `<i>`, `<a href>`, `<code>`, `<pre>`, `<spoiler>`. `md` allows `**bold**`, `__italic__`, `` `code` ``, `[text](url)`, `\|\|spoiler\|\|`. v1.1.0+ |
34 changes: 34 additions & 0 deletions tests/tgcli/test_phase12_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def _args(**kw):
"json": True,
"human": False,
"caption": None,
"parse_mode": "plain",
"reply_to": None,
"silent": False,
"ttl": None,
Expand Down Expand Up @@ -234,3 +235,36 @@ def test_upload_pre_audit_logs_absolute_path(monkeypatch, tmp_path):
first_entry = json.loads(audit.read_text().splitlines()[0])
assert first_entry["phase"] == "before"
assert first_entry["payload_preview"]["file_path"] == str(path.resolve())


def test_media_caption_with_parse_mode_html_passes_html(monkeypatch, tmp_path):
_patch_db(monkeypatch, tmp_path)
path = _write(tmp_path / "photo.jpg", b"\xff\xd8\xff\xe0data")
fake = FakeClient()
monkeypatch.setattr(media, "make_client", lambda session_path: fake)
args = _args(
chat="@alpha",
file=str(path),
caption="<b>look</b>",
parse_mode="html",
)

asyncio.run(media._upload_photo_runner(args))

send_call = next(c for c in fake.calls if c[0] == "send_file")
assert send_call[3]["caption"] == "<b>look</b>"
assert send_call[3]["parse_mode"] == "html"


def test_media_no_caption_omits_parse_mode_kwarg(monkeypatch, tmp_path):
"""When --caption is omitted, parse_mode should not be threaded to send_file."""
_patch_db(monkeypatch, tmp_path)
path = _write(tmp_path / "photo.jpg", b"\xff\xd8\xff\xe0data")
fake = FakeClient()
monkeypatch.setattr(media, "make_client", lambda session_path: fake)
args = _args(chat="@alpha", file=str(path), parse_mode="html") # no caption

asyncio.run(media._upload_photo_runner(args))

send_call = next(c for c in fake.calls if c[0] == "send_file")
assert "parse_mode" not in send_call[3]
5 changes: 3 additions & 2 deletions tests/tgcli/test_phase61_topics.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def _args(**kw):
"fuzzy": False,
"json": True,
"human": False,
"parse_mode": "plain",
}
defaults.update(kw)
return argparse.Namespace(**defaults)
Expand Down Expand Up @@ -311,7 +312,7 @@ async def get_entity(self, chat_id):
return f"entity-{chat_id}"

async def send_message(
self, entity, text, *, reply_to=None, silent=False, link_preview=True
self, entity, text, *, reply_to=None, silent=False, link_preview=True, parse_mode=None
):
self.calls.append(("send_message", entity, text, reply_to, silent, link_preview))
return FakeMessage()
Expand Down Expand Up @@ -348,7 +349,7 @@ async def get_entity(self, chat_id):
return f"entity-{chat_id}"

async def send_message(
self, entity, text, *, reply_to=None, silent=False, link_preview=True
self, entity, text, *, reply_to=None, silent=False, link_preview=True, parse_mode=None
):
self.reply_to = reply_to
return FakeMessage()
Expand Down
167 changes: 155 additions & 12 deletions tests/tgcli/test_phase6_writes.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def _args(**kw):
"fuzzy": False,
"json": True,
"human": False,
"parse_mode": "plain",
}
defaults.update(kw)
return argparse.Namespace(**defaults)
Expand Down Expand Up @@ -105,9 +106,18 @@ async def get_entity(self, chat_id):
return f"entity-{chat_id}"

async def send_message(
self, entity, text, *, reply_to=None, silent=False, link_preview=True
self,
entity,
text,
*,
reply_to=None,
silent=False,
link_preview=True,
parse_mode=None,
):
self.calls.append(("send_message", entity, text, reply_to, silent, link_preview))
self.calls.append(
("send_message", entity, text, reply_to, silent, link_preview, parse_mode)
)
return FakeMessage()

async def disconnect(self):
Expand All @@ -120,10 +130,97 @@ async def disconnect(self):
data = asyncio.run(messages._send_runner(args))

assert data["message_id"] == 777
assert ("send_message", "entity-123", "hello", 5, True, False) in fake.calls
assert ("send_message", "entity-123", "hello", 5, True, False, None) in fake.calls
assert fake.calls[-1] == ("disconnect",)


def _make_send_fake(monkeypatch, tmp_path):
"""Helper: seeds a chat + monkeypatches a FakeClient. Returns the fake."""
db = tmp_path / "telegram.sqlite"
_seed_chat(db)
monkeypatch.setattr(messages, "DB_PATH", db)

class FakeMessage:
id = 778

class FakeClient:
def __init__(self):
self.calls = []

async def start(self):
self.calls.append(("start",))

async def get_entity(self, chat_id):
return f"entity-{chat_id}"

async def send_message(
self,
entity,
text,
*,
reply_to=None,
silent=False,
link_preview=True,
parse_mode=None,
):
self.calls.append(
("send_message", entity, text, reply_to, silent, link_preview, parse_mode)
)
return FakeMessage()

async def disconnect(self):
self.calls.append(("disconnect",))

fake = FakeClient()
monkeypatch.setattr(messages, "make_client", lambda session_path: fake)
return fake


def test_send_with_parse_mode_html_passes_html_to_telethon(monkeypatch, tmp_path):
fake = _make_send_fake(monkeypatch, tmp_path)
args = _args(
chat="@alpha",
text="<b>hi</b>",
reply_to=None,
silent=False,
no_webpage=False,
parse_mode="html",
)

asyncio.run(messages._send_runner(args))

sent_call = next(c for c in fake.calls if c[0] == "send_message")
assert sent_call == ("send_message", "entity-123", "<b>hi</b>", None, False, True, "html")


def test_send_with_parse_mode_md_passes_md_to_telethon(monkeypatch, tmp_path):
fake = _make_send_fake(monkeypatch, tmp_path)
args = _args(
chat="@alpha",
text="**bold**",
reply_to=None,
silent=False,
no_webpage=False,
parse_mode="md",
)

asyncio.run(messages._send_runner(args))

sent_call = next(c for c in fake.calls if c[0] == "send_message")
assert sent_call == ("send_message", "entity-123", "**bold**", None, False, True, "md")


def test_send_default_parse_mode_is_plain_passes_none_to_telethon(monkeypatch, tmp_path):
"""v1.1.0 behavior change: default flips from Telethon's implicit MD to plain (None)."""
fake = _make_send_fake(monkeypatch, tmp_path)
args = _args(chat="@alpha", text="C# rocks", reply_to=None, silent=False, no_webpage=False)

asyncio.run(messages._send_runner(args))

sent_call = next(c for c in fake.calls if c[0] == "send_message")
assert sent_call[-1] is None


def test_edit_msg_calls_telethon(monkeypatch, tmp_path):
db = tmp_path / "telegram.sqlite"
_seed_chat(db)
Expand All @@ -142,8 +239,8 @@ async def start(self):
async def get_entity(self, chat_id):
return f"entity-{chat_id}"

async def edit_message(self, entity, message_id, text):
self.calls.append(("edit_message", entity, message_id, text))
async def edit_message(self, entity, message_id, text, *, parse_mode=None):
self.calls.append(("edit_message", entity, message_id, text, parse_mode))
return FakeMessage()

async def disconnect(self):
Expand All @@ -156,10 +253,60 @@ async def disconnect(self):
data = asyncio.run(messages._edit_msg_runner(args))

assert data["message_id"] == 55
assert ("edit_message", "entity-123", 55, "updated") in fake.calls
assert ("edit_message", "entity-123", 55, "updated", None) in fake.calls
assert fake.calls[-1] == ("disconnect",)


def _make_edit_fake(monkeypatch, tmp_path):
db = tmp_path / "telegram.sqlite"
_seed_chat(db)
monkeypatch.setattr(messages, "DB_PATH", db)

class FakeMessage:
id = 56

class FakeClient:
def __init__(self):
self.calls = []

async def start(self):
self.calls.append(("start",))

async def get_entity(self, chat_id):
return f"entity-{chat_id}"

async def edit_message(self, entity, message_id, text, *, parse_mode=None):
self.calls.append(("edit_message", entity, message_id, text, parse_mode))
return FakeMessage()

async def disconnect(self):
self.calls.append(("disconnect",))

fake = FakeClient()
monkeypatch.setattr(messages, "make_client", lambda session_path: fake)
return fake


def test_edit_msg_with_parse_mode_html_passes_html(monkeypatch, tmp_path):
fake = _make_edit_fake(monkeypatch, tmp_path)
args = _args(chat="@alpha", message_id=56, text="<b>updated</b>", parse_mode="html")

asyncio.run(messages._edit_msg_runner(args))

edit_call = next(c for c in fake.calls if c[0] == "edit_message")
assert edit_call == ("edit_message", "entity-123", 56, "<b>updated</b>", "html")


def test_edit_msg_with_parse_mode_md_passes_md(monkeypatch, tmp_path):
fake = _make_edit_fake(monkeypatch, tmp_path)
args = _args(chat="@alpha", message_id=56, text="**updated**", parse_mode="md")

asyncio.run(messages._edit_msg_runner(args))

edit_call = next(c for c in fake.calls if c[0] == "edit_message")
assert edit_call == ("edit_message", "entity-123", 56, "**updated**", "md")


def test_forward_calls_telethon(monkeypatch, tmp_path):
db = tmp_path / "telegram.sqlite"
_seed_chat(db)
Expand Down Expand Up @@ -349,9 +496,7 @@ async def start(self):
async def get_entity(self, chat_id):
return f"entity-{chat_id}"

async def send_message(
self, entity, text, *, reply_to=None, silent=False, link_preview=True
):
async def send_message(self, entity, text, **kw):
self.send_count += 1
return FakeMessage()

Expand Down Expand Up @@ -464,9 +609,7 @@ async def start(self):
async def get_entity(self, chat_id):
return f"entity-{chat_id}"

async def send_message(
self, entity, text, *, reply_to=None, silent=False, link_preview=True
):
async def send_message(self, entity, text, **kw):
return FakeMessage()

async def disconnect(self):
Expand Down
Loading
Loading