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.
- {chatwire-0.4.0/chatwire.egg-info → chatwire-0.4.2}/PKG-INFO +1 -1
- {chatwire-0.4.0 → chatwire-0.4.2}/_version.py +1 -1
- {chatwire-0.4.0 → chatwire-0.4.2/chatwire.egg-info}/PKG-INFO +1 -1
- {chatwire-0.4.0 → chatwire-0.4.2}/chatwire.egg-info/SOURCES.txt +9 -1
- {chatwire-0.4.0 → chatwire-0.4.2}/pyproject.toml +1 -1
- {chatwire-0.4.0 → chatwire-0.4.2}/web/main.py +116 -16
- {chatwire-0.4.0 → chatwire-0.4.2}/web/static/style.css +7 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/web/templates/_conversation.html +28 -39
- chatwire-0.4.2/web/templates/_history_page.html +13 -0
- chatwire-0.4.2/web/templates/_messages.html +43 -0
- chatwire-0.4.2/web/templates/wizard/_done.html +35 -0
- chatwire-0.4.2/web/templates/wizard/_identity.html +49 -0
- chatwire-0.4.2/web/templates/wizard/_permissions.html +66 -0
- chatwire-0.4.2/web/templates/wizard/_telegram.html +60 -0
- chatwire-0.4.2/web/templates/wizard/_whitelist.html +26 -0
- chatwire-0.4.2/web/templates/wizard/index.html +44 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/LICENSE +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/README.md +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/bridge.py +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/chat_db.py +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/chat_send.py +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/chatwire.egg-info/dependency_links.txt +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/chatwire.egg-info/entry_points.txt +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/chatwire.egg-info/requires.txt +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/chatwire.egg-info/top_level.txt +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/chatwire_cli.py +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/config.py +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/contacts.py +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/echo_log.py +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/integrations/__init__.py +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/integrations/base.py +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/integrations/telegram/__init__.py +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/integrations/web/__init__.py +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/integrations/webhook/__init__.py +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/migrations/0001_initial.py +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/migrations/0002_integration_split.py +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/migrations/0003_state_dir_paths.py +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/migrations/__init__.py +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/prefix.py +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/setup.cfg +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/templates/__init__.py +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/templates/launchd/bridge.plist.template +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/templates/launchd/keepawake.plist.template +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/templates/launchd/web.plist.template +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/web/setup_wizard.py +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/web/static/favicon.svg +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/web/static/sw.js +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/web/static/themes/THEMES.md +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/web/static/themes/_bridge.css +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/web/static/themes/default.css +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/web/static/update-check.js +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/web/templates/_contact.html +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/web/templates/_conversations.html +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/web/templates/_settings.html +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/web/templates/_whitelist_rows.html +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/web/templates/index.html +0 -0
- {chatwire-0.4.0 → chatwire-0.4.2}/whitelist.py +0 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{%
|
|
31
|
-
<div class="
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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="bubble file-link" href="/attachment?path={{ a.path | urlencode }}" target="_blank">🎬 {{ 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
|
-
{%
|
|
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="bubble file-link" href="/attachment?path={{ a.path | urlencode }}" target="_blank">🎬 {{ 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
|
|
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
|
|
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
|