line-ext-msg 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,34 @@
1
+ """line-ext-msg: pull messages from LINE Chrome Extension via Playwright CDP."""
2
+
3
+ __version__ = "1.0.0"
4
+
5
+ from .client import LineClient
6
+ from .messages import sender_stats
7
+ from .errors import (
8
+ AppNotReady,
9
+ AttachFailed,
10
+ ChromeNotReady,
11
+ ExtensionMissing,
12
+ LineError,
13
+ LoginRequired,
14
+ RoomNotFound,
15
+ )
16
+ from .models import Message, Room, StepResult
17
+ from .settings import Settings
18
+
19
+ __all__ = [
20
+ "__version__",
21
+ "LineClient",
22
+ "sender_stats",
23
+ "Room",
24
+ "Message",
25
+ "StepResult",
26
+ "Settings",
27
+ "LineError",
28
+ "ChromeNotReady",
29
+ "AttachFailed",
30
+ "ExtensionMissing",
31
+ "AppNotReady",
32
+ "LoginRequired",
33
+ "RoomNotFound",
34
+ ]
line_ext_msg/auth.py ADDED
@@ -0,0 +1,58 @@
1
+ """Login check: distinguish 'not logged in' from 'selector mismatch'."""
2
+
3
+ from playwright.sync_api import Page
4
+
5
+ from .settings import SELECTORS
6
+
7
+ # Fast login-screen locators (CSS-module hashes change, so match stable parts).
8
+ LOGIN_LOCATORS = [
9
+ "[class*='loginPage']",
10
+ "text=QR code login",
11
+ "text=Log in",
12
+ ]
13
+
14
+ # Fallback text markers when locators miss (checked on small scope only).
15
+ LOGIN_MARKERS = [
16
+ "qr code", "qr login", "email address",
17
+ "เข้าสู่ระบบ", "ล็อกอิน",
18
+ ]
19
+
20
+
21
+ def _has_login_ui(page: Page) -> bool:
22
+ """True if any login marker is visible (cheap locator checks)."""
23
+ for sel in LOGIN_LOCATORS:
24
+ try:
25
+ if page.locator(sel).count() > 0:
26
+ return True
27
+ except Exception:
28
+ continue
29
+ return False
30
+
31
+
32
+ def check_login(page: Page, timeout_ms: int = 10000) -> tuple[bool, str]:
33
+ """Poll chat UI vs login UI; return as soon as either appears.
34
+
35
+ Return (logged_in, reason): 'chat' / 'login' / 'unknown'.
36
+ Caller must ensure the app finished rendering (wait_for_app_ready).
37
+ """
38
+ # Poll room rows (not the container: it may not exist as one element).
39
+ rows = [s.strip() for s in SELECTORS["room_item"].split(",")]
40
+ # Poll every 500ms; worst case only when page genuinely stuck.
41
+ for _ in range(max(1, timeout_ms // 500)):
42
+ for sel in rows:
43
+ try:
44
+ if page.locator(sel).count() > 0:
45
+ return True, "chat"
46
+ except Exception:
47
+ continue
48
+ if _has_login_ui(page):
49
+ return False, "login"
50
+ page.wait_for_timeout(500)
51
+ # Final text fallback on rendered body before giving up.
52
+ try:
53
+ text = (page.inner_text("body") or "").lower()
54
+ except Exception:
55
+ text = ""
56
+ if any(m in text for m in LOGIN_MARKERS):
57
+ return False, "login"
58
+ return False, "unknown"
line_ext_msg/chrome.py ADDED
@@ -0,0 +1,94 @@
1
+ """Chrome lifecycle: ensure a debuggable instance on the isolated profile."""
2
+
3
+ import os
4
+ import subprocess
5
+ import time
6
+ import urllib.request
7
+
8
+ from .settings import Settings
9
+
10
+
11
+ def expand(path: str) -> str:
12
+ """Expand %VAR% on Windows and ~ on all platforms."""
13
+ return os.path.expandvars(os.path.expanduser(path))
14
+
15
+
16
+ def find_chrome_exe() -> str | None:
17
+ """Search common install locations, return exe path or None."""
18
+ candidates = [
19
+ r"C:\Program Files\Google\Chrome\Application\chrome.exe",
20
+ r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
21
+ expand(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe"),
22
+ ]
23
+ for path in candidates:
24
+ if path and os.path.isfile(path):
25
+ return path
26
+ return None
27
+
28
+
29
+ def is_debug_ready(settings: Settings, timeout_sec: int = 2) -> bool:
30
+ """Check CDP endpoint responds with valid Browser field."""
31
+ try:
32
+ with urllib.request.urlopen(f"{settings.cdp_endpoint}/json/version", timeout=timeout_sec) as res:
33
+ return "Browser" in res.read().decode("utf-8", errors="ignore")
34
+ except Exception:
35
+ return False
36
+
37
+
38
+ def is_headless(settings: Settings, timeout_sec: int = 2) -> bool:
39
+ """Detect a headless instance squatting the debug port (no visible window)."""
40
+ try:
41
+ with urllib.request.urlopen(f"{settings.cdp_endpoint}/json/version", timeout=timeout_sec) as res:
42
+ return "Headless" in res.read().decode("utf-8", errors="ignore")
43
+ except Exception:
44
+ return False
45
+
46
+
47
+ def _is_profile_locked(data_dir: str) -> bool:
48
+ """Detect SingletonLock (profile in use by a normal window)."""
49
+ return any(
50
+ os.path.exists(os.path.join(data_dir, name))
51
+ for name in ("SingletonLock", "SingletonSocket", "SingletonCookie")
52
+ )
53
+
54
+
55
+ def start_chrome_debug(settings: Settings) -> None:
56
+ """Start detached Chrome on the isolated profile.
57
+
58
+ Chrome 136+ ignores --remote-debugging-port on the default profile,
59
+ hence the isolated dir. Login + install persist there after first setup.
60
+ """
61
+ from .errors import ChromeNotReady
62
+
63
+ exe = find_chrome_exe()
64
+ if exe is None:
65
+ raise ChromeNotReady("ไม่พบ chrome.exe กรุณาติดตั้ง Chrome ก่อน")
66
+ data_dir = expand(settings.profile_dir)
67
+ if _is_profile_locked(data_dir):
68
+ raise ChromeNotReady("โปรไฟล์ Chrome ถูกใช้อยู่ ปิดหน้าต่าง debug เก่าก่อนแล้วรันใหม่")
69
+ os.makedirs(data_dir, exist_ok=True)
70
+ subprocess.Popen(
71
+ [exe, f"--remote-debugging-port={settings.port}", f"--user-data-dir={data_dir}"],
72
+ stdout=subprocess.DEVNULL,
73
+ stderr=subprocess.DEVNULL,
74
+ creationflags=getattr(subprocess, "DETACHED_PROCESS", 0),
75
+ )
76
+ for _ in range(30):
77
+ if is_debug_ready(settings, timeout_sec=1):
78
+ return
79
+ time.sleep(0.5)
80
+ raise ChromeNotReady("เปิด Chrome แล้วแต่พอร์ต debug ไม่ตอบใน 15 วิ")
81
+
82
+
83
+ def ensure_chrome(settings: Settings) -> None:
84
+ """Ensure headed debug Chrome is running; start it if the port is closed."""
85
+ from .errors import ChromeNotReady
86
+
87
+ if is_debug_ready(settings):
88
+ if is_headless(settings):
89
+ raise ChromeNotReady(
90
+ "พอร์ต debug ถูกโปรแกรมอื่น (headless) ใช้อยู่ ไม่มีหน้าต่างให้เห็น "
91
+ "รัน: Get-Process chrome | Stop-Process -Force แล้วรันใหม่"
92
+ )
93
+ return
94
+ start_chrome_debug(settings)
line_ext_msg/cli.py ADDED
@@ -0,0 +1,99 @@
1
+ """Thin CLI over LineClient: parse args, print results."""
2
+ import argparse
3
+
4
+ from .client import LineClient
5
+ from .errors import LineError
6
+ from .models import Room
7
+
8
+
9
+ def _display(m) -> str:
10
+ """Readable line: text, or media label when the bubble has no text."""
11
+ if m.text:
12
+ return m.text[:80]
13
+ if m.type == "image":
14
+ return f"[รูปภาพ: {m.media or 'โหลดไม่สำเร็จ'}]"
15
+ if m.type == "sticker":
16
+ return "[สติกเกอร์]"
17
+ if m.type == "system":
18
+ return m.text
19
+ return f"[{m.type}]"
20
+
21
+
22
+ def pick_room(rooms: list[Room]) -> Room:
23
+ """Show rooms in terminal and return user choice."""
24
+ print("\nห้องแชททั้งหมด:")
25
+ for r in rooms:
26
+ unread = f" ({r.unread} ไม่อ่าน)" if r.unread else ""
27
+ print(f" [{r.index}] {r.name}{unread}")
28
+ while True:
29
+ raw = input("พิมพ์เลขห้อง > ").strip()
30
+ if raw.isdigit() and any(r.index == int(raw) for r in rooms):
31
+ return next(r for r in rooms if r.index == int(raw))
32
+ print("เลขไม่ถูกต้อง ลองใหม่")
33
+
34
+
35
+ def main():
36
+ parser = argparse.ArgumentParser(description="ดึงข้อความล่าสุดจาก LINE Extension")
37
+ parser.add_argument("--limit", type=int, default=5, help="จำนวนข้อความล่าสุด")
38
+ parser.add_argument("--date", default=None, help="กรองเฉพาะวันที่ YYYY-MM-DD")
39
+ parser.add_argument("--date-from", default=None, help="ตั้งแต่วันที่ YYYY-MM-DD")
40
+ parser.add_argument("--date-to", default=None, help="ถึงวันที่ YYYY-MM-DD")
41
+ parser.add_argument("--time-from", default=None, help="ตั้งแต่เวลา HH:MM")
42
+ parser.add_argument("--time-to", default=None, help="ถึงเวลา HH:MM")
43
+ parser.add_argument("--sender", default=None, help="กรองชื่อคนส่ง (บางส่วน)")
44
+ parser.add_argument("--keyword", default=None, help="ค้นคำในข้อความ")
45
+ parser.add_argument("--search", default=None, metavar="KEYWORD",
46
+ help="ค้นทุกห้อง แล้วสรุปห้องที่เจอ")
47
+ parser.add_argument("--unread", action="store_true", help="แสดงเฉพาะห้องที่ไม่อ่าน")
48
+ parser.add_argument("--save", action="store_true", help="เขียน rooms.json + messages ลงไฟล์ (default พิมพ์จออย่างเดียว)")
49
+ parser.add_argument("--dump", action="store_true", help="บันทึก DOM ดิบเพื่อจูน selector")
50
+ parser.add_argument("--dump-room", type=int, default=None, metavar="INDEX",
51
+ help="เปิดห้องลำดับ INDEX แล้วบันทึก DOM เพื่อจูน selector ข้อความ")
52
+ args = parser.parse_args()
53
+
54
+ try:
55
+ with LineClient() as line:
56
+ if args.dump:
57
+ state = line.dump_page()
58
+ print(f"บันทึก line_dom.html แล้ว (state={state}) ส่งไฟล์นี้มาเพื่อจูน selector")
59
+ return
60
+ line.status()
61
+ if args.dump_room is not None:
62
+ room = line.dump_room(args.dump_room)
63
+ print(f"บันทึก line_room.html แล้ว (ห้อง {room.name}) ส่งไฟล์นี้มาเพื่อจูน selector ข้อความ")
64
+ return
65
+ rooms = line.list_rooms(unread_only=args.unread)
66
+ if not rooms:
67
+ print("อ่านรายชื่อห้องไม่ได้ รัน --dump แล้วส่ง line_dom.html มา")
68
+ return
69
+ if args.save:
70
+ path = line.save_rooms(unread_only=args.unread)
71
+ print(f"บันทึก {path} ({len(rooms)} ห้อง)")
72
+ if args.search:
73
+ for hit in line.search_all(args.search, date_from=args.date_from,
74
+ date_to=args.date_to or args.date):
75
+ print(f"\n== {hit['room']['name']} ({len(hit['messages'])} ข้อความ) ==")
76
+ for m in hit["messages"]:
77
+ print(f"[{m['date']} {m['ts']}] {m['sender']}: {m['text'][:80]}")
78
+ return
79
+ chosen = pick_room(rooms)
80
+ filt = dict(limit=args.limit, date=args.date, date_from=args.date_from,
81
+ date_to=args.date_to, time_from=args.time_from, time_to=args.time_to,
82
+ sender=args.sender, keyword=args.keyword, media_dir="media")
83
+ if args.save:
84
+ out = line.save_messages(chosen, **filt)
85
+ print(f"บันทึก {out}")
86
+ print(f"กำลังเปิดห้อง {chosen.name} ...", flush=True)
87
+ msgs = line.get_messages(chosen, **filt)
88
+ if not msgs:
89
+ print("เปิดห้องได้แต่อ่านข้อความไม่ได้")
90
+ print(f"รัน uv run line-ext-msg --dump-room {chosen.index} แล้วส่ง line_room.html มา")
91
+ return
92
+ for m in msgs:
93
+ print(f"[{m.date} {m.ts}] {m.sender}: {_display(m)}")
94
+ except LineError as e:
95
+ print(e)
96
+
97
+
98
+ if __name__ == "__main__":
99
+ main()
line_ext_msg/client.py ADDED
@@ -0,0 +1,287 @@
1
+ """LineClient: single facade owning the whole lifecycle.
2
+
3
+ Typical use (also the shape MCP tools wrap)::
4
+
5
+ with LineClient() as line:
6
+ rooms = line.list_rooms(unread_only=True)
7
+ msgs = line.get_messages("ครอบครัว", limit=5)
8
+ """
9
+
10
+ from dataclasses import asdict
11
+
12
+ from . import auth
13
+ from . import chrome as _chrome
14
+ from . import messages as _messages
15
+ from . import rooms as _rooms
16
+ from . import session as _session
17
+ from . import storage as _storage
18
+ from .errors import (
19
+ AppNotReady,
20
+ AttachFailed,
21
+ ChromeNotReady,
22
+ ExtensionMissing,
23
+ LineError,
24
+ LoginRequired,
25
+ )
26
+ from .models import Message, Room, StepResult
27
+ from .progress import Steps
28
+ from .settings import Settings
29
+
30
+
31
+ class LineClient:
32
+ """Owns ensure-chrome, attach, page, and readiness. Use as context manager."""
33
+
34
+ def __init__(self, settings: Settings | None = None, **overrides):
35
+ self.settings = settings or Settings(**overrides)
36
+ self._steps = Steps(5, quiet=self.settings.quiet)
37
+ self._pw = None
38
+ self._browser = None
39
+ self._context = None
40
+ self._page = None
41
+
42
+ # -- lifecycle ----------------------------------------------------
43
+
44
+ def __enter__(self) -> "LineClient":
45
+ return self
46
+
47
+ def __exit__(self, *exc) -> None:
48
+ self.close()
49
+
50
+ def close(self) -> None:
51
+ if self._pw is not None:
52
+ try:
53
+ self._pw.stop()
54
+ except Exception:
55
+ pass
56
+ self._pw = None
57
+ self._browser = self._context = self._page = None
58
+
59
+ # -- startup checklist (also serves MCP line_status) --------------
60
+
61
+ def status(self) -> list[StepResult]:
62
+ """Run the 5 readiness checks. Raises typed LineError on first failure."""
63
+ steps = self._steps
64
+ if not self.settings.quiet:
65
+ print("[0/5] เริ่มตรวจเงื่อนไข...", flush=True)
66
+ results: list[StepResult] = []
67
+
68
+ def record(name: str, passed: bool, detail: str = "", hint: str = "") -> StepResult:
69
+ if detail:
70
+ steps.detail(detail)
71
+ steps.check(name, passed, "" if passed else hint)
72
+ result = StepResult(name=name, passed=passed, detail=detail or hint)
73
+ results.append(result)
74
+ return result
75
+
76
+ try:
77
+ _chrome.ensure_chrome(self.settings)
78
+ except ChromeNotReady as e:
79
+ record("Chrome debug พร้อม", False, str(e))
80
+ steps.skip_rest("หยุดก่อน")
81
+ raise
82
+ record("Chrome debug พร้อม", True)
83
+
84
+ try:
85
+ self._pw, self._browser, self._context = _session.connect(self.settings)
86
+ except AttachFailed as e:
87
+ record("เกาะเบราว์เซอร์", False, str(e))
88
+ steps.skip_rest("หยุดก่อน")
89
+ raise
90
+ record("เกาะเบราว์เซอร์", True)
91
+
92
+ installed, detail = _session.check_installed(self._context, self.settings, self._browser)
93
+ if not record("Extension ติดตั้ง", installed,
94
+ detail=detail, hint=f"ติดตั้งเองจาก: {self.settings.webstore_url}").passed:
95
+ _session.open_store_page(self._context, self.settings)
96
+ steps.skip_rest("รอติดตั้งก่อน")
97
+ raise ExtensionMissing(f"ติดตั้งเองจาก: {self.settings.webstore_url}")
98
+
99
+ self._page = _session.ensure_line_page(self._context, self.settings, self._browser)
100
+ state = _session.wait_ready(self._page, self.settings)
101
+ if not record("หน้า LINE พร้อม", state == "ready",
102
+ detail=f"state={state}", hint="แอปโหลดไม่เสร็จในเวลาที่กำหนด ลองรันใหม่").passed:
103
+ steps.skip_rest("หยุดก่อน")
104
+ raise AppNotReady("แอปโหลดไม่เสร็จในเวลาที่กำหนด ลองรันใหม่")
105
+
106
+ logged_in, reason = auth.check_login(self._page, self.settings.login_poll_ms)
107
+ detail = "โครงหน้าเว็บไม่ตรง selector ที่รู้จัก" if reason == "unknown" and not logged_in else ""
108
+ if not record("ล็อกอินแล้ว", logged_in,
109
+ detail=detail, hint="เปิดแท็บ LINE ล็อกอินด้วย QR/อีเมลก่อน แล้วรันใหม่").passed:
110
+ steps.skip_rest("รอ login ก่อน")
111
+ raise LoginRequired("ยังไม่ล็อกอิน LINE")
112
+ return results
113
+
114
+ def _ready_page(self):
115
+ """Page guaranteed ready; runs status() once, then reuses the session."""
116
+ if self._page is None:
117
+ self.status()
118
+ assert self._page is not None
119
+ return self._page
120
+
121
+ # -- domain API ---------------------------------------------------
122
+
123
+ def list_rooms(self, unread_only: bool = False, query: str | None = None) -> list[Room]:
124
+ """Rooms from the chats view, optionally filtered."""
125
+ rooms = _rooms.list_rooms(self._ready_page(), self.settings)
126
+ if unread_only:
127
+ rooms = [r for r in rooms if r.unread > 0]
128
+ if query:
129
+ rooms = [r for r in rooms if query in r.name]
130
+ return rooms
131
+
132
+ def open_room(self, ref: int | str | Room) -> Room:
133
+ """Open a room by index, data-mid, Room, or name substring."""
134
+ rooms = self.list_rooms()
135
+ return _rooms.open_room(self._ready_page(), ref, rooms, self.settings)
136
+
137
+ def get_messages(
138
+ self,
139
+ room: int | str | Room | None = None,
140
+ limit: int = 5,
141
+ date: str | None = None,
142
+ date_from: str | None = None,
143
+ date_to: str | None = None,
144
+ time_from: str | None = None,
145
+ time_to: str | None = None,
146
+ sender: str | None = None,
147
+ keyword: str | None = None,
148
+ media_dir: str | None = None,
149
+ include_media_data: bool = False,
150
+ ) -> list[Message]:
151
+ """Latest messages, optionally opening a room first and filtering.
152
+
153
+ Only rendered rows are visible (virtualized list); wide ranges
154
+ still return just what the DOM holds. media_dir=None keeps the
155
+ call side-effect free; set it to download image bubbles.
156
+ include_media_data embeds data URIs (for MCP/AI, off by default).
157
+ """
158
+ if room is not None:
159
+ rooms = self.list_rooms()
160
+ _rooms.open_room(self._ready_page(), room, rooms, self.settings)
161
+ return _messages.get_messages(
162
+ self._ready_page(), self.settings, limit=limit, date=date,
163
+ date_from=date_from, date_to=date_to, time_from=time_from,
164
+ time_to=time_to, sender=sender, keyword=keyword, media_dir=media_dir,
165
+ include_media_data=include_media_data,
166
+ )
167
+
168
+ def unread_digest(self) -> list[dict]:
169
+ """Rooms with unread>0 plus latest preview: answers 'what is unread' in one call."""
170
+ return [asdict(r) for r in self.list_rooms(unread_only=True)]
171
+
172
+ def unread_full(self, date: str | None = None, limit_per_room: int = 20) -> list[dict]:
173
+ """Unread rooms with their messages. date=None means today (local)."""
174
+ from datetime import date as _date
175
+ day = date or _date.today().isoformat()
176
+ out = []
177
+ for i, room in enumerate(self.list_rooms(unread_only=True)):
178
+ if not self.settings.quiet:
179
+ print(f" ... ห้อง {i + 1}: {room.name}", flush=True)
180
+ rooms = self.list_rooms()
181
+ _rooms.open_room(self._ready_page(), room, rooms, self.settings)
182
+ msgs = self.get_messages(limit=limit_per_room, date=day)
183
+ out.append({"room": asdict(room), "messages": [asdict(m) for m in msgs]})
184
+ return out
185
+
186
+ def search_all(
187
+ self,
188
+ keyword: str,
189
+ date_from: str | None = None,
190
+ date_to: str | None = None,
191
+ rooms: list[int | str | Room] | None = None,
192
+ limit_per_room: int = 100,
193
+ ) -> list[dict]:
194
+ """Search keyword across rooms. Returns only rooms with matches.
195
+
196
+ Sequential (one room at a time); cost ~ seconds per room.
197
+ """
198
+ targets = self.list_rooms()
199
+ if rooms is not None:
200
+ wanted = []
201
+ for ref in rooms:
202
+ try:
203
+ wanted.append(_rooms.resolve_ref(ref, targets))
204
+ except Exception:
205
+ continue
206
+ targets = wanted
207
+ out = []
208
+ for i, room in enumerate(targets):
209
+ if not self.settings.quiet:
210
+ print(f" ... ค้นห้อง {i + 1}/{len(targets)}: {room.name}", flush=True)
211
+ _rooms.open_room(self._ready_page(), room, self.list_rooms(), self.settings)
212
+ msgs = self.get_messages(
213
+ limit=limit_per_room, date_from=date_from, date_to=date_to, keyword=keyword,
214
+ )
215
+ if msgs:
216
+ out.append({"room": asdict(room), "messages": [asdict(m) for m in msgs]})
217
+ return out
218
+
219
+ # -- debugging helpers --------------------------------------------
220
+
221
+ def dump_page(self, path: str = "line_dom.html") -> str:
222
+ """Save current chats DOM for selector tuning. No login required."""
223
+ from . import chrome as _c
224
+
225
+ _c.ensure_chrome(self.settings)
226
+ if self._pw is None:
227
+ self._pw, self._browser, self._context = _session.connect(self.settings)
228
+ assert self._context is not None
229
+ page = _session.ensure_line_page(self._context, self.settings, self._browser)
230
+ state = _session.wait_ready(page, self.settings)
231
+ with open(path, "w", encoding="utf-8") as f:
232
+ f.write(page.content())
233
+ return state
234
+
235
+ def dump_room(self, ref: int | str | Room, path: str = "line_room.html") -> Room:
236
+ """Open a room then save its DOM for message-selector tuning."""
237
+ room = self.open_room(ref)
238
+ assert self._page is not None
239
+ with open(path, "w", encoding="utf-8") as f:
240
+ f.write(self._page.content())
241
+ return room
242
+
243
+ def save_rooms(
244
+ self,
245
+ path: str = "rooms.json",
246
+ unread_only: bool = False,
247
+ query: str | None = None,
248
+ ) -> str:
249
+ """Fetch rooms and save JSON. The only room method that writes files."""
250
+ rooms = self.list_rooms(unread_only=unread_only, query=query)
251
+ _storage.save_json(path, {"rooms": [asdict(r) for r in rooms]})
252
+ return path
253
+
254
+ def save_messages(
255
+ self,
256
+ ref: int | str | Room,
257
+ limit: int = 5,
258
+ date: str | None = None,
259
+ date_from: str | None = None,
260
+ date_to: str | None = None,
261
+ time_from: str | None = None,
262
+ time_to: str | None = None,
263
+ sender: str | None = None,
264
+ keyword: str | None = None,
265
+ media_dir: str | None = None,
266
+ include_media_data: bool = False,
267
+ path: str | None = None,
268
+ ) -> str:
269
+ """Open room, fetch messages, save JSON. The only message method that writes files."""
270
+ from datetime import datetime, timezone
271
+
272
+ room = self.open_room(ref)
273
+ msgs = self.get_messages(
274
+ limit=limit, date=date, date_from=date_from, date_to=date_to,
275
+ time_from=time_from, time_to=time_to, sender=sender, keyword=keyword,
276
+ media_dir=media_dir, include_media_data=include_media_data,
277
+ )
278
+ out = path or f"messages_{room.index}.json"
279
+ _storage.save_json(out, {
280
+ "room": asdict(room),
281
+ "fetched_at": datetime.now(timezone.utc).isoformat(),
282
+ "messages": [asdict(m) for m in msgs],
283
+ })
284
+ return out
285
+
286
+
287
+ __all__ = ["LineClient", "LineError"]
line_ext_msg/errors.py ADDED
@@ -0,0 +1,34 @@
1
+ """Typed errors so callers (CLI, MCP) branch on kind, not message text."""
2
+
3
+
4
+ class LineError(Exception):
5
+ """Base for all line-ext-msg failures."""
6
+
7
+
8
+ class ChromeNotReady(LineError):
9
+ """Debug Chrome could not be started or reached."""
10
+
11
+
12
+ class AttachFailed(LineError):
13
+ """CDP attach to the running Chrome failed."""
14
+
15
+
16
+ class ExtensionMissing(LineError):
17
+ """LINE Extension is not installed in the debug profile."""
18
+
19
+
20
+ class AppNotReady(LineError):
21
+ """LINE SPA did not finish rendering in time."""
22
+
23
+
24
+ class LoginRequired(LineError):
25
+ """Login screen is showing; user must log in first."""
26
+
27
+
28
+ class RoomNotFound(LineError):
29
+ """No visible room matches the given reference."""
30
+
31
+ def __init__(self, ref: object, available: list[str]):
32
+ self.ref = ref
33
+ self.available = available
34
+ super().__init__(f"ไม่เจอห้อง '{ref}' (มี: {', '.join(available) or '—'})")