chatwire 0.4.0__tar.gz → 0.4.2__tar.gz

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.
Files changed (57) hide show
  1. {chatwire-0.4.0/chatwire.egg-info → chatwire-0.4.2}/PKG-INFO +1 -1
  2. {chatwire-0.4.0 → chatwire-0.4.2}/_version.py +1 -1
  3. {chatwire-0.4.0 → chatwire-0.4.2/chatwire.egg-info}/PKG-INFO +1 -1
  4. {chatwire-0.4.0 → chatwire-0.4.2}/chatwire.egg-info/SOURCES.txt +9 -1
  5. {chatwire-0.4.0 → chatwire-0.4.2}/pyproject.toml +1 -1
  6. {chatwire-0.4.0 → chatwire-0.4.2}/web/main.py +116 -16
  7. {chatwire-0.4.0 → chatwire-0.4.2}/web/static/style.css +7 -0
  8. {chatwire-0.4.0 → chatwire-0.4.2}/web/templates/_conversation.html +28 -39
  9. chatwire-0.4.2/web/templates/_history_page.html +13 -0
  10. chatwire-0.4.2/web/templates/_messages.html +43 -0
  11. chatwire-0.4.2/web/templates/wizard/_done.html +35 -0
  12. chatwire-0.4.2/web/templates/wizard/_identity.html +49 -0
  13. chatwire-0.4.2/web/templates/wizard/_permissions.html +66 -0
  14. chatwire-0.4.2/web/templates/wizard/_telegram.html +60 -0
  15. chatwire-0.4.2/web/templates/wizard/_whitelist.html +26 -0
  16. chatwire-0.4.2/web/templates/wizard/index.html +44 -0
  17. {chatwire-0.4.0 → chatwire-0.4.2}/LICENSE +0 -0
  18. {chatwire-0.4.0 → chatwire-0.4.2}/README.md +0 -0
  19. {chatwire-0.4.0 → chatwire-0.4.2}/bridge.py +0 -0
  20. {chatwire-0.4.0 → chatwire-0.4.2}/chat_db.py +0 -0
  21. {chatwire-0.4.0 → chatwire-0.4.2}/chat_send.py +0 -0
  22. {chatwire-0.4.0 → chatwire-0.4.2}/chatwire.egg-info/dependency_links.txt +0 -0
  23. {chatwire-0.4.0 → chatwire-0.4.2}/chatwire.egg-info/entry_points.txt +0 -0
  24. {chatwire-0.4.0 → chatwire-0.4.2}/chatwire.egg-info/requires.txt +0 -0
  25. {chatwire-0.4.0 → chatwire-0.4.2}/chatwire.egg-info/top_level.txt +0 -0
  26. {chatwire-0.4.0 → chatwire-0.4.2}/chatwire_cli.py +0 -0
  27. {chatwire-0.4.0 → chatwire-0.4.2}/config.py +0 -0
  28. {chatwire-0.4.0 → chatwire-0.4.2}/contacts.py +0 -0
  29. {chatwire-0.4.0 → chatwire-0.4.2}/echo_log.py +0 -0
  30. {chatwire-0.4.0 → chatwire-0.4.2}/integrations/__init__.py +0 -0
  31. {chatwire-0.4.0 → chatwire-0.4.2}/integrations/base.py +0 -0
  32. {chatwire-0.4.0 → chatwire-0.4.2}/integrations/telegram/__init__.py +0 -0
  33. {chatwire-0.4.0 → chatwire-0.4.2}/integrations/web/__init__.py +0 -0
  34. {chatwire-0.4.0 → chatwire-0.4.2}/integrations/webhook/__init__.py +0 -0
  35. {chatwire-0.4.0 → chatwire-0.4.2}/migrations/0001_initial.py +0 -0
  36. {chatwire-0.4.0 → chatwire-0.4.2}/migrations/0002_integration_split.py +0 -0
  37. {chatwire-0.4.0 → chatwire-0.4.2}/migrations/0003_state_dir_paths.py +0 -0
  38. {chatwire-0.4.0 → chatwire-0.4.2}/migrations/__init__.py +0 -0
  39. {chatwire-0.4.0 → chatwire-0.4.2}/prefix.py +0 -0
  40. {chatwire-0.4.0 → chatwire-0.4.2}/setup.cfg +0 -0
  41. {chatwire-0.4.0 → chatwire-0.4.2}/templates/__init__.py +0 -0
  42. {chatwire-0.4.0 → chatwire-0.4.2}/templates/launchd/bridge.plist.template +0 -0
  43. {chatwire-0.4.0 → chatwire-0.4.2}/templates/launchd/keepawake.plist.template +0 -0
  44. {chatwire-0.4.0 → chatwire-0.4.2}/templates/launchd/web.plist.template +0 -0
  45. {chatwire-0.4.0 → chatwire-0.4.2}/web/setup_wizard.py +0 -0
  46. {chatwire-0.4.0 → chatwire-0.4.2}/web/static/favicon.svg +0 -0
  47. {chatwire-0.4.0 → chatwire-0.4.2}/web/static/sw.js +0 -0
  48. {chatwire-0.4.0 → chatwire-0.4.2}/web/static/themes/THEMES.md +0 -0
  49. {chatwire-0.4.0 → chatwire-0.4.2}/web/static/themes/_bridge.css +0 -0
  50. {chatwire-0.4.0 → chatwire-0.4.2}/web/static/themes/default.css +0 -0
  51. {chatwire-0.4.0 → chatwire-0.4.2}/web/static/update-check.js +0 -0
  52. {chatwire-0.4.0 → chatwire-0.4.2}/web/templates/_contact.html +0 -0
  53. {chatwire-0.4.0 → chatwire-0.4.2}/web/templates/_conversations.html +0 -0
  54. {chatwire-0.4.0 → chatwire-0.4.2}/web/templates/_settings.html +0 -0
  55. {chatwire-0.4.0 → chatwire-0.4.2}/web/templates/_whitelist_rows.html +0 -0
  56. {chatwire-0.4.0 → chatwire-0.4.2}/web/templates/index.html +0 -0
  57. {chatwire-0.4.0 → chatwire-0.4.2}/whitelist.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chatwire
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: macOS chat-relay bridge: iMessage <-> Telegram, web UI, and pluggable integrations
5
5
  Author: Allen Bina
