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/chats.py ADDED
@@ -0,0 +1,229 @@
1
+ """Chat / search / contacts / profile commands."""
2
+
3
+ from telethon import functions, types
4
+
5
+ from .client import run_with_client
6
+ from .output import TgError, fmt_message_row, json_pp
7
+ from .resolve import resolve_chat
8
+
9
+
10
+ async def cmd_me(args) -> None:
11
+ async with run_with_client(args) as client:
12
+ me = await client.get_me()
13
+ print(json_pp(me.to_dict() if me else {}))
14
+
15
+
16
+ async def cmd_chats(args) -> None:
17
+ async with run_with_client(args) as client:
18
+ dialogs = await client.get_dialogs(limit=args.limit)
19
+ q = args.query.lower() if args.query else None
20
+ rows = []
21
+ for d in dialogs:
22
+ kind = kind_of(d)
23
+ if args.type and kind != args.type:
24
+ continue
25
+ if args.unread and not d.unread_count:
26
+ continue
27
+ if args.archived is not None and bool(d.archived) != args.archived:
28
+ continue
29
+ name = d.name or ""
30
+ uname = getattr(d.entity, "username", None) or ""
31
+ if q and q not in f"{name} {uname}".lower():
32
+ continue
33
+ rows.append((d, kind, name, uname))
34
+ if args.json:
35
+ print(
36
+ json_pp(
37
+ [
38
+ {
39
+ "id": d.id,
40
+ "name": name,
41
+ "username": uname,
42
+ "type": kind,
43
+ "unread": d.unread_count,
44
+ "muted": muted_of(d),
45
+ "archived": d.archived,
46
+ }
47
+ for d, kind, name, uname in rows
48
+ ]
49
+ )
50
+ )
51
+ return
52
+ if not rows:
53
+ print("(no chats matched)")
54
+ return
55
+ for d, kind, name, uname in rows:
56
+ mark = "M" if muted_of(d) else " "
57
+ unread_s = f"({d.unread_count})" if d.unread_count else ""
58
+ print(f"{kind:<8} {d.id:>14} {mark}{unread_s:<5} {name} {'@' + uname if uname else ''}")
59
+
60
+
61
+ def kind_of(d) -> str:
62
+ if d.is_group:
63
+ return "group"
64
+ if d.is_channel:
65
+ return "channel"
66
+ return "user"
67
+
68
+
69
+ def muted_of(d) -> bool:
70
+ # The custom Dialog wrapper drops the mute state; read it from the raw dialog.
71
+ notify = getattr(getattr(d, "dialog", None), "notify_settings", None)
72
+ mute_until = getattr(notify, "mute_until", None)
73
+ return bool(mute_until)
74
+
75
+
76
+ async def cmd_hist(args) -> None:
77
+ async with run_with_client(args) as client:
78
+ entity = await resolve_chat(client, args.chat)
79
+ msgs = await client.get_messages(entity, limit=args.n)
80
+ if not msgs:
81
+ print("(no messages)")
82
+ return
83
+ if args.json:
84
+ print(json_pp([m.to_dict() for m in msgs]))
85
+ return
86
+ for m in msgs:
87
+ print(fmt_message_row(m))
88
+
89
+
90
+ async def cmd_pinned(args) -> None:
91
+ async with run_with_client(args) as client:
92
+ entity = await resolve_chat(client, args.chat)
93
+ # telethon 1.44 removed get_pinned_messages; use the raw search
94
+ res = await client(
95
+ functions.messages.SearchRequest(
96
+ peer=entity,
97
+ q="",
98
+ filter=types.InputMessagesFilterPinned(),
99
+ min_date=None,
100
+ max_date=None,
101
+ offset_id=0,
102
+ add_offset=0,
103
+ limit=10,
104
+ max_id=0,
105
+ min_id=0,
106
+ hash=0,
107
+ )
108
+ )
109
+ msgs = getattr(res, "messages", [])
110
+ if not msgs:
111
+ print("No pinned messages found in this chat.")
112
+ return
113
+ print(json_pp([m.to_dict() for m in msgs]))
114
+
115
+
116
+ async def cmd_search(args) -> None:
117
+ async with run_with_client(args) as client:
118
+ entity = await resolve_chat(client, args.chat)
119
+ msgs = await client.get_messages(entity, search=args.query, limit=args.n)
120
+ if not msgs:
121
+ print("(no matches)")
122
+ return
123
+ print(json_pp([m.to_dict() for m in msgs]))
124
+
125
+
126
+ async def cmd_gsearch(args) -> None:
127
+ async with run_with_client(args) as client:
128
+ req = functions.messages.SearchGlobalRequest(
129
+ q=args.query,
130
+ filter=types.InputMessagesFilterEmpty(),
131
+ min_date=None,
132
+ max_date=None,
133
+ offset_rate=0,
134
+ offset_peer=types.InputPeerEmpty(),
135
+ offset_id=0,
136
+ limit=20,
137
+ )
138
+ results: list = []
139
+ page = args.page
140
+ while page > 0:
141
+ res = await client(req)
142
+ results.extend(getattr(res, "messages", []))
143
+ if not isinstance(res, (types.messages.MessagesSlice, types.messages.ChannelMessages)):
144
+ break
145
+ if not res.next_rate:
146
+ break
147
+ req.offset_rate, req.offset_peer, req.offset_id = (
148
+ res.next_rate,
149
+ res.next_peer,
150
+ res.next_id,
151
+ )
152
+ page -= 1
153
+ if not results:
154
+ print("(no results)")
155
+ return
156
+ print(json_pp([m.to_dict() for m in results]))
157
+
158
+
159
+ async def cmd_contacts(args) -> None:
160
+ async with run_with_client(args) as client:
161
+ res = await client(functions.contacts.GetContactsRequest(hash=0))
162
+ print(json_pp([u.to_dict() for u in res.users]))
163
+
164
+
165
+ async def cmd_profile(args) -> None:
166
+ if not (args.name or args.bio or args.photo):
167
+ raise TgError("nothing to update; pass at least one of --name / --bio / --photo")
168
+ async with run_with_client(args) as client:
169
+ if args.name:
170
+ first, _, last = args.name.partition(" ")
171
+ await client(
172
+ functions.account.UpdateProfileRequest(first_name=first, last_name=last or "")
173
+ )
174
+ if args.bio:
175
+ await client(functions.account.UpdateProfileRequest(about=args.bio))
176
+ if args.photo:
177
+ uploaded = await client.upload_file(args.photo)
178
+ await client(functions.photos.UploadProfilePhotoRequest(file=uploaded))
179
+ me = await client.get_me()
180
+ print(json_pp(me.to_dict() if me else {}))
181
+
182
+
183
+ def setup(subparsers, common=None) -> None:
184
+ parents = [common] if common else []
185
+ sp = subparsers.add_parser("me", parents=parents, help="Show your account info")
186
+ sp.set_defaults(func=cmd_me)
187
+
188
+ sp = subparsers.add_parser(
189
+ "chats", parents=parents, help="List dialogs (optional keyword filter)"
190
+ )
191
+ sp.add_argument("query", nargs="?", default=None, help="filter by name/username")
192
+ sp.add_argument("-n", "--limit", type=int, default=20)
193
+ sp.add_argument("-t", "--type", choices=["user", "group", "channel"])
194
+ sp.add_argument("-u", "--unread", action="store_true", help="unread only")
195
+ sp.add_argument("--archived", action="store_true", default=None, help="archived only")
196
+ sp.set_defaults(func=cmd_chats)
197
+
198
+ sp = subparsers.add_parser(
199
+ "hist", parents=parents, help='Show recent messages, e.g. tg hist "Music Bot" 10'
200
+ )
201
+ sp.add_argument("chat")
202
+ sp.add_argument("n", nargs="?", type=int, default=10, help="count (default 10)")
203
+ sp.set_defaults(func=cmd_hist)
204
+
205
+ sp = subparsers.add_parser("pinned", parents=parents, help="Show pinned messages of a chat")
206
+ sp.add_argument("chat")
207
+ sp.set_defaults(func=cmd_pinned)
208
+
209
+ sp = subparsers.add_parser("search", parents=parents, help="Search messages inside a chat")
210
+ sp.add_argument("chat")
211
+ sp.add_argument("query")
212
+ sp.add_argument("-n", "--limit", type=int, default=20, dest="n")
213
+ sp.set_defaults(func=cmd_search)
214
+
215
+ sp = subparsers.add_parser("gsearch", parents=parents, help="Global search across public chats")
216
+ sp.add_argument("query")
217
+ sp.add_argument("-p", "--page", type=int, default=1)
218
+ sp.set_defaults(func=cmd_gsearch)
219
+
220
+ sp = subparsers.add_parser("contacts", parents=parents, help="List contacts")
221
+ sp.set_defaults(func=cmd_contacts)
222
+
223
+ sp = subparsers.add_parser(
224
+ "profile", parents=parents, help="Update your profile (name / bio / photo)"
225
+ )
226
+ sp.add_argument("--name", default=None, help='first and last name, e.g. "John Doe"')
227
+ sp.add_argument("--bio", default=None)
228
+ sp.add_argument("--photo", default=None, help="path to an image")
229
+ sp.set_defaults(func=cmd_profile)
tgcli/cli.py ADDED
@@ -0,0 +1,101 @@
1
+ """tg — standalone Telegram command-line client (direct Telethon, no MCP)."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import sys
6
+
7
+ from telethon import errors
8
+
9
+ from . import __version__, auth, chats, media, messages, resolve
10
+ from .output import TgError
11
+
12
+
13
+ def build_parser() -> argparse.ArgumentParser:
14
+ common = argparse.ArgumentParser(add_help=False)
15
+ common.add_argument("--account", default=None, help="account label (v1: default only)")
16
+ common.add_argument(
17
+ "--json", action="store_true", help="print raw tool output without line formatting"
18
+ )
19
+
20
+ p = argparse.ArgumentParser(
21
+ prog="tg",
22
+ description="Standalone Telegram command-line client (direct Telethon connection, no MCP).",
23
+ epilog='Tip: messages starting with "-" need "--", e.g. tg send me -- -hello',
24
+ )
25
+ p.add_argument("--version", action="version", version=f"tg {__version__}")
26
+ sub = p.add_subparsers(dest="cmd", required=True)
27
+
28
+ # auth (no --account/--json needed, but harmless to keep consistent)
29
+ sp = sub.add_parser("login", parents=[common], help="Log in (default: QR code)")
30
+ sp.add_argument(
31
+ "--qr", action="store_true", default=False, help="QR login (this is the default)"
32
+ )
33
+ sp.add_argument(
34
+ "--phone",
35
+ nargs="?",
36
+ const="",
37
+ default=None,
38
+ help="phone login instead of QR; optionally pass the number",
39
+ )
40
+ sp.set_defaults(func=auth.cmd_login)
41
+ sp2 = sub.add_parser("logout", parents=[common], help="Log out and remove the session")
42
+ sp2.set_defaults(func=auth.cmd_logout)
43
+
44
+ for mod in (messages, chats, media):
45
+ mod.setup(sub, common)
46
+
47
+ resolve.setup_alias(sub, common)
48
+ return p
49
+
50
+
51
+ def main(argv=None) -> int:
52
+ args = build_parser().parse_args(argv)
53
+ try:
54
+ asyncio.run(args.func(args))
55
+ return 0
56
+ except TgError as e:
57
+ print(f"tg: {e}", file=sys.stderr)
58
+ if e.hint:
59
+ print(f"hint: {e.hint}", file=sys.stderr)
60
+ return e.code
61
+ except errors.AuthKeyDuplicatedError:
62
+ print(
63
+ "tg: session is in use by another connection (AuthKeyDuplicatedError)", file=sys.stderr
64
+ )
65
+ print(
66
+ "hint: this CLI and the MCP daemon must use separate sessions; "
67
+ "run `tg logout` then `tg login --qr` to get a fresh one",
68
+ file=sys.stderr,
69
+ )
70
+ return 2
71
+ except (errors.UnauthorizedError, errors.AuthKeyInvalidError):
72
+ print("tg: session is no longer valid", file=sys.stderr)
73
+ print("hint: log in again with: tg login --qr", file=sys.stderr)
74
+ return 2
75
+ except errors.SessionPasswordNeededError:
76
+ print("tg: two-step verification password required", file=sys.stderr)
77
+ return 2
78
+ except errors.FloodWaitError as e:
79
+ print(f"tg: rate-limited; Telegram asks to wait {e.seconds}s", file=sys.stderr)
80
+ return 1
81
+ except ValueError as e:
82
+ # e.g. entity resolution failures ("Cannot find any entity ...")
83
+ print(f"tg: {e}", file=sys.stderr)
84
+ return 1
85
+ except errors.RPCError as e:
86
+ print(f"tg: Telegram API error: {e}", file=sys.stderr)
87
+ return 1
88
+ except (OSError, ConnectionError) as e:
89
+ print(f"tg: cannot reach Telegram: {e}", file=sys.stderr)
90
+ print(
91
+ "hint: check that the proxy is up (e.g. Clash 127.0.0.1:7890) "
92
+ "and TELEGRAM_PROXY_* in .env",
93
+ file=sys.stderr,
94
+ )
95
+ return 2
96
+ except (KeyboardInterrupt, BrokenPipeError):
97
+ return 130
98
+
99
+
100
+ if __name__ == "__main__":
101
+ sys.exit(main())
tgcli/client.py ADDED
@@ -0,0 +1,46 @@
1
+ """Telethon client factory and per-invocation lifecycle."""
2
+
3
+ import contextlib
4
+ import os
5
+ from collections.abc import AsyncIterator
6
+
7
+ from telethon import TelegramClient
8
+ from telethon.sessions import StringSession
9
+
10
+ from . import config
11
+ from .output import TgError
12
+
13
+
14
+ def build_client(session: str | StringSession, api_id: int, api_hash: str) -> TelegramClient:
15
+ """Build a client with proxy and device identity injected."""
16
+ kwargs: dict = {}
17
+ proxy = config.build_proxy()
18
+ if proxy is None:
19
+ raise TgError(
20
+ "No proxy configured (TELEGRAM_PROXY_TYPE/HOST/PORT are empty)",
21
+ "Telegram is unreachable directly from this network; set the proxy in .env "
22
+ "(e.g. socks5 127.0.0.1:7890 for Clash)",
23
+ code=2,
24
+ )
25
+ kwargs["proxy"] = proxy
26
+ for kw, env in (
27
+ ("device_model", "TELEGRAM_DEVICE_MODEL"),
28
+ ("system_version", "TELEGRAM_SYSTEM_VERSION"),
29
+ ("app_version", "TELEGRAM_APP_VERSION"),
30
+ ):
31
+ if os.environ.get(env):
32
+ kwargs[kw] = os.environ[env]
33
+ return TelegramClient(session, api_id, api_hash, **kwargs)
34
+
35
+
36
+ @contextlib.asynccontextmanager
37
+ async def run_with_client(args) -> AsyncIterator[TelegramClient]:
38
+ """One short-lived client per invocation: connect → yield → disconnect."""
39
+ api_id, api_hash = config.require_credentials()
40
+ session_str = config.load_session(getattr(args, "account", None))
41
+ client = build_client(StringSession(session_str), api_id, api_hash)
42
+ try:
43
+ await client.connect()
44
+ yield client
45
+ finally:
46
+ await client.disconnect()
tgcli/config.py ADDED
@@ -0,0 +1,129 @@
1
+ """Configuration: .env precedence, proxy tuple, session persistence."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from dotenv import load_dotenv
7
+
8
+ from .output import TgError
9
+
10
+ # src/tgcli/config.py -> project root (parents[2]); works for both source
11
+ # checkouts and editable installs (which point at the same file).
12
+ PROJECT_ROOT = Path(__file__).resolve().parents[2]
13
+
14
+ # Precedence (highest wins): real env vars > .env in CWD > .env in project root.
15
+ # load_dotenv never overrides already-set variables, so load project root first.
16
+ load_dotenv(PROJECT_ROOT / ".env")
17
+ load_dotenv(Path.cwd() / ".env")
18
+
19
+
20
+ def require_credentials() -> tuple[int, str]:
21
+ """Return (api_id, api_hash) or raise with a config hint."""
22
+ raw_id = os.environ.get("TELEGRAM_API_ID", "").strip()
23
+ api_hash = os.environ.get("TELEGRAM_API_HASH", "").strip()
24
+ if not raw_id or not api_hash:
25
+ raise TgError(
26
+ "TELEGRAM_API_ID / TELEGRAM_API_HASH are not configured",
27
+ "Get them from https://my.telegram.org/apps and put them in .env",
28
+ code=2,
29
+ )
30
+ try:
31
+ api_id = int(raw_id)
32
+ except ValueError:
33
+ raise TgError("TELEGRAM_API_ID must be an integer", code=2) from None
34
+ return api_id, api_hash
35
+
36
+
37
+ def build_proxy() -> tuple | None:
38
+ """Build the telethon proxy tuple from TELEGRAM_PROXY_* vars.
39
+
40
+ Telethon tuple form: (type, addr, port[, rdns, username, password]).
41
+ NOTE: the 4th element is rdns, NOT username.
42
+ """
43
+ ptype = os.environ.get("TELEGRAM_PROXY_TYPE", "").strip().lower()
44
+ host = os.environ.get("TELEGRAM_PROXY_HOST", "").strip()
45
+ port = os.environ.get("TELEGRAM_PROXY_PORT", "").strip()
46
+ if not (ptype and host and port):
47
+ return None
48
+ user = os.environ.get("TELEGRAM_PROXY_USERNAME", "").strip()
49
+ pw = os.environ.get("TELEGRAM_PROXY_PASSWORD", "").strip()
50
+ rdns_raw = os.environ.get("TELEGRAM_PROXY_RDNS", "true").strip().lower()
51
+ rdns = rdns_raw not in ("0", "false", "no")
52
+ try:
53
+ port_int = int(port)
54
+ except ValueError:
55
+ raise TgError(f"TELEGRAM_PROXY_PORT must be an integer, got: {port}", code=2) from None
56
+ if user:
57
+ return (ptype, host, port_int, rdns, user, pw)
58
+ return (ptype, host, port_int)
59
+
60
+
61
+ def session_env_var(account: str | None) -> str:
62
+ """Env var name holding the session string for an account (v1: default only)."""
63
+ if account is None:
64
+ return "TELEGRAM_SESSION_STRING"
65
+ return f"TELEGRAM_SESSION_STRING_{account.strip().upper()}"
66
+
67
+
68
+ def load_session(account: str | None) -> str:
69
+ """Return the session string, or raise a config hint if not logged in."""
70
+ var = session_env_var(account)
71
+ value = os.environ.get(var, "").strip()
72
+ if not value:
73
+ if account is not None:
74
+ raise TgError(
75
+ f"No session configured for account '{account}'",
76
+ f"Set {var} in .env (v1 supports the default account only)",
77
+ code=2,
78
+ )
79
+ raise TgError(
80
+ "Not logged in: TELEGRAM_SESSION_STRING not found",
81
+ "Run: tg login --qr",
82
+ code=2,
83
+ )
84
+ return value
85
+
86
+
87
+ def write_env(key: str, value: str, comment: str | None = None) -> Path:
88
+ """Rewrite .env in the project root, preserving comments; replace the
89
+ existing key line in place or append. Returns the .env path."""
90
+ env_path = PROJECT_ROOT / ".env"
91
+ lines = env_path.read_text(encoding="utf-8").splitlines() if env_path.exists() else []
92
+ out: list[str] = []
93
+ done = False
94
+ for line in lines:
95
+ if line.startswith(f"{key}="):
96
+ if comment:
97
+ out.append(f"# {comment}")
98
+ out.append(f"{key}={value}")
99
+ done = True
100
+ else:
101
+ out.append(line)
102
+ if not done:
103
+ if out and out[-1].strip():
104
+ out.append("")
105
+ if comment:
106
+ out.append(f"# {comment}")
107
+ out.append(f"{key}={value}")
108
+ env_path.write_text("\n".join(out) + "\n", encoding="utf-8")
109
+ return env_path
110
+
111
+
112
+ def clear_env(key: str) -> None:
113
+ """Remove a key line (and the comment line directly above it) from .env."""
114
+ env_path = PROJECT_ROOT / ".env"
115
+ if not env_path.exists():
116
+ return
117
+ lines = env_path.read_text(encoding="utf-8").splitlines()
118
+ out: list[str] = []
119
+ i = 0
120
+ while i < len(lines):
121
+ if lines[i].startswith(f"{key}="):
122
+ # drop the comment line immediately above, if any
123
+ if out and out[-1].startswith("#"):
124
+ out.pop()
125
+ i += 1
126
+ continue
127
+ out.append(lines[i])
128
+ i += 1
129
+ env_path.write_text("\n".join(out) + "\n", encoding="utf-8")
tgcli/media.py ADDED
@@ -0,0 +1,91 @@
1
+ """Media commands: dl (download), sf (send file/album), voice."""
2
+
3
+ import os
4
+ import re
5
+
6
+ from .client import run_with_client
7
+ from .output import TgError
8
+ from .resolve import resolve_chat
9
+
10
+
11
+ async def cmd_dl(args) -> None:
12
+ async with run_with_client(args) as client:
13
+ entity = await resolve_chat(client, args.chat)
14
+ msgs = await client.get_messages(entity, ids=args.msg_id)
15
+ if not isinstance(msgs, list): # single id returns a bare Message
16
+ msgs = [msgs]
17
+ msg = msgs[0] if msgs else None
18
+ if msg is None or not msg.media:
19
+ raise TgError(
20
+ f"message {args.msg_id} has no media",
21
+ hint=f"check with: tg hist {args.chat} 5",
22
+ )
23
+ if args.out:
24
+ out = os.path.expanduser(args.out)
25
+ else:
26
+ safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(args.chat))
27
+ out = os.path.expanduser(f"~/Downloads/telegram_{safe}_{args.msg_id}")
28
+ # append the real extension when the caller did not give one
29
+ ext = getattr(getattr(msg, "file", None), "ext", None)
30
+ if ext and not os.path.splitext(out)[1]:
31
+ out += ext
32
+ try:
33
+ path = await client.download_media(msg, file=out)
34
+ except OSError as e:
35
+ raise TgError(
36
+ f"cannot write to {out}: {e}",
37
+ "macOS: grant the terminal app access to this folder "
38
+ "(System Settings → Privacy & Security → Files and Folders), "
39
+ "or pass -o with a different path",
40
+ code=2,
41
+ ) from None
42
+ if not path:
43
+ raise TgError("download failed (no data returned)")
44
+ print(f"Media downloaded to {path}")
45
+
46
+
47
+ async def cmd_sf(args) -> None:
48
+ async with run_with_client(args) as client:
49
+ entity = await resolve_chat(client, args.chat)
50
+ files = [os.path.expanduser(f) for f in args.files]
51
+ # single file must be a string; a list of 2-10 becomes an album
52
+ payload = files[0] if len(files) == 1 else files
53
+ sent = await client.send_file(entity, payload, caption=args.caption)
54
+ count = len(sent) if isinstance(sent, list) else 1
55
+ print(f"Sent {count} file(s)")
56
+
57
+
58
+ async def cmd_voice(args) -> None:
59
+ async with run_with_client(args) as client:
60
+ entity = await resolve_chat(client, args.chat)
61
+ await client.send_file(entity, os.path.expanduser(args.file), voice_note=True)
62
+ print("Voice note sent")
63
+
64
+
65
+ def setup(subparsers, common=None) -> None:
66
+ parents = [common] if common else []
67
+ sp = subparsers.add_parser(
68
+ "dl", parents=parents, help="Download media, e.g. tg dl 7381828427 785"
69
+ )
70
+ sp.add_argument("chat")
71
+ sp.add_argument("msg_id", type=int)
72
+ sp.add_argument(
73
+ "-o",
74
+ "--out",
75
+ default=None,
76
+ help="output path (default ~/Downloads/telegram_<chat>_<msgid>)",
77
+ )
78
+ sp.set_defaults(func=cmd_dl)
79
+
80
+ sp = subparsers.add_parser(
81
+ "sf", parents=parents, help="Send files (2-10 files become an album)"
82
+ )
83
+ sp.add_argument("chat")
84
+ sp.add_argument("files", nargs="+")
85
+ sp.add_argument("-c", "--caption", default=None)
86
+ sp.set_defaults(func=cmd_sf)
87
+
88
+ sp = subparsers.add_parser("voice", parents=parents, help="Send a voice note")
89
+ sp.add_argument("chat")
90
+ sp.add_argument("file")
91
+ sp.set_defaults(func=cmd_voice)