mcgram 0.2.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.
mcgram/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """mcgram - notification bridge for Claude Code (Telegram + ntfy.sh)."""
2
+
3
+ __version__ = "0.2.0"
mcgram/ask_registry.py ADDED
@@ -0,0 +1,140 @@
1
+ """Ask registry: open a question, await operator reply (button or freetext), or time out."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import secrets
8
+ import time
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from .tg_client import TelegramClient
14
+
15
+ log = logging.getLogger("mcgram.ask")
16
+
17
+
18
+ @dataclass
19
+ class PendingAsk:
20
+ question_id: str
21
+ future: asyncio.Future
22
+ message_id: int
23
+ chat_id: int
24
+ options: list[str] | None
25
+ posted_at: float # epoch seconds; only replies posted AFTER this resolve the ask
26
+
27
+
28
+ def _build_keyboard(question_id: str, options: list[str]) -> dict[str, Any]:
29
+ """One button per row. callback_data encodes question_id + option index."""
30
+ rows = [
31
+ [{"text": opt, "callback_data": f"{question_id}:{idx}"}]
32
+ for idx, opt in enumerate(options)
33
+ ]
34
+ return {"inline_keyboard": rows}
35
+
36
+
37
+ class AskRegistry:
38
+ """In-process map of open questions; handler resolves each on operator reply or timeout."""
39
+
40
+ def __init__(self) -> None:
41
+ self._pending: dict[str, PendingAsk] = {}
42
+
43
+ @property
44
+ def open_count(self) -> int:
45
+ return len(self._pending)
46
+
47
+ async def open(
48
+ self,
49
+ client: TelegramClient,
50
+ *,
51
+ chat_id: int,
52
+ question: str,
53
+ options: list[str] | None,
54
+ timeout_s: int,
55
+ ) -> dict[str, Any]:
56
+ q_id = "q_" + secrets.token_hex(4)
57
+ reply_markup = _build_keyboard(q_id, options) if options else None
58
+ msg = await client.send_message(chat_id, question, reply_markup=reply_markup)
59
+ loop = asyncio.get_running_loop()
60
+ fut: asyncio.Future = loop.create_future()
61
+ self._pending[q_id] = PendingAsk(
62
+ question_id=q_id,
63
+ future=fut,
64
+ message_id=msg["message_id"],
65
+ chat_id=chat_id,
66
+ options=options,
67
+ posted_at=time.time(),
68
+ )
69
+ try:
70
+ value, source = await asyncio.wait_for(fut, timeout=timeout_s)
71
+ await _strip_keyboard(client, chat_id, msg["message_id"])
72
+ return {"value": value, "source": source, "question_id": q_id}
73
+ except TimeoutError:
74
+ await _mark_timed_out(client, chat_id, msg["message_id"], question)
75
+ return {"value": "", "source": "timeout", "question_id": q_id}
76
+ finally:
77
+ self._pending.pop(q_id, None)
78
+
79
+ async def handle_update(self, update: dict[str, Any]) -> None:
80
+ if cq := update.get("callback_query"):
81
+ await self._handle_callback(cq)
82
+ return
83
+ if msg := update.get("message"):
84
+ self._handle_message(msg)
85
+
86
+ async def _handle_callback(self, cq: dict[str, Any]) -> None:
87
+ data = cq.get("data", "")
88
+ q_id, _, idx_str = data.partition(":")
89
+ pending = self._pending.get(q_id)
90
+ if pending and pending.options and idx_str.isdigit():
91
+ idx = int(idx_str)
92
+ if 0 <= idx < len(pending.options) and not pending.future.done():
93
+ pending.future.set_result((pending.options[idx], "button"))
94
+ # Always answer the callback so Telegram stops showing a spinner.
95
+ # The client reference travels via the registry's known client (passed at open()).
96
+ # Since handle_update lives on AppState, we look up the client via cq's bot wiring:
97
+ # in mcgram, the dispatcher passes the raw update; we don't have client here.
98
+ # The runtime injects an `_answer` hook below.
99
+ if self._answer_hook is not None:
100
+ try:
101
+ await self._answer_hook(cq["id"])
102
+ except Exception:
103
+ log.exception("answer_callback_query failed")
104
+
105
+ def _handle_message(self, msg: dict[str, Any]) -> None:
106
+ chat_id = msg.get("chat", {}).get("id")
107
+ text = msg.get("text") or ""
108
+ msg_ts = float(msg.get("date") or 0)
109
+ # Resolve the oldest pending ask in the same chat posted before this message.
110
+ candidates = sorted(
111
+ (p for p in self._pending.values() if p.chat_id == chat_id and msg_ts > p.posted_at),
112
+ key=lambda p: p.posted_at,
113
+ )
114
+ for p in candidates:
115
+ if not p.future.done():
116
+ p.future.set_result((text, "freetext"))
117
+ break
118
+
119
+ # Hook injected by server bootstrap so we can call answer_callback_query
120
+ # without making the registry import the client (avoids cycle in runtime).
121
+ _answer_hook: Any = None
122
+
123
+ def set_answer_hook(self, hook: Any) -> None:
124
+ self._answer_hook = hook
125
+
126
+
127
+ async def _strip_keyboard(client: TelegramClient, chat_id: int, message_id: int) -> None:
128
+ try:
129
+ await client.edit_message_reply_markup(chat_id, message_id, reply_markup=None)
130
+ except Exception:
131
+ log.debug("edit_message_reply_markup failed (non-fatal)", exc_info=True)
132
+
133
+
134
+ async def _mark_timed_out(
135
+ client: TelegramClient, chat_id: int, message_id: int, question: str
136
+ ) -> None:
137
+ try:
138
+ await client.edit_message_text(chat_id, message_id, f"{question}\n\n(timed out)")
139
+ except Exception:
140
+ log.debug("edit_message_text on timeout failed (non-fatal)", exc_info=True)
mcgram/audit.py ADDED
@@ -0,0 +1,144 @@
1
+ """JSONL audit logger with fsync, rotation, retention, and optional text redaction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import logging
8
+ import os
9
+ import threading
10
+ import time
11
+ from datetime import UTC, datetime, timedelta, tzinfo
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
15
+
16
+ log = logging.getLogger("mcgram.audit")
17
+
18
+ _PRUNE_INTERVAL_S = 3600.0
19
+
20
+
21
+ def _resolve_tz(name: str) -> tzinfo:
22
+ try:
23
+ return ZoneInfo(name)
24
+ except ZoneInfoNotFoundError:
25
+ log.warning("unknown timezone %r; falling back to UTC", name)
26
+ try:
27
+ return ZoneInfo("UTC")
28
+ except ZoneInfoNotFoundError:
29
+ return UTC
30
+
31
+
32
+ class AuditLog:
33
+ """Thread-safe append-only JSONL writer.
34
+
35
+ Each record is one JSON line with `ts`, `tool`, `status` keys at minimum.
36
+ `fsync` is called per-write so a `kill -9` still preserves the audit trail.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ path: str | Path,
42
+ rotate_mb: int = 25,
43
+ timezone: str = "UTC",
44
+ redact_text: bool = False,
45
+ retention_days: int | None = None,
46
+ ) -> None:
47
+ self.path = str(Path(path).expanduser())
48
+ self.rotate_bytes = max(1, rotate_mb) * 1024 * 1024
49
+ self.redact_text = redact_text
50
+ self.retention_days = retention_days
51
+ self._tz = _resolve_tz(timezone)
52
+ self._lock = threading.Lock()
53
+ self._last_prune_ts = 0.0
54
+ Path(self.path).parent.mkdir(parents=True, exist_ok=True)
55
+ if retention_days:
56
+ with self._lock:
57
+ self._prune_old_entries()
58
+
59
+ def write(self, record: dict[str, Any]) -> None:
60
+ """Append a record. `ts` is set automatically if missing."""
61
+ rec = dict(record)
62
+ rec.setdefault("ts", datetime.now(self._tz).isoformat(timespec="seconds"))
63
+ if self.redact_text and "text" in rec:
64
+ text = rec["text"]
65
+ if isinstance(text, str):
66
+ rec["text_len"] = len(text)
67
+ rec["text"] = "<redacted>"
68
+ line = json.dumps(rec, ensure_ascii=False, default=str) + "\n"
69
+ with self._lock:
70
+ self._maybe_rotate()
71
+ self._maybe_prune()
72
+ with open(self.path, "a", encoding="utf-8") as f:
73
+ f.write(line)
74
+ f.flush()
75
+ os.fsync(f.fileno())
76
+
77
+ def _maybe_rotate(self) -> None:
78
+ try:
79
+ size = os.path.getsize(self.path)
80
+ except FileNotFoundError:
81
+ return
82
+ if size < self.rotate_bytes:
83
+ return
84
+ tail = f"{self.path}.3"
85
+ if os.path.exists(tail):
86
+ try:
87
+ os.remove(tail)
88
+ except OSError as e:
89
+ log.warning("rotate: failed to remove %s: %s", tail, e)
90
+ return
91
+ for i in (3, 2, 1):
92
+ src = self.path if i == 1 else f"{self.path}.{i - 1}"
93
+ dst = f"{self.path}.{i}"
94
+ if os.path.exists(src):
95
+ try:
96
+ os.replace(src, dst)
97
+ except OSError as e:
98
+ log.warning("rotate: %s -> %s failed: %s", src, dst, e)
99
+ return
100
+
101
+ def _maybe_prune(self) -> None:
102
+ if not self.retention_days:
103
+ return
104
+ now = time.monotonic()
105
+ if now - self._last_prune_ts < _PRUNE_INTERVAL_S:
106
+ return
107
+ self._prune_old_entries()
108
+
109
+ def _prune_old_entries(self) -> None:
110
+ if not self.retention_days:
111
+ return
112
+ cutoff = datetime.now(self._tz) - timedelta(days=self.retention_days)
113
+ for path in (self.path, f"{self.path}.1", f"{self.path}.2", f"{self.path}.3"):
114
+ if os.path.exists(path):
115
+ _rewrite_newer_than(path, cutoff)
116
+ self._last_prune_ts = time.monotonic()
117
+
118
+
119
+ def _rewrite_newer_than(path: str, cutoff: datetime) -> None:
120
+ tmp = f"{path}.tmp"
121
+ try:
122
+ with open(path, encoding="utf-8") as src:
123
+ kept = [line for line in src if _entry_newer_than(line, cutoff)]
124
+ with open(tmp, "w", encoding="utf-8") as dst:
125
+ dst.writelines(kept)
126
+ dst.flush()
127
+ os.fsync(dst.fileno())
128
+ os.replace(tmp, path)
129
+ except OSError as e:
130
+ log.warning("prune: rewrite %s failed: %s", path, e)
131
+ with contextlib.suppress(OSError):
132
+ os.remove(tmp)
133
+
134
+
135
+ def _entry_newer_than(line: str, cutoff: datetime) -> bool:
136
+ """Keep entries with `ts >= cutoff`. Malformed lines kept (fail-safe)."""
137
+ try:
138
+ rec = json.loads(line)
139
+ ts = datetime.fromisoformat(rec["ts"])
140
+ if ts.tzinfo is None:
141
+ ts = ts.replace(tzinfo=cutoff.tzinfo)
142
+ return ts >= cutoff
143
+ except (json.JSONDecodeError, ValueError, KeyError, TypeError):
144
+ return True
mcgram/cli.py ADDED
@@ -0,0 +1,72 @@
1
+ """CLI dispatcher for mcgram. Lazy-imports heavy submodules for <500ms startup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from . import __version__
8
+
9
+ _HELP = """\
10
+ mcgram — notification bridge MCP for Claude Code (Telegram + ntfy.sh).
11
+
12
+ USAGE:
13
+ mcgram start the MCP stdio server (expects MCGRAM_CONFIG)
14
+ mcgram init [--force] scaffold ~/.mcgram/ + install Claude Code skill
15
+ mcgram doctor check config + transport connectivity (sends test pings)
16
+ mcgram audit [opts] analyze audit.jsonl (--since, --tool, --rejected, --tail)
17
+ mcgram channel list list configured channels (both transports)
18
+ mcgram channel add NAME CHAT_ID add a Telegram channel
19
+ mcgram channel add-ntfy NAME add a ntfy.sh channel (auto-generates topic)
20
+ mcgram channel remove NAME remove a channel
21
+ mcgram install-skill [--force] install / reinstall ~/.claude/skills/mcgram/SKILL.md
22
+ mcgram clear-lock remove stale ~/.mcgram/.lock (use if MCP fails to start)
23
+ mcgram --version print version
24
+ mcgram --help print this help
25
+ """
26
+
27
+
28
+ def main() -> None:
29
+ args = sys.argv[1:]
30
+ if args:
31
+ first = args[0]
32
+ if first in {"-V", "--version", "version"}:
33
+ print(f"mcgram {__version__}")
34
+ return
35
+ if first in {"-h", "--help", "help"}:
36
+ sys.stdout.write(_HELP)
37
+ return
38
+ if first == "init":
39
+ from .cli_init import init_config
40
+ force = "--force" in args[1:]
41
+ sys.exit(init_config(force=force))
42
+ if first == "doctor":
43
+ from .cli_doctor import doctor
44
+ sys.exit(doctor())
45
+ if first == "audit":
46
+ from .cli_audit import main as audit_main
47
+ sys.exit(audit_main(args[1:]))
48
+ if first == "channel":
49
+ from .cli_channel import main as channel_main
50
+ sys.exit(channel_main(args[1:]))
51
+ if first == "install-skill":
52
+ from .skill_installer import install_skill
53
+ force = "--force" in args[1:]
54
+ sys.exit(install_skill(force=force))
55
+ if first == "clear-lock":
56
+ from pathlib import Path
57
+ lock = Path("~/.mcgram/.lock").expanduser()
58
+ if lock.exists():
59
+ lock.unlink()
60
+ print(f"removed {lock}")
61
+ else:
62
+ print(f"no lock file at {lock} (already clean)")
63
+ sys.exit(0)
64
+ print(f"unknown argument: {first}. Try `mcgram --help`.", file=sys.stderr)
65
+ sys.exit(2)
66
+ # No args → run MCP stdio server.
67
+ from .server import main as server_main
68
+ server_main()
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()
mcgram/cli_audit.py ADDED
@@ -0,0 +1,191 @@
1
+ """`mcgram audit` — analyze audit.jsonl (+ rotated backups). dbread-equivalent flags."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import re
9
+ import sys
10
+ import time
11
+ from collections.abc import Iterator
12
+ from datetime import UTC, datetime, timedelta
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ _DUR_RE = re.compile(r"^\s*(\d+)\s*([smhd])\s*$", re.IGNORECASE)
17
+ _UNIT_SECS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
18
+
19
+
20
+ def _parse_duration(s: str) -> timedelta:
21
+ m = _DUR_RE.match(s)
22
+ if not m:
23
+ raise ValueError(f"bad duration {s!r}; expected e.g. 1h, 30m, 7d")
24
+ return timedelta(seconds=int(m.group(1)) * _UNIT_SECS[m.group(2).lower()])
25
+
26
+
27
+ def _rotated_paths(base: str) -> list[str]:
28
+ paths = [f"{base}.3", f"{base}.2", f"{base}.1", base]
29
+ return [p for p in paths if os.path.exists(p)]
30
+
31
+
32
+ def _parse_ts(raw: str) -> datetime | None:
33
+ try:
34
+ return datetime.fromisoformat(raw)
35
+ except (ValueError, TypeError):
36
+ return None
37
+
38
+
39
+ def _iter_entries(
40
+ base: str, *, since: timedelta | None = None, tool: str | None = None
41
+ ) -> Iterator[dict[str, Any]]:
42
+ cutoff = datetime.now(UTC) - since if since else None
43
+ for path in _rotated_paths(base):
44
+ try:
45
+ with open(path, encoding="utf-8") as f:
46
+ for line in f:
47
+ line = line.strip()
48
+ if not line:
49
+ continue
50
+ try:
51
+ rec = json.loads(line)
52
+ except json.JSONDecodeError:
53
+ continue
54
+ if tool and rec.get("tool") != tool:
55
+ continue
56
+ if cutoff is not None:
57
+ ts = _parse_ts(rec.get("ts", ""))
58
+ if ts is None:
59
+ continue
60
+ if ts.tzinfo is None:
61
+ ts = ts.replace(tzinfo=UTC)
62
+ if ts < cutoff:
63
+ continue
64
+ yield rec
65
+ except OSError:
66
+ continue
67
+
68
+
69
+ def _summarize(entries: list[dict[str, Any]]) -> dict[str, Any]:
70
+ status_counts: dict[str, int] = {}
71
+ reason_counts: dict[str, int] = {}
72
+ tool_counts: dict[str, int] = {}
73
+ for rec in entries:
74
+ status_counts[rec.get("status", "?")] = status_counts.get(rec.get("status", "?"), 0) + 1
75
+ tool_counts[rec.get("tool", "?")] = tool_counts.get(rec.get("tool", "?"), 0) + 1
76
+ if rec.get("status") == "rejected":
77
+ r = rec.get("reason", "unknown")
78
+ reason_counts[r] = reason_counts.get(r, 0) + 1
79
+ return {
80
+ "total": len(entries),
81
+ "status": status_counts,
82
+ "tool": tool_counts,
83
+ "rejected_reasons": reason_counts,
84
+ }
85
+
86
+
87
+ def _fmt_summary(s: dict[str, Any]) -> str:
88
+ out = [f"Total entries: {s['total']}", "", "Status:"]
89
+ for k, n in sorted(s["status"].items(), key=lambda x: -x[1]):
90
+ out.append(f" {k:<12} {n}")
91
+ out.append("")
92
+ out.append("By tool:")
93
+ for k, n in sorted(s["tool"].items(), key=lambda x: -x[1]):
94
+ out.append(f" {k:<20} {n}")
95
+ if s["rejected_reasons"]:
96
+ out.append("")
97
+ out.append("Rejection reasons:")
98
+ for r, n in sorted(s["rejected_reasons"].items(), key=lambda x: -x[1]):
99
+ out.append(f" {n:>5} {r}")
100
+ return "\n".join(out)
101
+
102
+
103
+ def _fmt_rejected(entries: list[dict[str, Any]]) -> str:
104
+ groups: dict[str, list[dict[str, Any]]] = {}
105
+ for rec in entries:
106
+ if rec.get("status") != "rejected":
107
+ continue
108
+ groups.setdefault(rec.get("reason", "unknown"), []).append(rec)
109
+ if not groups:
110
+ return "No rejections found."
111
+ out: list[str] = []
112
+ for reason, recs in sorted(groups.items(), key=lambda x: -len(x[1])):
113
+ out.append(f"[{len(recs)}] {reason}")
114
+ for r in recs[:5]:
115
+ out.append(f" {r.get('ts','?')} [{r.get('tool','?')}] {r}")
116
+ if len(recs) > 5:
117
+ out.append(f" ... +{len(recs) - 5} more")
118
+ return "\n".join(out)
119
+
120
+
121
+ def _tail(base: str, tool: str | None) -> int:
122
+ path = base
123
+ pos = os.path.getsize(path) if os.path.exists(path) else 0
124
+ while True:
125
+ try:
126
+ size = os.path.getsize(path)
127
+ except FileNotFoundError:
128
+ time.sleep(1)
129
+ continue
130
+ if size < pos:
131
+ pos = 0
132
+ if size > pos:
133
+ with open(path, encoding="utf-8") as f:
134
+ f.seek(pos)
135
+ for line in f:
136
+ if not line.strip():
137
+ continue
138
+ try:
139
+ rec = json.loads(line)
140
+ except json.JSONDecodeError:
141
+ continue
142
+ if tool and rec.get("tool") != tool:
143
+ continue
144
+ sys.stdout.write(line if line.endswith("\n") else line + "\n")
145
+ sys.stdout.flush()
146
+ pos = f.tell()
147
+ time.sleep(1)
148
+
149
+
150
+ def _default_audit_path() -> str:
151
+ env = os.environ.get("MCGRAM_AUDIT_PATH")
152
+ if env:
153
+ return str(Path(env).expanduser())
154
+ cfg = os.environ.get("MCGRAM_CONFIG")
155
+ if cfg:
156
+ try:
157
+ from .config import Settings
158
+ return Settings.load(cfg).audit.path
159
+ except Exception:
160
+ pass
161
+ return str(Path("~/.mcgram/audit.jsonl").expanduser())
162
+
163
+
164
+ def main(argv: list[str] | None = None) -> int:
165
+ p = argparse.ArgumentParser(prog="mcgram audit", description="Analyze audit.jsonl")
166
+ p.add_argument("--path", default=None, help="override audit.jsonl path")
167
+ p.add_argument("--since", help="e.g. 1h, 30m, 7d")
168
+ p.add_argument("--tool", help="filter by tool name")
169
+ p.add_argument("--rejected", action="store_true", help="show only rejections grouped")
170
+ p.add_argument("--tail", action="store_true", help="follow new entries")
171
+ args = p.parse_args(argv)
172
+
173
+ base = args.path or _default_audit_path()
174
+ if args.tail:
175
+ try:
176
+ return _tail(base, args.tool)
177
+ except KeyboardInterrupt:
178
+ return 0
179
+
180
+ since = _parse_duration(args.since) if args.since else None
181
+ entries = list(_iter_entries(base, since=since, tool=args.tool))
182
+ if not entries:
183
+ suffix = " (filtered)" if (since or args.tool) else ""
184
+ print(f"No audit entries found at {base}{suffix}")
185
+ return 0
186
+
187
+ if args.rejected:
188
+ print(_fmt_rejected(entries))
189
+ else:
190
+ print(_fmt_summary(_summarize(entries)))
191
+ return 0