6
6
  License: MIT License
@@ -9,4 +9,4 @@ Format: PEP 440-flavored semver. Pre-1.0 dev builds carry a `-dev` suffix
9
9
  which the update-check JS treats as "skip the check" (no point pinging
10
10
  GitHub when you cloned from main).
11
11
  """
12
- __version__ = "0.4.0"
12
+ __version__ = "0.4.2"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chatwire
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: macOS chat-relay bridge: iMessage <-> Telegram, web UI, and pluggable integrations
5
5
  Author: Allen Bina
6
6
  License: MIT License
@@ -42,6 +42,14 @@ web/static/themes/default.css
42
42
  web/templates/_contact.html
43
43
  web/templates/_conversation.html
44
44
  web/templates/_conversations.html
45
+ web/templates/_history_page.html
46
+ web/templates/_messages.html
45
47
  web/templates/_settings.html
46
48
  web/templates/_whitelist_rows.html
47
- web/templates/index.html
49
+ web/templates/index.html
50
+ web/templates/wizard/_done.html
51
+ web/templates/wizard/_identity.html
52
+ web/templates/wizard/_permissions.html
53
+ web/templates/wizard/_telegram.html
54
+ web/templates/wizard/_whitelist.html
55
+ web/templates/wizard/index.html
@@ -100,7 +100,7 @@ include = ["integrations*", "migrations*", "web*", "templates*"]
100
100
  [tool.setuptools.package-data]
101
101
  # Web UI ships its templates and static assets inline so a wheel install
102
102
  # can serve the UI without the source tree alongside.
103
- web = ["templates/*.html", "static/**/*"]
103
+ web = ["templates/**/*.html", "static/**/*"]
104
104
  # launchd plist templates rendered by `chatwire install-agents`. The CLI
105
105
  # resolves them relative to its own __file__, so they need to ship in the
106
106
  # wheel (not just the sdist) for a pipx install to work.
@@ -192,7 +192,8 @@ LEFT JOIN handle h ON m.handle_id = h.ROWID
192
192
  JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
193
193
  JOIN chat c ON c.ROWID = cmj.chat_id
194
194
  WHERE h.id IN ({placeholders}) AND c.style = 45
195
- ORDER BY m.date DESC
195
+ {cursor_clause}
196
+ ORDER BY m.date DESC, m.ROWID DESC
196
197
  LIMIT ?
197
198
  """
198
199
 
@@ -207,7 +208,7 @@ WHERE maj.message_id = ?
207
208
  # per row so the UI can label "from Alice" above each bubble (group members
208
209
  # are only distinguishable by handle_id). Outgoing group rows have handle_id
209
210
  # NULL, so sender_handle is empty for those — the UI renders them as "me".
210
- HISTORY_GROUP_SQL = """
211
+ HISTORY_GROUP_SQL_TEMPLATE = """
211
212
  SELECT
212
213
  m.ROWID AS rowid,
213
214
  m.is_from_me AS is_from_me,
@@ -224,7 +225,8 @@ LEFT JOIN handle h ON m.handle_id = h.ROWID
224
225
  JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
225
226
  JOIN chat c ON c.ROWID = cmj.chat_id
226
227
  WHERE c.guid = ?
227
- ORDER BY m.date DESC
228
+ {cursor_clause}
229
+ ORDER BY m.date DESC, m.ROWID DESC
228
230
  LIMIT ?
