diff --git a/AGENTS.md b/AGENTS.md index e65a058..b37ded2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index b470704..f843e8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: `bold`, `italic`, ``, + ``, `
`, `` 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.
diff --git a/README.md b/README.md
index 665cecc..78f443b 100644
--- a/README.md
+++ b/README.md
@@ -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 "hello from tgctl" --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
 
diff --git a/docs/commands.md b/docs/commands.md
index 1253518..03e4814 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -152,3 +152,4 @@ For write commands:
 | `--idempotency-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 ` | 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 ``, ``, ``, ``, `
`, ``. `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,
         )