`, ``, ``. `md` allows `**bold**`, `__italic__`, `` `code` ``, `[text](url)`, `\|\|spoiler\|\|`. v1.1.0+ |
diff --git a/tests/tgcli/test_phase12_media.py b/tests/tgcli/test_phase12_media.py
index 898a2bd..b2c1856 100644
--- a/tests/tgcli/test_phase12_media.py
+++ b/tests/tgcli/test_phase12_media.py
@@ -29,6 +29,7 @@ def _args(**kw):
"json": True,
"human": False,
"caption": None,
+ "parse_mode": "plain",
"reply_to": None,
"silent": False,
"ttl": None,
@@ -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="look",
+ 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"] == "look"
+ 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]
diff --git a/tests/tgcli/test_phase61_topics.py b/tests/tgcli/test_phase61_topics.py
index d7e8898..76ea133 100644
--- a/tests/tgcli/test_phase61_topics.py
+++ b/tests/tgcli/test_phase61_topics.py
@@ -26,6 +26,7 @@ def _args(**kw):
"fuzzy": False,
"json": True,
"human": False,
+ "parse_mode": "plain",
}
defaults.update(kw)
return argparse.Namespace(**defaults)
@@ -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()
@@ -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()
diff --git a/tests/tgcli/test_phase6_writes.py b/tests/tgcli/test_phase6_writes.py
index 327f9fd..ad2f0bb 100644
--- a/tests/tgcli/test_phase6_writes.py
+++ b/tests/tgcli/test_phase6_writes.py
@@ -28,6 +28,7 @@ def _args(**kw):
"fuzzy": False,
"json": True,
"human": False,
+ "parse_mode": "plain",
}
defaults.update(kw)
return argparse.Namespace(**defaults)
@@ -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):
@@ -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="hi",
+ 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", "hi", 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)
@@ -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):
@@ -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="updated", 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, "updated", "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)
@@ -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()
@@ -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):
diff --git a/tests/tgcli/test_sdk.py b/tests/tgcli/test_sdk.py
index 92557e4..827356a 100644
--- a/tests/tgcli/test_sdk.py
+++ b/tests/tgcli/test_sdk.py
@@ -175,3 +175,71 @@ def test_admin_chat_title_without_allow_write_raises():
c = Client()
with pytest.raises(WriteDisallowed):
c.admin.chat_title(chat=-1001234567890, title="X")
+
+
+def test_sdk_messages_send_with_parse_mode_html_dry_run(tmp_path, monkeypatch):
+ """SDK forwards parse_mode to the runner; dry-run reflects it in the payload."""
+ from tgcli.db import connect
+
+ db = tmp_path / "db.sqlite"
+ con = connect(db)
+ con.execute(
+ "INSERT INTO tg_chats (chat_id, type, title) VALUES (?, ?, ?)",
+ (12345, "user", "Test"),
+ )
+ con.commit()
+ con.close()
+
+ from tgcli.commands import messages as msg_mod
+
+ monkeypatch.setattr(msg_mod, "DB_PATH", db)
+ monkeypatch.setattr(msg_mod, "AUDIT_PATH", tmp_path / "audit.log")
+
+ from tgcli import Client
+
+ c = Client()
+ result = c.messages.send(
+ chat=12345,
+ text="hi",
+ allow_write=True,
+ dry_run=True,
+ parse_mode="html",
+ )
+ assert result["dry_run"] is True
+ assert result["payload"]["parse_mode"] == "html"
+
+
+def test_sdk_messages_edit_method_exists_and_dry_runs(tmp_path, monkeypatch):
+ """Phase 1.1.0 adds Client().messages.edit(); verify it routes to the runner."""
+ from tgcli.db import connect
+
+ db = tmp_path / "db.sqlite"
+ con = connect(db)
+ con.execute(
+ "INSERT INTO tg_chats (chat_id, type, title) VALUES (?, ?, ?)",
+ (12345, "user", "Test"),
+ )
+ con.commit()
+ con.close()
+
+ from tgcli.commands import messages as msg_mod
+
+ monkeypatch.setattr(msg_mod, "DB_PATH", db)
+ monkeypatch.setattr(msg_mod, "AUDIT_PATH", tmp_path / "audit.log")
+
+ from tgcli import Client
+
+ c = Client()
+ assert hasattr(c.messages, "edit")
+ result = c.messages.edit(
+ chat=12345,
+ message_id=99,
+ text="**updated**",
+ allow_write=True,
+ dry_run=True,
+ parse_mode="md",
+ )
+ assert result["dry_run"] is True
+ assert result["command"] == "edit-msg"
+ assert result["payload"]["parse_mode"] == "md"
+ assert result["payload"]["message_id"] == 99
diff --git a/tgcli/__init__.py b/tgcli/__init__.py
index 2f80b59..663a846 100644
--- a/tgcli/__init__.py
+++ b/tgcli/__init__.py
@@ -1,6 +1,6 @@
"""Telegram agent CLI."""
-__version__ = "1.0.1"
+__version__ = "1.1.0"
from tgcli.sdk import Client
diff --git a/tgcli/commands/media.py b/tgcli/commands/media.py
index e27a516..ce08508 100644
--- a/tgcli/commands/media.py
+++ b/tgcli/commands/media.py
@@ -56,6 +56,12 @@ def _add_media_args(parser: argparse.ArgumentParser, *, allow_ttl: bool) -> None
parser.add_argument("chat", help="Chat id, @username, or fuzzy title with --fuzzy")
parser.add_argument("file", help="Local file path to upload")
parser.add_argument("--caption", default=None, help="Optional media caption")
+ parser.add_argument(
+ "--parse-mode",
+ choices=["plain", "html", "md"],
+ default="plain",
+ help="Parse caption as HTML or Markdown. Default: plain. Ignored when --caption is omitted.",
+ )
parser.add_argument(
"--reply-to", type=int, default=None, help="Reply to this Telegram message id"
)
@@ -159,6 +165,9 @@ def _send_file_kwargs(args, kind: MediaKind, mime_type: str | None) -> dict[str,
"reply_to": args.reply_to,
"silent": bool(args.silent),
}
+ if args.caption is not None:
+ parse_mode_arg = getattr(args, "parse_mode", "plain")
+ kwargs["parse_mode"] = None if parse_mode_arg == "plain" else parse_mode_arg
if kind in {"photo", "video"} and getattr(args, "ttl", None) is not None:
kwargs["ttl"] = args.ttl
attrs = _media_attributes(kind)
diff --git a/tgcli/commands/messages.py b/tgcli/commands/messages.py
index 7c5dc06..dbd2ae0 100644
--- a/tgcli/commands/messages.py
+++ b/tgcli/commands/messages.py
@@ -122,6 +122,12 @@ def register(sub: argparse._SubParsersAction) -> None:
)
snd.add_argument("--silent", action="store_true", help="Send without notification")
snd.add_argument("--no-webpage", action="store_true", help="Disable link preview")
+ snd.add_argument(
+ "--parse-mode",
+ choices=["plain", "html", "md"],
+ default="plain",
+ help="Parse text as HTML or Markdown. Default: plain (no parsing).",
+ )
add_write_flags(snd, destructive=False)
add_output_flags(snd)
snd.set_defaults(func=run_send)
@@ -130,6 +136,12 @@ def register(sub: argparse._SubParsersAction) -> None:
edit.add_argument("chat", help="Chat id, @username, or fuzzy title with --fuzzy")
edit.add_argument("message_id", type=int, help="Telegram message id to edit")
edit.add_argument("text", help="Replacement text, or '-' to read from stdin")
+ edit.add_argument(
+ "--parse-mode",
+ choices=["plain", "html", "md"],
+ default="plain",
+ help="Parse text as HTML or Markdown. Default: plain (no parsing).",
+ )
add_write_flags(edit, destructive=False)
add_output_flags(edit)
edit.set_defaults(func=run_edit_msg)
@@ -782,6 +794,8 @@ async def _send_runner(args) -> dict[str, Any]:
reply_to, warnings = _topic_reply_to(
reply_to=args.reply_to, topic=getattr(args, "topic", None)
)
+ parse_mode_arg = getattr(args, "parse_mode", "plain")
+ parse_mode = None if parse_mode_arg == "plain" else parse_mode_arg
payload = {
"chat": chat,
"text": text,
@@ -789,6 +803,7 @@ async def _send_runner(args) -> dict[str, Any]:
"topic_id": getattr(args, "topic", None),
"silent": bool(args.silent),
"link_preview": not bool(args.no_webpage),
+ "parse_mode": parse_mode,
"telethon_method": "client.send_message",
"warnings": warnings,
}
@@ -817,6 +832,7 @@ async def _send_runner(args) -> dict[str, Any]:
reply_to=reply_to,
silent=bool(args.silent),
link_preview=not bool(args.no_webpage),
+ parse_mode=parse_mode,
)
data = {
"chat": chat,
@@ -859,10 +875,13 @@ async def _edit_msg_runner(args) -> dict[str, Any]:
data["idempotent_replay"] = True
return data
chat = _resolve_write_chat(con, args, args.chat)
+ parse_mode_arg = getattr(args, "parse_mode", "plain")
+ parse_mode = None if parse_mode_arg == "plain" else parse_mode_arg
payload = {
"chat": chat,
"message_id": int(args.message_id),
"text": text,
+ "parse_mode": parse_mode,
"telethon_method": "client.edit_message",
}
if args.dry_run:
@@ -882,7 +901,9 @@ async def _edit_msg_runner(args) -> dict[str, Any]:
await client.start()
try:
entity = await client.get_entity(chat["chat_id"])
- edited = await client.edit_message(entity, int(args.message_id), text)
+ edited = await client.edit_message(
+ entity, int(args.message_id), text, parse_mode=parse_mode
+ )
data = {
"chat": chat,
"message_id": int(getattr(edited, "id", args.message_id)),
diff --git a/tgcli/sdk.py b/tgcli/sdk.py
index b002330..cb553ee 100644
--- a/tgcli/sdk.py
+++ b/tgcli/sdk.py
@@ -42,6 +42,7 @@ def _ns(**kwargs: Any) -> SimpleNamespace:
"topic": None,
"silent": False,
"no_webpage": False,
+ "parse_mode": "plain",
"include_deleted": False,
"reverse": False,
"limit": 50,
@@ -171,6 +172,7 @@ def send(
topic: int | None = None,
silent: bool = False,
no_webpage: bool = False,
+ parse_mode: str = "plain",
) -> dict[str, Any]:
from tgcli.commands.messages import _send_runner
@@ -186,6 +188,33 @@ def send(
topic=topic,
silent=silent,
no_webpage=no_webpage,
+ parse_mode=parse_mode,
+ )
+
+ def edit(
+ self,
+ *,
+ chat: int | str,
+ message_id: int,
+ text: str,
+ allow_write: bool = False,
+ idempotency_key: str | None = None,
+ fuzzy: bool = False,
+ dry_run: bool = False,
+ parse_mode: str = "plain",
+ ) -> dict[str, Any]:
+ from tgcli.commands.messages import _edit_msg_runner
+
+ return self._c._call(
+ _edit_msg_runner,
+ chat=chat,
+ message_id=message_id,
+ text=text,
+ allow_write=allow_write,
+ idempotency_key=idempotency_key,
+ fuzzy=fuzzy,
+ dry_run=dry_run,
+ parse_mode=parse_mode,
)