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.
- telegram_userbot_cli-0.1.0.dist-info/METADATA +126 -0
- telegram_userbot_cli-0.1.0.dist-info/RECORD +16 -0
- telegram_userbot_cli-0.1.0.dist-info/WHEEL +4 -0
- telegram_userbot_cli-0.1.0.dist-info/entry_points.txt +2 -0
- telegram_userbot_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- tgcli/__init__.py +3 -0
- tgcli/__main__.py +6 -0
- tgcli/auth.py +119 -0
- tgcli/chats.py +229 -0
- tgcli/cli.py +101 -0
- tgcli/client.py +46 -0
- tgcli/config.py +129 -0
- tgcli/media.py +91 -0
- tgcli/messages.py +122 -0
- tgcli/output.py +46 -0
- tgcli/resolve.py +113 -0
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)
|