mirror of
https://github.com/prdlk/omarchy-beeper.git
synced 2026-11-02 13:12:48 +00:00
feat: omarchy-beeper bar widget for unread Beeper chats
This commit is contained in:
commit
7fc78f1075
17 files changed
+3483
No files matched your search
@@ -0,0 +1,36 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Contract tests
|
||||||
|
run: python3 -m unittest discover -s tests -t tests -v
|
||||||
|
|
||||||
|
- name: Compile Python
|
||||||
|
run: python3 -m compileall -q lib tests
|
||||||
|
|
||||||
|
- name: Manifest JSON
|
||||||
|
run: python3 -c "import json,sys; json.load(open('manifest.json')); print('ok')"
|
||||||
|
|
||||||
|
- name: Shell syntax
|
||||||
|
run: bash -n bin/omarchy-beeper
|
||||||
|
|
||||||
|
- name: ShellCheck
|
||||||
|
uses: ludeeus/action-shellcheck@2.0.0
|
||||||
|
env:
|
||||||
|
SHELLCHECK_OPTS: -x
|
||||||
|
with:
|
||||||
|
scandir: '.'
|
||||||
|
ignore_paths: tests
|
||||||
|
version: v0.10.0
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
*~
|
||||||
|
*.swp
|
||||||
|
*.qmlc
|
||||||
|
.DS_Store
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Window
|
||||||
|
import qs.Commons
|
||||||
|
|
||||||
|
// Chat bubble: rounded outline with a tail, and three dots that fade in when
|
||||||
|
// something is unread. Stroke, not a filled blob, so the silhouette keeps
|
||||||
|
// contrast on a transparent bar over light, dark, or mixed wallpapers.
|
||||||
|
// Every painted extent stays inside the optical canvas at every dot pose.
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property real iconSize: Style.bar.iconCanvas
|
||||||
|
property color color: Color.foreground
|
||||||
|
property color dotColor: color
|
||||||
|
property bool hasMail: false
|
||||||
|
|
||||||
|
width: iconSize
|
||||||
|
height: iconSize
|
||||||
|
implicitWidth: iconSize
|
||||||
|
implicitHeight: iconSize
|
||||||
|
|
||||||
|
readonly property real dpr: {
|
||||||
|
var win = Window.window
|
||||||
|
return (win && win.devicePixelRatio > 0) ? win.devicePixelRatio : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function snap(v) {
|
||||||
|
return Math.round(v * root.dpr) / root.dpr
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapStroke(v) {
|
||||||
|
return Math.max(1, Math.round(v * root.dpr)) / root.dpr
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property real stroke: snapStroke(Math.max(1.5, iconSize * 0.11))
|
||||||
|
readonly property real pad: snap(Math.max(stroke / 2, 0.5))
|
||||||
|
|
||||||
|
readonly property real tailH: snap(Math.max(stroke * 1.2, iconSize * 0.16))
|
||||||
|
readonly property real bodyX: snap(pad)
|
||||||
|
readonly property real bodyY: snap(pad)
|
||||||
|
readonly property real bodyW: snap(iconSize - pad * 2)
|
||||||
|
readonly property real bodyH: snap(Math.max(stroke * 3, iconSize - pad * 2 - tailH))
|
||||||
|
readonly property real radius: snap(Math.min(bodyW, bodyH) * 0.3)
|
||||||
|
|
||||||
|
// The tail hangs off the bottom edge, left of centre, and never reaches
|
||||||
|
// the corner arc: keep both feet between the arcs.
|
||||||
|
readonly property real tailLeft: snap(bodyX + Math.max(radius, bodyW * 0.2))
|
||||||
|
readonly property real tailRight: snap(tailLeft + Math.max(stroke, bodyW * 0.2))
|
||||||
|
readonly property real tailTipX: snap(tailLeft + Math.max(stroke / 2, bodyW * 0.04))
|
||||||
|
|
||||||
|
readonly property real dotR: snap(Math.max(1, Math.min(iconSize * 0.075, bodyH * 0.16)))
|
||||||
|
readonly property real dotGap: snap(Math.max(dotR * 2.2, bodyW * 0.24))
|
||||||
|
readonly property real dotY: snap(bodyY + bodyH / 2)
|
||||||
|
|
||||||
|
property real dotAmount: hasMail ? 1 : 0
|
||||||
|
Behavior on dotAmount {
|
||||||
|
NumberAnimation { duration: 180; easing.type: Easing.InOutQuad }
|
||||||
|
}
|
||||||
|
|
||||||
|
Canvas {
|
||||||
|
id: canvas
|
||||||
|
anchors.fill: parent
|
||||||
|
antialiasing: true
|
||||||
|
renderTarget: Canvas.FramebufferObject
|
||||||
|
renderStrategy: Canvas.Cooperative
|
||||||
|
|
||||||
|
onPaint: {
|
||||||
|
var ctx = getContext("2d")
|
||||||
|
ctx.reset()
|
||||||
|
ctx.clearRect(0, 0, width, height)
|
||||||
|
ctx.lineCap = "round"
|
||||||
|
ctx.lineJoin = "round"
|
||||||
|
ctx.strokeStyle = root.color
|
||||||
|
ctx.lineWidth = root.stroke
|
||||||
|
|
||||||
|
var x = root.bodyX
|
||||||
|
var y = root.bodyY
|
||||||
|
var w = root.bodyW
|
||||||
|
var h = root.bodyH
|
||||||
|
var r = Math.min(root.radius, w / 2, h / 2)
|
||||||
|
|
||||||
|
// One closed path: bubble outline with the tail cut into the bottom
|
||||||
|
// edge, so there is no seam where a second stroke would overlap.
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x + r, y)
|
||||||
|
ctx.lineTo(x + w - r, y)
|
||||||
|
ctx.arcTo(x + w, y, x + w, y + r, r)
|
||||||
|
ctx.lineTo(x + w, y + h - r)
|
||||||
|
ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
|
||||||
|
ctx.lineTo(root.tailRight, y + h)
|
||||||
|
ctx.lineTo(root.tailTipX, y + h + root.tailH)
|
||||||
|
ctx.lineTo(root.tailLeft, y + h)
|
||||||
|
ctx.lineTo(x + r, y + h)
|
||||||
|
ctx.arcTo(x, y + h, x, y + h - r, r)
|
||||||
|
ctx.lineTo(x, y + r)
|
||||||
|
ctx.arcTo(x, y, x + r, y, r)
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
if (root.dotAmount <= 0.01)
|
||||||
|
return
|
||||||
|
|
||||||
|
ctx.fillStyle = root.dotColor
|
||||||
|
ctx.globalAlpha = root.dotAmount
|
||||||
|
var cx = x + w / 2
|
||||||
|
var scaled = root.dotR * (0.6 + 0.4 * root.dotAmount)
|
||||||
|
for (var i = -1; i <= 1; i++) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(cx + i * root.dotGap, root.dotY, scaled, 0, Math.PI * 2, false)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onColorChanged: canvas.requestPaint()
|
||||||
|
onDotColorChanged: canvas.requestPaint()
|
||||||
|
onDotAmountChanged: canvas.requestPaint()
|
||||||
|
onIconSizeChanged: canvas.requestPaint()
|
||||||
|
onBodyWChanged: canvas.requestPaint()
|
||||||
|
onDotRChanged: canvas.requestPaint()
|
||||||
|
onDprChanged: canvas.requestPaint()
|
||||||
|
Component.onCompleted: canvas.requestPaint()
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.0
|
||||||
|
|
||||||
|
First release.
|
||||||
|
|
||||||
|
- Bar icon: stroked chat bubble with an unread-chat badge in the bar
|
||||||
|
foreground colour, dimmed to half opacity when Beeper Desktop is unreachable.
|
||||||
|
- Panel: one row per unread chat across every connected network, newest first,
|
||||||
|
with the network as a chip when more than one account is connected.
|
||||||
|
- Click a row (or `Enter`/`Space`/`o`) to focus that chat in Beeper Desktop and
|
||||||
|
mark it read; the row disappears immediately and the mark is queued.
|
||||||
|
- `a` marks the row under the cursor read without opening it. `A` marks every
|
||||||
|
unread chat read behind a two-press confirm, with a 100-second budget and a
|
||||||
|
partial count.
|
||||||
|
- Paging with `n`/`p`, page size 1–50, poll interval 15–3600 s.
|
||||||
|
- `bin/omarchy-beeper` CLI: `list`, `read`, `read-all`, `open`, `auth`. Every
|
||||||
|
command prints one JSON object and exits 0.
|
||||||
|
- Muted, archived, and low-priority chats are excluded from the pile.
|
||||||
|
- Token stored at `~/.config/omarchy-beeper/secrets/token.json` (600, in a 700
|
||||||
|
directory), read with `O_NOFOLLOW`, never passed in argv.
|
||||||
|
- All HTTP bound to `http://localhost:23373/v1/`, no redirects, 2 MiB body cap
|
||||||
|
and 64 KiB error cap, 30 s per request.
|
||||||
|
- Read and mark-read only: the plugin never sends a message.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 prdlk
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,868 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import Quickshell.Io
|
||||||
|
import qs.Commons
|
||||||
|
import qs.Ui
|
||||||
|
|
||||||
|
// Beeper: unread conversations only. Click a row to open that chat in Beeper
|
||||||
|
// Desktop and mark it read.
|
||||||
|
//
|
||||||
|
// Data comes from `bin/omarchy-beeper`, which is the only thing that talks to
|
||||||
|
// the local Beeper API. This file never sees the token and never opens a
|
||||||
|
// socket; it runs the CLI and parses one JSON object from stdout.
|
||||||
|
//
|
||||||
|
// Every string below the header is text someone else wrote, so each Text
|
||||||
|
// carries `textFormat: Text.PlainText`.
|
||||||
|
Panel {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
moduleName: "prdlk.beeper"
|
||||||
|
ipcTarget: "prdlk.beeper"
|
||||||
|
|
||||||
|
readonly property string script:
|
||||||
|
Qt.resolvedUrl("bin/omarchy-beeper").toString().replace(/^file:\/\//, "")
|
||||||
|
|
||||||
|
readonly property string iconOpenApp: "\uF08E"
|
||||||
|
readonly property string iconMarkAll: "\uF2B6"
|
||||||
|
readonly property string iconConfirm: "\uF00C"
|
||||||
|
readonly property string iconPrev: "\uF053"
|
||||||
|
readonly property string iconNext: "\uF054"
|
||||||
|
|
||||||
|
readonly property color foreground: bar ? bar.foreground : Color.foreground
|
||||||
|
readonly property color accent: Color.accent
|
||||||
|
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
|
||||||
|
|
||||||
|
property var messages: []
|
||||||
|
property int unread: 0
|
||||||
|
property var inboxes: []
|
||||||
|
property bool reachable: true
|
||||||
|
property string errorText: ""
|
||||||
|
property string warningText: ""
|
||||||
|
property string pendingId: ""
|
||||||
|
property var readQueue: []
|
||||||
|
property var dismissedIds: ({})
|
||||||
|
property bool markAllArmed: false
|
||||||
|
property bool markAllBusy: false
|
||||||
|
property string actionWarning: ""
|
||||||
|
property int cursor: -1
|
||||||
|
|
||||||
|
property string pageToken: ""
|
||||||
|
property var pageStack: []
|
||||||
|
property string nextPage: ""
|
||||||
|
property int accountCount: 0
|
||||||
|
readonly property bool hasPrev: pageStack.length > 0
|
||||||
|
readonly property bool hasNext: nextPage !== ""
|
||||||
|
|
||||||
|
property double now: 0
|
||||||
|
|
||||||
|
readonly property int badgeCount: unread
|
||||||
|
readonly property bool hasUnread: unread > 0
|
||||||
|
|
||||||
|
readonly property int badgeWidth: badgeCount > 0
|
||||||
|
? Math.max(Style.space(12), String(badgeCount).length * Style.space(6) + Style.space(8))
|
||||||
|
: 0
|
||||||
|
readonly property int barContentWidth: Style.bar.iconFont + badgeWidth + Style.space(5)
|
||||||
|
readonly property int barSlot: barContentWidth + Style.space(10)
|
||||||
|
|
||||||
|
implicitWidth: bar && bar.vertical ? (bar ? bar.barSize : Style.bar.sizeHorizontal) : barSlot
|
||||||
|
implicitHeight: bar && bar.vertical ? barSlot : (bar ? bar.barSize : Style.bar.sizeHorizontal)
|
||||||
|
|
||||||
|
// Page tokens are plain offsets produced by the CLI.
|
||||||
|
function validToken(t) {
|
||||||
|
return /^[0-9]{1,9}$/.test(String(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
// beeper:<base64url of the chat id>, exactly what `list` emits.
|
||||||
|
function validId(id) {
|
||||||
|
return /^beeper:[A-Za-z0-9_-]{1,1024}$/.test(String(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property int pageSize: {
|
||||||
|
var n = parseInt(setting("max", 25), 10)
|
||||||
|
if (!(n > 0)) n = 25
|
||||||
|
return Math.max(1, Math.min(50, n))
|
||||||
|
}
|
||||||
|
readonly property int refreshMs: {
|
||||||
|
var n = parseInt(setting("refreshIntervalSec", 60), 10)
|
||||||
|
if (!(n > 0)) n = 60
|
||||||
|
return Math.max(15, Math.min(3600, n)) * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (listProc.running || root.markAllBusy) return
|
||||||
|
var argv = [root.script, "list", "--limit", String(root.pageSize)]
|
||||||
|
if (pageToken !== "" && validToken(pageToken)) argv.push("--page", pageToken)
|
||||||
|
listProc.command = argv
|
||||||
|
listProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function goNextPage() {
|
||||||
|
if (!hasNext || listProc.running || root.markAllBusy) return
|
||||||
|
var stack = pageStack.slice()
|
||||||
|
stack.push(pageToken)
|
||||||
|
pageStack = stack
|
||||||
|
pageToken = nextPage
|
||||||
|
cursor = -1
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPrevPage() {
|
||||||
|
if (!hasPrev || listProc.running || root.markAllBusy) return
|
||||||
|
var stack = pageStack.slice()
|
||||||
|
pageToken = stack.pop()
|
||||||
|
pageStack = stack
|
||||||
|
cursor = -1
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstPage() {
|
||||||
|
pageToken = ""
|
||||||
|
pageStack = []
|
||||||
|
cursor = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleText() {
|
||||||
|
if (root.unread === 1) return "1 unread chat"
|
||||||
|
return root.unread + " unread chats"
|
||||||
|
}
|
||||||
|
|
||||||
|
function tooltipFor() {
|
||||||
|
if (!root.reachable)
|
||||||
|
return root.errorText !== "" ? root.errorText : "Beeper unreachable"
|
||||||
|
if (!root.hasUnread) return "No unread chats"
|
||||||
|
return root.titleText()
|
||||||
|
}
|
||||||
|
|
||||||
|
// "WhatsApp 3 · Slack 1", newest networks first, so the header says where
|
||||||
|
// the pile is without opening every row.
|
||||||
|
function inboxSummary() {
|
||||||
|
var parts = []
|
||||||
|
var list = root.inboxes || []
|
||||||
|
for (var i = 0; i < list.length && parts.length < 3; i++) {
|
||||||
|
var box = list[i] || {}
|
||||||
|
var n = parseInt(box.unread, 10)
|
||||||
|
var name = String(box.account || "")
|
||||||
|
if (!(n > 0) || name === "") continue
|
||||||
|
parts.push(name + " " + n)
|
||||||
|
}
|
||||||
|
return parts.join(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberDismissed(id) {
|
||||||
|
var next = Object.assign({}, root.dismissedIds)
|
||||||
|
next[id] = true
|
||||||
|
root.dismissedIds = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissLocal(id) {
|
||||||
|
rememberDismissed(id)
|
||||||
|
var next = []
|
||||||
|
for (var i = 0; i < messages.length; i++) {
|
||||||
|
if (messages[i].id !== id) next.push(messages[i])
|
||||||
|
}
|
||||||
|
messages = next
|
||||||
|
if (unread > 0) unread -= 1
|
||||||
|
if (cursor > messages.length - 1) cursor = messages.length - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueRead(id) {
|
||||||
|
var q = root.readQueue.slice()
|
||||||
|
q.push(id)
|
||||||
|
root.readQueue = q
|
||||||
|
root.pumpRead()
|
||||||
|
}
|
||||||
|
|
||||||
|
function pumpRead() {
|
||||||
|
if (readProc.running || root.readQueue.length === 0) return
|
||||||
|
var q = root.readQueue.slice()
|
||||||
|
var id = q.shift()
|
||||||
|
root.readQueue = q
|
||||||
|
root.pendingId = id
|
||||||
|
readProc.command = [root.script, "read", id]
|
||||||
|
readProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beeper has no web URL for a chat, so opening one means asking Beeper
|
||||||
|
// Desktop to focus it through the same local API.
|
||||||
|
function openChat(message) {
|
||||||
|
if (root.markAllBusy) return
|
||||||
|
if (!message || !validId(message.id)) return
|
||||||
|
openProc.command = [root.script, "open", message.id]
|
||||||
|
openProc.running = true
|
||||||
|
dismissLocal(message.id)
|
||||||
|
enqueueRead(message.id)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBeeper() {
|
||||||
|
if (root.markAllBusy) return
|
||||||
|
openProc.command = [root.script, "open"]
|
||||||
|
openProc.running = true
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function markCursorRead() {
|
||||||
|
if (root.markAllBusy) return
|
||||||
|
if (cursor < 0 || cursor >= messages.length) return
|
||||||
|
var message = messages[cursor]
|
||||||
|
if (!message || !validId(message.id)) return
|
||||||
|
cancelMarkAllConfirm()
|
||||||
|
dismissLocal(message.id)
|
||||||
|
enqueueRead(message.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelMarkAllConfirm() {
|
||||||
|
markAllArmed = false
|
||||||
|
if (markAllArmTimer.running) markAllArmTimer.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestMarkAll() {
|
||||||
|
if (!root.hasUnread || !root.reachable || root.markAllBusy) return
|
||||||
|
if (!root.markAllArmed) {
|
||||||
|
root.markAllArmed = true
|
||||||
|
markAllArmTimer.restart()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root.cancelMarkAllConfirm()
|
||||||
|
root.markAllBusy = true
|
||||||
|
readAllProc.command = [root.script, "read-all"]
|
||||||
|
readAllProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyReadAllPayload(text) {
|
||||||
|
root.markAllBusy = false
|
||||||
|
root.cancelMarkAllConfirm()
|
||||||
|
root.dismissedIds = ({})
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(text)
|
||||||
|
var marked = parseInt(data.marked, 10)
|
||||||
|
if (!(marked > 0)) marked = 0
|
||||||
|
if (data.ok === true) {
|
||||||
|
root.actionWarning = data.warning || ""
|
||||||
|
firstPage()
|
||||||
|
refresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root.actionWarning = data.error || "could not mark all as read"
|
||||||
|
if (marked > 0) {
|
||||||
|
firstPage()
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
root.actionWarning = "unexpected output from omarchy-beeper"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveCursor(delta) {
|
||||||
|
if (messages.length === 0) return
|
||||||
|
var next = cursor + delta
|
||||||
|
if (next < 0) next = 0
|
||||||
|
if (next > messages.length - 1) next = messages.length - 1
|
||||||
|
cursor = next
|
||||||
|
list.positionViewAtIndex(next, ListView.Contain)
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateCursor() {
|
||||||
|
if (cursor < 0 || cursor >= messages.length) return
|
||||||
|
openChat(messages[cursor])
|
||||||
|
}
|
||||||
|
|
||||||
|
function ageLabel(ts) {
|
||||||
|
if (!ts || ts <= 0) return ""
|
||||||
|
var seconds = Math.max(0, root.now - ts)
|
||||||
|
if (seconds < 60) return "now"
|
||||||
|
if (seconds < 3600) return Math.floor(seconds / 60) + "m"
|
||||||
|
if (seconds < 86400) return Math.floor(seconds / 3600) + "h"
|
||||||
|
if (seconds < 604800) return Math.floor(seconds / 86400) + "d"
|
||||||
|
if (seconds < 2592000) return Math.floor(seconds / 604800) + "w"
|
||||||
|
return Qt.formatDate(new Date(ts * 1000), "d MMM")
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneLine(value) {
|
||||||
|
return String(value || "").replace(/\s+/g, " ").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPayload(text) {
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(text)
|
||||||
|
reachable = data.ok === true
|
||||||
|
errorText = data.error || ""
|
||||||
|
warningText = reachable ? (data.warning || "") : ""
|
||||||
|
// Keep actionWarning across this refresh: a write can fail while list works.
|
||||||
|
if (!reachable) return
|
||||||
|
var incoming = data.messages || []
|
||||||
|
var kept = []
|
||||||
|
var dropped = 0
|
||||||
|
for (var i = 0; i < incoming.length; i++) {
|
||||||
|
var row = incoming[i]
|
||||||
|
if (row && root.dismissedIds[row.id]) dropped += 1
|
||||||
|
else kept.push(row)
|
||||||
|
}
|
||||||
|
messages = kept
|
||||||
|
unread = Math.max(0, (data.unread || 0) - dropped)
|
||||||
|
inboxes = data.inboxes || []
|
||||||
|
accountCount = data.accountCount || 0
|
||||||
|
nextPage = validToken(data.nextPage) ? data.nextPage : ""
|
||||||
|
if (cursor > messages.length - 1) cursor = messages.length - 1
|
||||||
|
} catch (e) {
|
||||||
|
reachable = false
|
||||||
|
errorText = "unexpected output from omarchy-beeper"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenedChanged: {
|
||||||
|
if (opened) {
|
||||||
|
now = Date.now() / 1000
|
||||||
|
refresh()
|
||||||
|
} else {
|
||||||
|
cursor = -1
|
||||||
|
firstPage()
|
||||||
|
cancelMarkAllConfirm()
|
||||||
|
actionWarning = ""
|
||||||
|
dismissedIds = ({})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: now = Date.now() / 1000
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: listProc
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.applyPayload(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: readProc
|
||||||
|
onExited: function(exitCode) {
|
||||||
|
root.pendingId = ""
|
||||||
|
if (root.readQueue.length > 0) {
|
||||||
|
root.pumpRead()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: openProc
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: readAllProc
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.applyReadAllPayload(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
interval: root.refreshMs
|
||||||
|
running: true
|
||||||
|
repeat: true
|
||||||
|
triggeredOnStart: true
|
||||||
|
onTriggered: {
|
||||||
|
root.now = Date.now() / 1000
|
||||||
|
root.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: markAllArmTimer
|
||||||
|
interval: 4000
|
||||||
|
repeat: false
|
||||||
|
onTriggered: root.markAllArmed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
BarIconButton {
|
||||||
|
id: button
|
||||||
|
anchors.fill: parent
|
||||||
|
bar: root.bar
|
||||||
|
opacity: root.reachable ? 1 : 0.5
|
||||||
|
slotSize: root.barSlot
|
||||||
|
opticalSize: root.barContentWidth
|
||||||
|
tooltipText: root.tooltipFor()
|
||||||
|
|
||||||
|
iconComponent: Component {
|
||||||
|
Item {
|
||||||
|
Row {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Style.space(5)
|
||||||
|
|
||||||
|
BeeperIcon {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
iconSize: Style.bar.iconCanvas
|
||||||
|
color: button.foreground
|
||||||
|
dotColor: button.foreground
|
||||||
|
hasMail: root.hasUnread && root.reachable
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: root.reachable && root.badgeCount > 0
|
||||||
|
height: Style.space(12)
|
||||||
|
width: root.badgeWidth
|
||||||
|
radius: height / 2
|
||||||
|
color: Qt.rgba(button.foreground.r, button.foreground.g,
|
||||||
|
button.foreground.b, 0.14)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.badgeCount
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
renderType: Text.NativeRendering
|
||||||
|
color: button.foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPressed: function(b) {
|
||||||
|
if (b === Qt.RightButton) {
|
||||||
|
root.openBeeper()
|
||||||
|
} else if (b === Qt.MiddleButton) {
|
||||||
|
root.refresh()
|
||||||
|
} else {
|
||||||
|
root.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyboardPanel {
|
||||||
|
id: panel
|
||||||
|
anchorItem: button
|
||||||
|
owner: root
|
||||||
|
bar: root.bar
|
||||||
|
open: root.opened
|
||||||
|
focusTarget: keyCatcher
|
||||||
|
contentWidth: panel.fittedContentWidth(Style.space(400))
|
||||||
|
contentHeight: panel.fittedContentHeight(content.implicitHeight)
|
||||||
|
|
||||||
|
PanelKeyCatcher {
|
||||||
|
id: keyCatcher
|
||||||
|
anchors.fill: parent
|
||||||
|
onCloseRequested: {
|
||||||
|
if (root.markAllArmed) {
|
||||||
|
root.cancelMarkAllConfirm()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root.close()
|
||||||
|
}
|
||||||
|
onMoveRequested: function(dx, dy) { if (dy !== 0) root.moveCursor(dy) }
|
||||||
|
onActivateRequested: root.activateCursor()
|
||||||
|
onTabRequested: function(direction) { root.switchPanel(direction) }
|
||||||
|
onTextKey: function(t) {
|
||||||
|
var onCursor = root.cursor >= 0 && root.cursor < root.messages.length
|
||||||
|
if (t === "o" && onCursor)
|
||||||
|
root.openChat(root.messages[root.cursor])
|
||||||
|
else if (t === "i")
|
||||||
|
root.openBeeper()
|
||||||
|
else if (t === "a")
|
||||||
|
root.markCursorRead()
|
||||||
|
else if (t === "A")
|
||||||
|
root.requestMarkAll()
|
||||||
|
else if (t === "n")
|
||||||
|
root.goNextPage()
|
||||||
|
else if (t === "p")
|
||||||
|
root.goPrevPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: content
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: Style.space(6)
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
height: Math.max(heading.implicitHeight, openAppButton.height)
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: heading
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.right: headerActions.left
|
||||||
|
anchors.rightMargin: Style.space(8)
|
||||||
|
spacing: Style.space(1)
|
||||||
|
|
||||||
|
PanelSectionHeader {
|
||||||
|
width: parent.width
|
||||||
|
text: root.titleText()
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
foreground: root.foreground
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.accountCount > 1 && text !== ""
|
||||||
|
text: root.inboxSummary()
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
color: Qt.darker(root.foreground, 1.6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: headerActions
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: Style.space(2)
|
||||||
|
|
||||||
|
PanelActionButton {
|
||||||
|
id: markAllButton
|
||||||
|
visible: root.hasUnread && root.reachable
|
||||||
|
enabled: root.hasUnread && root.reachable && !root.markAllBusy
|
||||||
|
iconText: root.markAllArmed || root.markAllBusy
|
||||||
|
? root.iconConfirm : root.iconMarkAll
|
||||||
|
tooltipText: root.markAllBusy
|
||||||
|
? "Marking unread chats as read…"
|
||||||
|
: (root.markAllArmed
|
||||||
|
? "Click again to confirm"
|
||||||
|
: "Mark all unread as read (A)")
|
||||||
|
foreground: root.foreground
|
||||||
|
hoverColor: root.accent
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
fontSize: Style.font.iconSmall
|
||||||
|
onClicked: root.requestMarkAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelActionButton {
|
||||||
|
id: openAppButton
|
||||||
|
enabled: !root.markAllBusy
|
||||||
|
iconText: root.iconOpenApp
|
||||||
|
tooltipText: "Open Beeper Desktop (i)"
|
||||||
|
foreground: root.foreground
|
||||||
|
hoverColor: root.accent
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
fontSize: Style.font.iconSmall
|
||||||
|
onClicked: root.openBeeper()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelSeparator { width: parent.width }
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
height: root.reachable ? 0 : staleWarning.implicitHeight + Style.space(6)
|
||||||
|
visible: !root.reachable
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: staleWarning
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: parent.width
|
||||||
|
text: root.errorText !== ""
|
||||||
|
? root.errorText
|
||||||
|
: "Could not reach Beeper. Showing the last list."
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
color: bar ? bar.urgent : Color.urgent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
height: (root.reachable && root.warningText !== "")
|
||||||
|
? partialWarning.implicitHeight + Style.space(6) : 0
|
||||||
|
visible: root.reachable && root.warningText !== ""
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: partialWarning
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: parent.width
|
||||||
|
text: root.warningText
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
color: bar ? bar.urgent : Color.urgent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
height: (root.actionWarning !== "" && !root.markAllBusy)
|
||||||
|
? actionWarningLabel.implicitHeight + Style.space(6) : 0
|
||||||
|
visible: root.actionWarning !== "" && !root.markAllBusy
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: actionWarningLabel
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: parent.width
|
||||||
|
text: root.actionWarning
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
color: bar ? bar.urgent : Color.urgent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
height: root.markAllBusy ? markAllBusyLabel.implicitHeight + Style.space(6) : 0
|
||||||
|
visible: root.markAllBusy
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: markAllBusyLabel
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: parent.width
|
||||||
|
text: "Marking unread chats as read…"
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
color: Qt.darker(root.foreground, 1.6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
id: list
|
||||||
|
width: parent.width
|
||||||
|
visible: root.messages.length > 0
|
||||||
|
clip: true
|
||||||
|
opacity: root.markAllBusy ? 0.4 : 1
|
||||||
|
enabled: !root.markAllBusy
|
||||||
|
model: root.messages
|
||||||
|
spacing: Style.space(1)
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
flickableDirection: Flickable.VerticalFlick
|
||||||
|
interactive: contentHeight > height && !root.markAllBusy
|
||||||
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||||
|
|
||||||
|
readonly property int cap: {
|
||||||
|
var chrome = Style.space(70)
|
||||||
|
if (root.hasPrev || root.hasNext) chrome += Style.space(38)
|
||||||
|
if (!root.reachable) chrome += Style.space(24)
|
||||||
|
if (root.reachable && root.warningText !== "") chrome += Style.space(24)
|
||||||
|
if (root.actionWarning !== "" && !root.markAllBusy) chrome += Style.space(24)
|
||||||
|
if (root.markAllBusy) chrome += Style.space(24)
|
||||||
|
return Math.max(Style.space(200),
|
||||||
|
panel.availableCardHeight - panel.verticalContentInset - chrome)
|
||||||
|
}
|
||||||
|
height: Math.min(contentHeight, cap)
|
||||||
|
|
||||||
|
delegate: Rectangle {
|
||||||
|
id: row
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
readonly property bool active: root.cursor === row.index || rowMouse.containsMouse
|
||||||
|
|
||||||
|
width: list.width - (list.interactive ? Style.space(10) : 0)
|
||||||
|
height: rowContent.implicitHeight + Style.space(10)
|
||||||
|
radius: Style.cornerRadius
|
||||||
|
opacity: root.pendingId === modelData.id ? 0.4 : 1
|
||||||
|
color: active
|
||||||
|
? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08)
|
||||||
|
: "transparent"
|
||||||
|
|
||||||
|
Behavior on color { ColorAnimation { duration: 80 } }
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: rowMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onContainsMouseChanged: if (containsMouse) root.cursor = row.index
|
||||||
|
onClicked: if (!root.markAllBusy) root.openChat(row.modelData)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: rowContent
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.leftMargin: Style.space(6)
|
||||||
|
anchors.rightMargin: Style.space(6)
|
||||||
|
spacing: Style.space(2)
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
height: subject.implicitHeight
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: line
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: age.left
|
||||||
|
anchors.rightMargin: Style.space(6)
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: Style.space(5)
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: chips
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: Style.space(3)
|
||||||
|
visible: root.accountCount > 1
|
||||||
|
&& (row.modelData.labels || []).length > 0
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: (row.modelData.labels || []).slice(0, 1)
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
required property string modelData
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
height: chipText.implicitHeight + Style.space(3)
|
||||||
|
width: chipText.width + Style.space(8)
|
||||||
|
radius: Style.space(3)
|
||||||
|
color: Qt.rgba(root.foreground.r, root.foreground.g,
|
||||||
|
root.foreground.b, 0.14)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: chipText
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: parent.modelData
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
wrapMode: Text.NoWrap
|
||||||
|
maximumLineCount: 1
|
||||||
|
width: Math.min(implicitWidth, Style.space(64))
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
color: Qt.darker(root.foreground, 1.35)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: subject
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: Math.max(Style.space(40),
|
||||||
|
line.width - (chips.visible ? chips.width + line.spacing : 0))
|
||||||
|
text: root.oneLine(row.modelData.subject)
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
wrapMode: Text.NoWrap
|
||||||
|
maximumLineCount: 1
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
font.bold: true
|
||||||
|
color: root.foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: age
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: root.ageLabel(row.modelData.ts)
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
color: Qt.darker(root.foreground, 1.7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: fromLabel
|
||||||
|
text: row.modelData.from || ""
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
width: Math.min(implicitWidth, parent.width * 0.5)
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
font.bold: true
|
||||||
|
color: Qt.darker(root.foreground, 1.15)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: {
|
||||||
|
var body = root.oneLine(row.modelData.snippet)
|
||||||
|
if (body === "") return ""
|
||||||
|
return (fromLabel.text !== "" ? " - " : "") + body
|
||||||
|
}
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
wrapMode: Text.NoWrap
|
||||||
|
maximumLineCount: 1
|
||||||
|
elide: Text.ElideRight
|
||||||
|
width: parent.width - fromLabel.width
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
color: Qt.darker(root.foreground, 1.7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
height: (root.hasPrev || root.hasNext) ? pagerRow.implicitHeight + Style.space(8) : 0
|
||||||
|
visible: root.hasPrev || root.hasNext
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: pagerRow
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Style.space(10)
|
||||||
|
|
||||||
|
PanelActionButton {
|
||||||
|
iconText: root.iconPrev
|
||||||
|
tooltipText: "Previous page"
|
||||||
|
enabled: root.hasPrev && !root.markAllBusy
|
||||||
|
opacity: enabled ? 1 : 0.3
|
||||||
|
foreground: root.foreground
|
||||||
|
hoverColor: root.accent
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
fontSize: Style.font.iconSmall
|
||||||
|
onClicked: root.goPrevPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "page " + (root.pageStack.length + 1)
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
color: Qt.darker(root.foreground, 1.7)
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelActionButton {
|
||||||
|
iconText: root.iconNext
|
||||||
|
tooltipText: "Next page"
|
||||||
|
enabled: root.hasNext && !root.markAllBusy
|
||||||
|
opacity: enabled ? 1 : 0.3
|
||||||
|
foreground: root.foreground
|
||||||
|
hoverColor: root.accent
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
fontSize: Style.font.iconSmall
|
||||||
|
onClicked: root.goNextPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
height: root.messages.length === 0 ? Style.space(60) : 0
|
||||||
|
visible: root.messages.length === 0
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: parent.width - Style.space(20)
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
text: root.reachable
|
||||||
|
? "You're all caught up."
|
||||||
|
: (root.errorText !== "" ? root.errorText : "Beeper unreachable")
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
color: root.foreground
|
||||||
|
opacity: 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# omarchy-beeper
|
||||||
|
|
||||||
|
An Omarchy bar widget for [Beeper](https://www.beeper.com/): every unread
|
||||||
|
conversation across every network Beeper bridges — WhatsApp, Signal, Slack,
|
||||||
|
Telegram, Discord, Instagram, iMessage — in one pile. Click a row to open that
|
||||||
|
chat in Beeper Desktop and mark it read.
|
||||||
|
|
||||||
|
It is read and mark-read only. The plugin **never sends** a message, a
|
||||||
|
reaction, a draft, or an attachment.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Omarchy 4.0 or newer (the plugin uses the `bar-widget` plugin kind).
|
||||||
|
- Beeper Desktop, running, with the local API enabled.
|
||||||
|
- `python3` (Omarchy ships it; no extra packages, no `sudo`, no `pkexec`).
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
omarchy plugin add https://github.com/prdlk/omarchy-beeper.git --enable
|
||||||
|
```
|
||||||
|
|
||||||
|
Then set up the token once, in a terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/.config/omarchy/plugins/prdlk.beeper/bin/omarchy-beeper auth
|
||||||
|
```
|
||||||
|
|
||||||
|
The prompt tells you where to get the token: **Beeper Desktop → Settings →
|
||||||
|
Integrations → create an access token** (read access is enough). Paste it at
|
||||||
|
the hidden prompt. It is stored in
|
||||||
|
`~/.config/omarchy-beeper/secrets/token.json`, mode `600`, in a `700`
|
||||||
|
directory, and is never passed on a command line.
|
||||||
|
|
||||||
|
Full walkthrough, including troubleshooting: [docs/SETUP.md](docs/SETUP.md).
|
||||||
|
|
||||||
|
Update and remove:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
omarchy plugin update prdlk.beeper
|
||||||
|
omarchy plugin update prdlk.beeper --yes # skip the prompt
|
||||||
|
omarchy plugin remove prdlk.beeper
|
||||||
|
```
|
||||||
|
|
||||||
|
Removing the plugin does **not** delete `~/.config/omarchy-beeper/`. Delete it
|
||||||
|
yourself, and revoke the token in Beeper Desktop → Settings → Integrations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf ~/.config/omarchy-beeper
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
| Action | What happens |
|
||||||
|
| --- | --- |
|
||||||
|
| Click the bar icon | Open or close the panel |
|
||||||
|
| Right-click the bar icon | Focus Beeper Desktop |
|
||||||
|
| Middle-click the bar icon | Refresh now |
|
||||||
|
| Click a row | Open that chat in Beeper Desktop, mark it read, close the panel |
|
||||||
|
|
||||||
|
### Keys
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| `j` / `k`, `Down` / `Up` | Move the cursor |
|
||||||
|
| `Enter`, `Space`, `o` | Open the chat under the cursor |
|
||||||
|
| `a` | Mark the chat under the cursor as read, without opening it |
|
||||||
|
| `A` | Mark every unread chat as read — press twice to confirm |
|
||||||
|
| `n` / `p` | Next / previous page |
|
||||||
|
| `i` | Focus Beeper Desktop |
|
||||||
|
| `Tab` | Switch to the next bar panel |
|
||||||
|
| `Esc` | Cancel a pending mark-all, or close the panel |
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
Configure these in the Omarchy bar widget settings (they live in `shell.json`):
|
||||||
|
|
||||||
|
| Key | Range | Default | Meaning |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `max` | 1–50 | 25 | Rows per page |
|
||||||
|
| `refreshIntervalSec` | 15–3600 | 60 | Poll interval |
|
||||||
|
|
||||||
|
`~/.config/omarchy-beeper/config` can also carry `max=25` for CLI runs, and
|
||||||
|
`OMARCHY_BEEPER_MAX` overrides both for a single invocation.
|
||||||
|
|
||||||
|
## What counts as unread
|
||||||
|
|
||||||
|
One row is one unread **chat**, not one message, sorted newest activity first.
|
||||||
|
|
||||||
|
Not in the pile:
|
||||||
|
|
||||||
|
- **muted** chats (`includeMuted=false`),
|
||||||
|
- **archived** chats,
|
||||||
|
- **low-priority** chats.
|
||||||
|
|
||||||
|
The last two come free with `inbox=primary` on `GET /v1/chats/search`, which
|
||||||
|
Beeper defines as non-archived and non-low-priority. If you want a muted chat
|
||||||
|
to nag you, unmute it in Beeper.
|
||||||
|
|
||||||
|
The badge counts unread chats, capped at 200 — a deeper pile is a Beeper
|
||||||
|
problem, not a bar problem. When the cap is hit the panel says so. `A`
|
||||||
|
(mark-all) is not capped at 200: it walks up to 1000 chats with a 100-second
|
||||||
|
budget and reports a partial count if it runs out.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
The panel is a thin drawing layer; everything else is `bin/omarchy-beeper`.
|
||||||
|
Every command prints exactly one JSON object and exits 0, even on failure.
|
||||||
|
|
||||||
|
```
|
||||||
|
omarchy-beeper list [--limit N] [--page OFFSET] # the pile, newest first
|
||||||
|
omarchy-beeper read <id> # mark one chat read
|
||||||
|
omarchy-beeper read-all # mark every unread chat read
|
||||||
|
omarchy-beeper open [<id>] # focus Beeper, optionally a chat
|
||||||
|
omarchy-beeper auth # store the token (terminal only)
|
||||||
|
```
|
||||||
|
|
||||||
|
`list` emits:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"unread": 7,
|
||||||
|
"accountCount": 3,
|
||||||
|
"nextPage": "25",
|
||||||
|
"thisPage": "0",
|
||||||
|
"inboxes": [{ "account": "WhatsApp", "unread": 3, "searchUrl": "" }],
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"id": "beeper:<base64url chat id>",
|
||||||
|
"threadId": "<chat id>",
|
||||||
|
"subject": "<chat title>",
|
||||||
|
"from": "<last sender>",
|
||||||
|
"snippet": "<last message>",
|
||||||
|
"ts": 1770804000,
|
||||||
|
"labels": ["WhatsApp"],
|
||||||
|
"url": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Failures look like `{"ok":false,"error":"…"}`. The two you will actually see:
|
||||||
|
|
||||||
|
- `Beeper Desktop is not running` — start Beeper.
|
||||||
|
- `token missing or invalid; run: omarchy-beeper auth` — the token was never
|
||||||
|
set up, or you revoked it.
|
||||||
|
|
||||||
|
## Design decisions
|
||||||
|
|
||||||
|
- **The QML layer never talks to the network.** `Panel.qml` runs the CLI and
|
||||||
|
parses one JSON object from stdout. It never sees the token, never opens a
|
||||||
|
socket, and renders every API string with `textFormat: Text.PlainText`.
|
||||||
|
- **Everything is bound to `http://localhost:23373`.** `lib/beeper.py` refuses
|
||||||
|
any path outside `/v1/`, refuses any URL that would leave that origin, and
|
||||||
|
does not follow redirects — a 3xx could point off localhost.
|
||||||
|
- **`url` is always empty.** Beeper has no web address for a chat, so opening
|
||||||
|
one means `POST /v1/focus` with the chat id: Beeper Desktop comes forward
|
||||||
|
with that conversation selected. There is no browser hand-off and no deep
|
||||||
|
link to validate.
|
||||||
|
- **Snippets cost one request per visible row.** The chat objects Beeper
|
||||||
|
returns carry no message preview, so the last message is fetched with
|
||||||
|
`GET /v1/chats/{chatID}/messages` — but only for the rows on screen, never
|
||||||
|
for the whole pile. A snippet that fails to load costs the snippet, not the
|
||||||
|
row.
|
||||||
|
- **Paging is a numeric offset.** The API is cursor-based; the CLI walks the
|
||||||
|
cursors once, caps at 200 chats, sorts, and slices. `nextPage` is the next
|
||||||
|
offset, so `p` can step back without replaying cursors.
|
||||||
|
- **`accountCount` comes from `GET /v1/accounts`**, not from the pile, so the
|
||||||
|
network chip appears whenever you actually have several networks connected.
|
||||||
|
- **No WebSocket.** Beeper's `ws://localhost:23373/v1/ws` is experimental and
|
||||||
|
the panel is on a 15–3600 s poll anyway; a second event path would only add
|
||||||
|
a way to disagree with the list. Polling only, deliberately.
|
||||||
|
- **`read-all` snapshots first.** It collects the unread chat ids before it
|
||||||
|
marks anything, because the query it walks is defined by what is unread.
|
||||||
|
|
||||||
|
## Known Beeper limitations
|
||||||
|
|
||||||
|
- Message history can be incomplete until Beeper finishes indexing after a
|
||||||
|
fresh install or a new bridge. Titles and unread counts are right; a snippet
|
||||||
|
may be blank for a while.
|
||||||
|
- On-device (local) connections are the most reliable; cloud bridges can lag.
|
||||||
|
- iMessage is macOS-only. On Linux it simply never appears; there is no
|
||||||
|
special case for it.
|
||||||
|
- Beeper Desktop must be running. The API is local and dies with the app; the
|
||||||
|
bar icon dims to half opacity and the panel keeps the last list.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest discover -s tests -t tests -v # mocked, offline
|
||||||
|
python3 -m compileall -q lib tests
|
||||||
|
bash -n bin/omarchy-beeper
|
||||||
|
qmllint -I <shell-qml-root> Panel.qml BeeperIcon.qml
|
||||||
|
```
|
||||||
|
|
||||||
|
The tests never touch a live Beeper: `tests/support.py` fakes the HTTP opener
|
||||||
|
and serves payloads shaped like the documented API.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE).
|
||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# omarchy-beeper - data source for the Omarchy bar widget.
|
||||||
|
#
|
||||||
|
# The panel calls:
|
||||||
|
# omarchy-beeper list [--limit N] [--page OFFSET]
|
||||||
|
# omarchy-beeper read <id>
|
||||||
|
# omarchy-beeper read-all
|
||||||
|
# omarchy-beeper open [<id>]
|
||||||
|
#
|
||||||
|
# The token is set up once in a terminal:
|
||||||
|
# omarchy-beeper auth
|
||||||
|
#
|
||||||
|
# list/read/read-all/open always print one JSON object. Failures print
|
||||||
|
# {"ok":false,...} and exit 0 so the widget never crashes on empty stdout.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
export OMARCHY_BEEPER_ROOT="$ROOT"
|
||||||
|
|
||||||
|
# The bar is not a login shell, so PATH may miss user-managed runtimes.
|
||||||
|
for candidate in "$HOME/.local/share/mise/shims" "$HOME/.local/bin"; do
|
||||||
|
case ":$PATH:" in
|
||||||
|
*":$candidate:"*) ;;
|
||||||
|
*) [ -d "$candidate" ] && PATH="$PATH:$candidate" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
export PATH
|
||||||
|
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
printf '{"ok":false,"error":"python3 is required"}\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec python3 "$ROOT/lib/cli.py" "$@"
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Setup
|
||||||
|
|
||||||
|
## 1. Turn on the Beeper Desktop API
|
||||||
|
|
||||||
|
Beeper Desktop serves a local-only HTTP API on `http://localhost:23373`. Check
|
||||||
|
it is up — this endpoint needs no token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:23373/v1/info
|
||||||
|
```
|
||||||
|
|
||||||
|
You should get JSON describing the app and `"status": "running"`. If the
|
||||||
|
connection is refused, Beeper Desktop is not running, or the API is disabled in
|
||||||
|
its settings.
|
||||||
|
|
||||||
|
## 2. Create an access token
|
||||||
|
|
||||||
|
In Beeper Desktop: **Settings → Integrations → create an access token.**
|
||||||
|
|
||||||
|
Read access is all this plugin needs. It calls exactly six endpoints:
|
||||||
|
|
||||||
|
| Call | Why |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /v1/info` | Tell "Beeper is down" apart from "the token is wrong" |
|
||||||
|
| `GET /v1/accounts` | Network names for the chips, and the account count |
|
||||||
|
| `GET /v1/chats/search` | The unread pile |
|
||||||
|
| `GET /v1/chats/{chatID}/messages` | The last message, for the row snippet |
|
||||||
|
| `POST /v1/chats/{chatID}/read` | Mark a chat read |
|
||||||
|
| `POST /v1/focus` | Bring Beeper Desktop forward on a chat |
|
||||||
|
|
||||||
|
Nothing else. No sends, no edits, no reactions, no drafts.
|
||||||
|
|
||||||
|
## 3. Store the token
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/.config/omarchy/plugins/prdlk.beeper/bin/omarchy-beeper auth
|
||||||
|
```
|
||||||
|
|
||||||
|
The command refuses to run unless stdin and stderr are both terminals, so a
|
||||||
|
token can never be piped in from a script or a shell history. It reads the
|
||||||
|
token with a hidden prompt, verifies it with one `GET /v1/accounts`, and only
|
||||||
|
then writes it to:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.config/omarchy-beeper/secrets/token.json # mode 600
|
||||||
|
~/.config/omarchy-beeper/secrets/ # mode 700
|
||||||
|
```
|
||||||
|
|
||||||
|
The write is atomic: a temp file in the same directory, `fchmod` 600, `fsync`,
|
||||||
|
`rename`, then `fsync` on the directory.
|
||||||
|
|
||||||
|
## 4. Check it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/.config/omarchy/plugins/prdlk.beeper/bin/omarchy-beeper list --limit 5
|
||||||
|
```
|
||||||
|
|
||||||
|
A healthy answer starts with `{"ok": true, "unread": …}`. Then click the bar
|
||||||
|
icon.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Message | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `Beeper Desktop is not running` | Nothing is listening on `localhost:23373`. Start Beeper. |
|
||||||
|
| `token missing or invalid; run: omarchy-beeper auth` | No token stored, or Beeper rejected it (401/403). Re-run `auth`. |
|
||||||
|
| `secret file for beeper is too open; chmod 600 it` | Someone widened the token file. `chmod 600 ~/.config/omarchy-beeper/secrets/token.json`. |
|
||||||
|
| `secret directory for beeper is too open; chmod 700 it` | `chmod 700 ~/.config/omarchy-beeper/secrets`. |
|
||||||
|
| `showing the newest 200 unread chats` | The pile is deeper than the display cap. Use `A` to clear it. |
|
||||||
|
| Snippets are blank | Beeper is still indexing, or that chat has only media. Titles and counts are still correct. |
|
||||||
|
| `python3 is required` | The bar could not find `python3` on `PATH`. |
|
||||||
|
|
||||||
|
The bar is not a login shell, so `bin/omarchy-beeper` only extends `PATH` with
|
||||||
|
`~/.local/share/mise/shims` and `~/.local/bin`. If your `python3` lives
|
||||||
|
somewhere else, symlink it into `~/.local/bin`.
|
||||||
|
|
||||||
|
## Removing everything
|
||||||
|
|
||||||
|
```bash
|
||||||
|
omarchy plugin remove prdlk.beeper
|
||||||
|
rm -rf ~/.config/omarchy-beeper
|
||||||
|
```
|
||||||
|
|
||||||
|
Then revoke the token in Beeper Desktop → Settings → Integrations. Removing the
|
||||||
|
plugin does not revoke it for you.
|
||||||
+326
@@ -0,0 +1,326 @@
|
|||||||
|
"""All HTTP to the local Beeper Desktop API.
|
||||||
|
|
||||||
|
Everything here is bound to http://localhost:23373. The token is read from a
|
||||||
|
0600 file and only ever leaves this process inside an Authorization header:
|
||||||
|
never in argv, never on stderr, never in a payload.
|
||||||
|
|
||||||
|
Endpoints used (https://developers.beeper.com/desktop-api-reference/):
|
||||||
|
|
||||||
|
GET /v1/info server discovery, no auth
|
||||||
|
GET /v1/accounts connected chat accounts
|
||||||
|
GET /v1/chats/search unread chats, cursor paginated
|
||||||
|
GET /v1/chats/{chatID}/messages last message for the row snippet
|
||||||
|
POST /v1/chats/{chatID}/read mark a chat read
|
||||||
|
POST /v1/focus focus Beeper Desktop, optionally a chat
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from common import (
|
||||||
|
API_BASE,
|
||||||
|
FETCH_CAP,
|
||||||
|
MAX_HTTP_ERROR,
|
||||||
|
encode_id,
|
||||||
|
one_line,
|
||||||
|
read_http_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
REQUEST_TIMEOUT = 30
|
||||||
|
SEARCH_PAGE = 200 # /v1/chats/search caps limit at 200
|
||||||
|
SNIPPET_WORKERS = 8
|
||||||
|
|
||||||
|
# Message kinds with no body text. Shown as a short placeholder instead.
|
||||||
|
MEDIA_LABELS = {
|
||||||
|
"IMAGE": "[photo]",
|
||||||
|
"VIDEO": "[video]",
|
||||||
|
"VOICE": "[voice message]",
|
||||||
|
"AUDIO": "[audio]",
|
||||||
|
"FILE": "[file]",
|
||||||
|
"STICKER": "[sticker]",
|
||||||
|
"LOCATION": "[location]",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ApiError(Exception):
|
||||||
|
"""An API call that came back, but not with a usable answer."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status: int = 0) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status = status
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
class UnreachableError(Exception):
|
||||||
|
"""Beeper Desktop is not listening on the local port."""
|
||||||
|
|
||||||
|
|
||||||
|
class AuthError(Exception):
|
||||||
|
"""The token is missing, expired, or was revoked."""
|
||||||
|
|
||||||
|
|
||||||
|
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
|
"""A redirect could point off localhost, so treat any 3xx as an error."""
|
||||||
|
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_OPENER = urllib.request.build_opener(_NoRedirect)
|
||||||
|
|
||||||
|
|
||||||
|
def _url(path: str, params: dict | None = None) -> str:
|
||||||
|
if not path.startswith("/v1/"):
|
||||||
|
raise ApiError(f"refusing to call {path}")
|
||||||
|
url = API_BASE + path
|
||||||
|
if params:
|
||||||
|
pairs: list[tuple[str, str]] = []
|
||||||
|
for key, value in params.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, bool):
|
||||||
|
pairs.append((key, "true" if value else "false"))
|
||||||
|
elif isinstance(value, (list, tuple)):
|
||||||
|
pairs.extend((key, str(item)) for item in value)
|
||||||
|
else:
|
||||||
|
pairs.append((key, str(value)))
|
||||||
|
if pairs:
|
||||||
|
url += "?" + urllib.parse.urlencode(pairs)
|
||||||
|
if not url.startswith(API_BASE + "/"):
|
||||||
|
raise ApiError("refusing to leave the local API")
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def request(
|
||||||
|
token: str,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
params: dict | None = None,
|
||||||
|
body: dict | None = None,
|
||||||
|
timeout: int = REQUEST_TIMEOUT,
|
||||||
|
) -> object:
|
||||||
|
"""One call to the local API. Returns the decoded JSON body, or None."""
|
||||||
|
payload = None if body is None else json.dumps(body).encode("utf-8")
|
||||||
|
req = urllib.request.Request(_url(path, params), data=payload, method=method)
|
||||||
|
if token:
|
||||||
|
req.add_header("Authorization", f"Bearer {token}")
|
||||||
|
if payload is not None:
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
req.add_header("Accept", "application/json")
|
||||||
|
try:
|
||||||
|
with _OPENER.open(req, timeout=timeout) as fp:
|
||||||
|
raw = read_http_body(fp)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = ""
|
||||||
|
try:
|
||||||
|
detail = read_http_body(exc, MAX_HTTP_ERROR).decode("utf-8", "replace")
|
||||||
|
except Exception: # noqa: BLE001 - the status matters, the body does not
|
||||||
|
detail = ""
|
||||||
|
finally:
|
||||||
|
exc.close()
|
||||||
|
if exc.code in (401, 403):
|
||||||
|
raise AuthError("token missing or invalid; run: omarchy-beeper auth") from exc
|
||||||
|
raise ApiError(_http_message(exc.code, detail), exc.code) from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
reason = exc.reason
|
||||||
|
if isinstance(reason, (socket.timeout, TimeoutError)):
|
||||||
|
raise ApiError("the local API timed out") from exc
|
||||||
|
if isinstance(reason, OSError):
|
||||||
|
# Only localhost is ever dialled, so any socket error means the
|
||||||
|
# port is not being served: Beeper Desktop is down or restarting.
|
||||||
|
raise UnreachableError("Beeper Desktop is not running") from exc
|
||||||
|
raise ApiError(one_line(str(reason)) or "the local API failed") from exc
|
||||||
|
except (socket.timeout, TimeoutError) as exc:
|
||||||
|
raise ApiError("the local API timed out") from exc
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise ApiError("the local API returned invalid JSON") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _http_message(status: int, detail: str) -> str:
|
||||||
|
text = ""
|
||||||
|
try:
|
||||||
|
data = json.loads(detail)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
text = str(data.get("message") or data.get("error") or "")
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
text = ""
|
||||||
|
text = one_line(text, 120)
|
||||||
|
return f"Beeper API error {status}: {text}" if text else f"Beeper API error {status}"
|
||||||
|
|
||||||
|
|
||||||
|
def server_info() -> dict:
|
||||||
|
"""GET /v1/info needs no token, so it separates 'down' from 'bad token'."""
|
||||||
|
data = request("", "GET", "/v1/info", timeout=5)
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def probe(token: str) -> None:
|
||||||
|
"""Cheapest authenticated call, used to verify a token during `auth`."""
|
||||||
|
request(token, "GET", "/v1/accounts", timeout=10)
|
||||||
|
|
||||||
|
|
||||||
|
def accounts(token: str) -> list[dict]:
|
||||||
|
data = request(token, "GET", "/v1/accounts")
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return []
|
||||||
|
return [item for item in data if isinstance(item, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def account_label(account: dict) -> str:
|
||||||
|
return one_line(str(account.get("network") or account.get("accountID") or ""), 40)
|
||||||
|
|
||||||
|
|
||||||
|
def unread_chats(token: str, cap: int = FETCH_CAP, budget: float = 0.0) -> tuple[list[dict], bool]:
|
||||||
|
"""Unread chats, newest first.
|
||||||
|
|
||||||
|
Muted, archived, and low-priority chats are not part of the pile:
|
||||||
|
`inbox=primary` drops archived and low-priority, `includeMuted=false`
|
||||||
|
drops muted. Returns (chats, truncated) where truncated means the walk
|
||||||
|
hit `cap` or `budget` before the API ran out of pages.
|
||||||
|
"""
|
||||||
|
out: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
cursor = ""
|
||||||
|
truncated = False
|
||||||
|
deadline = (_now() + budget) if budget > 0 else 0.0
|
||||||
|
while True:
|
||||||
|
params = {
|
||||||
|
"unreadOnly": True,
|
||||||
|
"includeMuted": False,
|
||||||
|
"inbox": "primary",
|
||||||
|
"limit": min(SEARCH_PAGE, max(1, cap)),
|
||||||
|
}
|
||||||
|
if cursor:
|
||||||
|
params["cursor"] = cursor
|
||||||
|
params["direction"] = "before"
|
||||||
|
data = request(token, "GET", "/v1/chats/search", params)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ApiError("the local API returned invalid JSON")
|
||||||
|
items = data.get("items")
|
||||||
|
if not isinstance(items, list):
|
||||||
|
items = []
|
||||||
|
for chat in items:
|
||||||
|
if not isinstance(chat, dict):
|
||||||
|
continue
|
||||||
|
chat_id = str(chat.get("id") or "")
|
||||||
|
if not chat_id or chat_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(chat_id)
|
||||||
|
out.append(chat)
|
||||||
|
if len(out) >= cap:
|
||||||
|
truncated = bool(data.get("hasMore")) or len(out) > cap
|
||||||
|
del out[cap:]
|
||||||
|
break
|
||||||
|
cursor = str(data.get("oldestCursor") or "")
|
||||||
|
if not data.get("hasMore") or not cursor or not items:
|
||||||
|
break
|
||||||
|
if deadline and _now() >= deadline:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
out.sort(key=lambda chat: (_ts(chat.get("lastActivity")), str(chat.get("id"))), reverse=True)
|
||||||
|
return out, truncated
|
||||||
|
|
||||||
|
|
||||||
|
def last_message(token: str, chat_id: str) -> dict:
|
||||||
|
"""Newest displayable message in a chat, for the row snippet."""
|
||||||
|
data = request(token, "GET", f"/v1/chats/{urllib.parse.quote(chat_id, safe='')}/messages")
|
||||||
|
items = data.get("items") if isinstance(data, dict) else None
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return {}
|
||||||
|
best: dict = {}
|
||||||
|
best_key = ("", 0.0)
|
||||||
|
for msg in items:
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
continue
|
||||||
|
if msg.get("isDeleted") or msg.get("isHidden") or msg.get("type") == "REACTION":
|
||||||
|
continue
|
||||||
|
key = (str(msg.get("sortKey") or ""), _ts(msg.get("timestamp")))
|
||||||
|
if not best or key[1] > best_key[1] or (key[1] == best_key[1] and key[0] > best_key[0]):
|
||||||
|
best, best_key = msg, key
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def message_preview(msg: dict) -> str:
|
||||||
|
text = one_line(str(msg.get("text") or ""))
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
kind = str(msg.get("type") or "")
|
||||||
|
if kind in MEDIA_LABELS:
|
||||||
|
return MEDIA_LABELS[kind]
|
||||||
|
attachments = msg.get("attachments")
|
||||||
|
if isinstance(attachments, list) and attachments:
|
||||||
|
return "[attachment]"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def mark_read(token: str, chat_id: str, timeout: int = REQUEST_TIMEOUT) -> None:
|
||||||
|
request(
|
||||||
|
token,
|
||||||
|
"POST",
|
||||||
|
f"/v1/chats/{urllib.parse.quote(chat_id, safe='')}/read",
|
||||||
|
body={},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def focus(token: str, chat_id: str = "") -> None:
|
||||||
|
body: dict = {}
|
||||||
|
if chat_id:
|
||||||
|
body["chatID"] = chat_id
|
||||||
|
request(token, "POST", "/v1/focus", body=body)
|
||||||
|
|
||||||
|
|
||||||
|
def chat_row(chat: dict, msg: dict | None = None) -> dict:
|
||||||
|
"""One panel row. Every display string goes through one_line()."""
|
||||||
|
msg = msg or {}
|
||||||
|
title = one_line(str(chat.get("title") or ""))
|
||||||
|
network = one_line(str(chat.get("network") or ""), 40)
|
||||||
|
if not title:
|
||||||
|
title = network or "(no title)"
|
||||||
|
sender = one_line(str(msg.get("senderName") or ""), 60)
|
||||||
|
if msg.get("isSender"):
|
||||||
|
sender = "You"
|
||||||
|
ts = _ts(msg.get("timestamp")) or _ts(chat.get("lastActivity"))
|
||||||
|
return {
|
||||||
|
"id": encode_id(str(chat.get("id") or "")),
|
||||||
|
"threadId": str(chat.get("id") or ""),
|
||||||
|
"subject": title,
|
||||||
|
"from": sender,
|
||||||
|
"snippet": message_preview(msg),
|
||||||
|
"ts": int(ts),
|
||||||
|
"labels": [network] if network else [],
|
||||||
|
"url": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ts(value: object) -> float:
|
||||||
|
"""ISO 8601 (or unix seconds) to unix seconds. Unparsable is 0."""
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return float(value)
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return 0.0
|
||||||
|
if text.endswith("Z"):
|
||||||
|
text = text[:-1] + "+00:00"
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
return 0.0
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed.timestamp()
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> float:
|
||||||
|
return time.monotonic()
|
||||||
Executable
+313
@@ -0,0 +1,313 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""CLI entry for omarchy-beeper.
|
||||||
|
|
||||||
|
Every command prints exactly one JSON object and exits 0, so the bar widget
|
||||||
|
never has to deal with empty stdout or a stack trace.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import getpass
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
LIB = Path(__file__).resolve().parent
|
||||||
|
if str(LIB) not in sys.path:
|
||||||
|
sys.path.insert(0, str(LIB))
|
||||||
|
|
||||||
|
import beeper
|
||||||
|
from common import (
|
||||||
|
FETCH_CAP,
|
||||||
|
TOKEN_FILE,
|
||||||
|
die,
|
||||||
|
decode_id,
|
||||||
|
emit,
|
||||||
|
load_token,
|
||||||
|
max_chats,
|
||||||
|
save_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The panel is on a refresh timer, so a slow list must still return.
|
||||||
|
LIST_BUDGET = 40.0
|
||||||
|
READ_ALL_BUDGET = 100.0
|
||||||
|
READ_ALL_CAP = 1000
|
||||||
|
READ_ALL_WORKERS = 6
|
||||||
|
|
||||||
|
HELP = """\
|
||||||
|
omarchy-beeper list [--limit N] [--page OFFSET]
|
||||||
|
omarchy-beeper read <id>
|
||||||
|
omarchy-beeper read-all
|
||||||
|
omarchy-beeper open [<id>]
|
||||||
|
omarchy-beeper auth
|
||||||
|
|
||||||
|
The bar widget calls list, read, read-all, and open. The token is set up
|
||||||
|
once in a terminal:
|
||||||
|
|
||||||
|
omarchy-beeper auth
|
||||||
|
|
||||||
|
--limit is the panel page size (1-50); the CLI also honours
|
||||||
|
OMARCHY_BEEPER_MAX. One row is one unread chat. Muted, archived, and
|
||||||
|
low-priority chats are not part of the pile.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _fail(exc: Exception) -> None:
|
||||||
|
"""Turn an API exception into the single JSON error line."""
|
||||||
|
if isinstance(exc, beeper.UnreachableError):
|
||||||
|
die("Beeper Desktop is not running")
|
||||||
|
if isinstance(exc, beeper.AuthError):
|
||||||
|
die("token missing or invalid; run: omarchy-beeper auth")
|
||||||
|
die(str(exc) or "the local Beeper API failed")
|
||||||
|
|
||||||
|
|
||||||
|
def _token() -> str:
|
||||||
|
token = load_token()
|
||||||
|
if token:
|
||||||
|
return token
|
||||||
|
# No token yet: say which of the two setup steps is missing.
|
||||||
|
try:
|
||||||
|
beeper.server_info()
|
||||||
|
except (beeper.UnreachableError, beeper.ApiError):
|
||||||
|
die("Beeper Desktop is not running")
|
||||||
|
die("token missing or invalid; run: omarchy-beeper auth")
|
||||||
|
return "" # unreachable, keeps type checkers quiet
|
||||||
|
|
||||||
|
|
||||||
|
def _inboxes(accounts: list[dict], chats: list[dict]) -> list[dict]:
|
||||||
|
"""Unread chats per connected account, in the order the API lists them."""
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for chat in chats:
|
||||||
|
acc_id = str(chat.get("accountID") or "")
|
||||||
|
counts[acc_id] = counts.get(acc_id, 0) + 1
|
||||||
|
out = []
|
||||||
|
claimed = 0
|
||||||
|
for account in accounts:
|
||||||
|
acc_id = str(account.get("accountID") or "")
|
||||||
|
unread = counts.get(acc_id, 0)
|
||||||
|
claimed += unread
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"account": beeper.account_label(account) or acc_id,
|
||||||
|
"unread": unread,
|
||||||
|
"searchUrl": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
leftover = len(chats) - claimed
|
||||||
|
if leftover > 0:
|
||||||
|
# A chat on an account /v1/accounts did not report; still count it.
|
||||||
|
out.append({"account": "Other", "unread": leftover, "searchUrl": ""})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _snippets(token: str, chats: list[dict], deadline: float) -> dict[str, dict]:
|
||||||
|
"""Last message per displayed chat. A failure just loses the snippet."""
|
||||||
|
if not chats:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def work(chat: dict) -> tuple[str, dict]:
|
||||||
|
chat_id = str(chat.get("id") or "")
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
return chat_id, {}
|
||||||
|
try:
|
||||||
|
return chat_id, beeper.last_message(token, chat_id)
|
||||||
|
except (beeper.ApiError, beeper.UnreachableError, beeper.AuthError, OSError, ValueError):
|
||||||
|
return chat_id, {}
|
||||||
|
|
||||||
|
workers = min(beeper.SNIPPET_WORKERS, len(chats))
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||||
|
return dict(pool.map(work, chats))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list(page_token: str) -> None:
|
||||||
|
started = time.monotonic()
|
||||||
|
token = _token()
|
||||||
|
per = max_chats()
|
||||||
|
start = 0
|
||||||
|
if page_token:
|
||||||
|
try:
|
||||||
|
start = max(0, int(page_token))
|
||||||
|
except ValueError:
|
||||||
|
start = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
chats, truncated = beeper.unread_chats(token, cap=FETCH_CAP, budget=LIST_BUDGET * 0.5)
|
||||||
|
accounts = beeper.accounts(token)
|
||||||
|
except Exception as exc: # noqa: BLE001 - mapped to the JSON error contract
|
||||||
|
_fail(exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
unread = len(chats)
|
||||||
|
chunk = chats[start : start + per]
|
||||||
|
deadline = started + LIST_BUDGET
|
||||||
|
previews = _snippets(token, chunk, deadline)
|
||||||
|
rows = [beeper.chat_row(chat, previews.get(str(chat.get("id") or ""))) for chat in chunk]
|
||||||
|
|
||||||
|
# A page past the pile would always be empty, so do not offer one.
|
||||||
|
next_start = start + per
|
||||||
|
next_page = str(next_start) if next_start < unread else ""
|
||||||
|
|
||||||
|
out = {
|
||||||
|
"ok": True,
|
||||||
|
"unread": unread,
|
||||||
|
"accountCount": len(accounts),
|
||||||
|
"nextPage": next_page,
|
||||||
|
"thisPage": str(start),
|
||||||
|
"inboxes": _inboxes(accounts, chats),
|
||||||
|
"messages": rows,
|
||||||
|
}
|
||||||
|
if truncated:
|
||||||
|
out["warning"] = f"showing the newest {FETCH_CAP} unread chats"
|
||||||
|
emit(out)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_read(opaque: str) -> None:
|
||||||
|
chat_id = decode_id(opaque)
|
||||||
|
token = _token()
|
||||||
|
try:
|
||||||
|
beeper.mark_read(token, chat_id)
|
||||||
|
except Exception as exc: # noqa: BLE001 - mapped to the JSON error contract
|
||||||
|
_fail(exc)
|
||||||
|
return
|
||||||
|
emit({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_read_all() -> None:
|
||||||
|
started = time.monotonic()
|
||||||
|
deadline = started + READ_ALL_BUDGET
|
||||||
|
token = _token()
|
||||||
|
try:
|
||||||
|
# Snapshot first: marking changes what an unread query would return.
|
||||||
|
chats, _ = beeper.unread_chats(token, cap=READ_ALL_CAP, budget=READ_ALL_BUDGET * 0.3)
|
||||||
|
except Exception as exc: # noqa: BLE001 - mapped to the JSON error contract
|
||||||
|
_fail(exc)
|
||||||
|
return
|
||||||
|
ids = [str(chat.get("id") or "") for chat in chats]
|
||||||
|
ids = [chat_id for chat_id in ids if chat_id]
|
||||||
|
if not ids:
|
||||||
|
emit({"ok": True, "marked": 0})
|
||||||
|
return
|
||||||
|
|
||||||
|
marked = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
ran_out = False
|
||||||
|
|
||||||
|
def work(chat_id: str) -> str:
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 1:
|
||||||
|
return "budget"
|
||||||
|
try:
|
||||||
|
beeper.mark_read(token, chat_id, timeout=int(min(beeper.REQUEST_TIMEOUT, remaining)))
|
||||||
|
except Exception as exc: # noqa: BLE001 - one chat failing is not fatal
|
||||||
|
return str(exc) or "could not mark as read"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=READ_ALL_WORKERS) as pool:
|
||||||
|
for result in pool.map(work, ids):
|
||||||
|
if result == "":
|
||||||
|
marked += 1
|
||||||
|
elif result == "budget":
|
||||||
|
ran_out = True
|
||||||
|
elif len(errors) < 3:
|
||||||
|
errors.append(result)
|
||||||
|
|
||||||
|
if ran_out and not errors:
|
||||||
|
errors.append(f"stopped after {int(READ_ALL_BUDGET)}s")
|
||||||
|
if errors and marked == 0:
|
||||||
|
emit({"ok": False, "marked": 0, "error": "; ".join(errors)})
|
||||||
|
return
|
||||||
|
if errors:
|
||||||
|
emit({"ok": False, "marked": marked, "error": "; ".join(errors)})
|
||||||
|
return
|
||||||
|
emit({"ok": True, "marked": marked})
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_open(opaque: str) -> None:
|
||||||
|
chat_id = decode_id(opaque) if opaque else ""
|
||||||
|
token = _token()
|
||||||
|
try:
|
||||||
|
beeper.focus(token, chat_id)
|
||||||
|
except Exception as exc: # noqa: BLE001 - mapped to the JSON error contract
|
||||||
|
_fail(exc)
|
||||||
|
return
|
||||||
|
emit({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
AUTH_HELP = """\
|
||||||
|
Create an access token in Beeper Desktop:
|
||||||
|
|
||||||
|
Settings -> Integrations -> create a token (read access is enough)
|
||||||
|
|
||||||
|
Then paste it here. It is stored in
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_auth() -> None:
|
||||||
|
# A token typed into a pipe would end up in a shell history or a log.
|
||||||
|
if not (sys.stdin.isatty() and sys.stderr.isatty()):
|
||||||
|
sys.stderr.write("omarchy-beeper auth needs a terminal; see docs/SETUP.md\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
try:
|
||||||
|
info = beeper.server_info()
|
||||||
|
except (beeper.UnreachableError, beeper.ApiError):
|
||||||
|
sys.stderr.write("Beeper Desktop is not running on localhost:23373; start it first.\n")
|
||||||
|
raise SystemExit(1) from None
|
||||||
|
app = info.get("app") if isinstance(info.get("app"), dict) else {}
|
||||||
|
sys.stderr.write(f"Found {app.get('name', 'Beeper')} {app.get('version', '')}".rstrip() + "\n")
|
||||||
|
sys.stderr.write(AUTH_HELP + f"{TOKEN_FILE} with mode 600.\n")
|
||||||
|
token = getpass.getpass("Access token: ").strip()
|
||||||
|
if not token:
|
||||||
|
sys.stderr.write("No token entered; nothing was saved.\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
try:
|
||||||
|
beeper.probe(token)
|
||||||
|
except beeper.AuthError:
|
||||||
|
sys.stderr.write("Beeper rejected that token; nothing was saved.\n")
|
||||||
|
raise SystemExit(1) from None
|
||||||
|
except (beeper.ApiError, beeper.UnreachableError) as exc:
|
||||||
|
sys.stderr.write(f"Could not verify the token: {exc}\n")
|
||||||
|
raise SystemExit(1) from None
|
||||||
|
save_token(token)
|
||||||
|
sys.stdout.write(f"Token saved to {TOKEN_FILE}. The panel picks it up on the next refresh.\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> None:
|
||||||
|
args = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
if not args or args[0] == "list":
|
||||||
|
page = ""
|
||||||
|
rest = args[1:] if args else []
|
||||||
|
while rest:
|
||||||
|
if rest[0] == "--page" and len(rest) > 1:
|
||||||
|
page = rest[1]
|
||||||
|
rest = rest[2:]
|
||||||
|
elif rest[0] == "--limit" and len(rest) > 1:
|
||||||
|
os.environ["OMARCHY_BEEPER_MAX"] = rest[1]
|
||||||
|
rest = rest[2:]
|
||||||
|
else:
|
||||||
|
rest = rest[1:]
|
||||||
|
cmd_list(page)
|
||||||
|
return
|
||||||
|
if args[0] == "read":
|
||||||
|
if len(args) < 2:
|
||||||
|
die("usage: omarchy-beeper read <id>")
|
||||||
|
cmd_read(args[1])
|
||||||
|
return
|
||||||
|
if args[0] == "read-all":
|
||||||
|
cmd_read_all()
|
||||||
|
return
|
||||||
|
if args[0] == "open":
|
||||||
|
cmd_open(args[1] if len(args) > 1 else "")
|
||||||
|
return
|
||||||
|
if args[0] == "auth":
|
||||||
|
cmd_auth()
|
||||||
|
return
|
||||||
|
if args[0] in ("-h", "--help", "help"):
|
||||||
|
sys.stdout.write(HELP)
|
||||||
|
return
|
||||||
|
die("unknown command; try: omarchy-beeper --help")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+283
@@ -0,0 +1,283 @@
|
|||||||
|
"""Shared helpers for omarchy-beeper. No secrets are printed.
|
||||||
|
|
||||||
|
Ported from omarchy-you-got-mail: the file-handling and body-cap rules are
|
||||||
|
identical, only the config directory and the id shape changed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import errno
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "omarchy-beeper"
|
||||||
|
SECRETS_DIR = CONFIG_DIR / "secrets"
|
||||||
|
TOKEN_FILE = SECRETS_DIR / "token.json"
|
||||||
|
|
||||||
|
# Only the local Beeper Desktop API. Nothing else is reachable from here.
|
||||||
|
API_HOST = "localhost"
|
||||||
|
API_PORT = 23373
|
||||||
|
API_BASE = f"http://{API_HOST}:{API_PORT}"
|
||||||
|
|
||||||
|
PAGE_SIZE_MAX = 50
|
||||||
|
FETCH_CAP = 200
|
||||||
|
MAX_HTTP_BODY = 2 * 1024 * 1024
|
||||||
|
MAX_HTTP_ERROR = 64 * 1024
|
||||||
|
MAX_LOCAL_FILE = 64 * 1024
|
||||||
|
|
||||||
|
# Beeper chat ids are Matrix-ish: "!room:server", or a numeric local chat id.
|
||||||
|
CHAT_ID_RE = re.compile(r"^[!@#$+]?[A-Za-z0-9._=/+-]{1,255}(?::[A-Za-z0-9._-]{1,255})?$")
|
||||||
|
ID_PREFIX = "beeper"
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseTooLargeError(ValueError):
|
||||||
|
def __init__(self, message: str = "response too large") -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class FileTooLargeError(ValueError):
|
||||||
|
def __init__(self, message: str = "file too large") -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
def _declared_length(fp: object) -> int | None:
|
||||||
|
headers = getattr(fp, "headers", None)
|
||||||
|
if headers is None or not hasattr(headers, "get"):
|
||||||
|
return None
|
||||||
|
raw = headers.get("Content-Length")
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
n = int(str(raw).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if n < 0:
|
||||||
|
return None
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def read_http_body(fp: object, limit: int = MAX_HTTP_BODY) -> bytes:
|
||||||
|
declared = _declared_length(fp)
|
||||||
|
if declared is not None and declared > limit:
|
||||||
|
raise ResponseTooLargeError()
|
||||||
|
read = getattr(fp, "read")
|
||||||
|
data = read(limit + 1)
|
||||||
|
if not isinstance(data, (bytes, bytearray)):
|
||||||
|
data = bytes(data)
|
||||||
|
if len(data) > limit:
|
||||||
|
raise ResponseTooLargeError()
|
||||||
|
return bytes(data)
|
||||||
|
|
||||||
|
|
||||||
|
def die(message: str, code: int = 0) -> None:
|
||||||
|
sys.stdout.write(json.dumps({"ok": False, "error": message}, ensure_ascii=False) + "\n")
|
||||||
|
raise SystemExit(code)
|
||||||
|
|
||||||
|
|
||||||
|
def emit(payload: dict) -> None:
|
||||||
|
sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def read_owned_file(
|
||||||
|
path: Path,
|
||||||
|
limit: int = MAX_LOCAL_FILE,
|
||||||
|
*,
|
||||||
|
require_private: bool = False,
|
||||||
|
) -> bytes | None:
|
||||||
|
"""Open path once, validate the fd, and read at most *limit* bytes.
|
||||||
|
|
||||||
|
Uses O_NOFOLLOW|O_NONBLOCK so a swapped symlink or FIFO cannot redirect
|
||||||
|
or block the read. Missing paths, dangling/final-component symlinks, and
|
||||||
|
non-regular files return None. Wrong owner is None unless require_private
|
||||||
|
is set, in which case PermissionError is raised (and too-open mode too).
|
||||||
|
"""
|
||||||
|
flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
|
||||||
|
try:
|
||||||
|
fd = os.open(path, flags)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno in (errno.ELOOP, errno.ENXIO, errno.EAGAIN, errno.EWOULDBLOCK, errno.ENOTDIR):
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
st = os.fstat(fd)
|
||||||
|
if not stat.S_ISREG(st.st_mode):
|
||||||
|
return None
|
||||||
|
if st.st_uid != os.getuid():
|
||||||
|
if require_private:
|
||||||
|
raise PermissionError("not owned")
|
||||||
|
return None
|
||||||
|
if require_private and st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
||||||
|
raise PermissionError("too open")
|
||||||
|
if st.st_size > limit:
|
||||||
|
raise FileTooLargeError()
|
||||||
|
data = os.read(fd, limit + 1)
|
||||||
|
if len(data) > limit:
|
||||||
|
raise FileTooLargeError()
|
||||||
|
return data
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
|
||||||
|
def _config_value(key: str) -> str | None:
|
||||||
|
path = CONFIG_DIR / "config"
|
||||||
|
try:
|
||||||
|
raw = read_owned_file(path)
|
||||||
|
except (OSError, FileTooLargeError, PermissionError):
|
||||||
|
return None
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
lines = raw.decode("utf-8").splitlines()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return None
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||||
|
continue
|
||||||
|
name, _, rest = stripped.partition("=")
|
||||||
|
if name.strip().lower() == key:
|
||||||
|
return rest.strip().strip("\"'")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_int(raw: object, default: int, lo: int, hi: int) -> int:
|
||||||
|
try:
|
||||||
|
n = int(str(raw).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
return max(lo, min(hi, n))
|
||||||
|
|
||||||
|
|
||||||
|
def max_chats() -> int:
|
||||||
|
raw = os.environ.get("OMARCHY_BEEPER_MAX") or _config_value("max") or "25"
|
||||||
|
return clamp_int(raw, 25, 1, PAGE_SIZE_MAX)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_limit(raw: object | None = None) -> int:
|
||||||
|
if raw is None:
|
||||||
|
raw = os.environ.get("OMARCHY_BEEPER_FETCH") or os.environ.get("OMARCHY_BEEPER_MAX") or "25"
|
||||||
|
return clamp_int(raw, 25, 1, FETCH_CAP)
|
||||||
|
|
||||||
|
|
||||||
|
def one_line(value: str, limit: int = 180) -> str:
|
||||||
|
text = re.sub(r"[\u00ad\u034f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]+", "", value or "")
|
||||||
|
text = re.sub(r"\s+", " ", text).strip()
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
return text[: limit - 1].rstrip() + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_config_dirs() -> None:
|
||||||
|
CONFIG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
os.chmod(CONFIG_DIR, 0o700)
|
||||||
|
SECRETS_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
os.chmod(SECRETS_DIR, 0o700)
|
||||||
|
|
||||||
|
|
||||||
|
def write_private(path: Path, text: str) -> None:
|
||||||
|
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent))
|
||||||
|
replaced = False
|
||||||
|
try:
|
||||||
|
os.fchmod(fd, 0o600)
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||||
|
fd = -1
|
||||||
|
fh.write(text)
|
||||||
|
fh.flush()
|
||||||
|
os.fsync(fh.fileno())
|
||||||
|
os.replace(tmp, path)
|
||||||
|
replaced = True
|
||||||
|
flags = os.O_RDONLY
|
||||||
|
if hasattr(os, "O_DIRECTORY"):
|
||||||
|
flags |= os.O_DIRECTORY
|
||||||
|
dirfd = os.open(str(path.parent), flags)
|
||||||
|
try:
|
||||||
|
os.fsync(dirfd)
|
||||||
|
finally:
|
||||||
|
os.close(dirfd)
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
finally:
|
||||||
|
if fd >= 0:
|
||||||
|
try:
|
||||||
|
os.close(fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if not replaced:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def load_secret_file(path: Path, label: str = "") -> dict:
|
||||||
|
label = label or path.name
|
||||||
|
try:
|
||||||
|
raw = read_owned_file(path, require_private=True)
|
||||||
|
except FileTooLargeError:
|
||||||
|
die(f"secret file for {label} is too large")
|
||||||
|
except PermissionError as exc:
|
||||||
|
reason = str(exc)
|
||||||
|
if "too open" in reason:
|
||||||
|
die(f"secret file for {label} is too open; chmod 600 it")
|
||||||
|
if "not owned" in reason:
|
||||||
|
die(f"secret file for {label} is not owned by you")
|
||||||
|
die(f"secret file for {label} is not readable")
|
||||||
|
except OSError:
|
||||||
|
die(f"secret file for {label} is not readable")
|
||||||
|
if raw is None:
|
||||||
|
return {}
|
||||||
|
parent = path.parent
|
||||||
|
if parent.is_symlink():
|
||||||
|
die(f"secret directory for {label} is a symlink")
|
||||||
|
try:
|
||||||
|
pst = parent.stat()
|
||||||
|
except OSError:
|
||||||
|
die(f"secret directory for {label} is not readable")
|
||||||
|
if pst.st_uid != os.getuid() or pst.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
||||||
|
die(f"secret directory for {label} is too open; chmod 700 it")
|
||||||
|
try:
|
||||||
|
data = json.loads(raw.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
die(f"secret file for {label} is not valid JSON")
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def load_token() -> str:
|
||||||
|
"""Read the access token. Empty string means 'not set up yet'."""
|
||||||
|
data = load_secret_file(TOKEN_FILE, "beeper")
|
||||||
|
token = data.get("token")
|
||||||
|
return str(token) if isinstance(token, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def save_token(token: str) -> None:
|
||||||
|
ensure_config_dirs()
|
||||||
|
write_private(TOKEN_FILE, json.dumps({"token": token}, indent=2) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def encode_id(chat_id: str) -> str:
|
||||||
|
"""One row per chat, so the opaque id carries only the chat id."""
|
||||||
|
blob = base64.urlsafe_b64encode(chat_id.encode("utf-8")).decode("ascii").rstrip("=")
|
||||||
|
return f"{ID_PREFIX}:{blob}"
|
||||||
|
|
||||||
|
|
||||||
|
def decode_id(opaque: str) -> str:
|
||||||
|
prefix, _, blob = str(opaque).partition(":")
|
||||||
|
if prefix != ID_PREFIX or not blob:
|
||||||
|
die("not a chat id")
|
||||||
|
pad = "=" * ((4 - len(blob) % 4) % 4)
|
||||||
|
try:
|
||||||
|
chat_id = base64.urlsafe_b64decode(blob + pad).decode("utf-8")
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
die("not a chat id")
|
||||||
|
if not CHAT_ID_RE.match(chat_id):
|
||||||
|
die("not a chat id")
|
||||||
|
return chat_id
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "prdlk.beeper",
|
||||||
|
"name": "Beeper",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "prdlk",
|
||||||
|
"description": "Unread chats from every Beeper network in the bar. Click a row to open that chat in Beeper Desktop and mark it read.",
|
||||||
|
"homepage": "https://github.com/prdlk/omarchy-beeper",
|
||||||
|
"license": "MIT",
|
||||||
|
"kinds": [
|
||||||
|
"bar-widget"
|
||||||
|
],
|
||||||
|
"entryPoints": {
|
||||||
|
"barWidget": "Panel.qml"
|
||||||
|
},
|
||||||
|
"barWidget": {
|
||||||
|
"displayName": "Beeper",
|
||||||
|
"description": "One pile of unread conversations across WhatsApp, Signal, Slack, Telegram, and every other network Beeper bridges. Read and mark-read only.",
|
||||||
|
"category": "Apps",
|
||||||
|
"allowMultiple": false,
|
||||||
|
"defaultSection": "right",
|
||||||
|
"defaults": {
|
||||||
|
"max": 25,
|
||||||
|
"refreshIntervalSec": 60
|
||||||
|
},
|
||||||
|
"schema": [
|
||||||
|
{
|
||||||
|
"key": "max",
|
||||||
|
"type": "integer",
|
||||||
|
"label": "Page size",
|
||||||
|
"min": 1,
|
||||||
|
"max": 50,
|
||||||
|
"step": 1,
|
||||||
|
"defaultValue": 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "refreshIntervalSec",
|
||||||
|
"type": "integer",
|
||||||
|
"label": "Refresh interval (seconds)",
|
||||||
|
"min": 15,
|
||||||
|
"max": 3600,
|
||||||
|
"step": 15,
|
||||||
|
"defaultValue": 60
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""Shared helpers for mocked contract tests. Never talks to Beeper."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
from contextlib import contextmanager, redirect_stdout
|
||||||
|
from io import BytesIO, StringIO
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
LIB = ROOT / "lib"
|
||||||
|
|
||||||
|
if str(LIB) not in sys.path:
|
||||||
|
sys.path.insert(0, str(LIB))
|
||||||
|
|
||||||
|
|
||||||
|
def capture_json(fn, *args, **kwargs) -> dict:
|
||||||
|
buf = StringIO()
|
||||||
|
with redirect_stdout(buf):
|
||||||
|
try:
|
||||||
|
fn(*args, **kwargs)
|
||||||
|
except SystemExit:
|
||||||
|
pass
|
||||||
|
text = buf.getvalue().strip()
|
||||||
|
if not text:
|
||||||
|
raise AssertionError("expected JSON on stdout")
|
||||||
|
return json.loads(text.splitlines()[-1])
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
"""Enough of an http.client.HTTPResponse for read_http_body()."""
|
||||||
|
|
||||||
|
def __init__(self, payload: object, status: int = 200, content_length: object | None = None):
|
||||||
|
if isinstance(payload, (bytes, bytearray)):
|
||||||
|
raw = bytes(payload)
|
||||||
|
else:
|
||||||
|
raw = json.dumps(payload).encode("utf-8")
|
||||||
|
self._buf = BytesIO(raw)
|
||||||
|
self.status = status
|
||||||
|
declared = len(raw) if content_length is None else content_length
|
||||||
|
self.headers = {} if declared is False else {"Content-Length": str(declared)}
|
||||||
|
|
||||||
|
def read(self, n: int = -1) -> bytes:
|
||||||
|
return self._buf.read(n)
|
||||||
|
|
||||||
|
def __enter__(self) -> FakeResponse:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc: object) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Call:
|
||||||
|
def __init__(self, method: str, url: str, body: object) -> None:
|
||||||
|
parsed = urllib.parse.urlsplit(url)
|
||||||
|
self.method = method
|
||||||
|
self.url = url
|
||||||
|
self.host = parsed.netloc
|
||||||
|
self.path = parsed.path
|
||||||
|
self.query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
|
||||||
|
self.body = body
|
||||||
|
|
||||||
|
def one(self, key: str, default: str = "") -> str:
|
||||||
|
values = self.query.get(key) or []
|
||||||
|
return values[0] if values else default
|
||||||
|
|
||||||
|
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
||||||
|
return f"<Call {self.method} {self.path} {self.query}>"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHttp:
|
||||||
|
"""Route (METHOD, path) to a callable or a fixed response."""
|
||||||
|
|
||||||
|
def __init__(self, routes: dict[tuple[str, str], object] | None = None) -> None:
|
||||||
|
self.routes = dict(routes or {})
|
||||||
|
self.calls: list[Call] = []
|
||||||
|
|
||||||
|
def route(self, method: str, path: str, handler: object) -> None:
|
||||||
|
self.routes[(method, path)] = handler
|
||||||
|
|
||||||
|
def open(self, req, timeout: int | None = None): # noqa: D102 - urlopen shape
|
||||||
|
body = None
|
||||||
|
if req.data:
|
||||||
|
body = json.loads(req.data.decode("utf-8"))
|
||||||
|
call = Call(req.get_method(), req.full_url, body)
|
||||||
|
self.calls.append(call)
|
||||||
|
handler = self.routes.get((call.method, call.path))
|
||||||
|
if handler is None:
|
||||||
|
raise urllib.error.HTTPError(call.url, 404, "Not Found", {}, BytesIO(b"{}"))
|
||||||
|
result = handler(call) if callable(handler) else handler
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise result
|
||||||
|
if isinstance(result, FakeResponse):
|
||||||
|
return result
|
||||||
|
return FakeResponse(result)
|
||||||
|
|
||||||
|
def paths(self, method: str = "") -> list[str]:
|
||||||
|
return [c.path for c in self.calls if not method or c.method == method]
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def fake_api(http: FakeHttp):
|
||||||
|
import beeper
|
||||||
|
|
||||||
|
with patch.object(beeper, "_OPENER", http):
|
||||||
|
yield http
|
||||||
|
|
||||||
|
|
||||||
|
def http_error(status: int, message: str = "nope", url: str = "http://localhost:23373/v1/x"):
|
||||||
|
body = json.dumps({"message": message, "code": "error"}).encode("utf-8")
|
||||||
|
return urllib.error.HTTPError(url, status, message, {"Content-Length": str(len(body))}, BytesIO(body))
|
||||||
|
|
||||||
|
|
||||||
|
def url_error(reason: Exception | None = None):
|
||||||
|
return urllib.error.URLError(reason or ConnectionRefusedError(111, "Connection refused"))
|
||||||
|
|
||||||
|
|
||||||
|
def chat(
|
||||||
|
chat_id: str,
|
||||||
|
*,
|
||||||
|
title: str = "Ada Lovelace",
|
||||||
|
network: str = "WhatsApp",
|
||||||
|
account: str = "local-whatsapp_1",
|
||||||
|
last_activity: str = "2026-02-11T10:00:00.000Z",
|
||||||
|
unread: int = 2,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"id": chat_id,
|
||||||
|
"localChatID": "1",
|
||||||
|
"accountID": account,
|
||||||
|
"network": network,
|
||||||
|
"title": title,
|
||||||
|
"type": "single",
|
||||||
|
"participants": {"items": [], "hasMore": False, "total": 1},
|
||||||
|
"lastActivity": last_activity,
|
||||||
|
"unreadCount": unread,
|
||||||
|
"isArchived": False,
|
||||||
|
"isMuted": False,
|
||||||
|
"isPinned": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def message(
|
||||||
|
chat_id: str,
|
||||||
|
*,
|
||||||
|
text: str = "are you coming tonight?",
|
||||||
|
sender: str = "Ada",
|
||||||
|
timestamp: str = "2026-02-11T10:00:00.000Z",
|
||||||
|
kind: str = "TEXT",
|
||||||
|
sort_key: str = "100",
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"id": "m1",
|
||||||
|
"chatID": chat_id,
|
||||||
|
"accountID": "local-whatsapp_1",
|
||||||
|
"senderID": "@ada:example",
|
||||||
|
"senderName": sender,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"sortKey": sort_key,
|
||||||
|
"type": kind,
|
||||||
|
"text": text,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def account(account_id: str, network: str) -> dict:
|
||||||
|
return {
|
||||||
|
"accountID": account_id,
|
||||||
|
"loginID": account_id,
|
||||||
|
"bridge": {"id": network.lower(), "type": network.lower(), "provider": "local"},
|
||||||
|
"network": network,
|
||||||
|
"user": {"id": "@me:example", "isSelf": True},
|
||||||
|
"status": "connected",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def search_page(chats: list[dict], has_more: bool = False, cursor: str | None = None) -> dict:
|
||||||
|
return {
|
||||||
|
"items": chats,
|
||||||
|
"hasMore": has_more,
|
||||||
|
"oldestCursor": cursor,
|
||||||
|
"newestCursor": None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stderr
|
||||||
|
from io import StringIO
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from support import ( # noqa: I001 - puts lib/ on sys.path
|
||||||
|
FakeHttp,
|
||||||
|
FakeResponse,
|
||||||
|
account,
|
||||||
|
capture_json,
|
||||||
|
chat,
|
||||||
|
fake_api,
|
||||||
|
http_error,
|
||||||
|
message,
|
||||||
|
search_page,
|
||||||
|
url_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
import beeper
|
||||||
|
import cli
|
||||||
|
import common
|
||||||
|
|
||||||
|
TOKEN = "test-token"
|
||||||
|
SEARCH = "/v1/chats/search"
|
||||||
|
ACCOUNTS = "/v1/accounts"
|
||||||
|
FOCUS = "/v1/focus"
|
||||||
|
|
||||||
|
|
||||||
|
def messages_path(chat_id: str) -> str:
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
return f"/v1/chats/{urllib.parse.quote(chat_id, safe='')}/messages"
|
||||||
|
|
||||||
|
|
||||||
|
def read_path(chat_id: str) -> str:
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
return f"/v1/chats/{urllib.parse.quote(chat_id, safe='')}/read"
|
||||||
|
|
||||||
|
|
||||||
|
def wire(http: FakeHttp, chats: list[dict], accounts: list[dict] | None = None) -> FakeHttp:
|
||||||
|
"""Standard happy-path routing: one search page, snippets, marks."""
|
||||||
|
http.route("GET", SEARCH, lambda call: search_page(chats))
|
||||||
|
http.route("GET", ACCOUNTS, accounts if accounts is not None else [account("a1", "WhatsApp")])
|
||||||
|
http.route("POST", FOCUS, {"success": True})
|
||||||
|
for item in chats:
|
||||||
|
chat_id = item["id"]
|
||||||
|
http.route("GET", messages_path(chat_id), {"items": [message(chat_id)], "hasMore": False})
|
||||||
|
http.route("POST", read_path(chat_id), item)
|
||||||
|
return http
|
||||||
|
|
||||||
|
|
||||||
|
def run_list(http: FakeHttp, page: str = "", limit: int = 25) -> dict:
|
||||||
|
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN), patch.dict(
|
||||||
|
"os.environ", {"OMARCHY_BEEPER_MAX": str(limit)}, clear=False
|
||||||
|
):
|
||||||
|
return capture_json(cli.cmd_list, page)
|
||||||
|
|
||||||
|
|
||||||
|
class UrlBindingTests(unittest.TestCase):
|
||||||
|
def test_every_call_stays_on_the_local_api(self) -> None:
|
||||||
|
self.assertTrue(beeper._url("/v1/accounts").startswith("http://localhost:23373/"))
|
||||||
|
|
||||||
|
def test_refuses_paths_outside_v1(self) -> None:
|
||||||
|
for bad in ("/oauth/token", "v1/accounts", "http://evil.test/v1/x", "//evil.test/v1/x"):
|
||||||
|
with self.assertRaises(beeper.ApiError, msg=bad):
|
||||||
|
beeper._url(bad)
|
||||||
|
|
||||||
|
def test_booleans_and_lists_become_query_values(self) -> None:
|
||||||
|
url = beeper._url("/v1/chats/search", {"unreadOnly": True, "includeMuted": False})
|
||||||
|
self.assertIn("unreadOnly=true", url)
|
||||||
|
self.assertIn("includeMuted=false", url)
|
||||||
|
|
||||||
|
def test_redirects_are_not_followed(self) -> None:
|
||||||
|
self.assertIsNone(
|
||||||
|
beeper._NoRedirect().redirect_request(None, None, 302, "Found", {}, "http://evil.test/")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ListContractTests(unittest.TestCase):
|
||||||
|
def test_filters_out_muted_archived_and_low_priority(self) -> None:
|
||||||
|
http = wire(FakeHttp(), [chat("!a:x")])
|
||||||
|
run_list(http)
|
||||||
|
search = next(c for c in http.calls if c.path == SEARCH)
|
||||||
|
self.assertEqual(search.one("unreadOnly"), "true")
|
||||||
|
self.assertEqual(search.one("includeMuted"), "false")
|
||||||
|
self.assertEqual(search.one("inbox"), "primary")
|
||||||
|
|
||||||
|
def test_one_row_per_chat_newest_first(self) -> None:
|
||||||
|
chats = [
|
||||||
|
chat("!old:x", title="Old", last_activity="2026-02-01T00:00:00Z"),
|
||||||
|
chat("!new:x", title="New", last_activity="2026-02-11T00:00:00Z"),
|
||||||
|
chat("!mid:x", title="Mid", last_activity="2026-02-05T00:00:00Z"),
|
||||||
|
]
|
||||||
|
payload = run_list(wire(FakeHttp(), chats))
|
||||||
|
self.assertEqual([row["subject"] for row in payload["messages"]], ["New", "Mid", "Old"])
|
||||||
|
self.assertEqual(payload["unread"], 3)
|
||||||
|
|
||||||
|
def test_row_shape_matches_the_panel_contract(self) -> None:
|
||||||
|
chats = [chat("!a:x", title="Ada Lovelace\n", network="Signal")]
|
||||||
|
payload = run_list(wire(FakeHttp(), chats))
|
||||||
|
row = payload["messages"][0]
|
||||||
|
self.assertEqual(
|
||||||
|
sorted(row),
|
||||||
|
["from", "id", "labels", "snippet", "subject", "threadId", "ts", "url"],
|
||||||
|
)
|
||||||
|
self.assertEqual(row["subject"], "Ada Lovelace")
|
||||||
|
self.assertEqual(row["threadId"], "!a:x")
|
||||||
|
self.assertEqual(row["id"], common.encode_id("!a:x"))
|
||||||
|
self.assertEqual(common.decode_id(row["id"]), "!a:x")
|
||||||
|
self.assertEqual(row["labels"], ["Signal"])
|
||||||
|
self.assertEqual(row["from"], "Ada")
|
||||||
|
self.assertEqual(row["snippet"], "are you coming tonight?")
|
||||||
|
self.assertEqual(row["url"], "")
|
||||||
|
self.assertEqual(row["ts"], 1770804000) # 2026-02-11T10:00:00Z
|
||||||
|
|
||||||
|
def test_unread_is_the_pile_not_the_page(self) -> None:
|
||||||
|
chats = [chat(f"!c{i}:x", last_activity=f"2026-02-11T10:{i:02d}:00Z") for i in range(40)]
|
||||||
|
payload = run_list(wire(FakeHttp(), chats), limit=25)
|
||||||
|
self.assertEqual(payload["unread"], 40)
|
||||||
|
self.assertEqual(len(payload["messages"]), 25)
|
||||||
|
self.assertEqual(payload["thisPage"], "0")
|
||||||
|
self.assertEqual(payload["nextPage"], "25")
|
||||||
|
|
||||||
|
def test_second_page_is_the_offset_slice(self) -> None:
|
||||||
|
# Descending activity, so c0 is newest and the offset slice starts at c25.
|
||||||
|
chats = [
|
||||||
|
chat(f"!c{i}:x", title=f"c{i}", last_activity=f"2026-02-11T10:{59 - i:02d}:00Z")
|
||||||
|
for i in range(40)
|
||||||
|
]
|
||||||
|
payload = run_list(wire(FakeHttp(), chats), page="25", limit=25)
|
||||||
|
self.assertEqual(payload["thisPage"], "25")
|
||||||
|
self.assertEqual(len(payload["messages"]), 15)
|
||||||
|
self.assertEqual(payload["messages"][0]["subject"], "c25")
|
||||||
|
self.assertEqual(payload["nextPage"], "")
|
||||||
|
|
||||||
|
def test_bad_page_token_falls_back_to_the_first_page(self) -> None:
|
||||||
|
payload = run_list(wire(FakeHttp(), [chat("!a:x")]), page="../etc/passwd")
|
||||||
|
self.assertEqual(payload["thisPage"], "0")
|
||||||
|
self.assertEqual(len(payload["messages"]), 1)
|
||||||
|
|
||||||
|
def test_snippets_are_only_fetched_for_the_visible_page(self) -> None:
|
||||||
|
chats = [chat(f"!c{i}:x", last_activity=f"2026-02-11T10:{i:02d}:00Z") for i in range(40)]
|
||||||
|
http = wire(FakeHttp(), chats)
|
||||||
|
run_list(http, limit=5)
|
||||||
|
message_calls = [p for p in http.paths("GET") if p.endswith("/messages")]
|
||||||
|
self.assertEqual(len(message_calls), 5)
|
||||||
|
|
||||||
|
def test_a_failed_snippet_only_loses_the_snippet(self) -> None:
|
||||||
|
chats = [chat("!a:x")]
|
||||||
|
http = wire(FakeHttp(), chats)
|
||||||
|
http.route("GET", messages_path("!a:x"), lambda call: http_error(500, "boom"))
|
||||||
|
payload = run_list(http)
|
||||||
|
self.assertTrue(payload["ok"])
|
||||||
|
self.assertEqual(payload["messages"][0]["snippet"], "")
|
||||||
|
self.assertEqual(payload["messages"][0]["subject"], "Ada Lovelace")
|
||||||
|
self.assertEqual(payload["messages"][0]["ts"], 1770804000)
|
||||||
|
|
||||||
|
def test_media_messages_get_a_placeholder_snippet(self) -> None:
|
||||||
|
http = wire(FakeHttp(), [chat("!a:x")])
|
||||||
|
http.route(
|
||||||
|
"GET",
|
||||||
|
messages_path("!a:x"),
|
||||||
|
{"items": [message("!a:x", text="", kind="IMAGE")], "hasMore": False},
|
||||||
|
)
|
||||||
|
payload = run_list(http)
|
||||||
|
self.assertEqual(payload["messages"][0]["snippet"], "[photo]")
|
||||||
|
|
||||||
|
def test_newest_undeleted_message_wins(self) -> None:
|
||||||
|
http = wire(FakeHttp(), [chat("!a:x")])
|
||||||
|
items = [
|
||||||
|
message("!a:x", text="older", timestamp="2026-02-11T09:00:00Z", sort_key="1"),
|
||||||
|
message("!a:x", text="newest", timestamp="2026-02-11T11:00:00Z", sort_key="3"),
|
||||||
|
dict(
|
||||||
|
message("!a:x", text="deleted", timestamp="2026-02-11T12:00:00Z", sort_key="4"),
|
||||||
|
isDeleted=True,
|
||||||
|
),
|
||||||
|
dict(message("!a:x", text="👍", timestamp="2026-02-11T13:00:00Z", sort_key="5"), type="REACTION"),
|
||||||
|
]
|
||||||
|
http.route("GET", messages_path("!a:x"), {"items": items, "hasMore": False})
|
||||||
|
payload = run_list(http)
|
||||||
|
self.assertEqual(payload["messages"][0]["snippet"], "newest")
|
||||||
|
|
||||||
|
def test_inboxes_and_account_count_come_from_accounts(self) -> None:
|
||||||
|
chats = [
|
||||||
|
chat("!a:x", account="wa", network="WhatsApp"),
|
||||||
|
chat("!b:x", account="wa", network="WhatsApp"),
|
||||||
|
chat("!c:x", account="slack", network="Slack"),
|
||||||
|
]
|
||||||
|
accounts = [account("wa", "WhatsApp"), account("slack", "Slack"), account("sig", "Signal")]
|
||||||
|
payload = run_list(wire(FakeHttp(), chats, accounts))
|
||||||
|
self.assertEqual(payload["accountCount"], 3)
|
||||||
|
self.assertEqual(
|
||||||
|
payload["inboxes"],
|
||||||
|
[
|
||||||
|
{"account": "WhatsApp", "unread": 2, "searchUrl": ""},
|
||||||
|
{"account": "Slack", "unread": 1, "searchUrl": ""},
|
||||||
|
{"account": "Signal", "unread": 0, "searchUrl": ""},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_chats_on_unknown_accounts_are_still_counted(self) -> None:
|
||||||
|
chats = [chat("!a:x", account="ghost", network="Ghost")]
|
||||||
|
payload = run_list(wire(FakeHttp(), chats, [account("wa", "WhatsApp")]))
|
||||||
|
self.assertEqual(payload["inboxes"][-1], {"account": "Other", "unread": 1, "searchUrl": ""})
|
||||||
|
|
||||||
|
def test_caught_up_is_a_success_with_an_empty_pile(self) -> None:
|
||||||
|
payload = run_list(wire(FakeHttp(), []))
|
||||||
|
self.assertTrue(payload["ok"])
|
||||||
|
self.assertEqual(payload["unread"], 0)
|
||||||
|
self.assertEqual(payload["messages"], [])
|
||||||
|
self.assertEqual(payload["nextPage"], "")
|
||||||
|
|
||||||
|
|
||||||
|
class PaginationWalkTests(unittest.TestCase):
|
||||||
|
def test_walks_cursors_until_the_api_runs_out(self) -> None:
|
||||||
|
pages = [
|
||||||
|
search_page([chat(f"!p1-{i}:x") for i in range(3)], has_more=True, cursor="cur1"),
|
||||||
|
search_page([chat(f"!p2-{i}:x") for i in range(2)], has_more=False, cursor=None),
|
||||||
|
]
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
def handler(call):
|
||||||
|
seen.append(call.one("cursor"))
|
||||||
|
return pages[len(seen) - 1]
|
||||||
|
|
||||||
|
http = FakeHttp({("GET", SEARCH): handler})
|
||||||
|
with fake_api(http):
|
||||||
|
chats, truncated = beeper.unread_chats(TOKEN)
|
||||||
|
self.assertEqual(seen, ["", "cur1"])
|
||||||
|
self.assertEqual(len(chats), 5)
|
||||||
|
self.assertFalse(truncated)
|
||||||
|
|
||||||
|
def test_walk_stops_at_the_fetch_cap_and_reports_truncation(self) -> None:
|
||||||
|
def handler(call):
|
||||||
|
return search_page(
|
||||||
|
[chat(f"!c{call.one('cursor', '0')}-{i}:x") for i in range(150)],
|
||||||
|
has_more=True,
|
||||||
|
cursor="next",
|
||||||
|
)
|
||||||
|
|
||||||
|
http = FakeHttp({("GET", SEARCH): handler})
|
||||||
|
with fake_api(http):
|
||||||
|
chats, truncated = beeper.unread_chats(TOKEN, cap=common.FETCH_CAP)
|
||||||
|
self.assertEqual(len(chats), common.FETCH_CAP)
|
||||||
|
self.assertTrue(truncated)
|
||||||
|
|
||||||
|
def test_truncated_walk_warns_in_the_list_payload(self) -> None:
|
||||||
|
def handler(call):
|
||||||
|
return search_page(
|
||||||
|
[chat(f"!c{call.one('cursor', '0')}-{i}:x") for i in range(200)],
|
||||||
|
has_more=True,
|
||||||
|
cursor="next",
|
||||||
|
)
|
||||||
|
|
||||||
|
http = wire(FakeHttp(), [])
|
||||||
|
http.route("GET", SEARCH, handler)
|
||||||
|
for i in range(200):
|
||||||
|
cid = f"!c0-{i}:x"
|
||||||
|
http.route("GET", messages_path(cid), {"items": [message(cid)], "hasMore": False})
|
||||||
|
payload = run_list(http, limit=2)
|
||||||
|
self.assertEqual(payload["unread"], common.FETCH_CAP)
|
||||||
|
self.assertIn(str(common.FETCH_CAP), payload["warning"])
|
||||||
|
|
||||||
|
def test_duplicate_chats_across_pages_are_collapsed(self) -> None:
|
||||||
|
pages = [
|
||||||
|
search_page([chat("!dup:x"), chat("!a:x")], has_more=True, cursor="cur1"),
|
||||||
|
search_page([chat("!dup:x"), chat("!b:x")], has_more=False, cursor=None),
|
||||||
|
]
|
||||||
|
state = {"n": 0}
|
||||||
|
|
||||||
|
def handler(call):
|
||||||
|
page = pages[state["n"]]
|
||||||
|
state["n"] += 1
|
||||||
|
return page
|
||||||
|
|
||||||
|
http = FakeHttp({("GET", SEARCH): handler})
|
||||||
|
with fake_api(http):
|
||||||
|
chats, _ = beeper.unread_chats(TOKEN)
|
||||||
|
self.assertEqual(len(chats), 3)
|
||||||
|
|
||||||
|
|
||||||
|
class ReadTests(unittest.TestCase):
|
||||||
|
def test_read_marks_exactly_that_chat(self) -> None:
|
||||||
|
http = wire(FakeHttp(), [chat("!a:x")])
|
||||||
|
opaque = common.encode_id("!a:x")
|
||||||
|
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||||
|
payload = capture_json(cli.cmd_read, opaque)
|
||||||
|
self.assertEqual(payload, {"ok": True})
|
||||||
|
self.assertEqual(http.paths("POST"), [read_path("!a:x")])
|
||||||
|
|
||||||
|
def test_read_refuses_a_forged_id(self) -> None:
|
||||||
|
http = FakeHttp()
|
||||||
|
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||||
|
payload = capture_json(cli.cmd_read, "beeper:!!!")
|
||||||
|
self.assertFalse(payload["ok"])
|
||||||
|
self.assertEqual(http.calls, [])
|
||||||
|
|
||||||
|
def test_read_reports_an_api_failure(self) -> None:
|
||||||
|
http = wire(FakeHttp(), [chat("!a:x")])
|
||||||
|
http.route("POST", read_path("!a:x"), lambda call: http_error(500, "bridge offline"))
|
||||||
|
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||||
|
payload = capture_json(cli.cmd_read, common.encode_id("!a:x"))
|
||||||
|
self.assertFalse(payload["ok"])
|
||||||
|
self.assertIn("500", payload["error"])
|
||||||
|
|
||||||
|
|
||||||
|
class ReadAllTests(unittest.TestCase):
|
||||||
|
def _chats(self, n: int) -> list[dict]:
|
||||||
|
return [chat(f"!c{i}:x", last_activity=f"2026-02-11T10:{i % 60:02d}:00Z") for i in range(n)]
|
||||||
|
|
||||||
|
def _run(self, http: FakeHttp) -> dict:
|
||||||
|
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||||
|
return capture_json(cli.cmd_read_all)
|
||||||
|
|
||||||
|
def test_snapshots_before_marking(self) -> None:
|
||||||
|
chats = self._chats(3)
|
||||||
|
http = wire(FakeHttp(), chats)
|
||||||
|
payload = self._run(http)
|
||||||
|
self.assertEqual(payload, {"ok": True, "marked": 3})
|
||||||
|
methods = [c.method for c in http.calls]
|
||||||
|
self.assertEqual(methods[0], "GET")
|
||||||
|
self.assertEqual(http.calls[0].path, SEARCH)
|
||||||
|
self.assertNotIn("GET", methods[1:])
|
||||||
|
|
||||||
|
def test_walks_past_the_display_cap(self) -> None:
|
||||||
|
chats = self._chats(300)
|
||||||
|
http = wire(FakeHttp(), chats)
|
||||||
|
payload = self._run(http)
|
||||||
|
self.assertEqual(payload, {"ok": True, "marked": 300})
|
||||||
|
self.assertEqual(len([c for c in http.calls if c.method == "POST"]), 300)
|
||||||
|
|
||||||
|
def test_partial_failure_keeps_the_marked_count(self) -> None:
|
||||||
|
chats = self._chats(4)
|
||||||
|
http = wire(FakeHttp(), chats)
|
||||||
|
http.route("POST", read_path("!c2:x"), lambda call: http_error(500, "bridge offline"))
|
||||||
|
payload = self._run(http)
|
||||||
|
self.assertFalse(payload["ok"])
|
||||||
|
self.assertEqual(payload["marked"], 3)
|
||||||
|
self.assertIn("500", payload["error"])
|
||||||
|
|
||||||
|
def test_budget_stops_the_run_with_a_partial_count(self) -> None:
|
||||||
|
chats = self._chats(5)
|
||||||
|
http = wire(FakeHttp(), chats)
|
||||||
|
with patch.object(cli, "READ_ALL_BUDGET", 0.0), patch.object(cli, "READ_ALL_WORKERS", 1):
|
||||||
|
payload = self._run(http)
|
||||||
|
self.assertFalse(payload["ok"])
|
||||||
|
self.assertEqual(payload["marked"], 0)
|
||||||
|
self.assertIn("stopped after", payload["error"])
|
||||||
|
|
||||||
|
def test_nothing_unread_is_a_no_op(self) -> None:
|
||||||
|
payload = self._run(wire(FakeHttp(), []))
|
||||||
|
self.assertEqual(payload, {"ok": True, "marked": 0})
|
||||||
|
|
||||||
|
def test_a_failed_snapshot_does_not_mark_anything(self) -> None:
|
||||||
|
http = FakeHttp({("GET", SEARCH): lambda call: http_error(500, "index rebuilding")})
|
||||||
|
payload = self._run(http)
|
||||||
|
self.assertFalse(payload["ok"])
|
||||||
|
self.assertEqual(http.paths("POST"), [])
|
||||||
|
|
||||||
|
|
||||||
|
class OpenTests(unittest.TestCase):
|
||||||
|
def test_open_focuses_the_chat(self) -> None:
|
||||||
|
http = wire(FakeHttp(), [chat("!a:x")])
|
||||||
|
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||||
|
payload = capture_json(cli.cmd_open, common.encode_id("!a:x"))
|
||||||
|
self.assertEqual(payload, {"ok": True})
|
||||||
|
focus = next(c for c in http.calls if c.path == FOCUS)
|
||||||
|
self.assertEqual(focus.body, {"chatID": "!a:x"})
|
||||||
|
|
||||||
|
def test_open_without_an_id_only_focuses_the_app(self) -> None:
|
||||||
|
http = wire(FakeHttp(), [])
|
||||||
|
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||||
|
payload = capture_json(cli.cmd_open, "")
|
||||||
|
self.assertEqual(payload, {"ok": True})
|
||||||
|
focus = next(c for c in http.calls if c.path == FOCUS)
|
||||||
|
self.assertEqual(focus.body, {})
|
||||||
|
|
||||||
|
def test_the_only_writes_are_mark_read_and_focus(self) -> None:
|
||||||
|
source = (common.ROOT / "lib" / "beeper.py").read_text(encoding="utf-8")
|
||||||
|
mutations = re.findall(
|
||||||
|
r'"(POST|PUT|PATCH|DELETE)",\s*(?:#[^\n]*\n\s*)?(f?"[^"]+")', source
|
||||||
|
)
|
||||||
|
self.assertTrue(mutations, "expected to find the mutating calls")
|
||||||
|
for method, path in mutations:
|
||||||
|
self.assertTrue(
|
||||||
|
path.endswith('/read"') or path == '"/v1/focus"',
|
||||||
|
msg=f"unexpected write: {method} {path}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FailureMappingTests(unittest.TestCase):
|
||||||
|
def test_401_asks_for_auth(self) -> None:
|
||||||
|
http = FakeHttp({("GET", SEARCH): lambda call: http_error(401, "Unauthorized")})
|
||||||
|
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||||
|
payload = capture_json(cli.cmd_list, "")
|
||||||
|
self.assertEqual(payload["error"], "token missing or invalid; run: omarchy-beeper auth")
|
||||||
|
|
||||||
|
def test_connection_refused_says_beeper_is_not_running(self) -> None:
|
||||||
|
http = FakeHttp({("GET", SEARCH): lambda call: url_error()})
|
||||||
|
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||||
|
payload = capture_json(cli.cmd_list, "")
|
||||||
|
self.assertEqual(payload["error"], "Beeper Desktop is not running")
|
||||||
|
|
||||||
|
def test_missing_token_is_told_apart_from_a_dead_app(self) -> None:
|
||||||
|
http = FakeHttp({("GET", "/v1/info"): {"app": {"name": "Beeper"}}})
|
||||||
|
with fake_api(http), patch.object(cli, "load_token", return_value=""):
|
||||||
|
payload = capture_json(cli.cmd_list, "")
|
||||||
|
self.assertEqual(payload["error"], "token missing or invalid; run: omarchy-beeper auth")
|
||||||
|
|
||||||
|
http = FakeHttp({("GET", "/v1/info"): lambda call: url_error()})
|
||||||
|
with fake_api(http), patch.object(cli, "load_token", return_value=""):
|
||||||
|
payload = capture_json(cli.cmd_list, "")
|
||||||
|
self.assertEqual(payload["error"], "Beeper Desktop is not running")
|
||||||
|
|
||||||
|
def test_oversized_response_is_refused_before_decode(self) -> None:
|
||||||
|
huge = b'{"items":[' + b" " * (common.MAX_HTTP_BODY + 10) + b"]}"
|
||||||
|
http = FakeHttp({("GET", SEARCH): FakeResponse(huge, content_length=False)})
|
||||||
|
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||||
|
payload = capture_json(cli.cmd_list, "")
|
||||||
|
self.assertFalse(payload["ok"])
|
||||||
|
self.assertIn("too large", payload["error"])
|
||||||
|
|
||||||
|
def test_declared_oversized_response_is_refused_without_reading(self) -> None:
|
||||||
|
http = FakeHttp(
|
||||||
|
{("GET", SEARCH): FakeResponse({"items": []}, content_length=common.MAX_HTTP_BODY + 1)}
|
||||||
|
)
|
||||||
|
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||||
|
payload = capture_json(cli.cmd_list, "")
|
||||||
|
self.assertFalse(payload["ok"])
|
||||||
|
|
||||||
|
def test_error_bodies_are_capped(self) -> None:
|
||||||
|
self.assertEqual(beeper.MAX_HTTP_ERROR, 64 * 1024)
|
||||||
|
|
||||||
|
def test_invalid_json_is_reported_not_raised(self) -> None:
|
||||||
|
http = FakeHttp({("GET", SEARCH): FakeResponse(b"not json")})
|
||||||
|
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||||
|
payload = capture_json(cli.cmd_list, "")
|
||||||
|
self.assertFalse(payload["ok"])
|
||||||
|
self.assertIn("invalid JSON", payload["error"])
|
||||||
|
|
||||||
|
def test_api_error_text_is_one_line(self) -> None:
|
||||||
|
http = FakeHttp({("GET", SEARCH): lambda call: http_error(503, "a\nb\n c")})
|
||||||
|
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||||
|
payload = capture_json(cli.cmd_list, "")
|
||||||
|
self.assertNotIn("\n", payload["error"])
|
||||||
|
self.assertIn("a b c", payload["error"])
|
||||||
|
|
||||||
|
|
||||||
|
class CliSurfaceTests(unittest.TestCase):
|
||||||
|
def test_unknown_command_still_prints_one_json_object(self) -> None:
|
||||||
|
payload = capture_json(cli.main, ["frobnicate"])
|
||||||
|
self.assertFalse(payload["ok"])
|
||||||
|
self.assertIn("unknown command", payload["error"])
|
||||||
|
|
||||||
|
def test_read_without_an_id_is_a_usage_error(self) -> None:
|
||||||
|
payload = capture_json(cli.main, ["read"])
|
||||||
|
self.assertFalse(payload["ok"])
|
||||||
|
|
||||||
|
def test_limit_flag_sets_the_page_size(self) -> None:
|
||||||
|
http = wire(FakeHttp(), [chat(f"!c{i}:x", last_activity=f"2026-02-11T10:{i:02d}:00Z") for i in range(10)])
|
||||||
|
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||||
|
payload = capture_json(cli.main, ["list", "--limit", "3"])
|
||||||
|
self.assertEqual(len(payload["messages"]), 3)
|
||||||
|
|
||||||
|
def test_auth_refuses_a_non_terminal(self) -> None:
|
||||||
|
stderr = StringIO()
|
||||||
|
with patch("sys.stdin.isatty", return_value=False), redirect_stderr(stderr):
|
||||||
|
with self.assertRaises(SystemExit) as raised:
|
||||||
|
cli.cmd_auth()
|
||||||
|
self.assertEqual(raised.exception.code, 1)
|
||||||
|
self.assertIn("needs a terminal", stderr.getvalue())
|
||||||
|
|
||||||
|
def test_the_token_never_reaches_argv_or_the_shell(self) -> None:
|
||||||
|
bin_script = (common.ROOT / "bin" / "omarchy-beeper").read_text(encoding="utf-8")
|
||||||
|
for banned in ("--token", "$TOKEN", "token.json", "secrets"):
|
||||||
|
self.assertNotIn(banned, bin_script)
|
||||||
|
cli_source = (common.ROOT / "lib" / "cli.py").read_text(encoding="utf-8")
|
||||||
|
# Read from a hidden prompt, and only ever handed to the API client.
|
||||||
|
self.assertIn("getpass.getpass", cli_source)
|
||||||
|
self.assertNotIn("input(", cli_source)
|
||||||
|
panel = (common.ROOT / "Panel.qml").read_text(encoding="utf-8")
|
||||||
|
for banned in ("Authorization", "token.json", "secrets", "XMLHttpRequest", "WebSocket"):
|
||||||
|
self.assertNotIn(banned, panel)
|
||||||
|
api = (common.ROOT / "lib" / "beeper.py").read_text(encoding="utf-8")
|
||||||
|
self.assertEqual(api.count('add_header("Authorization"'), 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from support import ROOT, capture_json # noqa: I001 - puts lib/ on sys.path
|
||||||
|
|
||||||
|
import common
|
||||||
|
|
||||||
|
|
||||||
|
def _workdir() -> Path:
|
||||||
|
# Outside the plugin folder: `omarchy plugin validate` rejects symlinks
|
||||||
|
# anywhere inside it, and one of these tests plants one on purpose.
|
||||||
|
return Path(tempfile.mkdtemp(prefix="omarchy-beeper-tests."))
|
||||||
|
|
||||||
|
|
||||||
|
class HelperTests(unittest.TestCase):
|
||||||
|
def test_clamp_int(self) -> None:
|
||||||
|
self.assertEqual(common.clamp_int("25", 10, 1, 50), 25)
|
||||||
|
self.assertEqual(common.clamp_int("0", 10, 1, 50), 1)
|
||||||
|
self.assertEqual(common.clamp_int("99", 10, 1, 50), 50)
|
||||||
|
self.assertEqual(common.clamp_int("nope", 10, 1, 50), 10)
|
||||||
|
self.assertEqual(common.clamp_int(None, 7, 1, 50), 7)
|
||||||
|
|
||||||
|
def test_max_chats_clamps_to_page_size(self) -> None:
|
||||||
|
with patch.dict(os.environ, {"OMARCHY_BEEPER_MAX": "500"}, clear=False):
|
||||||
|
self.assertEqual(common.max_chats(), common.PAGE_SIZE_MAX)
|
||||||
|
with patch.dict(os.environ, {"OMARCHY_BEEPER_MAX": "0"}, clear=False):
|
||||||
|
self.assertEqual(common.max_chats(), 1)
|
||||||
|
|
||||||
|
def test_fetch_limit_caps_at_200(self) -> None:
|
||||||
|
self.assertEqual(common.fetch_limit("200"), 200)
|
||||||
|
self.assertEqual(common.fetch_limit("999"), 200)
|
||||||
|
self.assertEqual(common.fetch_limit("0"), 1)
|
||||||
|
|
||||||
|
def test_one_line_collapses_and_truncates(self) -> None:
|
||||||
|
self.assertEqual(common.one_line("Hello\r\nworld\t there"), "Hello world there")
|
||||||
|
self.assertEqual(common.one_line("zero\u200bwidth"), "zerowidth")
|
||||||
|
long = common.one_line("x" * 400)
|
||||||
|
self.assertEqual(len(long), 180)
|
||||||
|
self.assertTrue(long.endswith("…"))
|
||||||
|
|
||||||
|
def test_encode_decode_chat_id(self) -> None:
|
||||||
|
opaque = common.encode_id("!NCdzlIaMjZUmvmvyHU:beeper.com")
|
||||||
|
self.assertTrue(opaque.startswith("beeper:"))
|
||||||
|
self.assertNotIn("=", opaque)
|
||||||
|
self.assertEqual(common.decode_id(opaque), "!NCdzlIaMjZUmvmvyHU:beeper.com")
|
||||||
|
|
||||||
|
def test_encode_decode_survives_slashes_and_plus(self) -> None:
|
||||||
|
raw = "!a+b/c=d:local-whatsapp.localhost"
|
||||||
|
self.assertEqual(common.decode_id(common.encode_id(raw)), raw)
|
||||||
|
|
||||||
|
def test_decode_id_rejects_junk(self) -> None:
|
||||||
|
for bad in ("", "nope", "beeper:", "other:aGk", "beeper:!!!!", "beeper:" + "A" * 4):
|
||||||
|
payload = capture_json(common.decode_id, bad)
|
||||||
|
self.assertFalse(payload["ok"], msg=bad)
|
||||||
|
self.assertEqual(payload["error"], "not a chat id")
|
||||||
|
|
||||||
|
def test_api_base_is_local_only(self) -> None:
|
||||||
|
self.assertEqual(common.API_BASE, "http://localhost:23373")
|
||||||
|
|
||||||
|
|
||||||
|
class HttpBodyTests(unittest.TestCase):
|
||||||
|
def _body(self, data: bytes, content_length: object | None = ""):
|
||||||
|
class Body:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._buf = BytesIO(data)
|
||||||
|
self.headers = {}
|
||||||
|
if content_length != "":
|
||||||
|
self.headers["Content-Length"] = content_length
|
||||||
|
|
||||||
|
def read(self, n: int = -1) -> bytes:
|
||||||
|
return self._buf.read(n)
|
||||||
|
|
||||||
|
return Body()
|
||||||
|
|
||||||
|
def test_accepts_body_at_limit(self) -> None:
|
||||||
|
raw = common.read_http_body(self._body(b"a" * 16), 16)
|
||||||
|
self.assertEqual(raw, b"a" * 16)
|
||||||
|
|
||||||
|
def test_rejects_body_over_limit(self) -> None:
|
||||||
|
with self.assertRaises(common.ResponseTooLargeError):
|
||||||
|
common.read_http_body(self._body(b"a" * 17), 16)
|
||||||
|
|
||||||
|
def test_rejects_declared_length_over_limit(self) -> None:
|
||||||
|
with self.assertRaises(common.ResponseTooLargeError):
|
||||||
|
common.read_http_body(self._body(b"a", content_length="99999"), 16)
|
||||||
|
|
||||||
|
def test_ignores_bogus_declared_length(self) -> None:
|
||||||
|
raw = common.read_http_body(self._body(b"ab", content_length="not-a-number"), 16)
|
||||||
|
self.assertEqual(raw, b"ab")
|
||||||
|
|
||||||
|
def test_default_caps_are_the_documented_ones(self) -> None:
|
||||||
|
self.assertEqual(common.MAX_HTTP_BODY, 2 * 1024 * 1024)
|
||||||
|
self.assertEqual(common.MAX_HTTP_ERROR, 64 * 1024)
|
||||||
|
self.assertEqual(common.MAX_LOCAL_FILE, 64 * 1024)
|
||||||
|
|
||||||
|
|
||||||
|
class SecretFileTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.dir = _workdir()
|
||||||
|
self.path = self.dir / "token.json"
|
||||||
|
self.addCleanup(shutil.rmtree, self.dir, True)
|
||||||
|
|
||||||
|
def _write(self, text: str, mode: int = 0o600) -> None:
|
||||||
|
self.path.write_text(text, encoding="utf-8")
|
||||||
|
os.chmod(self.path, mode)
|
||||||
|
os.chmod(self.dir, 0o700)
|
||||||
|
|
||||||
|
def test_missing_token_file_is_empty_not_an_error(self) -> None:
|
||||||
|
with patch.object(common, "TOKEN_FILE", self.path):
|
||||||
|
self.assertEqual(common.load_token(), "")
|
||||||
|
|
||||||
|
def test_reads_private_token(self) -> None:
|
||||||
|
self._write(json.dumps({"token": "abc123"}))
|
||||||
|
with patch.object(common, "TOKEN_FILE", self.path):
|
||||||
|
self.assertEqual(common.load_token(), "abc123")
|
||||||
|
|
||||||
|
def test_rejects_group_or_world_readable_token(self) -> None:
|
||||||
|
self._write(json.dumps({"token": "abc123"}), mode=0o644)
|
||||||
|
with patch.object(common, "TOKEN_FILE", self.path):
|
||||||
|
payload = capture_json(common.load_token)
|
||||||
|
self.assertFalse(payload["ok"])
|
||||||
|
self.assertIn("too open", payload["error"])
|
||||||
|
|
||||||
|
def test_rejects_too_open_secrets_directory(self) -> None:
|
||||||
|
self._write(json.dumps({"token": "abc123"}))
|
||||||
|
os.chmod(self.dir, 0o755)
|
||||||
|
try:
|
||||||
|
with patch.object(common, "TOKEN_FILE", self.path):
|
||||||
|
payload = capture_json(common.load_token)
|
||||||
|
finally:
|
||||||
|
os.chmod(self.dir, 0o700)
|
||||||
|
self.assertFalse(payload["ok"])
|
||||||
|
self.assertIn("chmod 700", payload["error"])
|
||||||
|
|
||||||
|
def test_rejects_invalid_json(self) -> None:
|
||||||
|
self._write("{not json")
|
||||||
|
with patch.object(common, "TOKEN_FILE", self.path):
|
||||||
|
payload = capture_json(common.load_token)
|
||||||
|
self.assertFalse(payload["ok"])
|
||||||
|
self.assertIn("not valid JSON", payload["error"])
|
||||||
|
|
||||||
|
def test_rejects_oversized_token_file(self) -> None:
|
||||||
|
self._write("x" * (common.MAX_LOCAL_FILE + 1))
|
||||||
|
with patch.object(common, "TOKEN_FILE", self.path):
|
||||||
|
payload = capture_json(common.load_token)
|
||||||
|
self.assertFalse(payload["ok"])
|
||||||
|
self.assertIn("too large", payload["error"])
|
||||||
|
|
||||||
|
def test_symlinked_token_file_is_not_followed(self) -> None:
|
||||||
|
target = self.dir / "real.json"
|
||||||
|
target.write_text(json.dumps({"token": "leak"}), encoding="utf-8")
|
||||||
|
os.chmod(target, 0o600)
|
||||||
|
link = self.dir / "link.json"
|
||||||
|
link.symlink_to(target)
|
||||||
|
with patch.object(common, "TOKEN_FILE", link):
|
||||||
|
self.assertEqual(common.load_token(), "")
|
||||||
|
|
||||||
|
def test_save_token_writes_600_and_survives_replace(self) -> None:
|
||||||
|
with patch.object(common, "CONFIG_DIR", self.dir), patch.object(
|
||||||
|
common, "SECRETS_DIR", self.dir
|
||||||
|
), patch.object(common, "TOKEN_FILE", self.path):
|
||||||
|
common.save_token("first")
|
||||||
|
common.save_token("second")
|
||||||
|
self.assertEqual(common.load_token(), "second")
|
||||||
|
mode = stat.S_IMODE(self.path.stat().st_mode)
|
||||||
|
self.assertEqual(mode, 0o600)
|
||||||
|
self.assertEqual(len(list(self.dir.glob("*.tmp"))), 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from support import ROOT # noqa: I001 - puts lib/ on sys.path
|
||||||
|
|
||||||
|
|
||||||
|
class PanelContractTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
cls.qml = (ROOT / "Panel.qml").read_text(encoding="utf-8")
|
||||||
|
cls.icon = (ROOT / "BeeperIcon.qml").read_text(encoding="utf-8")
|
||||||
|
cls.manifest = json.loads((ROOT / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
cls.readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_panel_only_ever_runs_the_cli(self) -> None:
|
||||||
|
self.assertIn('Qt.resolvedUrl("bin/omarchy-beeper")', self.qml)
|
||||||
|
self.assertIn('listProc.command = argv', self.qml)
|
||||||
|
self.assertIn('readProc.command = [root.script, "read", id]', self.qml)
|
||||||
|
self.assertIn('readAllProc.command = [root.script, "read-all"]', self.qml)
|
||||||
|
self.assertIn('openProc.command = [root.script, "open", message.id]', self.qml)
|
||||||
|
self.assertIn('openProc.command = [root.script, "open"]', self.qml)
|
||||||
|
|
||||||
|
def test_passes_limit_from_widget_settings(self) -> None:
|
||||||
|
self.assertIn('setting("max", 25)', self.qml)
|
||||||
|
self.assertIn('setting("refreshIntervalSec", 60)', self.qml)
|
||||||
|
self.assertIn('"--limit"', self.qml)
|
||||||
|
self.assertIn('"--page"', self.qml)
|
||||||
|
|
||||||
|
def test_settings_schema_matches_the_panel_clamps(self) -> None:
|
||||||
|
schema = {item["key"]: item for item in self.manifest["barWidget"]["schema"]}
|
||||||
|
self.assertEqual((schema["max"]["min"], schema["max"]["max"]), (1, 50))
|
||||||
|
self.assertEqual(schema["max"]["defaultValue"], 25)
|
||||||
|
self.assertEqual(
|
||||||
|
(schema["refreshIntervalSec"]["min"], schema["refreshIntervalSec"]["max"]), (15, 3600)
|
||||||
|
)
|
||||||
|
self.assertEqual(schema["refreshIntervalSec"]["defaultValue"], 60)
|
||||||
|
self.assertEqual(self.manifest["barWidget"]["defaults"], {"max": 25, "refreshIntervalSec": 60})
|
||||||
|
self.assertEqual(self.manifest["kinds"], ["bar-widget"])
|
||||||
|
self.assertEqual(self.manifest["entryPoints"]["barWidget"], "Panel.qml")
|
||||||
|
self.assertEqual(self.manifest["schemaVersion"], 1)
|
||||||
|
self.assertIn("Math.min(50, n)", self.qml)
|
||||||
|
self.assertIn("Math.min(3600, n)", self.qml)
|
||||||
|
|
||||||
|
def test_every_untrusted_string_is_plain_text(self) -> None:
|
||||||
|
# One `textFormat: Text.PlainText` per Text that renders API data.
|
||||||
|
self.assertGreaterEqual(self.qml.count("textFormat: Text.PlainText"), 12)
|
||||||
|
for banned in ("Text.RichText", "Text.StyledText", "Text.MarkdownText"):
|
||||||
|
self.assertNotIn(banned, self.qml)
|
||||||
|
|
||||||
|
def test_ids_are_validated_before_use(self) -> None:
|
||||||
|
self.assertIn("/^beeper:[A-Za-z0-9_-]{1,1024}$/", self.qml)
|
||||||
|
self.assertIn("/^[0-9]{1,9}$/", self.qml)
|
||||||
|
self.assertIn("if (!message || !validId(message.id)) return", self.qml)
|
||||||
|
self.assertIn("nextPage = validToken(data.nextPage)", self.qml)
|
||||||
|
|
||||||
|
def test_row_click_opens_dismisses_and_marks_read(self) -> None:
|
||||||
|
self.assertIn("function openChat(message)", self.qml)
|
||||||
|
self.assertRegex(
|
||||||
|
self.qml,
|
||||||
|
r"openProc\.running = true\n dismissLocal\(message\.id\)\n enqueueRead\(message\.id\)\n close\(\)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_local_dismiss_and_read_queue_survive_a_slow_api(self) -> None:
|
||||||
|
self.assertIn("property var dismissedIds", self.qml)
|
||||||
|
self.assertIn("property var readQueue", self.qml)
|
||||||
|
self.assertIn("property string pendingId", self.qml)
|
||||||
|
self.assertIn("function enqueueRead(", self.qml)
|
||||||
|
self.assertIn("function pumpRead()", self.qml)
|
||||||
|
self.assertIn("unread = Math.max(0, (data.unread || 0) - dropped)", self.qml)
|
||||||
|
self.assertNotIn("unread = 0", self.qml)
|
||||||
|
self.assertNotIn("messages = []", self.qml)
|
||||||
|
|
||||||
|
def test_keyboard_map_is_complete(self) -> None:
|
||||||
|
self.assertIn("onMoveRequested", self.qml)
|
||||||
|
self.assertIn("onActivateRequested: root.activateCursor()", self.qml)
|
||||||
|
self.assertIn("onTabRequested", self.qml)
|
||||||
|
self.assertIn("onCloseRequested", self.qml)
|
||||||
|
for key in ("o", "i", "a", "A", "n", "p"):
|
||||||
|
self.assertIn(f't === "{key}"', self.qml)
|
||||||
|
self.assertRegex(self.qml, r't === "a"\)\s+root\.markCursorRead\(\)')
|
||||||
|
self.assertRegex(self.qml, r't === "A"\)\s+root\.requestMarkAll\(\)')
|
||||||
|
|
||||||
|
def test_mark_all_needs_two_presses_and_shows_progress(self) -> None:
|
||||||
|
self.assertIn("Mark all unread as read (A)", self.qml)
|
||||||
|
self.assertIn("Click again to confirm", self.qml)
|
||||||
|
self.assertIn("Marking unread chats as read…", self.qml)
|
||||||
|
self.assertIn("property bool markAllArmed", self.qml)
|
||||||
|
self.assertIn("property bool markAllBusy", self.qml)
|
||||||
|
self.assertIn("applyReadAllPayload", self.qml)
|
||||||
|
self.assertIn("opacity: root.markAllBusy ? 0.4 : 1", self.qml)
|
||||||
|
self.assertIn("markAllArmTimer.restart()", self.qml)
|
||||||
|
|
||||||
|
def test_unreachable_state_dims_the_icon_and_keeps_the_list(self) -> None:
|
||||||
|
self.assertIn("opacity: root.reachable ? 1 : 0.5", self.qml)
|
||||||
|
self.assertIn("Could not reach Beeper. Showing the last list.", self.qml)
|
||||||
|
self.assertIn("if (!reachable) return", self.qml)
|
||||||
|
self.assertIn("Beeper unreachable", self.qml)
|
||||||
|
|
||||||
|
def test_tooltip_counts_unread_chats(self) -> None:
|
||||||
|
self.assertIn("function tooltipFor()", self.qml)
|
||||||
|
self.assertIn("tooltipText: root.tooltipFor()", self.qml)
|
||||||
|
self.assertIn('return root.unread + " unread chats"', self.qml)
|
||||||
|
self.assertIn('return "No unread chats"', self.qml)
|
||||||
|
|
||||||
|
def test_badge_uses_bar_foreground_never_the_urgent_colour(self) -> None:
|
||||||
|
self.assertIn("color: button.foreground", self.qml)
|
||||||
|
self.assertIn("dotColor: button.foreground", self.qml)
|
||||||
|
self.assertIn(
|
||||||
|
"color: Qt.rgba(button.foreground.r, button.foreground.g,\n button.foreground.b, 0.14)",
|
||||||
|
self.qml,
|
||||||
|
)
|
||||||
|
self.assertNotIn("button.activeColor", self.qml)
|
||||||
|
self.assertNotIn("badgeCount\n color: bar.urgent", self.qml)
|
||||||
|
self.assertNotIn("active: root.opened", self.qml)
|
||||||
|
|
||||||
|
def test_network_chip_only_shows_with_more_than_one_account(self) -> None:
|
||||||
|
self.assertIn("visible: root.accountCount > 1", self.qml)
|
||||||
|
self.assertIn("(row.modelData.labels || []).slice(0, 1)", self.qml)
|
||||||
|
self.assertIn("elide: Text.ElideRight", self.qml)
|
||||||
|
self.assertIn("Style.space(64)", self.qml)
|
||||||
|
self.assertIn("Math.max(Style.space(40)", self.qml)
|
||||||
|
|
||||||
|
def test_icon_is_a_stroked_bubble_with_no_layer_effects(self) -> None:
|
||||||
|
self.assertIn("ctx.stroke()", self.icon)
|
||||||
|
self.assertIn("ctx.arcTo(", self.icon)
|
||||||
|
self.assertIn("dotAmount", self.icon)
|
||||||
|
self.assertIn("snapStroke", self.icon)
|
||||||
|
self.assertNotIn("layer.enabled", self.icon)
|
||||||
|
self.assertNotIn("Image {", self.icon)
|
||||||
|
|
||||||
|
def test_bubble_and_dots_stay_inside_the_canvas(self) -> None:
|
||||||
|
for size in (10, 12, 16, 20, 24, 32, 48):
|
||||||
|
for dpr in (1.0, 1.25, 1.5, 2.0):
|
||||||
|
geom = bubble_geometry(size, dpr)
|
||||||
|
self.assertGreater(geom["bodyH"], 0, msg=f"size={size} dpr={dpr}")
|
||||||
|
self.assertGreater(geom["tailRight"], geom["tailLeft"])
|
||||||
|
for amount in (0.0, 0.5, 1.0):
|
||||||
|
for x, y in painted_extents(geom, amount):
|
||||||
|
self.assertGreaterEqual(x, -0.51, msg=f"size={size} dpr={dpr} t={amount}")
|
||||||
|
self.assertGreaterEqual(y, -0.51, msg=f"size={size} dpr={dpr} t={amount}")
|
||||||
|
self.assertLessEqual(x, size + 0.51, msg=f"size={size} dpr={dpr} t={amount}")
|
||||||
|
self.assertLessEqual(y, size + 0.51, msg=f"size={size} dpr={dpr} t={amount}")
|
||||||
|
|
||||||
|
def test_tail_never_runs_into_a_corner_arc(self) -> None:
|
||||||
|
for size in (10, 16, 24, 48):
|
||||||
|
geom = bubble_geometry(size, 1.0)
|
||||||
|
self.assertGreaterEqual(geom["tailLeft"], geom["bodyX"] + geom["radius"] - 0.01)
|
||||||
|
self.assertLessEqual(
|
||||||
|
geom["tailRight"], geom["bodyX"] + geom["bodyW"] - geom["radius"] + 0.01
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_readme_documents_install_setup_and_removal(self) -> None:
|
||||||
|
self.assertIn("https://github.com/prdlk/omarchy-beeper.git", self.readme)
|
||||||
|
self.assertIn("omarchy plugin add", self.readme)
|
||||||
|
self.assertIn("--enable", self.readme)
|
||||||
|
self.assertIn("omarchy plugin update prdlk.beeper", self.readme)
|
||||||
|
self.assertIn("omarchy-beeper auth", self.readme)
|
||||||
|
self.assertIn("~/.config/omarchy-beeper/", self.readme)
|
||||||
|
self.assertIn("Settings", self.readme)
|
||||||
|
self.assertIn("Integrations", self.readme)
|
||||||
|
self.assertIn("revoke", self.readme)
|
||||||
|
self.assertIn("muted", self.readme)
|
||||||
|
self.assertIn("archived", self.readme)
|
||||||
|
self.assertIn("low-priority", self.readme)
|
||||||
|
self.assertIn("indexing", self.readme)
|
||||||
|
self.assertIn("never sends", self.readme.lower())
|
||||||
|
self.assertIn("preview.png", self.readme)
|
||||||
|
self.assertTrue((ROOT / "preview.png").is_file(), "marketplace listing wants preview.png")
|
||||||
|
self.assertTrue((ROOT / "docs" / "SETUP.md").is_file())
|
||||||
|
self.assertTrue((ROOT / "LICENSE").is_file())
|
||||||
|
|
||||||
|
def test_readme_key_map_matches_the_panel(self) -> None:
|
||||||
|
for key in ("`j`", "`k`", "`o`", "`a`", "`A`", "`n`", "`p`", "`i`", "`Tab`", "`Esc`"):
|
||||||
|
self.assertIn(key, self.readme)
|
||||||
|
|
||||||
|
def test_changelog_matches_manifest(self) -> None:
|
||||||
|
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||||
|
self.assertIn(f"## {self.manifest['version']}", changelog)
|
||||||
|
|
||||||
|
|
||||||
|
def _snap(value: float, dpr: float) -> float:
|
||||||
|
return round(value * dpr) / dpr
|
||||||
|
|
||||||
|
|
||||||
|
def _snap_stroke(value: float, dpr: float) -> float:
|
||||||
|
return max(1, round(value * dpr)) / dpr
|
||||||
|
|
||||||
|
|
||||||
|
def bubble_geometry(icon_size: float, dpr: float = 1.0) -> dict[str, float]:
|
||||||
|
"""Mirror of BeeperIcon.qml so the geometry can be checked without a GPU."""
|
||||||
|
stroke = _snap_stroke(max(1.5, icon_size * 0.11), dpr)
|
||||||
|
pad = _snap(max(stroke / 2, 0.5), dpr)
|
||||||
|
tail_h = _snap(max(stroke * 1.2, icon_size * 0.16), dpr)
|
||||||
|
body_x = _snap(pad, dpr)
|
||||||
|
body_y = _snap(pad, dpr)
|
||||||
|
body_w = _snap(icon_size - pad * 2, dpr)
|
||||||
|
body_h = _snap(max(stroke * 3, icon_size - pad * 2 - tail_h), dpr)
|
||||||
|
radius = _snap(min(body_w, body_h) * 0.3, dpr)
|
||||||
|
tail_left = _snap(body_x + max(radius, body_w * 0.2), dpr)
|
||||||
|
tail_right = _snap(tail_left + max(stroke, body_w * 0.2), dpr)
|
||||||
|
tail_tip_x = _snap(tail_left + max(stroke / 2, body_w * 0.04), dpr)
|
||||||
|
dot_r = _snap(max(1, min(icon_size * 0.075, body_h * 0.16)), dpr)
|
||||||
|
dot_gap = _snap(max(dot_r * 2.2, body_w * 0.24), dpr)
|
||||||
|
dot_y = _snap(body_y + body_h / 2, dpr)
|
||||||
|
return {
|
||||||
|
"size": icon_size,
|
||||||
|
"stroke": stroke,
|
||||||
|
"pad": pad,
|
||||||
|
"tailH": tail_h,
|
||||||
|
"bodyX": body_x,
|
||||||
|
"bodyY": body_y,
|
||||||
|
"bodyW": body_w,
|
||||||
|
"bodyH": body_h,
|
||||||
|
"radius": min(radius, body_w / 2, body_h / 2),
|
||||||
|
"tailLeft": tail_left,
|
||||||
|
"tailRight": tail_right,
|
||||||
|
"tailTipX": tail_tip_x,
|
||||||
|
"dotR": dot_r,
|
||||||
|
"dotGap": dot_gap,
|
||||||
|
"dotY": dot_y,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def painted_extents(geom: dict[str, float], dot_amount: float) -> list[tuple[float, float]]:
|
||||||
|
"""Outermost painted points, stroke width included."""
|
||||||
|
half = geom["stroke"] / 2
|
||||||
|
x = geom["bodyX"]
|
||||||
|
y = geom["bodyY"]
|
||||||
|
w = geom["bodyW"]
|
||||||
|
h = geom["bodyH"]
|
||||||
|
points = [
|
||||||
|
(x - half, y - half),
|
||||||
|
(x + w + half, y - half),
|
||||||
|
(x + w + half, y + h + half),
|
||||||
|
(x - half, y + h + half),
|
||||||
|
(geom["tailTipX"] - half, y + h + geom["tailH"] + half),
|
||||||
|
(geom["tailTipX"] + half, y + h + geom["tailH"] + half),
|
||||||
|
]
|
||||||
|
if dot_amount > 0.01:
|
||||||
|
scaled = geom["dotR"] * (0.6 + 0.4 * dot_amount)
|
||||||
|
cx = x + w / 2
|
||||||
|
for i in (-1, 0, 1):
|
||||||
|
points.append((cx + i * geom["dotGap"] - scaled, geom["dotY"] - scaled))
|
||||||
|
points.append((cx + i * geom["dotGap"] + scaled, geom["dotY"] + scaled))
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user