telegram-userbot-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tgcli/messages.py ADDED
@@ -0,0 +1,122 @@
1
+ """Messaging commands: send, reply, edit, del, fwd, poll."""
2
+
3
+ import random
4
+
5
+ from telethon import types
6
+
7
+ from .client import run_with_client
8
+ from .output import TgError
9
+ from .resolve import resolve_chat
10
+
11
+
12
+ async def cmd_send(args) -> None:
13
+ async with run_with_client(args) as client:
14
+ entity = await resolve_chat(client, args.chat)
15
+ msg = await client.send_message(entity, " ".join(args.text), parse_mode=args.parse_mode)
16
+ print(f"Message sent (id: {msg.id})")
17
+
18
+
19
+ async def cmd_reply(args) -> None:
20
+ async with run_with_client(args) as client:
21
+ entity = await resolve_chat(client, args.chat)
22
+ msg = await client.send_message(entity, " ".join(args.text), reply_to=args.msg_id)
23
+ print(f"Reply sent (id: {msg.id})")
24
+
25
+
26
+ async def cmd_edit(args) -> None:
27
+ async with run_with_client(args) as client:
28
+ entity = await resolve_chat(client, args.chat)
29
+ await client.edit_message(entity, args.msg_id, args.new_text)
30
+ print("Message edited")
31
+
32
+
33
+ async def cmd_del(args) -> None:
34
+ async with run_with_client(args) as client:
35
+ entity = await resolve_chat(client, args.chat)
36
+ await client.delete_messages(entity, args.ids, revoke=True)
37
+ print(f"Deleted {len(args.ids)} message(s)")
38
+
39
+
40
+ async def cmd_fwd(args) -> None:
41
+ async with run_with_client(args) as client:
42
+ from_entity = await resolve_chat(client, args.from_chat)
43
+ to_entity = await resolve_chat(client, args.to)
44
+ sent = await client.forward_messages(to_entity, args.msg_ids, from_peer=from_entity)
45
+ print(f"Forwarded {len(sent)} message(s)")
46
+
47
+
48
+ async def cmd_poll(args) -> None:
49
+ if len(args.options) < 2:
50
+ raise TgError("a poll needs at least 2 options")
51
+ async with run_with_client(args) as client:
52
+ entity = await resolve_chat(client, args.chat)
53
+ answers = [
54
+ types.PollAnswer(
55
+ text=types.TextWithEntities(text=a, entities=[]),
56
+ option=bytes([i]),
57
+ )
58
+ for i, a in enumerate(args.options)
59
+ ]
60
+ poll = types.Poll(
61
+ id=random.randint(1, 2**31),
62
+ question=types.TextWithEntities(text=args.question, entities=[]),
63
+ answers=answers,
64
+ hash=0,
65
+ closed=False,
66
+ public_voters=args.public,
67
+ quiz=args.quiz,
68
+ multiple_choice=args.multiple,
69
+ )
70
+ msg = await client.send_message(entity, file=types.InputMediaPoll(poll=poll))
71
+ print(f"Poll sent (id: {msg.id})")
72
+
73
+
74
+ def setup(subparsers, common=None) -> None:
75
+ parents = [common] if common else []
76
+ sp = subparsers.add_parser(
77
+ "send", parents=parents, help="Send a message, e.g. tg send me hello"
78
+ )
79
+ sp.add_argument("chat")
80
+ sp.add_argument("text", nargs="+")
81
+ sp.add_argument("-p", "--parse-mode", default=None, choices=["md", "markdown", "html"])
82
+ sp.set_defaults(func=cmd_send)
83
+
84
+ sp = subparsers.add_parser(
85
+ "reply", parents=parents, help="Reply to a message, e.g. tg reply me 12 got it"
86
+ )
87
+ sp.add_argument("chat")
88
+ sp.add_argument("msg_id", type=int)
89
+ sp.add_argument("text", nargs="+")
90
+ sp.set_defaults(func=cmd_reply)
91
+
92
+ sp = subparsers.add_parser("edit", parents=parents, help="Edit your own message")
93
+ sp.add_argument("chat")
94
+ sp.add_argument("msg_id", type=int)
95
+ sp.add_argument("new_text")
96
+ sp.set_defaults(func=cmd_edit)
97
+
98
+ sp = subparsers.add_parser(
99
+ "del", parents=parents, help="Delete messages (revoke for both sides)"
100
+ )
101
+ sp.add_argument("chat")
102
+ sp.add_argument("ids", nargs="+", type=int)
103
+ sp.set_defaults(func=cmd_del)
104
+
105
+ sp = subparsers.add_parser(
106
+ "fwd", parents=parents, help="Forward messages, e.g. tg fwd SRC 1 2 3 DST (last is target)"
107
+ )
108
+ sp.add_argument("from_chat")
109
+ sp.add_argument("msg_ids", nargs="+", type=int)
110
+ sp.add_argument("to")
111
+ sp.set_defaults(func=cmd_fwd)
112
+
113
+ sp = subparsers.add_parser(
114
+ "poll", parents=parents, help='Create a poll, e.g. tg poll me "Q?" a b c'
115
+ )
116
+ sp.add_argument("chat")
117
+ sp.add_argument("question")
118
+ sp.add_argument("options", nargs="+")
119
+ sp.add_argument("--multiple", action="store_true", help="allow multiple answers")
120
+ sp.add_argument("--quiz", action="store_true", help="quiz mode")
121
+ sp.add_argument("--public", action="store_true", help="public votes")
122
+ sp.set_defaults(func=cmd_poll)
tgcli/output.py ADDED
@@ -0,0 +1,46 @@
1
+ """Shared output helpers: errors, JSON pretty-print, row formatters."""
2
+
3
+ import contextlib
4
+ import json
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+
9
+ class TgError(Exception):
10
+ """Command error carrying an optional fix hint and exit code."""
11
+
12
+ def __init__(self, msg: str, hint: str | None = None, code: int = 1):
13
+ super().__init__(msg)
14
+ self.hint = hint
15
+ self.code = code
16
+
17
+
18
+ def json_pp(obj: Any) -> str:
19
+ """Pretty-print JSON-ish data; fall back to a plain string."""
20
+ try:
21
+ if isinstance(obj, str):
22
+ obj = json.loads(obj)
23
+ return json.dumps(obj, ensure_ascii=False, indent=2, default=str)
24
+ except (json.JSONDecodeError, TypeError, ValueError):
25
+ return str(obj)
26
+
27
+
28
+ def fmt_date(dt: Any) -> str:
29
+ """ISO-ish 'YYYY-MM-DD HH:MM' in local time."""
30
+ if not isinstance(dt, datetime):
31
+ return str(dt)[:16].replace("T", " ")
32
+ return dt.astimezone().strftime("%Y-%m-%d %H:%M")
33
+
34
+
35
+ def fmt_message_row(msg: Any) -> str:
36
+ if msg.text:
37
+ body = " ".join(msg.text.split())[:200]
38
+ elif msg.media:
39
+ body = f"[{type(msg.media).__name__}]"
40
+ else:
41
+ body = "[empty]"
42
+ sender = getattr(msg, "sender_id", None) or "?"
43
+ with contextlib.suppress(Exception):
44
+ sender = msg.sender.first_name if msg.sender else sender
45
+ out_mark = ">" if msg.out else " "
46
+ return f"[{msg.id}] {fmt_date(msg.date)} {out_mark}{sender}: {body}"
tgcli/resolve.py ADDED
@@ -0,0 +1,113 @@
1
+ """Chat resolution: aliases, 'me', @username / numeric id, fuzzy dialog match."""
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from .output import TgError
8
+
9
+ _ALIAS_FILE = Path.home() / ".config" / "tg" / "aliases.json"
10
+
11
+
12
+ def _load_aliases() -> dict:
13
+ try:
14
+ return json.loads(_ALIAS_FILE.read_text(encoding="utf-8"))
15
+ except (OSError, json.JSONDecodeError):
16
+ return {}
17
+
18
+
19
+ def _save_aliases(aliases: dict) -> None:
20
+ _ALIAS_FILE.parent.mkdir(parents=True, exist_ok=True)
21
+ _ALIAS_FILE.write_text(
22
+ json.dumps(aliases, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
23
+ )
24
+
25
+
26
+ async def resolve_chat(client, spec: str):
27
+ """Resolve a chat reference to an entity accepted by telethon.
28
+
29
+ Order: alias file → me/saved → numeric id → @username / fuzzy dialog match.
30
+ Dialog matches return the input entity (carries access_hash — a fresh
31
+ session cannot resolve bare user ids it has never seen).
32
+ """
33
+ s = str(spec).strip()
34
+ aliases = _load_aliases()
35
+ while s in aliases and aliases[s] != s:
36
+ s = aliases[s] # follow alias chains
37
+ if s.lower() in ("me", "saved"):
38
+ return "me"
39
+ dialogs = await client.get_dialogs(limit=200)
40
+ if re.fullmatch(r"-?\d+", s):
41
+ want = int(s)
42
+ for d in dialogs:
43
+ if d.id == want:
44
+ return d.input_entity # has access_hash
45
+ return want # not in dialogs; let telethon try (e.g. public channels)
46
+ if s.startswith("@"):
47
+ s = s[1:]
48
+ hits = [d for d in dialogs if s.lower() in (d.name or "").lower()]
49
+ if not hits:
50
+ # look at usernames too
51
+ hits = [
52
+ d
53
+ for d in dialogs
54
+ if d.entity is not None
55
+ and getattr(d.entity, "username", None)
56
+ and s.lower() in d.entity.username.lower()
57
+ ]
58
+ if not hits:
59
+ return s # let telethon resolve the bare username itself
60
+ exact = [d for d in hits if (d.name or "").lower() == s.lower()]
61
+ if len(exact) == 1:
62
+ return exact[0].input_entity
63
+ if len(hits) > 1:
64
+ ids = ", ".join(str(d.id) for d in hits[:5])
65
+ raise TgError(
66
+ f"'{spec}' matches multiple chats [{ids}]; use a more precise name or numeric id"
67
+ )
68
+ return hits[0].input_entity
69
+
70
+
71
+ # ---- alias subcommands (stretch) ----
72
+
73
+
74
+ async def cmd_alias_set(args) -> None:
75
+ aliases = _load_aliases()
76
+ aliases[args.name] = args.target
77
+ _save_aliases(aliases)
78
+ print(f"alias {args.name} → {args.target}")
79
+
80
+
81
+ async def cmd_alias_list(args) -> None:
82
+ aliases = _load_aliases()
83
+ if not aliases:
84
+ print("(no aliases)")
85
+ return
86
+ for name, target in sorted(aliases.items()):
87
+ print(f"{name}\t→ {target}")
88
+
89
+
90
+ async def cmd_alias_rm(args) -> None:
91
+ aliases = _load_aliases()
92
+ if args.name not in aliases:
93
+ raise TgError(f"alias '{args.name}' does not exist")
94
+ del aliases[args.name]
95
+ _save_aliases(aliases)
96
+ print(f"alias {args.name} removed")
97
+
98
+
99
+ def setup_alias(subparsers, common=None) -> None:
100
+ parents = [common] if common else []
101
+ sp = subparsers.add_parser(
102
+ "alias", parents=parents, help="Manage chat aliases (local shortcuts)"
103
+ )
104
+ sub = sp.add_subparsers(dest="alias_cmd", required=True)
105
+ s1 = sub.add_parser("set", help="alias set NAME TARGET")
106
+ s1.add_argument("name")
107
+ s1.add_argument("target")
108
+ s1.set_defaults(func=cmd_alias_set)
109
+ s2 = sub.add_parser("list", help="list aliases")
110
+ s2.set_defaults(func=cmd_alias_list)
111
+ s3 = sub.add_parser("rm", help="alias rm NAME")
112
+ s3.add_argument("name")
113
+ s3.set_defaults(func=cmd_alias_rm)