229
231
  """
230
232
 
@@ -453,13 +455,36 @@ def _group_info(guid: str) -> dict:
453
455
  return {"name": name, "members": int(row["members"] or 0)}
454
456
 
455
457
 
456
- def history_for(handle: str) -> list[dict]:
458
+ def history_for(
459
+ handle: str, before: tuple[int, int] | None = None
460
+ ) -> tuple[list[dict], bool]:
461
+ """Most-recent HISTORY_LIMIT messages for a 1:1, oldest-first for render.
462
+
463
+ `before` is an optional `(date, rowid)` cursor \u2014 return messages strictly
464
+ older than that point. Used by the load-older paging endpoint.
465
+
466
+ Fetches `HISTORY_LIMIT + 1` rows so we can tell whether more pages exist
467
+ without an extra round-trip; the extra row is dropped. Returns
468
+ `(msgs, has_more)`.
469
+ """
457
470
  handles = _handles_for_canonical(handle)
458
471
  placeholders = ",".join("?" * len(handles))
459
- sql = HISTORY_SQL_TEMPLATE.format(placeholders=placeholders)
472
+ if before is None:
473
+ sql = HISTORY_SQL_TEMPLATE.format(
474
+ placeholders=placeholders, cursor_clause=""
475
+ )
476
+ params = (*handles, HISTORY_LIMIT + 1)
477
+ else:
478
+ sql = HISTORY_SQL_TEMPLATE.format(
479
+ placeholders=placeholders,
480
+ cursor_clause="AND (m.date, m.ROWID) < (?, ?)",
481
+ )
482
+ params = (*handles, before[0], before[1], HISTORY_LIMIT + 1)
460
483
  conn = _snapshot()
461
484
  try:
462
- rows = conn.execute(sql, (*handles, HISTORY_LIMIT)).fetchall()
485
+ rows = conn.execute(sql, params).fetchall()
486
+ has_more = len(rows) > HISTORY_LIMIT
487
+ rows = rows[:HISTORY_LIMIT]
463
488
  atts_by_msg: dict[int, list[dict]] = defaultdict(list)
464
489
  for r in rows:
465
490
  if not r["has_attachments"]:
@@ -489,6 +514,7 @@ def history_for(handle: str) -> list[dict]:
489
514
  body = (r["text"] or "").replace("\ufffc", "").strip()
490
515
  entry = {
491
516
  "rowid": r["rowid"],
517
+ "date": int(r["date"] or 0),
492
518
  "from_me": bool(r["is_from_me"]),
493
519
  "ts": _ts(r["date"]),
494
520
  "text": body,
@@ -497,19 +523,36 @@ def history_for(handle: str) -> list[dict]:
497
523
  if entry["from_me"]:
498
524
  entry.update(_delivery_status(r))
499
525
  out.append(entry)
500
- return out
526
+ return out, has_more
501
527
 
502
528
 
503
- def history_for_group(guid: str) -> list[dict]:
529
+ def history_for_group(
530
+ guid: str, before: tuple[int, int] | None = None
531
+ ) -> tuple[list[dict], bool]:
504
532
  """Message history for a single group chat, oldest-first for render.
505
533
 
506
534
  Adds sender_handle/sender_name to every entry so the UI can label
507
535
  incoming bubbles with the group member's name; outgoing rows keep the
508
536
  existing delivery fields.
537
+
538
+ `before` is an optional `(date, rowid)` cursor — return messages strictly
539
+ older than that point. Used by the load-older paging endpoint. Returns
540
+ `(msgs, has_more)`; one extra row is fetched and dropped to compute the
541
+ has-more flag without an extra round-trip.
509
542
  """
543
+ if before is None:
544
+ sql = HISTORY_GROUP_SQL_TEMPLATE.format(cursor_clause="")
545
+ params = (guid, HISTORY_LIMIT + 1)
546
+ else:
547
+ sql = HISTORY_GROUP_SQL_TEMPLATE.format(
548
+ cursor_clause="AND (m.date, m.ROWID) < (?, ?)",
549
+ )
550
+ params = (guid, before[0], before[1], HISTORY_LIMIT + 1)
510
551
  conn = _snapshot()
511
552
  try:
512
- rows = conn.execute(HISTORY_GROUP_SQL, (guid, HISTORY_LIMIT)).fetchall()
553
+ rows = conn.execute(sql, params).fetchall()
554
+ has_more = len(rows) > HISTORY_LIMIT
555
+ rows = rows[:HISTORY_LIMIT]
513
556
  atts_by_msg: dict[int, list[dict]] = defaultdict(list)
514
557
  for r in rows:
515
558
  if not r["has_attachments"]:
@@ -538,6 +581,7 @@ def history_for_group(guid: str) -> list[dict]:
538
581
  sender = r["sender_handle"] or ""
539
582
  entry = {
540
583
  "rowid": r["rowid"],
584
+ "date": int(r["date"] or 0),
541
585
  "from_me": bool(r["is_from_me"]),
542
586
  "ts": _ts(r["date"]),
543
587
  "text": body,
@@ -548,7 +592,7 @@ def history_for_group(guid: str) -> list[dict]:
548
592
  if entry["from_me"]:
549
593
  entry.update(_delivery_status(r))
550
594
  out.append(entry)
551
- return out
595
+ return out, has_more
552
596
 
553
597
 
554
598
  def _cap_class_for(cap: str) -> str:
@@ -750,6 +794,27 @@ async def conversations(request: Request):
750
794
  return templates.TemplateResponse(request, "_conversations.html", {"convos": convos})
751
795
 
752
796
 
797
+ def _paging_meta(
798
+ msgs: list[dict], has_more: bool, handle: str, chat: str,
799
+ ) -> dict:
800
+ """Render `next_url` for the load-older trigger when more pages exist.
801
+
802
+ `msgs` is oldest-first as rendered. The cursor for the next page is the
803
+ (date, rowid) of the *oldest* message currently in the page — `msgs[0]`."""
804
+ if not has_more or not msgs:
805
+ return {"has_more": False, "next_url": ""}
806
+ first = msgs[0]
807
+ if chat:
808
+ from urllib.parse import quote
809
+ url = (f"/history?chat={quote(chat, safe='')}"
810
+ f"&before_date={first['date']}&before_rowid={first['rowid']}")
811
+ else:
812
+ from urllib.parse import quote
813
+ url = (f"/history?handle={quote(handle, safe='')}"
814
+ f"&before_date={first['date']}&before_rowid={first['rowid']}")
815
+ return {"has_more": True, "next_url": url}
816
+
817
+
753
818
  @app.get("/conversation", response_class=HTMLResponse)
754
819
  async def conversation(request: Request, handle: str = "", chat: str = ""):
755
820
  """Render a conversation. Exactly one of `handle` (1:1) or `chat`
