telegram-tgcli 1.0.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.
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: telegram-tgcli
3
+ Version: 1.0.0
4
+ Summary: A powerful, feature-rich Telegram CLI and webhook daemon using Telethon
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: httpx>=0.24.0
8
+ Requires-Dist: telethon>=1.35.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # tgcli
12
+
13
+ A powerful, feature-rich Telegram CLI and webhook daemon built on top of Telethon.
14
+
15
+ ## Installation
16
+
17
+ Install in editable mode or globally using `uv`:
18
+
19
+ ```bash
20
+ uv tool install .
21
+ ```
22
+
23
+ ## Features
24
+
25
+ - **Interactive Login**: `tgcli login`
26
+ - **List Chats**: `tgcli chats [--limit LIMIT]`
27
+ - **Send Messages / Media**:
28
+ - Text: `tgcli send --target TARGET --text "Hello"`
29
+ - Image/Video/Files: `tgcli send --target TARGET --file /path/to/image.jpg --caption "Beautiful sky"`
30
+ - Voice Notes: `tgcli send --target TARGET --file /path/to/audio.ogg --voice`
31
+ - **Edit Messages**: `tgcli edit --target TARGET --message-id ID --text "New Text"`
32
+ - **Delete Messages**: `tgcli delete --target TARGET --message-ids ID1 ID2`
33
+ - **History**: `tgcli history --target TARGET [--limit LIMIT]`
34
+ - **Members**: `tgcli members --target TARGET`
35
+ - **Listen & Webhook Daemon**:
36
+ - Listens to incoming Telegram events and logs them as JSON.
37
+ - Optional `--webhook-url` to forward incoming messages (and download media automatically!) to an external webhook.
38
+ - `tgcli listen --webhook-url https://example.com/webhook`
@@ -0,0 +1,12 @@
1
+ tgcli/__init__.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
2
+ tgcli/cli.py,sha256=RodEx6gVA_h9f1RULjWs02emOEn9RzsUezEb2PnXm-U,5962
3
+ tgcli/client.py,sha256=icRgBA7Jt0lOXTEXWf1QwwIGQ9kYYlnOnNxCl5Kd1_w,6225
4
+ tgcli/config.py,sha256=2pWUTEhRiLSD4INchLXlFp3_IEM6cJIetBwvHr3i30A,439
5
+ tgcli/daemon.py,sha256=P0JxAibXPpV5Vz6jCxU-Q3Xd2Lg4Z20cnCKFCV88B3c,5610
6
+ tgcli/stt.py,sha256=QbWWO95eKa8qrnqlTMZsyDKQ-Sbc5jxUEZeRc9YRL44,1016
7
+ tgcli/webhooks.py,sha256=KN0I8QCDuZGVbj39jBgQVbOeRkflahksLMzuKaSYU5I,6178
8
+ telegram_tgcli-1.0.0.dist-info/METADATA,sha256=wh5nEyqTmlGNR7G5_abAladc1U7IfZrCTSgYm9BX4N4,1388
9
+ telegram_tgcli-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
10
+ telegram_tgcli-1.0.0.dist-info/entry_points.txt,sha256=bGOSON7FJm4Zpm7LTKiW4Hiq1rQoAfCm-FWRWxfH0mU,41
11
+ telegram_tgcli-1.0.0.dist-info/licenses/LICENSE,sha256=KT8EI_4OumMSFsYqUD31iIudR2f0f_QL95LsOXnDlf4,1069
12
+ telegram_tgcli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tgcli = tgcli.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ali Alrabeei
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
tgcli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
tgcli/cli.py ADDED
@@ -0,0 +1,126 @@
1
+ import sys
2
+ import argparse
3
+ import asyncio
4
+ from .client import (
5
+ login_client,
6
+ list_chats,
7
+ send_message_or_file,
8
+ edit_message,
9
+ delete_messages,
10
+ get_history,
11
+ get_members
12
+ )
13
+ from .webhooks import run_listener
14
+ from .daemon import (
15
+ daemon_install,
16
+ daemon_uninstall,
17
+ daemon_status,
18
+ daemon_logs
19
+ )
20
+
21
+ def main():
22
+ parser = argparse.ArgumentParser(description="Telegram CLI - Feature-rich Telegram Client & Webhook Daemon")
23
+ subparsers = parser.add_subparsers(dest="command", required=True)
24
+
25
+ # login
26
+ subparsers.add_parser("login", help="Interactively log in to Telegram")
27
+
28
+ # chats
29
+ chats_parser = subparsers.add_parser("chats", help="List recent chats/dialogs")
30
+ chats_parser.add_argument("--limit", type=int, default=20, help="Max chats to list (default: 20)")
31
+
32
+ # send
33
+ send_parser = subparsers.add_parser("send", help="Send a message or file/media")
34
+ send_parser.add_argument("--target", required=True, help="Username, chat ID, or phone number")
35
+ send_parser.add_argument("--text", help="Text message content")
36
+ send_parser.add_argument("--file", help="Path to a file/media to send")
37
+ send_parser.add_argument("--caption", help="Caption to send with the file (overrides --text if both provided)")
38
+ send_parser.add_argument("--voice", action="store_true", help="Send the file as an audio voice note")
39
+
40
+ # edit
41
+ edit_parser = subparsers.add_parser("edit", help="Edit an existing sent message")
42
+ edit_parser.add_argument("--target", required=True, help="Username, chat ID, or phone number")
43
+ edit_parser.add_argument("--message-id", required=True, type=int, help="ID of the message to edit")
44
+ edit_parser.add_argument("--text", required=True, help="New text content for the message")
45
+
46
+ # delete
47
+ delete_parser = subparsers.add_parser("delete", help="Delete messages")
48
+ delete_parser.add_argument("--target", required=True, help="Username, chat ID, or phone number")
49
+ delete_parser.add_argument("--message-ids", required=True, nargs="+", help="One or more message IDs to delete (space-separated)")
50
+
51
+ # history
52
+ history_parser = subparsers.add_parser("history", help="Retrieve message history")
53
+ history_parser.add_argument("--target", required=True, help="Username, chat ID, or phone number")
54
+ history_parser.add_argument("--limit", type=int, default=10, help="Number of messages to retrieve (default: 10)")
55
+
56
+ # members
57
+ members_parser = subparsers.add_parser("members", help="Retrieve chat members/participants")
58
+ members_parser.add_argument("--target", required=True, help="Username, chat ID, or phone number")
59
+
60
+ # listen (webhook daemon foreground)
61
+ listen_parser = subparsers.add_parser("listen", help="Run webhook daemon and listen for new messages")
62
+ listen_parser.add_argument("--webhook-url", help="URL to send HTTP POST webhook payloads to when messages are received")
63
+ listen_parser.add_argument("--webhook-header", action="append", help="Custom header in 'Name: Value' format (can be specified multiple times)")
64
+ listen_parser.add_argument("--verbose", action="store_true", help="Print debug information and full JSON payloads")
65
+
66
+ # daemon (systemd management)
67
+ daemon_parser = subparsers.add_parser("daemon", help="Manage tgcli systemd background daemon")
68
+ daemon_subparsers = daemon_parser.add_subparsers(dest="daemon_command", required=True)
69
+
70
+ # daemon install
71
+ install_parser = daemon_subparsers.add_parser("install", help="Install & start systemd user daemon")
72
+ install_parser.add_argument("--webhook-url", required=True, help="URL to send webhook payloads to")
73
+ install_parser.add_argument("--webhook-header", action="append", help="Custom header in 'Name: Value' format (can be specified multiple times)")
74
+ install_parser.add_argument("--verbose", action="store_true", help="Enable verbose/debug logging in daemon")
75
+
76
+ # daemon uninstall
77
+ daemon_subparsers.add_parser("uninstall", help="Stop & remove systemd user daemon")
78
+
79
+ # daemon status
80
+ daemon_subparsers.add_parser("status", help="Show daemon systemd status")
81
+
82
+ # daemon logs
83
+ daemon_subparsers.add_parser("logs", help="Show recent daemon logs")
84
+
85
+ args = parser.parse_args()
86
+
87
+ try:
88
+ if args.command == "login":
89
+ asyncio.run(login_client())
90
+ elif args.command == "chats":
91
+ asyncio.run(list_chats(args.limit))
92
+ elif args.command == "send":
93
+ if not args.text and not args.file:
94
+ parser.error("At least one of --text or --file is required for send.")
95
+ asyncio.run(send_message_or_file(
96
+ target=args.target,
97
+ text=args.text,
98
+ file_path=args.file,
99
+ caption=args.caption,
100
+ voice=args.voice
101
+ ))
102
+ elif args.command == "edit":
103
+ asyncio.run(edit_message(args.target, args.message_id, args.text))
104
+ elif args.command == "delete":
105
+ asyncio.run(delete_messages(args.target, args.message_ids))
106
+ elif args.command == "history":
107
+ asyncio.run(get_history(args.target, args.limit))
108
+ elif args.command == "members":
109
+ asyncio.run(get_members(args.target))
110
+ elif args.command == "listen":
111
+ asyncio.run(run_listener(args.webhook_url, args.webhook_header, args.verbose))
112
+ elif args.command == "daemon":
113
+ if args.daemon_command == "install":
114
+ daemon_install(args.webhook_url, args.webhook_header, args.verbose)
115
+ elif args.daemon_command == "uninstall":
116
+ daemon_uninstall()
117
+ elif args.daemon_command == "status":
118
+ daemon_status()
119
+ elif args.daemon_command == "logs":
120
+ daemon_logs()
121
+ except KeyboardInterrupt:
122
+ print("\nExiting...")
123
+ sys.exit(0)
124
+
125
+ if __name__ == "__main__":
126
+ main()
tgcli/client.py ADDED
@@ -0,0 +1,202 @@
1
+ import sys
2
+ import json
3
+ from telethon import TelegramClient, utils
4
+ from .config import API_ID, API_HASH, SESSION_PATH
5
+
6
+ def get_client():
7
+ return TelegramClient(SESSION_PATH, API_ID, API_HASH)
8
+
9
+ async def check_auth(client):
10
+ await client.connect()
11
+ if not await client.is_user_authorized():
12
+ print(json.dumps({"success": False, "error": "Not authorized. Run 'tgcli login' first."}, indent=2))
13
+ await client.disconnect()
14
+ sys.exit(1)
15
+
16
+ async def login_client():
17
+ client = get_client()
18
+ print("Connecting to Telegram and starting interactive authorization...")
19
+ await client.start()
20
+ me = await client.get_me()
21
+ print(f"\nSuccessfully logged in as: {me.first_name} (@{me.username or 'No Username'}) [ID: {me.id}]")
22
+ await client.disconnect()
23
+
24
+ async def list_chats(limit=20):
25
+ client = get_client()
26
+ await check_auth(client)
27
+
28
+ chats_data = []
29
+ async for dialog in client.iter_dialogs(limit=limit):
30
+ entity = dialog.entity
31
+ entity_type = "user" if dialog.is_user else "group" if dialog.is_group else "channel" if dialog.is_channel else "unknown"
32
+ chats_data.append({
33
+ "id": dialog.id,
34
+ "name": dialog.name,
35
+ "username": getattr(entity, 'username', None),
36
+ "type": entity_type,
37
+ "unread_count": dialog.unread_count
38
+ })
39
+ print(json.dumps(chats_data, indent=2, ensure_ascii=False))
40
+ await client.disconnect()
41
+
42
+ async def send_message_or_file(target, text=None, file_path=None, caption=None, voice=False):
43
+ client = get_client()
44
+ await check_auth(client)
45
+
46
+ try:
47
+ resolved_target = int(target)
48
+ except ValueError:
49
+ resolved_target = target
50
+
51
+ try:
52
+ if file_path:
53
+ # Send file (image, video, document, or voice note)
54
+ msg = await client.send_file(
55
+ resolved_target,
56
+ file_path,
57
+ caption=caption or text,
58
+ voice_note=voice
59
+ )
60
+ media_type = "voice" if voice else "file"
61
+ else:
62
+ # Send text message
63
+ msg = await client.send_message(resolved_target, text)
64
+ media_type = None
65
+
66
+ print(json.dumps({
67
+ "success": True,
68
+ "message_id": msg.id,
69
+ "to": target,
70
+ "text": text or caption,
71
+ "media_type": media_type
72
+ }, indent=2))
73
+ except Exception as e:
74
+ print(json.dumps({
75
+ "success": False,
76
+ "error": str(e)
77
+ }, indent=2))
78
+ await client.disconnect()
79
+
80
+ async def edit_message(target, message_id, text):
81
+ client = get_client()
82
+ await check_auth(client)
83
+
84
+ try:
85
+ resolved_target = int(target)
86
+ except ValueError:
87
+ resolved_target = target
88
+
89
+ try:
90
+ msg = await client.edit_message(resolved_target, int(message_id), text)
91
+ print(json.dumps({
92
+ "success": True,
93
+ "message_id": msg.id,
94
+ "to": target,
95
+ "text": text
96
+ }, indent=2))
97
+ except Exception as e:
98
+ print(json.dumps({
99
+ "success": False,
100
+ "error": str(e)
101
+ }, indent=2))
102
+ await client.disconnect()
103
+
104
+ async def delete_messages(target, message_ids):
105
+ client = get_client()
106
+ await check_auth(client)
107
+
108
+ try:
109
+ resolved_target = int(target)
110
+ except ValueError:
111
+ resolved_target = target
112
+
113
+ try:
114
+ # Convert list of IDs to integers
115
+ ids = [int(x) for x in message_ids]
116
+ await client.delete_messages(resolved_target, ids)
117
+ print(json.dumps({
118
+ "success": True,
119
+ "deleted_ids": ids,
120
+ "from": target
121
+ }, indent=2))
122
+ except Exception as e:
123
+ print(json.dumps({
124
+ "success": False,
125
+ "error": str(e)
126
+ }, indent=2))
127
+ await client.disconnect()
128
+
129
+ async def get_history(target, limit=10):
130
+ client = get_client()
131
+ await check_auth(client)
132
+
133
+ try:
134
+ resolved_target = int(target)
135
+ except ValueError:
136
+ resolved_target = target
137
+
138
+ try:
139
+ messages = []
140
+ async for msg in client.iter_messages(resolved_target, limit=limit):
141
+ sender = await msg.get_sender()
142
+ sender_name = utils.get_display_name(sender) if sender else "Unknown"
143
+
144
+ # Check for media
145
+ media_type = None
146
+ if msg.media:
147
+ if msg.photo:
148
+ media_type = "photo"
149
+ elif msg.voice:
150
+ media_type = "voice"
151
+ elif msg.video:
152
+ media_type = "video"
153
+ elif msg.audio:
154
+ media_type = "audio"
155
+ elif msg.document:
156
+ media_type = "document"
157
+ else:
158
+ media_type = "other"
159
+
160
+ messages.append({
161
+ "id": msg.id,
162
+ "date": msg.date.isoformat(),
163
+ "sender_id": msg.sender_id,
164
+ "sender_name": sender_name,
165
+ "text": msg.text or "",
166
+ "media_type": media_type
167
+ })
168
+ print(json.dumps(messages, indent=2, ensure_ascii=False))
169
+ except Exception as e:
170
+ print(json.dumps({
171
+ "success": False,
172
+ "error": str(e)
173
+ }, indent=2))
174
+ await client.disconnect()
175
+
176
+ async def get_members(target):
177
+ client = get_client()
178
+ await check_auth(client)
179
+
180
+ try:
181
+ resolved_target = int(target)
182
+ except ValueError:
183
+ resolved_target = target
184
+
185
+ try:
186
+ members = []
187
+ async for user in client.iter_participants(resolved_target):
188
+ members.append({
189
+ "id": user.id,
190
+ "first_name": user.first_name,
191
+ "last_name": user.last_name or "",
192
+ "username": user.username or "",
193
+ "phone": user.phone or "",
194
+ "is_bot": user.bot
195
+ })
196
+ print(json.dumps(members, indent=2, ensure_ascii=False))
197
+ except Exception as e:
198
+ print(json.dumps({
199
+ "success": False,
200
+ "error": str(e)
201
+ }, indent=2))
202
+ await client.disconnect()
tgcli/config.py ADDED
@@ -0,0 +1,10 @@
1
+ import os
2
+
3
+ API_ID = int(os.getenv("TG_API_ID", "32206730"))
4
+ API_HASH = os.getenv("TG_API_HASH", "c3b333fd816ba995bc0fcf685e3fde60")
5
+ SESSION_PATH = os.getenv("TG_SESSION_PATH", os.path.expanduser("~/.tgcli/tg_session"))
6
+ DOWNLOADS_DIR = os.getenv("TG_DOWNLOADS_DIR", os.path.expanduser("~/.tgcli/downloads"))
7
+
8
+ # Create necessary directories
9
+ os.makedirs(os.path.dirname(SESSION_PATH), exist_ok=True)
10
+ os.makedirs(DOWNLOADS_DIR, exist_ok=True)
tgcli/daemon.py ADDED
@@ -0,0 +1,171 @@
1
+ import os
2
+ import sys
3
+ import shutil
4
+ import subprocess
5
+ import json
6
+
7
+ SERVICE_NAME = "tgcli-daemon"
8
+ USER_SERVICE_DIR = os.path.expanduser("~/.config/systemd/user")
9
+ SERVICE_FILE_PATH = os.path.join(USER_SERVICE_DIR, f"{SERVICE_NAME}.service")
10
+
11
+ def check_systemctl():
12
+ if not shutil.which("systemctl"):
13
+ return False
14
+ return True
15
+
16
+ def run_systemctl_cmd(args):
17
+ try:
18
+ result = subprocess.run(
19
+ ["systemctl", "--user"] + args,
20
+ capture_output=True,
21
+ text=True,
22
+ check=True
23
+ )
24
+ return True, result.stdout, result.stderr
25
+ except subprocess.CalledProcessError as e:
26
+ return False, e.stdout, e.stderr
27
+
28
+ def daemon_install(webhook_url, webhook_headers=None, verbose=False):
29
+ if not check_systemctl():
30
+ print(json.dumps({"success": False, "error": "systemctl not found. Is systemd installed?"}, indent=2))
31
+ return
32
+
33
+ # Find the absolute path to the tgcli binary
34
+ tgcli_path = shutil.which("tgcli")
35
+ if not tgcli_path:
36
+ # Fallback to sys.argv[0] if not found in PATH
37
+ tgcli_path = os.path.abspath(sys.argv[0])
38
+
39
+ verbose_flag = "--verbose" if verbose else ""
40
+
41
+ # Process custom headers into command arguments
42
+ header_args = []
43
+ if webhook_headers:
44
+ for header_str in webhook_headers:
45
+ header_args.append(f'--webhook-header "{header_str}"')
46
+ header_str = " ".join(header_args)
47
+
48
+ # Construct service contents
49
+ service_content = f"""[Unit]
50
+ Description=Telegram CLI Webhook Daemon
51
+ After=network.target
52
+
53
+ [Service]
54
+ ExecStart={tgcli_path} listen --webhook-url {webhook_url} {header_str} {verbose_flag}
55
+ Restart=always
56
+ RestartSec=10
57
+ StandardOutput=journal
58
+ StandardError=journal
59
+
60
+ [Install]
61
+ WantedBy=default.target
62
+ """
63
+
64
+ try:
65
+ os.makedirs(USER_SERVICE_DIR, exist_ok=True)
66
+ with open(SERVICE_FILE_PATH, "w") as f:
67
+ f.write(service_content)
68
+
69
+ # Reload daemon
70
+ success, out, err = run_systemctl_cmd(["daemon-reload"])
71
+ if not success:
72
+ raise Exception(f"daemon-reload failed: {err}")
73
+
74
+ # Enable and start service
75
+ success, out, err = run_systemctl_cmd(["enable", f"{SERVICE_NAME}.service"])
76
+ if not success:
77
+ raise Exception(f"enable failed: {err}")
78
+
79
+ success, out, err = run_systemctl_cmd(["start", f"{SERVICE_NAME}.service"])
80
+ if not success:
81
+ raise Exception(f"start failed: {err}")
82
+
83
+ print(json.dumps({
84
+ "success": True,
85
+ "message": "tgcli daemon installed and started successfully!",
86
+ "service": SERVICE_NAME,
87
+ "path": SERVICE_FILE_PATH,
88
+ "webhook_url": webhook_url,
89
+ "webhook_headers": webhook_headers or [],
90
+ "executable": tgcli_path
91
+ }, indent=2))
92
+
93
+ except Exception as e:
94
+ print(json.dumps({"success": False, "error": str(e)}, indent=2))
95
+
96
+ def daemon_uninstall():
97
+ if not check_systemctl():
98
+ print(json.dumps({"success": False, "error": "systemctl not found."}, indent=2))
99
+ return
100
+
101
+ try:
102
+ # Stop service
103
+ run_systemctl_cmd(["stop", f"{SERVICE_NAME}.service"])
104
+ # Disable service
105
+ run_systemctl_cmd(["disable", f"{SERVICE_NAME}.service"])
106
+
107
+ # Remove file
108
+ if os.path.exists(SERVICE_FILE_PATH):
109
+ os.remove(SERVICE_FILE_PATH)
110
+
111
+ # Reload daemon
112
+ run_systemctl_cmd(["daemon-reload"])
113
+
114
+ print(json.dumps({
115
+ "success": True,
116
+ "message": "tgcli daemon uninstalled and removed successfully!"
117
+ }, indent=2))
118
+ except Exception as e:
119
+ print(json.dumps({"success": False, "error": str(e)}, indent=2))
120
+
121
+ def daemon_status():
122
+ if not check_systemctl():
123
+ print(json.dumps({"success": False, "error": "systemctl not found."}, indent=2))
124
+ return
125
+
126
+ # Check if file exists
127
+ if not os.path.exists(SERVICE_FILE_PATH):
128
+ print(json.dumps({
129
+ "success": True,
130
+ "installed": False,
131
+ "status": "not installed"
132
+ }, indent=2))
133
+ return
134
+
135
+ # Run show and is-active
136
+ success_active, out_active, _ = run_systemctl_cmd(["is-active", f"{SERVICE_NAME}.service"])
137
+ success_enabled, out_enabled, _ = run_systemctl_cmd(["is-enabled", f"{SERVICE_NAME}.service"])
138
+
139
+ # Read properties for details
140
+ _, out_show, _ = run_systemctl_cmd(["show", f"{SERVICE_NAME}.service", "--property=ActiveState,SubState,LoadState,UnitFileState,MainPID"])
141
+
142
+ props = {}
143
+ for line in out_show.strip().split("\n"):
144
+ if "=" in line:
145
+ k, v = line.split("=", 1)
146
+ props[k] = v
147
+
148
+ print(json.dumps({
149
+ "success": True,
150
+ "installed": True,
151
+ "service": SERVICE_NAME,
152
+ "active_state": props.get("ActiveState", "unknown"),
153
+ "sub_state": props.get("SubState", "unknown"),
154
+ "enabled": out_enabled.strip() == "enabled",
155
+ "load_state": props.get("LoadState", "unknown"),
156
+ "main_pid": int(props.get("MainPID", "0")),
157
+ "unit_file_path": SERVICE_FILE_PATH
158
+ }, indent=2))
159
+
160
+ def daemon_logs():
161
+ try:
162
+ # Use journalctl to get recent daemon logs
163
+ result = subprocess.run(
164
+ ["journalctl", "--user", "-u", SERVICE_NAME, "-n", "30", "--no-pager"],
165
+ capture_output=True,
166
+ text=True,
167
+ check=True
168
+ )
169
+ print(result.stdout)
170
+ except Exception as e:
171
+ print(f"Error reading daemon logs: {e}")
tgcli/stt.py ADDED
@@ -0,0 +1,29 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger("tgcli-stt")
4
+
5
+ # Cached model instance
6
+ _whisper_model = None
7
+
8
+ def transcribe_audio(file_path):
9
+ global _whisper_model
10
+ try:
11
+ import whisper
12
+ except ImportError:
13
+ logger.debug("openai-whisper is not installed in the active environment. Skipping transcription.")
14
+ return None
15
+
16
+ try:
17
+ if _whisper_model is None:
18
+ logger.info("Loading local Whisper 'tiny' model for speech-to-text...")
19
+ # 'tiny' is extremely fast, low RAM/CPU usage, and perfect for short voice messages.
20
+ _whisper_model = whisper.load_model("tiny")
21
+
22
+ logger.info(f"Transcribing voice file: {file_path}")
23
+ result = _whisper_model.transcribe(file_path)
24
+ transcription = result.get("text", "").strip()
25
+ logger.info(f"Successfully transcribed: \"{transcription}\"")
26
+ return transcription
27
+ except Exception as e:
28
+ logger.error(f"Error during audio transcription: {e}")
29
+ return None
tgcli/webhooks.py ADDED
@@ -0,0 +1,144 @@
1
+ import os
2
+ import sys
3
+ import json
4
+ import logging
5
+ import httpx
6
+ from datetime import datetime
7
+ from telethon import events, utils
8
+ from .config import DOWNLOADS_DIR
9
+ from .client import get_client, check_auth
10
+ from .stt import transcribe_audio
11
+
12
+ # Set up logging for the webhook daemon
13
+ logging.basicConfig(
14
+ level=logging.INFO,
15
+ format="%(asctime)s [%(levelname)s] %(message)s",
16
+ handlers=[logging.StreamHandler(sys.stdout)]
17
+ )
18
+ logger = logging.getLogger("tgcli-webhook")
19
+
20
+ async def forward_webhook(url, payload, headers=None):
21
+ try:
22
+ async with httpx.AsyncClient() as client:
23
+ response = await client.post(url, json=payload, headers=headers, timeout=10.0)
24
+ if response.status_code >= 200 and response.status_code < 300:
25
+ logger.info(f"Webhook forwarded successfully! Status: {response.status_code}")
26
+ else:
27
+ logger.error(f"Webhook forward failed with status code {response.status_code}: {response.text}")
28
+ except Exception as e:
29
+ logger.error(f"Failed to forward webhook to {url}: {e}")
30
+
31
+ async def run_listener(webhook_url=None, webhook_headers=None, verbose=False):
32
+ if verbose:
33
+ logger.setLevel(logging.DEBUG)
34
+
35
+ client = get_client()
36
+ await check_auth(client)
37
+
38
+ # Parse custom headers
39
+ parsed_headers = {}
40
+ if webhook_headers:
41
+ for header_str in webhook_headers:
42
+ if ":" in header_str:
43
+ k, v = header_str.split(":", 1)
44
+ parsed_headers[k.strip()] = v.strip()
45
+ logger.info(f"Configured custom webhook header -> {k.strip()}: [REDACTED]")
46
+
47
+ me = await client.get_me()
48
+ logger.info(f"Logged in as: {me.first_name} (@{me.username or 'No Username'}) [ID: {me.id}]")
49
+ logger.info("Webhook daemon listening for new messages...")
50
+ if webhook_url:
51
+ logger.info(f"Webhooks will be emitted to: {webhook_url}")
52
+ else:
53
+ logger.info("No webhook URL provided. Messages will be printed as JSON to console.")
54
+
55
+ @client.on(events.NewMessage)
56
+ async def handler(event):
57
+ try:
58
+ msg = event.message
59
+ chat = await event.get_chat()
60
+ sender = await event.get_sender()
61
+
62
+ # Construct sender details
63
+ sender_id = msg.sender_id
64
+ sender_info = {
65
+ "id": sender_id,
66
+ "first_name": getattr(sender, "first_name", ""),
67
+ "last_name": getattr(sender, "last_name", ""),
68
+ "username": getattr(sender, "username", ""),
69
+ "phone": getattr(sender, "phone", ""),
70
+ "display_name": utils.get_display_name(sender) if sender else "Unknown"
71
+ }
72
+
73
+ # Construct chat details
74
+ chat_type = "user" if event.is_private else "group" if event.is_group else "channel" if event.is_channel else "unknown"
75
+ chat_info = {
76
+ "id": event.chat_id,
77
+ "name": utils.get_display_name(chat) if chat else "Unknown",
78
+ "username": getattr(chat, "username", ""),
79
+ "type": chat_type
80
+ }
81
+
82
+ # Media info & Download
83
+ media_info = {"present": False, "type": None, "local_path": None, "transcription": None}
84
+ if msg.media:
85
+ media_info["present"] = True
86
+ if msg.photo:
87
+ media_info["type"] = "photo"
88
+ elif msg.voice:
89
+ media_info["type"] = "voice"
90
+ elif msg.video:
91
+ media_info["type"] = "video"
92
+ elif msg.audio:
93
+ media_info["type"] = "audio"
94
+ elif msg.document:
95
+ media_info["type"] = "document"
96
+ else:
97
+ media_info["type"] = "other"
98
+
99
+ try:
100
+ logger.info(f"Downloading incoming {media_info['type']} media from message ID {msg.id}...")
101
+ # Download media to the configured downloads directory
102
+ local_path = await msg.download_media(file=DOWNLOADS_DIR)
103
+ if local_path:
104
+ media_info["local_path"] = os.path.abspath(local_path)
105
+ logger.info(f"Downloaded media saved to: {media_info['local_path']}")
106
+
107
+ # Auto-transcribe if it's a voice note or audio file
108
+ if media_info["type"] in ["voice", "audio"]:
109
+ transcription = transcribe_audio(media_info["local_path"])
110
+ if transcription:
111
+ media_info["transcription"] = transcription
112
+ except Exception as ex:
113
+ logger.error(f"Error downloading/transcribing media: {ex}")
114
+
115
+ # Build full message payload
116
+ payload = {
117
+ "event": "new_message",
118
+ "timestamp": datetime.utcnow().isoformat() + "Z",
119
+ "message": {
120
+ "id": msg.id,
121
+ "text": msg.text or "",
122
+ "date": msg.date.isoformat() if msg.date else "",
123
+ "reply_to_msg_id": msg.reply_to_msg_id
124
+ },
125
+ "chat": chat_info,
126
+ "sender": sender_info,
127
+ "media": media_info
128
+ }
129
+
130
+ # Print to stdout/logger
131
+ logger.info(f"New Message Recieved from {sender_info['display_name']} in {chat_info['name']}: {msg.text or '[Media]'}")
132
+ if verbose or not webhook_url:
133
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
134
+
135
+ # If webhook URL is set, emit webhook
136
+ if webhook_url:
137
+ # Forward using background asyncio task so we don't block the listener loop
138
+ client.loop.create_task(forward_webhook(webhook_url, payload, parsed_headers))
139
+
140
+ except Exception as e:
141
+ logger.error(f"Error in message handler: {e}")
142
+
143
+ # Run the client until disconnected
144
+ await client.run_until_disconnected()