From 7fc78f1075f36de61d4b30e1a837df1feb43cafd Mon Sep 17 00:00:00 2001 From: prdlk Date: Tue, 1 Sep 2026 11:15:22 -0400 Subject: [PATCH] feat: omarchy-beeper bar widget for unread Beeper chats --- .github/workflows/ci.yml | 36 ++ .gitignore | 6 + BeeperIcon.qml | 124 ++++++ CHANGELOG.md | 24 ++ LICENSE | 21 + Panel.qml | 868 +++++++++++++++++++++++++++++++++++++++ README.md | 204 +++++++++ bin/omarchy-beeper | 36 ++ docs/SETUP.md | 85 ++++ lib/beeper.py | 326 +++++++++++++++ lib/cli.py | 313 ++++++++++++++ lib/common.py | 283 +++++++++++++ manifest.json | 47 +++ tests/support.py | 186 +++++++++ tests/test_beeper.py | 493 ++++++++++++++++++++++ tests/test_common.py | 180 ++++++++ tests/test_panel.py | 251 +++++++++++ 17 files changed, 3483 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 BeeperIcon.qml create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 Panel.qml create mode 100644 README.md create mode 100755 bin/omarchy-beeper create mode 100644 docs/SETUP.md create mode 100644 lib/beeper.py create mode 100755 lib/cli.py create mode 100644 lib/common.py create mode 100644 manifest.json create mode 100644 tests/support.py create mode 100644 tests/test_beeper.py create mode 100644 tests/test_common.py create mode 100644 tests/test_panel.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..489d1ac --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7127029 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*~ +*.swp +*.qmlc +.DS_Store +__pycache__/ +*.pyc diff --git a/BeeperIcon.qml b/BeeperIcon.qml new file mode 100644 index 0000000..0f68f69 --- /dev/null +++ b/BeeperIcon.qml @@ -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() +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..32b5bed --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..712cfd1 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Panel.qml b/Panel.qml new file mode 100644 index 0000000..36d880f --- /dev/null +++ b/Panel.qml @@ -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:, 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 + } + } + } + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..25e9705 --- /dev/null +++ b/README.md @@ -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. + +![Preview](preview.png) + +## 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 # mark one chat read +omarchy-beeper read-all # mark every unread chat read +omarchy-beeper open [] # 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:", + "threadId": "", + "subject": "", + "from": "", + "snippet": "", + "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 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). diff --git a/bin/omarchy-beeper b/bin/omarchy-beeper new file mode 100755 index 0000000..a588fb7 --- /dev/null +++ b/bin/omarchy-beeper @@ -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 +# omarchy-beeper read-all +# omarchy-beeper open [] +# +# 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" "$@" diff --git a/docs/SETUP.md b/docs/SETUP.md new file mode 100644 index 0000000..af145e7 --- /dev/null +++ b/docs/SETUP.md @@ -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. diff --git a/lib/beeper.py b/lib/beeper.py new file mode 100644 index 0000000..31e5d77 --- /dev/null +++ b/lib/beeper.py @@ -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() diff --git a/lib/cli.py b/lib/cli.py new file mode 100755 index 0000000..1db0cf3 --- /dev/null +++ b/lib/cli.py @@ -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 +omarchy-beeper read-all +omarchy-beeper open [] +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 ") + 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() diff --git a/lib/common.py b/lib/common.py new file mode 100644 index 0000000..a03ddc3 --- /dev/null +++ b/lib/common.py @@ -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 diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..320ff4b --- /dev/null +++ b/manifest.json @@ -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 + } + ] + } +} diff --git a/tests/support.py b/tests/support.py new file mode 100644 index 0000000..aeb7a39 --- /dev/null +++ b/tests/support.py @@ -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"" + + +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, + } diff --git a/tests/test_beeper.py b/tests/test_beeper.py new file mode 100644 index 0000000..15766c6 --- /dev/null +++ b/tests/test_beeper.py @@ -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() diff --git a/tests/test_common.py b/tests/test_common.py new file mode 100644 index 0000000..ee95d9b --- /dev/null +++ b/tests/test_common.py @@ -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() diff --git a/tests/test_panel.py b/tests/test_panel.py new file mode 100644 index 0000000..001eb07 --- /dev/null +++ b/tests/test_panel.py @@ -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()