@@ -757,29 +822,64 @@ async def conversation(request: Request, handle: str = "", chat: str = ""):
757
822
  if chat:
758
823
  if chat not in wl_all_groups():
759
824
  raise HTTPException(403, "group not in whitelist")
760
- msgs = await asyncio.to_thread(history_for_group, chat)
825
+ msgs, has_more = await asyncio.to_thread(history_for_group, chat)
761
826
  info = await asyncio.to_thread(_group_info, chat)
762
- return templates.TemplateResponse(request, "_conversation.html", {
827
+ ctx = {
763
828
  "handle": "",
764
829
  "chat": chat,
765
830
  "name": info["name"],
766
831
  "subtitle": f"{info['members']} members" if info["members"] else "group chat",
767
832
  "is_group": True,
768
833
  "msgs": msgs,
769
- })
834
+ }
835
+ ctx.update(_paging_meta(msgs, has_more, "", chat))
836
+ return templates.TemplateResponse(request, "_conversation.html", ctx)
770
837
  if not handle:
771
838
  raise HTTPException(400, "missing handle or chat")
772
839
  if handle.lower() not in relay_handles():
773
840
  raise HTTPException(403, "handle not in relay scope")
774
- msgs = await asyncio.to_thread(history_for, handle)
775
- return templates.TemplateResponse(request, "_conversation.html", {
841
+ msgs, has_more = await asyncio.to_thread(history_for, handle)
842
+ ctx = {
776
843
  "handle": handle,
777
844
  "chat": "",
778
845
  "name": _name(handle),
779
846
  "subtitle": handle,
780
847
  "is_group": False,
781
848
  "msgs": msgs,
782
- })
849
+ }
850
+ ctx.update(_paging_meta(msgs, has_more, handle, ""))
851
+ return templates.TemplateResponse(request, "_conversation.html", ctx)
852
+
853
+
854
+ @app.get("/history", response_class=HTMLResponse)
855
+ async def history(
856
+ request: Request,
857
+ handle: str = "",
858
+ chat: str = "",
859
+ before_date: int = 0,
860
+ before_rowid: int = 0,
861
+ ):
862
+ """Older-message page for an open conversation. Returns the bubble
863
+ fragment (oldest-first) plus a fresh load-older trigger if there are
864
+ more pages still. Same authz as `/conversation`."""
865
+ if before_date <= 0 or before_rowid <= 0:
866
+ raise HTTPException(400, "missing or invalid cursor")
867
+ cursor = (before_date, before_rowid)
868
+ if chat:
869
+ if chat not in wl_all_groups():
870
+ raise HTTPException(403, "group not in whitelist")
871
+ msgs, has_more = await asyncio.to_thread(history_for_group, chat, cursor)
872
+ ctx = {"is_group": True, "msgs": msgs}
873
+ ctx.update(_paging_meta(msgs, has_more, "", chat))
874
+ return templates.TemplateResponse(request, "_history_page.html", ctx)
875
+ if not handle:
876
+ raise HTTPException(400, "missing handle or chat")
877
+ if handle.lower() not in relay_handles():
878
+ raise HTTPException(403, "handle not in relay scope")
879
+ msgs, has_more = await asyncio.to_thread(history_for, handle, cursor)
880
+ ctx = {"is_group": False, "msgs": msgs}
881
+ ctx.update(_paging_meta(msgs, has_more, handle, ""))
882
+ return templates.TemplateResponse(request, "_history_page.html", ctx)
783
883
 
784
884
 
