GuGa 1.0.3__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.
guga/__init__.py ADDED
File without changes
Binary file
Binary file
guga/alerter.py ADDED
@@ -0,0 +1,174 @@
1
+ import asyncio
2
+ import asyncio.subprocess
3
+ import re
4
+ import aiohttp
5
+ import os
6
+ import platform
7
+ import sys
8
+ from dotenv import load_dotenv
9
+
10
+ CONFIG_DIR = os.path.expanduser("~/.guga")
11
+ env_path = os.path.join(CONFIG_DIR, '.env')
12
+ load_dotenv(dotenv_path=env_path)
13
+
14
+ # Enforce Linux-only restriction
15
+ if platform.system() != "Linux":
16
+ print("❌ ERROR: OS Notification monitoring only works on Linux (D-Bus required).")
17
+ print(f"Current OS: {platform.system()}")
18
+ sys.exit(1)
19
+
20
+ # ------------------------------------------------------------
21
+ # Configuration
22
+ # ------------------------------------------------------------
23
+ SERVER_URL = os.getenv("ALERTER_SERVER_URL", "http://localhost:6769/send")
24
+ LOG_FILE = os.path.join(CONFIG_DIR, "alerter.log")
25
+
26
+ # ------------------------------------------------------------
27
+ # Text Cleaning (Stripping Formatting)
28
+ # ------------------------------------------------------------
29
+ def clean_text(text: str) -> str:
30
+ """
31
+ Strips HTML-like tags and common markdown formatting from text.
32
+ """
33
+ if not text:
34
+ return ""
35
+
36
+ # 1. Strip HTML tags (e.g., <b>, <i>, <a href="...">)
37
+ text = re.sub(r'<[^>]+>', '', text)
38
+
39
+ # 2. Strip Markdown bold/italic/strike (e.g., **, *, __, _, ~~)
40
+ text = re.sub(r'(\*\*|__|\*|_|~~)', '', text)
41
+
42
+ # 3. Strip Markdown links [text](url) -> text
43
+ text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
44
+
45
+ # 4. Strip excessive newlines and whitespace
46
+ text = re.sub(r'\n+', ' ', text)
47
+ text = text.strip()
48
+
49
+ return text
50
+
51
+ # ------------------------------------------------------------
52
+ # Forwarding Logic
53
+ # ------------------------------------------------------------
54
+ async def forward_to_server(app_name: str, title: str, body: str):
55
+ """
56
+ Sends the cleaned notification to the local server's /send route.
57
+ """
58
+ clean_title = clean_text(title)
59
+ clean_body = clean_text(body)
60
+
61
+ payload_msg = f"{clean_title}: {clean_body}"
62
+ if not clean_title and not clean_body:
63
+ return
64
+
65
+ log_entry = f"Forwarding: {payload_msg}\n"
66
+ print(log_entry.strip())
67
+ with open(LOG_FILE, "a") as f:
68
+ f.write(log_entry)
69
+
70
+ try:
71
+ async with aiohttp.ClientSession() as session:
72
+ async with session.post(SERVER_URL, json={"message": payload_msg , "title":app_name}) as resp:
73
+ status_msg = f"Server Response: {resp.status}\n"
74
+ print(status_msg.strip())
75
+ with open(LOG_FILE, "a") as f:
76
+ f.write(status_msg)
77
+ except Exception as e:
78
+ err_msg = f"Error forwarding: {e}\n"
79
+ print(err_msg.strip())
80
+ with open(LOG_FILE, "a") as f:
81
+ f.write(err_msg)
82
+
83
+ # ------------------------------------------------------------
84
+ # D-Bus Monitor via Subprocess
85
+ # ------------------------------------------------------------
86
+ async def monitor_notifications():
87
+ """
88
+ Runs dbus-monitor and parses its output to detect notifications.
89
+ """
90
+ # Explicitly filter for method_call to avoid duplicates from signals
91
+ cmd = ["dbus-monitor", "type='method_call',interface='org.freedesktop.Notifications',member='Notify'"]
92
+
93
+ try:
94
+ proc = await asyncio.create_subprocess_exec(
95
+ *cmd,
96
+ stdout=asyncio.subprocess.PIPE,
97
+ stderr=asyncio.subprocess.STDOUT
98
+ )
99
+ except Exception as e:
100
+ print(f"Failed to start dbus-monitor: {e}")
101
+ return
102
+
103
+ if not proc.stdout:
104
+ print("Failed to capture dbus-monitor stdout.")
105
+ return
106
+
107
+ print("Monitoring D-Bus for notifications (method calls only)...")
108
+
109
+ # State tracking
110
+ state = "IDLE"
111
+ app_name = ""
112
+ summary = ""
113
+ body = ""
114
+
115
+ while True:
116
+ line_bytes = await proc.stdout.readline()
117
+ if not line_bytes:
118
+ break
119
+
120
+ line = line_bytes.decode(errors='replace').strip()
121
+
122
+ # Detect start of method call specifically
123
+ if "method call" in line and "interface=org.freedesktop.Notifications; member=Notify" in line:
124
+ state = "APP_NAME"
125
+ app_name = ""
126
+ summary = ""
127
+ body = ""
128
+ continue
129
+
130
+ if state == "APP_NAME":
131
+ match = re.search(r'string\s+"(.*)"', line)
132
+ if match:
133
+ app_name = match.group(1)
134
+ state = "REPLACES_ID"
135
+
136
+ elif state == "REPLACES_ID":
137
+ if "uint32" in line:
138
+ state = "ICON"
139
+
140
+ elif state == "ICON":
141
+ match = re.search(r'string\s+"(.*)"', line)
142
+ if match:
143
+ state = "SUMMARY"
144
+
145
+ elif state == "SUMMARY":
146
+ match = re.search(r'string\s+"(.*)"', line)
147
+ if match:
148
+ summary = match.group(1)
149
+ state = "BODY"
150
+
151
+ elif state == "BODY":
152
+ # Body can be a string or we might see 'array [' if body is empty
153
+ match = re.search(r'string\s+"(.*)"', line)
154
+ if match:
155
+ body = match.group(1)
156
+ # Done capturing this notification
157
+ print(f"\n[Intercepted] App: {app_name}")
158
+ with open(LOG_FILE, "a") as f:
159
+ f.write(f"\n[Intercepted] App: {app_name}\nTitle: {summary}\nBody: {body}\n")
160
+ asyncio.create_task(forward_to_server(app_name, summary, body))
161
+ state = "IDLE"
162
+ elif "array [" in line:
163
+ # Body was empty, moving to actions
164
+ print(f"\n[Intercepted] App: {app_name} (No body)")
165
+ with open(LOG_FILE, "a") as f:
166
+ f.write(f"\n[Intercepted] App: {app_name}\nTitle: {summary}\nBody: (empty)\n")
167
+ asyncio.create_task(forward_to_server(app_name, summary, ""))
168
+ state = "IDLE"
169
+
170
+ if __name__ == "__main__":
171
+ try:
172
+ asyncio.run(monitor_notifications())
173
+ except KeyboardInterrupt:
174
+ print("\nStopping OS Notification Alerter...")
guga/cli.py ADDED
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ guga - Send notifications to your Android via the GuGa server.
4
+
5
+ Auto-detected mode:
6
+ echo "msg" | guga
7
+ guga "Build finished"
8
+ guga python train.py
9
+
10
+ Explicit mode (overrides auto-detection):
11
+ guga --message "build done"
12
+ guga --run calc maintenance.cobol
13
+ """
14
+
15
+ import argparse
16
+ import sys
17
+ import json
18
+ import os
19
+ import shlex
20
+ import shutil
21
+ import subprocess
22
+ import time
23
+ import urllib.request
24
+ import urllib.error
25
+
26
+
27
+ # ── Helpers ───────────────────────────────────────────────────────────────────
28
+
29
+ def format_duration(seconds):
30
+ seconds = int(seconds)
31
+ if seconds < 60:
32
+ return f"{seconds}s"
33
+ elif seconds < 3600:
34
+ m, s = divmod(seconds, 60)
35
+ return f"{m}m {s}s"
36
+ else:
37
+ h, rem = divmod(seconds, 3600)
38
+ m, _ = divmod(rem, 60)
39
+ return f"{h}h {m}m"
40
+
41
+
42
+ def is_runnable(token):
43
+ return shutil.which(token) is not None or os.path.isfile(token)
44
+
45
+
46
+ def last_meaningful_line(text):
47
+ lines = [l.strip() for l in text.splitlines() if l.strip()]
48
+ return lines[-1] if lines else None
49
+
50
+
51
+ # ── Core actions ──────────────────────────────────────────────────────────────
52
+
53
+ def send_notification(message, port, silent, title=None):
54
+ url = f"http://localhost:{port}/send"
55
+
56
+ payload = {"message": message}
57
+ if title:
58
+ payload["title"] = title
59
+
60
+ data = json.dumps(payload).encode("utf-8")
61
+ req = urllib.request.Request(
62
+ url,
63
+ data=data,
64
+ headers={"Content-Type": "application/json"},
65
+ method="POST",
66
+ )
67
+
68
+ try:
69
+ with urllib.request.urlopen(req, timeout=5) as response:
70
+ if not silent:
71
+ print(f"✅ Sent ({response.status}): {message}")
72
+ except urllib.error.URLError as e:
73
+ reason = getattr(e, "reason", str(e))
74
+ if not silent:
75
+ print(f"❌ Could not reach GuGa server on port {port}: {reason}", file=sys.stderr)
76
+ sys.exit(1)
77
+ except Exception as e:
78
+ if not silent:
79
+ print(f"❌ Unexpected error: {e}", file=sys.stderr)
80
+ sys.exit(1)
81
+
82
+
83
+ def run_command(cmd_args, port, silent, title):
84
+ cmd_label = " ".join(cmd_args)
85
+ start = time.time()
86
+
87
+ if not silent:
88
+ print(f"▶ guga watching: {cmd_label}\n")
89
+
90
+ captured_lines = []
91
+ try:
92
+ process = subprocess.Popen(
93
+ cmd_args,
94
+ stdout=subprocess.PIPE,
95
+ stderr=subprocess.STDOUT,
96
+ text=True,
97
+ bufsize=1,
98
+ )
99
+ for line in process.stdout:
100
+ print(line, end="")
101
+ captured_lines.append(line)
102
+
103
+ process.wait()
104
+ exit_code = process.returncode
105
+
106
+ except FileNotFoundError:
107
+ print(f"❌ Command not found: {cmd_args[0]}", file=sys.stderr)
108
+ sys.exit(1)
109
+ except KeyboardInterrupt:
110
+ elapsed = format_duration(time.time() - start)
111
+ send_notification(f"⚠️ {cmd_label} interrupted after {elapsed}", port, silent, title)
112
+ sys.exit(130)
113
+
114
+ elapsed = format_duration(time.time() - start)
115
+ last_line = last_meaningful_line("".join(captured_lines))
116
+
117
+ status, verb = ("✅", "done") if exit_code == 0 else ("❌", f"failed (exit {exit_code})")
118
+ parts = [f"{status} {cmd_label} {verb} — {elapsed}"]
119
+ if last_line:
120
+ parts.append(last_line)
121
+
122
+ send_notification("\n".join(parts), port, silent, title)
123
+ sys.exit(exit_code)
124
+
125
+
126
+ # ── Entry point ───────────────────────────────────────────────────────────────
127
+
128
+ def parse_args():
129
+ parser = argparse.ArgumentParser(
130
+ prog="guga",
131
+ description="Send notifications to Android, or watch a command and notify on completion.",
132
+ formatter_class=argparse.RawDescriptionHelpFormatter,
133
+ epilog="""
134
+ mode flags (override auto-detection):
135
+ -m / --message Force message mode — treat all args as a plain string.
136
+ -r / --run Force run mode — treat all args as a command to execute.
137
+
138
+ auto-detection rules:
139
+ 1. stdin piped, no args → message mode
140
+ 2. single non-executable string → message mode
141
+ 3. everything else → run mode
142
+
143
+ examples:
144
+ echo "Deploy done" | guga # auto: message via stdin
145
+ guga "Build finished" # auto: plain message
146
+ guga python train.py --epochs 100 # auto: run mode
147
+ guga calc maintenance.cobol # auto: run mode
148
+
149
+ guga -m "build done" # explicit: message
150
+ guga -m "python train.py" # sends the literal string, does not run
151
+ guga -r "sleep 5" # explicit: run (splits into tokens)
152
+ guga -r ./deploy.sh --title "Prod Server" # explicit: run + title
153
+
154
+ guga -r python train.py --silent --title "GPU" # run silently, labelled notification
155
+
156
+ setup & pairing:
157
+ guga --qr # show pairing QR code
158
+ guga --show-pin # show the latest pairing PIN
159
+ guga --install-service # initialise background service
160
+
161
+ for more details:
162
+ man guga
163
+ tldr guga
164
+ """,
165
+ )
166
+
167
+ parser.add_argument(
168
+ "args",
169
+ nargs="*",
170
+ help="Message string, or command + arguments.",
171
+ )
172
+
173
+ # Proxy mode flags
174
+ proxy_mode = parser.add_mutually_exclusive_group()
175
+ proxy_mode.add_argument(
176
+ "--qr",
177
+ action="store_true",
178
+ help="Show the pairing QR code and exit.",
179
+ )
180
+ proxy_mode.add_argument(
181
+ "--show-pin",
182
+ action="store_true",
183
+ help="Show the most recent pairing PIN and exit.",
184
+ )
185
+ proxy_mode.add_argument(
186
+ "--install-service",
187
+ action="store_true",
188
+ help="Initializes the Linux background systemd service and components.",
189
+ )
190
+
191
+ # Explicit mode flags — mutually exclusive
192
+ mode = parser.add_mutually_exclusive_group()
193
+ mode.add_argument(
194
+ "-m", "--message",
195
+ action="store_true",
196
+ help="Force message mode. Joins all positional args as a single string.",
197
+ )
198
+ mode.add_argument(
199
+ "-r", "--run",
200
+ action="store_true",
201
+ help="Force run mode. Executes positional args as a command.",
202
+ )
203
+
204
+ parser.add_argument(
205
+ "--server",
206
+ type=int,
207
+ default=6769,
208
+ metavar="PORT",
209
+ help="GuGa server port (default: 6769).",
210
+ )
211
+ parser.add_argument(
212
+ "--silent",
213
+ action="store_true",
214
+ help="Suppress guga's own output.",
215
+ )
216
+ parser.add_argument(
217
+ "--title",
218
+ default=None,
219
+ metavar="LABEL",
220
+ help='Label shown in the notification, e.g. "GPU Server".',
221
+ )
222
+
223
+ return parser.parse_args()
224
+
225
+
226
+ def main():
227
+ args = parse_args()
228
+
229
+ # ── Proxy modes ───────────────────────────────────────────────────────────
230
+ if args.install_service or args.qr or args.show_pin:
231
+ from guga.installer import run_system_installer
232
+ run_system_installer(qr_only=args.qr, pin_only=args.show_pin, setup_only=args.install_service)
233
+ return
234
+
235
+ positional = args.args
236
+
237
+ # ── Explicit message mode ─────────────────────────────────────────────────
238
+ if args.message:
239
+ if not positional:
240
+ # Fall back to stdin if no positional args given with --message
241
+ if not sys.stdin.isatty():
242
+ message = sys.stdin.read().strip()
243
+ else:
244
+ print("❌ --message requires a string argument.", file=sys.stderr)
245
+ sys.exit(1)
246
+ else:
247
+ message = " ".join(positional) # join so --message hello world works too
248
+ send_notification(message, args.server, args.silent, args.title)
249
+ return
250
+
251
+ # ── Explicit run mode ─────────────────────────────────────────────────────
252
+ if args.run:
253
+ if not positional:
254
+ print("❌ --run requires a command to execute.", file=sys.stderr)
255
+ sys.exit(1)
256
+ # If the user passed a single quoted string like "sleep 1" or "python train.py --lr 0.01",
257
+ # split it into proper tokens so subprocess can execute it correctly.
258
+ if len(positional) == 1:
259
+ positional = shlex.split(positional[0])
260
+ run_command(positional, args.server, args.silent, args.title)
261
+ return
262
+
263
+ # ── Auto-detection ────────────────────────────────────────────────────────
264
+
265
+ # 1. Stdin pipe with no positional args
266
+ if not sys.stdin.isatty() and not positional:
267
+ message = sys.stdin.read().strip()
268
+ if not message:
269
+ print("❌ Received empty stdin.", file=sys.stderr)
270
+ sys.exit(1)
271
+ send_notification(message, args.server, args.silent, args.title)
272
+ return
273
+
274
+ # 2. Nothing at all
275
+ if not positional:
276
+ print("❌ No message or command provided.\n", file=sys.stderr)
277
+ print("Usage:", file=sys.stderr)
278
+ print(' echo "msg" | guga', file=sys.stderr)
279
+ print(' guga "msg"', file=sys.stderr)
280
+ print(' guga python train.py', file=sys.stderr)
281
+ print(' guga --message "msg" # explicit message mode', file=sys.stderr)
282
+ print(' guga --run python train.py # explicit run mode', file=sys.stderr)
283
+ sys.exit(1)
284
+
285
+ # 3. Single non-executable string → message
286
+ if len(positional) == 1 and not is_runnable(positional[0]):
287
+ send_notification(positional[0], args.server, args.silent, args.title)
288
+ return
289
+
290
+ # 4. Otherwise → run mode
291
+ run_command(positional, args.server, args.silent, args.title)
292
+
293
+
294
+ if __name__ == "__main__":
295
+ main()