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.
- telegram_tgcli-1.0.0.dist-info/METADATA +38 -0
- telegram_tgcli-1.0.0.dist-info/RECORD +12 -0
- telegram_tgcli-1.0.0.dist-info/WHEEL +4 -0
- telegram_tgcli-1.0.0.dist-info/entry_points.txt +2 -0
- telegram_tgcli-1.0.0.dist-info/licenses/LICENSE +21 -0
- tgcli/__init__.py +1 -0
- tgcli/cli.py +126 -0
- tgcli/client.py +202 -0
- tgcli/config.py +10 -0
- tgcli/daemon.py +171 -0
- tgcli/stt.py +29 -0
- tgcli/webhooks.py +144 -0
|
@@ -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,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()
|