785
885
  async def _send_via_ctx_or_direct(
@@ -368,6 +368,13 @@ body { display: grid; grid-template-columns: 320px 1fr; }
368
368
 
369
369
  .messages { flex: 1; overflow-y: auto; padding: 24px 20px; display: flex; flex-direction: column; gap: 4px; }
370
370
  .messages > .msg:first-child { margin-top: auto; } /* push pinned to bottom when few msgs */
371
+ .load-older {
372
+ align-self: center;
373
+ padding: 8px 0 4px;
374
+ font-size: 18px;
375
+ color: var(--app-color-text-faint);
376
+ flex-shrink: 0;
377
+ }
371
378
  .msg { display: flex; flex-direction: column; max-width: 70%; }
372
379
  .msg.me { align-self: flex-end; align-items: flex-end; }
373
380
  .msg.them { align-self: flex-start; align-items: flex-start; }
@@ -27,46 +27,15 @@
27
27
  </header>
28
28
 
29
29
  <div class="messages" id="messages">
30
- {% for m in msgs %}
31
- <div class="msg {{ 'me' if m.from_me else 'them' }}{% if m.from_me and m.service == 'SMS' %} sms{% endif %}">
32
- {% if is_group and not m.from_me and m.sender_name %}
33
- <div class="sender">{{ m.sender_name }}</div>
34
- {% endif %}
35
- {% for a in m.attachments %}
36
- {% if not a.ready %}
37
- <div class="attach pending">
38
- <sl-spinner></sl-spinner>
39
- <span>syncing attachment…</span>
40
- </div>
41
- {% elif a.kind == 'image' %}
42
- <a class="media" href="/attachment?path={{ a.path | urlencode }}" target="_blank">
43
- <img src="/attachment?path={{ a.path | urlencode }}&size=thumb" alt="{{ a.name }}" loading="lazy">
44
- </a>
45
- {% elif a.kind == 'video' %}
46
- <video class="media" controls preload="metadata"
47
- src="/attachment?path={{ a.path | urlencode }}"
48
- onerror="this.outerHTML = '<a class=&quot;bubble file-link&quot; href=&quot;/attachment?path={{ a.path | urlencode }}&quot; target=&quot;_blank&quot;>🎬 {{ a.name }}</a>'"></video>
49
- {% elif a.kind == 'audio' %}
50
- <audio class="media" controls preload="none" src="/attachment?path={{ a.path | urlencode }}"></audio>
51
- {% else %}
52
- <a class="bubble file-link" href="/attachment?path={{ a.path | urlencode }}" target="_blank">📎 {{ a.name }}</a>
53
- {% endif %}
54
- {% endfor %}
55
- {% if m.text %}<div class="bubble"><div class="text">{{ m.text }}</div></div>{% endif %}
56
- <div class="ts">
57
- {{ m.ts }}
58
- {% if m.from_me and m.status and m.status != 'delivered' %}
59
- {% if m.status == 'failed' %}
60
- <sl-badge class="delivery" variant="danger" pill title="{{ m.hint }}">{{ m.label }}</sl-badge>
61
- {% elif m.status == 'sent' %}
62
- <sl-badge class="delivery" variant="neutral" pill title="{{ m.hint }}">sent</sl-badge>
63
- {% else %}
64
- <sl-badge class="delivery" variant="warning" pill pulse title="{{ m.hint }}">{{ m.label }}</sl-badge>
65
- {% endif %}
66
- {% endif %}
67
- </div>
30
+ {% if has_more %}
31
+ <div class="load-older"
32
+ hx-get="{{ next_url }}"
33
+ hx-trigger="intersect once"
34
+ hx-swap="outerHTML">
35
+ <sl-spinner></sl-spinner>
68
36
  </div>
69
- {% endfor %}
37
+ {% endif %}
38
+ {% include "_messages.html" %}
70
39
  </div>
71
40
 
72
41
  <div id="send-banner" class="send-banner" hidden></div>
@@ -226,6 +195,26 @@
226
195
  (function() {
227
196
  const m = document.getElementById('messages');
228
197
  if (m) m.scrollTop = m.scrollHeight;
198
+
199
+ // Scroll-anchor preservation around load-older swaps. When the trigger
200
+ // is replaced with a page of older bubbles + a fresh trigger, content
201
+ // grows above the user's view; we capture distance-from-bottom before
202
+ // the swap and restore it after so the user stays anchored on the
203
+ // message they were reading instead of being kicked to the new top.
204
+ if (m) {
205
+ let anchor = null;
206
+ m.addEventListener('htmx:beforeSwap', (e) => {
207
+ const t = e.detail && e.detail.target;
208
+ if (!t || !t.classList || !t.classList.contains('load-older')) return;
209
+ anchor = m.scrollHeight - m.scrollTop;
210
+ });
211
+ m.addEventListener('htmx:afterSwap', () => {
212
+ if (anchor === null) return;
213
+ m.scrollTop = m.scrollHeight - anchor;
214
+ anchor = null;
215
+ });
216
+ }
217
+
229
218
  const mainEl = document.getElementById('main');
230
219
  {% if is_group %}
231
220
  mainEl.dataset.chat = '{{ chat }}';
@@ -0,0 +1,13 @@
1
+ {# Response to GET /history. Replaces the load-older trigger in-place
2
+ (hx-swap=outerHTML) with: a new trigger for the next page (if any), then
3
+ the page of older messages (oldest-first). The conversation's #messages
4
+ container handles scroll-anchor preservation around this swap. #}
5
+ {% if has_more %}
6
+ <div class="load-older"
7
+ hx-get="{{ next_url }}"
8
+ hx-trigger="intersect once"
9
+ hx-swap="outerHTML">
10
+ <sl-spinner></sl-spinner>
11
+ </div>
12
+ {% endif %}
13
+ {% include "_messages.html" %}
@@ -0,0 +1,43 @@
1
+ {# Message bubble loop. Shared by _conversation.html (initial render) and
2
+ _history_page.html (load-older paging). Expects `msgs` (list, oldest-first)
3
+ and `is_group` (bool). #}
4
+ {% for m in msgs %}
5
+ <div class="msg {{ 'me' if m.from_me else 'them' }}{% if m.from_me and m.service == 'SMS' %} sms{% endif %}">
6
+ {% if is_group and not m.from_me and m.sender_name %}
7
+ <div class="sender">{{ m.sender_name }}</div>
8
+ {% endif %}
9
+ {% for a in m.attachments %}
10
+ {% if not a.ready %}
11
+ <div class="attach pending">
12
+ <sl-spinner></sl-spinner>
13
+ <span>syncing attachment…</span>
14
+ </div>
15
+ {% elif a.kind == 'image' %}
16
+ <a class="media" href="/attachment?path={{ a.path | urlencode }}" target="_blank">
17
+ <img src="/attachment?path={{ a.path | urlencode }}&size=thumb" alt="{{ a.name }}" loading="lazy">
18
+ </a>
19
+ {% elif a.kind == 'video' %}
20
+ <video class="media" controls preload="metadata"
21
+ src="/attachment?path={{ a.path | urlencode }}"
22
+ onerror="this.outerHTML = '<a class=&quot;bubble file-link&quot; href=&quot;/attachment?path={{ a.path | urlencode }}&quot; target=&quot;_blank&quot;>🎬 {{ a.name }}</a>'"></video>
23
+ {% elif a.kind == 'audio' %}
24
+ <audio class="media" controls preload="none" src="/attachment?path={{ a.path | urlencode }}"></audio>
25
+ {% else %}
26
+ <a class="bubble file-link" href="/attachment?path={{ a.path | urlencode }}" target="_blank">📎 {{ a.name }}</a>
27
+ {% endif %}
28
+ {% endfor %}
29
+ {% if m.text %}<div class="bubble"><div class="text">{{ m.text }}</div></div>{% endif %}
30
+ <div class="ts">
31
+ {{ m.ts }}
32
+ {% if m.from_me and m.status and m.status != 'delivered' %}
33
+ {% if m.status == 'failed' %}
34
+ <sl-badge class="delivery" variant="danger" pill title="{{ m.hint }}">{{ m.label }}</sl-badge>
35
+ {% elif m.status == 'sent' %}
36
+ <sl-badge class="delivery" variant="neutral" pill title="{{ m.hint }}">sent</sl-badge>
37
+ {% else %}
38
+ <sl-badge class="delivery" variant="warning" pill pulse title="{{ m.hint }}">{{ m.label }}</sl-badge>
39
+ {% endif %}
40
+ {% endif %}
41
+ </div>
42
+ </div>
43
+ {% endfor %}
@@ -0,0 +1,35 @@
1
+ <section class="wizard-step">
2
+ <h2>Setup complete</h2>
3
+ <p class="lead">
4
+ Configuration saved to <code>~/.chatwire/config.json</code>
5
+ (chmod 600). The bridge agents need a restart to pick up new env vars.
6
+ </p>
7
+
8
+ <div class="wizard-restart">
9
+ <h3>Restart the agents</h3>
10
+ <p>From a terminal on this Mac:</p>
11
+ <pre><code>launchctl kickstart -k gui/$(id -u)/dev.chatwire.bridge
12
+ launchctl kickstart -k gui/$(id -u)/dev.chatwire.web</code></pre>
13
+ <p>
14
+ <small>If you installed with a different label prefix, substitute that.
15
+ <code>chatwire doctor</code> lists the currently-loaded labels.</small>
16
+ </p>
17
+ </div>
18
+
19
+ <div class="wizard-restart">
20
+ <h3>What's next</h3>
21
+ <ul>
22
+ <li><a href="/">Open the main UI</a> — by the time you click, the web agent's restart will be visible (the page reloads itself).</li>
23
+ <li><a href="/setup?step=permissions">Re-run any wizard step</a> if you want to tweak settings.</li>
24
+ <li>Tail logs: <code>chatwire logs -f</code></li>
25
+ </ul>
26
+ </div>
27
+
28
+ <div class="wizard-actions">
29
+ <sl-button type="button"
30
+ hx-get="/setup/step/whitelist"
31
+ hx-target="#wizard"
32
+ hx-swap="innerHTML">← Back</sl-button>
33
+ <sl-button variant="primary" href="/">Open the bridge →</sl-button>
34
+ </div>
35
+ </section>
@@ -0,0 +1,49 @@
1
+ <section class="wizard-step">
2
+ <h2>Step 2 — your iMessage handles</h2>
3
+ <p class="lead">
4
+ Pick which handles are <em>you</em>. The bridge uses these to filter
5
+ out your own messages from the relay (otherwise replies you send from
6
+ one device would echo back as inbound).
7
+ </p>
8
+
9
+ <form hx-post="/setup/save/identity"
10
+ hx-target="#wizard"
11
+ hx-swap="innerHTML">
12
+ {% if all_candidates %}
13
+ <ul class="self-picks">
14
+ {% for h in all_candidates %}
15
+ <li>
16
+ <label>
17
+ <input type="checkbox" name="self" value="{{ h }}"
18
+ {% if h in configured %}checked{% endif %}>
19
+ <code>{{ h }}</code>
20
+ {% if h in detected and h not in configured %}<small>(detected)</small>{% endif %}
21
+ {% if h in configured and h not in detected %}<small>(configured)</small>{% endif %}
22
+ </label>
23
+ </li>
24
+ {% endfor %}
25
+ </ul>
26
+ {% else %}
27
+ <p class="wizard-hint">
28
+ No accounts detected in <code>chat.db</code> yet. If Messages.app has
29
+ never run on this Mac, sign in first, send a test message, then come
30
+ back.
31
+ </p>
32
+ {% endif %}
33
+
34
+ <label class="wizard-field">
35
+ <span>Add another handle</span>
36
+ <sl-input name="extra" placeholder="+15551234567 or you@icloud.com"></sl-input>
37
+ <small>Comma- or space-separated. Used if your account doesn't appear
38
+ above for some reason (e.g. shared-account quirks).</small>
39
+ </label>
40
+
41
+ <div class="wizard-actions">
42
+ <sl-button type="button"
43
+ hx-get="/setup/step/permissions"
44
+ hx-target="#wizard"
45
+ hx-swap="innerHTML">← Back</sl-button>
46
+ <sl-button type="submit" variant="primary">Save and continue →</sl-button>
47
+ </div>
48
+ </form>
49
+ </section>
@@ -0,0 +1,66 @@
1
+ <section class="wizard-step">
2
+ <h2>Step 1 — macOS permissions</h2>
3
+ <p class="lead">
4
+ The bridge needs two macOS privacy grants. Both are tied to the
5
+ python.org Python binary at
6
+ <code>/Library/Frameworks/Python.framework/Versions/3.14/Resources/Python.app</code>
7
+ (your install path may vary). Apple does not allow these to be granted
8
+ by an installer — you'll click through System Settings yourself.
9
+ </p>
10
+
11
+ <div class="probe-row">
12
+ <div class="probe-name">
13
+ <strong>Full Disk Access</strong>
14
+ <small>required to read <code>~/Library/Messages/chat.db</code></small>
15
+ </div>
16
+ <div class="probe-status probe-{{ fda.status }}">
17
+ {% if fda.status == "ok" %}✓ {{ fda.detail }}
18
+ {% else %}✗ {{ fda.detail }}{% endif %}
19
+ </div>
20
+ <div class="probe-action">
21
+ <sl-button href="x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles">
22
+ Open Settings
23
+ </sl-button>
24
+ </div>
25
+ </div>
26
+
27
+ <div class="probe-row">
28
+ <div class="probe-name">
29
+ <strong>Automation → Messages</strong>
30
+ <small>required to send via AppleScript</small>
31
+ </div>
32
+ <div class="probe-status probe-{{ automation.status }}">
33
+ {% if automation.status == "ok" %}✓ {{ automation.detail }}
34
+ {% else %}✗ {{ automation.detail }}{% endif %}
35
+ </div>
36
+ <div class="probe-action">
37
+ <sl-button href="x-apple.systempreferences:com.apple.preference.security?Privacy_Automation">
38
+ Open Settings
39
+ </sl-button>
40
+ </div>
41
+ </div>
42
+
43
+ <div class="wizard-actions">
44
+ <sl-button type="button"
45
+ hx-post="/setup/check/permissions"
46
+ hx-target="#wizard"
47
+ hx-swap="innerHTML">
48
+ Re-check
49
+ </sl-button>
50
+ <sl-button type="button"
51
+ variant="primary"
52
+ {% if fda.status != "ok" or automation.status != "ok" %}disabled{% endif %}
53
+ hx-get="/setup/step/identity"
54
+ hx-target="#wizard"
55
+ hx-swap="innerHTML">
56
+ Next →
57
+ </sl-button>
58
+ </div>
59
+
60
+ {% if fda.status != "ok" or automation.status != "ok" %}
61
+ <p class="wizard-hint">
62
+ Both grants must show ✓ before you can advance. After granting, click
63
+ Re-check.
64
+ </p>
65
+ {% endif %}
66
+ </section>
@@ -0,0 +1,60 @@
1
+ <section class="wizard-step">
2
+ <h2>Step 3 — Telegram bot</h2>
3
+ <p class="lead">
4
+ Telegram is the default integration. Skip it (leave blank) if you only
5
+ want the web UI.
6
+ </p>
7
+
8
+ <details class="wizard-howto">
9
+ <summary>How to get a bot token</summary>
10
+ <ol>
11
+ <li>Open Telegram, message <a href="https://t.me/BotFather" target="_blank" rel="noopener">@BotFather</a>.</li>
12
+ <li>Send <code>/newbot</code>. Pick a name and a username (must end in <code>bot</code>).</li>
13
+ <li>BotFather replies with a token like <code>123456:ABC-DEF...</code>. Paste it below.</li>
14
+ <li>Message <a href="https://t.me/userinfobot" target="_blank" rel="noopener">@userinfobot</a> to get
15
+ your numeric user ID. That goes in the second field.</li>
16
+ </ol>
17
+ </details>
18
+
19
+ <form hx-post="/setup/save/telegram"
20
+ hx-target="#wizard"
21
+ hx-swap="innerHTML">
22
+ <label class="wizard-field">
23
+ <span>Bot token</span>
24
+ <sl-input name="token" id="tg-token"
25
+ placeholder="123456:ABC-DEF..."
26
+ value="{{ token }}"
27
+ autocomplete="off"
28
+ spellcheck="false"></sl-input>
29
+ </label>
30
+
31
+ <label class="wizard-field">
32
+ <span>Your Telegram user IDs (comma-separated)</span>
33
+ <sl-input name="allowed_user_ids"
34
+ placeholder="123456789"
35
+ value="{{ allowed_user_ids }}"
36
+ autocomplete="off"
37
+ spellcheck="false"></sl-input>
38
+ <small>Only these users can talk to the bot. The bridge ignores everyone else.</small>
39
+ </label>
40
+
41
+ <div class="probe-row">
42
+ <sl-button type="button"
43
+ hx-post="/setup/test/telegram"
44
+ hx-include="#tg-token"
45
+ hx-target="#tg-test-result"
46
+ hx-swap="innerHTML">
47
+ Test token
48
+ </sl-button>
49
+ <span id="tg-test-result" class="probe-status"></span>
50
+ </div>
51
+
52
+ <div class="wizard-actions">
53
+ <sl-button type="button"
54
+ hx-get="/setup/step/identity"
55
+ hx-target="#wizard"
56
+ hx-swap="innerHTML">← Back</sl-button>
57
+ <sl-button type="submit" variant="primary">Save and continue →</sl-button>
58
+ </div>
59
+ </form>
60
+ </section>
@@ -0,0 +1,26 @@
1
+ <section class="wizard-step">
2
+ <h2>Step 4 — Whitelist</h2>
3
+ <p class="lead">
4
+ Pick whose messages should be relayed. Anyone not on this list is
5
+ ignored — their messages stay in Messages.app on your Mac and never
6
+ reach Telegram or the web UI. You can change this any time later from
7
+ Settings.
8
+ </p>
9
+
10
+ <div hx-get="/settings"
11
+ hx-trigger="load"
12
+ hx-swap="outerHTML">
13
+ <p class="loading">loading whitelist…</p>
14
+ </div>
15
+
16
+ <div class="wizard-actions">
17
+ <sl-button type="button"
18
+ hx-get="/setup/step/telegram"
19
+ hx-target="#wizard"
20
+ hx-swap="innerHTML">← Back</sl-button>
21
+ <sl-button type="button" variant="primary"
22
+ hx-get="/setup/step/done"
23
+ hx-target="#wizard"
24
+ hx-swap="innerHTML">Continue →</sl-button>
25
+ </div>
26
+ </section>
@@ -0,0 +1,44 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="default">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>iMessage bridge — setup</title>
7
+ <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
8
+ <!-- Shoelace component library — pinned, see docs/THEME_REDESIGN_PLAN.md. -->
9
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.18.0/cdn/themes/light.css">
10
+ <script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.18.0/cdn/shoelace-autoloader.js"></script>
11
+ <link rel="stylesheet" href="/static/themes/_bridge.css">
12
+ <link rel="stylesheet" href="/static/themes/default.css">
13
+ <link rel="stylesheet" href="/static/style.css">
14
+ <script src="https://unpkg.com/htmx.org@1.9.10" defer></script>
15
+ </head>
16
+ <body class="wizard-body">
17
+ <div class="wizard-shell">
18
+ <aside class="wizard-nav">
19
+ <h1>Setup</h1>
20
+ <ol>
21
+ {% for s in steps %}
22
+ <li class="{% if s == step %}active{% endif %} {% if loop.index0 < step_index %}done{% endif %}">
23
+ <a href="/setup?step={{ s }}"
24
+ hx-get="/setup/step/{{ s }}"
25
+ hx-target="#wizard"
26
+ hx-swap="innerHTML">
27
+ <span class="num">{% if loop.index0 < step_index %}✓{% else %}{{ loop.index }}{% endif %}</span>
28
+ <span class="label">{{ step_titles[s] }}</span>
29
+ </a>
30
+ </li>
31
+ {% endfor %}
32
+ </ol>
33
+ <p class="wizard-foot">
34
+ Once configured, the main UI is at
35
+ <a href="/">/</a>.
36
+ </p>
37
+ </aside>
38
+
39
+ <main id="wizard" class="wizard-main">
40
+ {% include "wizard/_" + step + ".html" %}
41
+ </main>
42
+ </div>
43
+ </body>
44
+ </html>
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes