feat: omarchy-beeper bar widget for unread Beeper chats

This commit is contained in:
prdlk committed 2026-09-01 11:15:22 -04:00
commit 7fc78f1075
17 files changed
+3483

No files matched your search

+36
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
*~
*.swp
*.qmlc
.DS_Store
__pycache__/
*.pyc
+124
View File
@@ -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()
}
+24
View File
@@ -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.
+21
View File
@@ -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.
+868
View File
@@ -0,0 +1,868 @@
import QtQuick
import QtQuick.Controls
import Quickshell.Io
import qs.Commons
import qs.Ui
// Beeper: unread conversations only. Click a row to open that chat in Beeper
// Desktop and mark it read.
//
// Data comes from `bin/omarchy-beeper`, which is the only thing that talks to
// the local Beeper API. This file never sees the token and never opens a
// socket; it runs the CLI and parses one JSON object from stdout.
//
// Every string below the header is text someone else wrote, so each Text
// carries `textFormat: Text.PlainText`.
Panel {
id: root
moduleName: "prdlk.beeper"
ipcTarget: "prdlk.beeper"
readonly property string script:
Qt.resolvedUrl("bin/omarchy-beeper").toString().replace(/^file:\/\//, "")
readonly property string iconOpenApp: "\uF08E"
readonly property string iconMarkAll: "\uF2B6"
readonly property string iconConfirm: "\uF00C"
readonly property string iconPrev: "\uF053"
readonly property string iconNext: "\uF054"
readonly property color foreground: bar ? bar.foreground : Color.foreground
readonly property color accent: Color.accent
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
property var messages: []
property int unread: 0
property var inboxes: []
property bool reachable: true
property string errorText: ""
property string warningText: ""
property string pendingId: ""
property var readQueue: []
property var dismissedIds: ({})
property bool markAllArmed: false
property bool markAllBusy: false
property string actionWarning: ""
property int cursor: -1
property string pageToken: ""
property var pageStack: []
property string nextPage: ""
property int accountCount: 0
readonly property bool hasPrev: pageStack.length > 0
readonly property bool hasNext: nextPage !== ""
property double now: 0
readonly property int badgeCount: unread
readonly property bool hasUnread: unread > 0
readonly property int badgeWidth: badgeCount > 0
? Math.max(Style.space(12), String(badgeCount).length * Style.space(6) + Style.space(8))
: 0
readonly property int barContentWidth: Style.bar.iconFont + badgeWidth + Style.space(5)
readonly property int barSlot: barContentWidth + Style.space(10)
implicitWidth: bar && bar.vertical ? (bar ? bar.barSize : Style.bar.sizeHorizontal) : barSlot
implicitHeight: bar && bar.vertical ? barSlot : (bar ? bar.barSize : Style.bar.sizeHorizontal)
// Page tokens are plain offsets produced by the CLI.
function validToken(t) {
return /^[0-9]{1,9}$/.test(String(t))
}
// beeper:<base64url of the chat id>, exactly what `list` emits.
function validId(id) {
return /^beeper:[A-Za-z0-9_-]{1,1024}$/.test(String(id))
}
readonly property int pageSize: {
var n = parseInt(setting("max", 25), 10)
if (!(n > 0)) n = 25
return Math.max(1, Math.min(50, n))
}
readonly property int refreshMs: {
var n = parseInt(setting("refreshIntervalSec", 60), 10)
if (!(n > 0)) n = 60
return Math.max(15, Math.min(3600, n)) * 1000
}
function refresh() {
if (listProc.running || root.markAllBusy) return
var argv = [root.script, "list", "--limit", String(root.pageSize)]
if (pageToken !== "" && validToken(pageToken)) argv.push("--page", pageToken)
listProc.command = argv
listProc.running = true
}
function goNextPage() {
if (!hasNext || listProc.running || root.markAllBusy) return
var stack = pageStack.slice()
stack.push(pageToken)
pageStack = stack
pageToken = nextPage
cursor = -1
refresh()
}
function goPrevPage() {
if (!hasPrev || listProc.running || root.markAllBusy) return
var stack = pageStack.slice()
pageToken = stack.pop()
pageStack = stack
cursor = -1
refresh()
}
function firstPage() {
pageToken = ""
pageStack = []
cursor = -1
}
function titleText() {
if (root.unread === 1) return "1 unread chat"
return root.unread + " unread chats"
}
function tooltipFor() {
if (!root.reachable)
return root.errorText !== "" ? root.errorText : "Beeper unreachable"
if (!root.hasUnread) return "No unread chats"
return root.titleText()
}
// "WhatsApp 3 · Slack 1", newest networks first, so the header says where
// the pile is without opening every row.
function inboxSummary() {
var parts = []
var list = root.inboxes || []
for (var i = 0; i < list.length && parts.length < 3; i++) {
var box = list[i] || {}
var n = parseInt(box.unread, 10)
var name = String(box.account || "")
if (!(n > 0) || name === "") continue
parts.push(name + " " + n)
}
return parts.join(" · ")
}
function rememberDismissed(id) {
var next = Object.assign({}, root.dismissedIds)
next[id] = true
root.dismissedIds = next
}
function dismissLocal(id) {
rememberDismissed(id)
var next = []
for (var i = 0; i < messages.length; i++) {
if (messages[i].id !== id) next.push(messages[i])
}
messages = next
if (unread > 0) unread -= 1
if (cursor > messages.length - 1) cursor = messages.length - 1
}
function enqueueRead(id) {
var q = root.readQueue.slice()
q.push(id)
root.readQueue = q
root.pumpRead()
}
function pumpRead() {
if (readProc.running || root.readQueue.length === 0) return
var q = root.readQueue.slice()
var id = q.shift()
root.readQueue = q
root.pendingId = id
readProc.command = [root.script, "read", id]
readProc.running = true
}
// Beeper has no web URL for a chat, so opening one means asking Beeper
// Desktop to focus it through the same local API.
function openChat(message) {
if (root.markAllBusy) return
if (!message || !validId(message.id)) return
openProc.command = [root.script, "open", message.id]
openProc.running = true
dismissLocal(message.id)
enqueueRead(message.id)
close()
}
function openBeeper() {
if (root.markAllBusy) return
openProc.command = [root.script, "open"]
openProc.running = true
close()
}
function markCursorRead() {
if (root.markAllBusy) return
if (cursor < 0 || cursor >= messages.length) return
var message = messages[cursor]
if (!message || !validId(message.id)) return
cancelMarkAllConfirm()
dismissLocal(message.id)
enqueueRead(message.id)
}
function cancelMarkAllConfirm() {
markAllArmed = false
if (markAllArmTimer.running) markAllArmTimer.stop()
}
function requestMarkAll() {
if (!root.hasUnread || !root.reachable || root.markAllBusy) return
if (!root.markAllArmed) {
root.markAllArmed = true
markAllArmTimer.restart()
return
}
root.cancelMarkAllConfirm()
root.markAllBusy = true
readAllProc.command = [root.script, "read-all"]
readAllProc.running = true
}
function applyReadAllPayload(text) {
root.markAllBusy = false
root.cancelMarkAllConfirm()
root.dismissedIds = ({})
try {
var data = JSON.parse(text)
var marked = parseInt(data.marked, 10)
if (!(marked > 0)) marked = 0
if (data.ok === true) {
root.actionWarning = data.warning || ""
firstPage()
refresh()
return
}
root.actionWarning = data.error || "could not mark all as read"
if (marked > 0) {
firstPage()
refresh()
}
} catch (e) {
root.actionWarning = "unexpected output from omarchy-beeper"
}
}
function moveCursor(delta) {
if (messages.length === 0) return
var next = cursor + delta
if (next < 0) next = 0
if (next > messages.length - 1) next = messages.length - 1
cursor = next
list.positionViewAtIndex(next, ListView.Contain)
}
function activateCursor() {
if (cursor < 0 || cursor >= messages.length) return
openChat(messages[cursor])
}
function ageLabel(ts) {
if (!ts || ts <= 0) return ""
var seconds = Math.max(0, root.now - ts)
if (seconds < 60) return "now"
if (seconds < 3600) return Math.floor(seconds / 60) + "m"
if (seconds < 86400) return Math.floor(seconds / 3600) + "h"
if (seconds < 604800) return Math.floor(seconds / 86400) + "d"
if (seconds < 2592000) return Math.floor(seconds / 604800) + "w"
return Qt.formatDate(new Date(ts * 1000), "d MMM")
}
function oneLine(value) {
return String(value || "").replace(/\s+/g, " ").trim()
}
function applyPayload(text) {
try {
var data = JSON.parse(text)
reachable = data.ok === true
errorText = data.error || ""
warningText = reachable ? (data.warning || "") : ""
// Keep actionWarning across this refresh: a write can fail while list works.
if (!reachable) return
var incoming = data.messages || []
var kept = []
var dropped = 0
for (var i = 0; i < incoming.length; i++) {
var row = incoming[i]
if (row && root.dismissedIds[row.id]) dropped += 1
else kept.push(row)
}
messages = kept
unread = Math.max(0, (data.unread || 0) - dropped)
inboxes = data.inboxes || []
accountCount = data.accountCount || 0
nextPage = validToken(data.nextPage) ? data.nextPage : ""
if (cursor > messages.length - 1) cursor = messages.length - 1
} catch (e) {
reachable = false
errorText = "unexpected output from omarchy-beeper"
}
}
onOpenedChanged: {
if (opened) {
now = Date.now() / 1000
refresh()
} else {
cursor = -1
firstPage()
cancelMarkAllConfirm()
actionWarning = ""
dismissedIds = ({})
}
}
Component.onCompleted: now = Date.now() / 1000
Process {
id: listProc
stdout: StdioCollector {
onStreamFinished: root.applyPayload(text)
}
}
Process {
id: readProc
onExited: function(exitCode) {
root.pendingId = ""
if (root.readQueue.length > 0) {
root.pumpRead()
return
}
root.refresh()
}
}
Process {
id: openProc
}
Process {
id: readAllProc
stdout: StdioCollector {
onStreamFinished: root.applyReadAllPayload(text)
}
}
Timer {
interval: root.refreshMs
running: true
repeat: true
triggeredOnStart: true
onTriggered: {
root.now = Date.now() / 1000
root.refresh()
}
}
Timer {
id: markAllArmTimer
interval: 4000
repeat: false
onTriggered: root.markAllArmed = false
}
BarIconButton {
id: button
anchors.fill: parent
bar: root.bar
opacity: root.reachable ? 1 : 0.5
slotSize: root.barSlot
opticalSize: root.barContentWidth
tooltipText: root.tooltipFor()
iconComponent: Component {
Item {
Row {
anchors.centerIn: parent
spacing: Style.space(5)
BeeperIcon {
anchors.verticalCenter: parent.verticalCenter
iconSize: Style.bar.iconCanvas
color: button.foreground
dotColor: button.foreground
hasMail: root.hasUnread && root.reachable
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
visible: root.reachable && root.badgeCount > 0
height: Style.space(12)
width: root.badgeWidth
radius: height / 2
color: Qt.rgba(button.foreground.r, button.foreground.g,
button.foreground.b, 0.14)
Text {
anchors.centerIn: parent
text: root.badgeCount
textFormat: Text.PlainText
font.family: root.fontFamily
font.pixelSize: Style.font.caption
renderType: Text.NativeRendering
color: button.foreground
}
}
}
}
}
onPressed: function(b) {
if (b === Qt.RightButton) {
root.openBeeper()
} else if (b === Qt.MiddleButton) {
root.refresh()
} else {
root.toggle()
}
}
}
KeyboardPanel {
id: panel
anchorItem: button
owner: root
bar: root.bar
open: root.opened
focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(400))
contentHeight: panel.fittedContentHeight(content.implicitHeight)
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
onCloseRequested: {
if (root.markAllArmed) {
root.cancelMarkAllConfirm()
return
}
root.close()
}
onMoveRequested: function(dx, dy) { if (dy !== 0) root.moveCursor(dy) }
onActivateRequested: root.activateCursor()
onTabRequested: function(direction) { root.switchPanel(direction) }
onTextKey: function(t) {
var onCursor = root.cursor >= 0 && root.cursor < root.messages.length
if (t === "o" && onCursor)
root.openChat(root.messages[root.cursor])
else if (t === "i")
root.openBeeper()
else if (t === "a")
root.markCursorRead()
else if (t === "A")
root.requestMarkAll()
else if (t === "n")
root.goNextPage()
else if (t === "p")
root.goPrevPage()
}
Column {
id: content
anchors.fill: parent
spacing: Style.space(6)
Item {
width: parent.width
height: Math.max(heading.implicitHeight, openAppButton.height)
Column {
id: heading
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
anchors.right: headerActions.left
anchors.rightMargin: Style.space(8)
spacing: Style.space(1)
PanelSectionHeader {
width: parent.width
text: root.titleText()
textFormat: Text.PlainText
elide: Text.ElideRight
foreground: root.foreground
fontFamily: root.fontFamily
}
Text {
width: parent.width
visible: root.accountCount > 1 && text !== ""
text: root.inboxSummary()
textFormat: Text.PlainText
elide: Text.ElideRight
font.family: root.fontFamily
font.pixelSize: Style.font.caption
color: Qt.darker(root.foreground, 1.6)
}
}
Row {
id: headerActions
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(2)
PanelActionButton {
id: markAllButton
visible: root.hasUnread && root.reachable
enabled: root.hasUnread && root.reachable && !root.markAllBusy
iconText: root.markAllArmed || root.markAllBusy
? root.iconConfirm : root.iconMarkAll
tooltipText: root.markAllBusy
? "Marking unread chats as read…"
: (root.markAllArmed
? "Click again to confirm"
: "Mark all unread as read (A)")
foreground: root.foreground
hoverColor: root.accent
fontFamily: root.fontFamily
fontSize: Style.font.iconSmall
onClicked: root.requestMarkAll()
}
PanelActionButton {
id: openAppButton
enabled: !root.markAllBusy
iconText: root.iconOpenApp
tooltipText: "Open Beeper Desktop (i)"
foreground: root.foreground
hoverColor: root.accent
fontFamily: root.fontFamily
fontSize: Style.font.iconSmall
onClicked: root.openBeeper()
}
}
}
PanelSeparator { width: parent.width }
Item {
width: parent.width
height: root.reachable ? 0 : staleWarning.implicitHeight + Style.space(6)
visible: !root.reachable
Text {
id: staleWarning
anchors.verticalCenter: parent.verticalCenter
width: parent.width
text: root.errorText !== ""
? root.errorText
: "Could not reach Beeper. Showing the last list."
textFormat: Text.PlainText
elide: Text.ElideRight
font.family: root.fontFamily
font.pixelSize: Style.font.caption
color: bar ? bar.urgent : Color.urgent
}
}
Item {
width: parent.width
height: (root.reachable && root.warningText !== "")
? partialWarning.implicitHeight + Style.space(6) : 0
visible: root.reachable && root.warningText !== ""
Text {
id: partialWarning
anchors.verticalCenter: parent.verticalCenter
width: parent.width
text: root.warningText
textFormat: Text.PlainText
elide: Text.ElideRight
font.family: root.fontFamily
font.pixelSize: Style.font.caption
color: bar ? bar.urgent : Color.urgent
}
}
Item {
width: parent.width
height: (root.actionWarning !== "" && !root.markAllBusy)
? actionWarningLabel.implicitHeight + Style.space(6) : 0
visible: root.actionWarning !== "" && !root.markAllBusy
Text {
id: actionWarningLabel
anchors.verticalCenter: parent.verticalCenter
width: parent.width
text: root.actionWarning
textFormat: Text.PlainText
elide: Text.ElideRight
font.family: root.fontFamily
font.pixelSize: Style.font.caption
color: bar ? bar.urgent : Color.urgent
}
}
Item {
width: parent.width
height: root.markAllBusy ? markAllBusyLabel.implicitHeight + Style.space(6) : 0
visible: root.markAllBusy
Text {
id: markAllBusyLabel
anchors.verticalCenter: parent.verticalCenter
width: parent.width
text: "Marking unread chats as read…"
textFormat: Text.PlainText
elide: Text.ElideRight
font.family: root.fontFamily
font.pixelSize: Style.font.caption
color: Qt.darker(root.foreground, 1.6)
}
}
ListView {
id: list
width: parent.width
visible: root.messages.length > 0
clip: true
opacity: root.markAllBusy ? 0.4 : 1
enabled: !root.markAllBusy
model: root.messages
spacing: Style.space(1)
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
interactive: contentHeight > height && !root.markAllBusy
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
readonly property int cap: {
var chrome = Style.space(70)
if (root.hasPrev || root.hasNext) chrome += Style.space(38)
if (!root.reachable) chrome += Style.space(24)
if (root.reachable && root.warningText !== "") chrome += Style.space(24)
if (root.actionWarning !== "" && !root.markAllBusy) chrome += Style.space(24)
if (root.markAllBusy) chrome += Style.space(24)
return Math.max(Style.space(200),
panel.availableCardHeight - panel.verticalContentInset - chrome)
}
height: Math.min(contentHeight, cap)
delegate: Rectangle {
id: row
required property var modelData
required property int index
readonly property bool active: root.cursor === row.index || rowMouse.containsMouse
width: list.width - (list.interactive ? Style.space(10) : 0)
height: rowContent.implicitHeight + Style.space(10)
radius: Style.cornerRadius
opacity: root.pendingId === modelData.id ? 0.4 : 1
color: active
? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08)
: "transparent"
Behavior on color { ColorAnimation { duration: 80 } }
MouseArea {
id: rowMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onContainsMouseChanged: if (containsMouse) root.cursor = row.index
onClicked: if (!root.markAllBusy) root.openChat(row.modelData)
}
Column {
id: rowContent
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.space(6)
anchors.rightMargin: Style.space(6)
spacing: Style.space(2)
Item {
width: parent.width
height: subject.implicitHeight
Row {
id: line
anchors.left: parent.left
anchors.right: age.left
anchors.rightMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(5)
Row {
id: chips
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(3)
visible: root.accountCount > 1
&& (row.modelData.labels || []).length > 0
Repeater {
model: (row.modelData.labels || []).slice(0, 1)
Rectangle {
required property string modelData
anchors.verticalCenter: parent.verticalCenter
height: chipText.implicitHeight + Style.space(3)
width: chipText.width + Style.space(8)
radius: Style.space(3)
color: Qt.rgba(root.foreground.r, root.foreground.g,
root.foreground.b, 0.14)
Text {
id: chipText
anchors.centerIn: parent
text: parent.modelData
textFormat: Text.PlainText
elide: Text.ElideRight
wrapMode: Text.NoWrap
maximumLineCount: 1
width: Math.min(implicitWidth, Style.space(64))
font.family: root.fontFamily
font.pixelSize: Style.font.caption
color: Qt.darker(root.foreground, 1.35)
}
}
}
}
Text {
id: subject
anchors.verticalCenter: parent.verticalCenter
width: Math.max(Style.space(40),
line.width - (chips.visible ? chips.width + line.spacing : 0))
text: root.oneLine(row.modelData.subject)
textFormat: Text.PlainText
wrapMode: Text.NoWrap
maximumLineCount: 1
elide: Text.ElideRight
font.family: root.fontFamily
font.pixelSize: Style.font.body
font.bold: true
color: root.foreground
}
}
Text {
id: age
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.ageLabel(row.modelData.ts)
textFormat: Text.PlainText
font.family: root.fontFamily
font.pixelSize: Style.font.caption
color: Qt.darker(root.foreground, 1.7)
}
}
Row {
width: parent.width
spacing: 0
Text {
id: fromLabel
text: row.modelData.from || ""
textFormat: Text.PlainText
elide: Text.ElideRight
width: Math.min(implicitWidth, parent.width * 0.5)
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
color: Qt.darker(root.foreground, 1.15)
}
Text {
text: {
var body = root.oneLine(row.modelData.snippet)
if (body === "") return ""
return (fromLabel.text !== "" ? " - " : "") + body
}
textFormat: Text.PlainText
wrapMode: Text.NoWrap
maximumLineCount: 1
elide: Text.ElideRight
width: parent.width - fromLabel.width
font.family: root.fontFamily
font.pixelSize: Style.font.caption
color: Qt.darker(root.foreground, 1.7)
}
}
}
}
}
Item {
width: parent.width
height: (root.hasPrev || root.hasNext) ? pagerRow.implicitHeight + Style.space(8) : 0
visible: root.hasPrev || root.hasNext
Row {
id: pagerRow
anchors.centerIn: parent
spacing: Style.space(10)
PanelActionButton {
iconText: root.iconPrev
tooltipText: "Previous page"
enabled: root.hasPrev && !root.markAllBusy
opacity: enabled ? 1 : 0.3
foreground: root.foreground
hoverColor: root.accent
fontFamily: root.fontFamily
fontSize: Style.font.iconSmall
onClicked: root.goPrevPage()
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: "page " + (root.pageStack.length + 1)
textFormat: Text.PlainText
font.family: root.fontFamily
font.pixelSize: Style.font.caption
color: Qt.darker(root.foreground, 1.7)
}
PanelActionButton {
iconText: root.iconNext
tooltipText: "Next page"
enabled: root.hasNext && !root.markAllBusy
opacity: enabled ? 1 : 0.3
foreground: root.foreground
hoverColor: root.accent
fontFamily: root.fontFamily
fontSize: Style.font.iconSmall
onClicked: root.goNextPage()
}
}
}
Item {
width: parent.width
height: root.messages.length === 0 ? Style.space(60) : 0
visible: root.messages.length === 0
Text {
anchors.centerIn: parent
width: parent.width - Style.space(20)
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
text: root.reachable
? "You're all caught up."
: (root.errorText !== "" ? root.errorText : "Beeper unreachable")
textFormat: Text.PlainText
font.family: root.fontFamily
font.pixelSize: Style.font.body
color: root.foreground
opacity: 0.6
}
}
}
}
}
}
+204
View File
@@ -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 <id> # mark one chat read
omarchy-beeper read-all # mark every unread chat read
omarchy-beeper open [<id>] # focus Beeper, optionally a chat
omarchy-beeper auth # store the token (terminal only)
```
`list` emits:
```json
{
"ok": true,
"unread": 7,
"accountCount": 3,
"nextPage": "25",
"thisPage": "0",
"inboxes": [{ "account": "WhatsApp", "unread": 3, "searchUrl": "" }],
"messages": [
{
"id": "beeper:<base64url chat id>",
"threadId": "<chat id>",
"subject": "<chat title>",
"from": "<last sender>",
"snippet": "<last message>",
"ts": 1770804000,
"labels": ["WhatsApp"],
"url": ""
}
]
}
```
Failures look like `{"ok":false,"error":"…"}`. The two you will actually see:
- `Beeper Desktop is not running` — start Beeper.
- `token missing or invalid; run: omarchy-beeper auth` — the token was never
set up, or you revoked it.
## Design decisions
- **The QML layer never talks to the network.** `Panel.qml` runs the CLI and
parses one JSON object from stdout. It never sees the token, never opens a
socket, and renders every API string with `textFormat: Text.PlainText`.
- **Everything is bound to `http://localhost:23373`.** `lib/beeper.py` refuses
any path outside `/v1/`, refuses any URL that would leave that origin, and
does not follow redirects — a 3xx could point off localhost.
- **`url` is always empty.** Beeper has no web address for a chat, so opening
one means `POST /v1/focus` with the chat id: Beeper Desktop comes forward
with that conversation selected. There is no browser hand-off and no deep
link to validate.
- **Snippets cost one request per visible row.** The chat objects Beeper
returns carry no message preview, so the last message is fetched with
`GET /v1/chats/{chatID}/messages` — but only for the rows on screen, never
for the whole pile. A snippet that fails to load costs the snippet, not the
row.
- **Paging is a numeric offset.** The API is cursor-based; the CLI walks the
cursors once, caps at 200 chats, sorts, and slices. `nextPage` is the next
offset, so `p` can step back without replaying cursors.
- **`accountCount` comes from `GET /v1/accounts`**, not from the pile, so the
network chip appears whenever you actually have several networks connected.
- **No WebSocket.** Beeper's `ws://localhost:23373/v1/ws` is experimental and
the panel is on a 15–3600 s poll anyway; a second event path would only add
a way to disagree with the list. Polling only, deliberately.
- **`read-all` snapshots first.** It collects the unread chat ids before it
marks anything, because the query it walks is defined by what is unread.
## Known Beeper limitations
- Message history can be incomplete until Beeper finishes indexing after a
fresh install or a new bridge. Titles and unread counts are right; a snippet
may be blank for a while.
- On-device (local) connections are the most reliable; cloud bridges can lag.
- iMessage is macOS-only. On Linux it simply never appears; there is no
special case for it.
- Beeper Desktop must be running. The API is local and dies with the app; the
bar icon dims to half opacity and the panel keeps the last list.
## Development
```bash
python3 -m unittest discover -s tests -t tests -v # mocked, offline
python3 -m compileall -q lib tests
bash -n bin/omarchy-beeper
qmllint -I <shell-qml-root> Panel.qml BeeperIcon.qml
```
The tests never touch a live Beeper: `tests/support.py` fakes the HTTP opener
and serves payloads shaped like the documented API.
## License
MIT — see [LICENSE](LICENSE).
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# omarchy-beeper - data source for the Omarchy bar widget.
#
# The panel calls:
# omarchy-beeper list [--limit N] [--page OFFSET]
# omarchy-beeper read <id>
# omarchy-beeper read-all
# omarchy-beeper open [<id>]
#
# The token is set up once in a terminal:
# omarchy-beeper auth
#
# list/read/read-all/open always print one JSON object. Failures print
# {"ok":false,...} and exit 0 so the widget never crashes on empty stdout.
set -uo pipefail
umask 077
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
export OMARCHY_BEEPER_ROOT="$ROOT"
# The bar is not a login shell, so PATH may miss user-managed runtimes.
for candidate in "$HOME/.local/share/mise/shims" "$HOME/.local/bin"; do
case ":$PATH:" in
*":$candidate:"*) ;;
*) [ -d "$candidate" ] && PATH="$PATH:$candidate" ;;
esac
done
export PATH
if ! command -v python3 >/dev/null 2>&1; then
printf '{"ok":false,"error":"python3 is required"}\n'
exit 0
fi
exec python3 "$ROOT/lib/cli.py" "$@"
+85
View File
@@ -0,0 +1,85 @@
# Setup
## 1. Turn on the Beeper Desktop API
Beeper Desktop serves a local-only HTTP API on `http://localhost:23373`. Check
it is up — this endpoint needs no token:
```bash
curl -s http://localhost:23373/v1/info
```
You should get JSON describing the app and `"status": "running"`. If the
connection is refused, Beeper Desktop is not running, or the API is disabled in
its settings.
## 2. Create an access token
In Beeper Desktop: **Settings → Integrations → create an access token.**
Read access is all this plugin needs. It calls exactly six endpoints:
| Call | Why |
| --- | --- |
| `GET /v1/info` | Tell "Beeper is down" apart from "the token is wrong" |
| `GET /v1/accounts` | Network names for the chips, and the account count |
| `GET /v1/chats/search` | The unread pile |
| `GET /v1/chats/{chatID}/messages` | The last message, for the row snippet |
| `POST /v1/chats/{chatID}/read` | Mark a chat read |
| `POST /v1/focus` | Bring Beeper Desktop forward on a chat |
Nothing else. No sends, no edits, no reactions, no drafts.
## 3. Store the token
```bash
~/.config/omarchy/plugins/prdlk.beeper/bin/omarchy-beeper auth
```
The command refuses to run unless stdin and stderr are both terminals, so a
token can never be piped in from a script or a shell history. It reads the
token with a hidden prompt, verifies it with one `GET /v1/accounts`, and only
then writes it to:
```
~/.config/omarchy-beeper/secrets/token.json # mode 600
~/.config/omarchy-beeper/secrets/ # mode 700
```
The write is atomic: a temp file in the same directory, `fchmod` 600, `fsync`,
`rename`, then `fsync` on the directory.
## 4. Check it
```bash
~/.config/omarchy/plugins/prdlk.beeper/bin/omarchy-beeper list --limit 5
```
A healthy answer starts with `{"ok": true, "unread": …}`. Then click the bar
icon.
## Troubleshooting
| Message | Meaning |
| --- | --- |
| `Beeper Desktop is not running` | Nothing is listening on `localhost:23373`. Start Beeper. |
| `token missing or invalid; run: omarchy-beeper auth` | No token stored, or Beeper rejected it (401/403). Re-run `auth`. |
| `secret file for beeper is too open; chmod 600 it` | Someone widened the token file. `chmod 600 ~/.config/omarchy-beeper/secrets/token.json`. |
| `secret directory for beeper is too open; chmod 700 it` | `chmod 700 ~/.config/omarchy-beeper/secrets`. |
| `showing the newest 200 unread chats` | The pile is deeper than the display cap. Use `A` to clear it. |
| Snippets are blank | Beeper is still indexing, or that chat has only media. Titles and counts are still correct. |
| `python3 is required` | The bar could not find `python3` on `PATH`. |
The bar is not a login shell, so `bin/omarchy-beeper` only extends `PATH` with
`~/.local/share/mise/shims` and `~/.local/bin`. If your `python3` lives
somewhere else, symlink it into `~/.local/bin`.
## Removing everything
```bash
omarchy plugin remove prdlk.beeper
rm -rf ~/.config/omarchy-beeper
```
Then revoke the token in Beeper Desktop → Settings → Integrations. Removing the
plugin does not revoke it for you.
+326
View File
@@ -0,0 +1,326 @@
"""All HTTP to the local Beeper Desktop API.
Everything here is bound to http://localhost:23373. The token is read from a
0600 file and only ever leaves this process inside an Authorization header:
never in argv, never on stderr, never in a payload.
Endpoints used (https://developers.beeper.com/desktop-api-reference/):
GET /v1/info server discovery, no auth
GET /v1/accounts connected chat accounts
GET /v1/chats/search unread chats, cursor paginated
GET /v1/chats/{chatID}/messages last message for the row snippet
POST /v1/chats/{chatID}/read mark a chat read
POST /v1/focus focus Beeper Desktop, optionally a chat
"""
from __future__ import annotations
import json
import socket
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from common import (
API_BASE,
FETCH_CAP,
MAX_HTTP_ERROR,
encode_id,
one_line,
read_http_body,
)
REQUEST_TIMEOUT = 30
SEARCH_PAGE = 200 # /v1/chats/search caps limit at 200
SNIPPET_WORKERS = 8
# Message kinds with no body text. Shown as a short placeholder instead.
MEDIA_LABELS = {
"IMAGE": "[photo]",
"VIDEO": "[video]",
"VOICE": "[voice message]",
"AUDIO": "[audio]",
"FILE": "[file]",
"STICKER": "[sticker]",
"LOCATION": "[location]",
}
class ApiError(Exception):
"""An API call that came back, but not with a usable answer."""
def __init__(self, message: str, status: int = 0) -> None:
super().__init__(message)
self.status = status
self.message = message
class UnreachableError(Exception):
"""Beeper Desktop is not listening on the local port."""
class AuthError(Exception):
"""The token is missing, expired, or was revoked."""
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""A redirect could point off localhost, so treat any 3xx as an error."""
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
return None
_OPENER = urllib.request.build_opener(_NoRedirect)
def _url(path: str, params: dict | None = None) -> str:
if not path.startswith("/v1/"):
raise ApiError(f"refusing to call {path}")
url = API_BASE + path
if params:
pairs: list[tuple[str, str]] = []
for key, value in params.items():
if value is None:
continue
if isinstance(value, bool):
pairs.append((key, "true" if value else "false"))
elif isinstance(value, (list, tuple)):
pairs.extend((key, str(item)) for item in value)
else:
pairs.append((key, str(value)))
if pairs:
url += "?" + urllib.parse.urlencode(pairs)
if not url.startswith(API_BASE + "/"):
raise ApiError("refusing to leave the local API")
return url
def request(
token: str,
method: str,
path: str,
params: dict | None = None,
body: dict | None = None,
timeout: int = REQUEST_TIMEOUT,
) -> object:
"""One call to the local API. Returns the decoded JSON body, or None."""
payload = None if body is None else json.dumps(body).encode("utf-8")
req = urllib.request.Request(_url(path, params), data=payload, method=method)
if token:
req.add_header("Authorization", f"Bearer {token}")
if payload is not None:
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "application/json")
try:
with _OPENER.open(req, timeout=timeout) as fp:
raw = read_http_body(fp)
except urllib.error.HTTPError as exc:
detail = ""
try:
detail = read_http_body(exc, MAX_HTTP_ERROR).decode("utf-8", "replace")
except Exception: # noqa: BLE001 - the status matters, the body does not
detail = ""
finally:
exc.close()
if exc.code in (401, 403):
raise AuthError("token missing or invalid; run: omarchy-beeper auth") from exc
raise ApiError(_http_message(exc.code, detail), exc.code) from exc
except urllib.error.URLError as exc:
reason = exc.reason
if isinstance(reason, (socket.timeout, TimeoutError)):
raise ApiError("the local API timed out") from exc
if isinstance(reason, OSError):
# Only localhost is ever dialled, so any socket error means the
# port is not being served: Beeper Desktop is down or restarting.
raise UnreachableError("Beeper Desktop is not running") from exc
raise ApiError(one_line(str(reason)) or "the local API failed") from exc
except (socket.timeout, TimeoutError) as exc:
raise ApiError("the local API timed out") from exc
if not raw:
return None
try:
return json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ApiError("the local API returned invalid JSON") from exc
def _http_message(status: int, detail: str) -> str:
text = ""
try:
data = json.loads(detail)
if isinstance(data, dict):
text = str(data.get("message") or data.get("error") or "")
except (json.JSONDecodeError, ValueError):
text = ""
text = one_line(text, 120)
return f"Beeper API error {status}: {text}" if text else f"Beeper API error {status}"
def server_info() -> dict:
"""GET /v1/info needs no token, so it separates 'down' from 'bad token'."""
data = request("", "GET", "/v1/info", timeout=5)
return data if isinstance(data, dict) else {}
def probe(token: str) -> None:
"""Cheapest authenticated call, used to verify a token during `auth`."""
request(token, "GET", "/v1/accounts", timeout=10)
def accounts(token: str) -> list[dict]:
data = request(token, "GET", "/v1/accounts")
if not isinstance(data, list):
return []
return [item for item in data if isinstance(item, dict)]
def account_label(account: dict) -> str:
return one_line(str(account.get("network") or account.get("accountID") or ""), 40)
def unread_chats(token: str, cap: int = FETCH_CAP, budget: float = 0.0) -> tuple[list[dict], bool]:
"""Unread chats, newest first.
Muted, archived, and low-priority chats are not part of the pile:
`inbox=primary` drops archived and low-priority, `includeMuted=false`
drops muted. Returns (chats, truncated) where truncated means the walk
hit `cap` or `budget` before the API ran out of pages.
"""
out: list[dict] = []
seen: set[str] = set()
cursor = ""
truncated = False
deadline = (_now() + budget) if budget > 0 else 0.0
while True:
params = {
"unreadOnly": True,
"includeMuted": False,
"inbox": "primary",
"limit": min(SEARCH_PAGE, max(1, cap)),
}
if cursor:
params["cursor"] = cursor
params["direction"] = "before"
data = request(token, "GET", "/v1/chats/search", params)
if not isinstance(data, dict):
raise ApiError("the local API returned invalid JSON")
items = data.get("items")
if not isinstance(items, list):
items = []
for chat in items:
if not isinstance(chat, dict):
continue
chat_id = str(chat.get("id") or "")
if not chat_id or chat_id in seen:
continue
seen.add(chat_id)
out.append(chat)
if len(out) >= cap:
truncated = bool(data.get("hasMore")) or len(out) > cap
del out[cap:]
break
cursor = str(data.get("oldestCursor") or "")
if not data.get("hasMore") or not cursor or not items:
break
if deadline and _now() >= deadline:
truncated = True
break
out.sort(key=lambda chat: (_ts(chat.get("lastActivity")), str(chat.get("id"))), reverse=True)
return out, truncated
def last_message(token: str, chat_id: str) -> dict:
"""Newest displayable message in a chat, for the row snippet."""
data = request(token, "GET", f"/v1/chats/{urllib.parse.quote(chat_id, safe='')}/messages")
items = data.get("items") if isinstance(data, dict) else None
if not isinstance(items, list):
return {}
best: dict = {}
best_key = ("", 0.0)
for msg in items:
if not isinstance(msg, dict):
continue
if msg.get("isDeleted") or msg.get("isHidden") or msg.get("type") == "REACTION":
continue
key = (str(msg.get("sortKey") or ""), _ts(msg.get("timestamp")))
if not best or key[1] > best_key[1] or (key[1] == best_key[1] and key[0] > best_key[0]):
best, best_key = msg, key
return best
def message_preview(msg: dict) -> str:
text = one_line(str(msg.get("text") or ""))
if text:
return text
kind = str(msg.get("type") or "")
if kind in MEDIA_LABELS:
return MEDIA_LABELS[kind]
attachments = msg.get("attachments")
if isinstance(attachments, list) and attachments:
return "[attachment]"
return ""
def mark_read(token: str, chat_id: str, timeout: int = REQUEST_TIMEOUT) -> None:
request(
token,
"POST",
f"/v1/chats/{urllib.parse.quote(chat_id, safe='')}/read",
body={},
timeout=timeout,
)
def focus(token: str, chat_id: str = "") -> None:
body: dict = {}
if chat_id:
body["chatID"] = chat_id
request(token, "POST", "/v1/focus", body=body)
def chat_row(chat: dict, msg: dict | None = None) -> dict:
"""One panel row. Every display string goes through one_line()."""
msg = msg or {}
title = one_line(str(chat.get("title") or ""))
network = one_line(str(chat.get("network") or ""), 40)
if not title:
title = network or "(no title)"
sender = one_line(str(msg.get("senderName") or ""), 60)
if msg.get("isSender"):
sender = "You"
ts = _ts(msg.get("timestamp")) or _ts(chat.get("lastActivity"))
return {
"id": encode_id(str(chat.get("id") or "")),
"threadId": str(chat.get("id") or ""),
"subject": title,
"from": sender,
"snippet": message_preview(msg),
"ts": int(ts),
"labels": [network] if network else [],
"url": "",
}
def _ts(value: object) -> float:
"""ISO 8601 (or unix seconds) to unix seconds. Unparsable is 0."""
if isinstance(value, (int, float)):
return float(value)
text = str(value or "").strip()
if not text:
return 0.0
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return 0.0
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.timestamp()
def _now() -> float:
return time.monotonic()
Executable
+313
View File
@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""CLI entry for omarchy-beeper.
Every command prints exactly one JSON object and exits 0, so the bar widget
never has to deal with empty stdout or a stack trace.
"""
from __future__ import annotations
import getpass
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
LIB = Path(__file__).resolve().parent
if str(LIB) not in sys.path:
sys.path.insert(0, str(LIB))
import beeper
from common import (
FETCH_CAP,
TOKEN_FILE,
die,
decode_id,
emit,
load_token,
max_chats,
save_token,
)
# The panel is on a refresh timer, so a slow list must still return.
LIST_BUDGET = 40.0
READ_ALL_BUDGET = 100.0
READ_ALL_CAP = 1000
READ_ALL_WORKERS = 6
HELP = """\
omarchy-beeper list [--limit N] [--page OFFSET]
omarchy-beeper read <id>
omarchy-beeper read-all
omarchy-beeper open [<id>]
omarchy-beeper auth
The bar widget calls list, read, read-all, and open. The token is set up
once in a terminal:
omarchy-beeper auth
--limit is the panel page size (1-50); the CLI also honours
OMARCHY_BEEPER_MAX. One row is one unread chat. Muted, archived, and
low-priority chats are not part of the pile.
"""
def _fail(exc: Exception) -> None:
"""Turn an API exception into the single JSON error line."""
if isinstance(exc, beeper.UnreachableError):
die("Beeper Desktop is not running")
if isinstance(exc, beeper.AuthError):
die("token missing or invalid; run: omarchy-beeper auth")
die(str(exc) or "the local Beeper API failed")
def _token() -> str:
token = load_token()
if token:
return token
# No token yet: say which of the two setup steps is missing.
try:
beeper.server_info()
except (beeper.UnreachableError, beeper.ApiError):
die("Beeper Desktop is not running")
die("token missing or invalid; run: omarchy-beeper auth")
return "" # unreachable, keeps type checkers quiet
def _inboxes(accounts: list[dict], chats: list[dict]) -> list[dict]:
"""Unread chats per connected account, in the order the API lists them."""
counts: dict[str, int] = {}
for chat in chats:
acc_id = str(chat.get("accountID") or "")
counts[acc_id] = counts.get(acc_id, 0) + 1
out = []
claimed = 0
for account in accounts:
acc_id = str(account.get("accountID") or "")
unread = counts.get(acc_id, 0)
claimed += unread
out.append(
{
"account": beeper.account_label(account) or acc_id,
"unread": unread,
"searchUrl": "",
}
)
leftover = len(chats) - claimed
if leftover > 0:
# A chat on an account /v1/accounts did not report; still count it.
out.append({"account": "Other", "unread": leftover, "searchUrl": ""})
return out
def _snippets(token: str, chats: list[dict], deadline: float) -> dict[str, dict]:
"""Last message per displayed chat. A failure just loses the snippet."""
if not chats:
return {}
def work(chat: dict) -> tuple[str, dict]:
chat_id = str(chat.get("id") or "")
if time.monotonic() >= deadline:
return chat_id, {}
try:
return chat_id, beeper.last_message(token, chat_id)
except (beeper.ApiError, beeper.UnreachableError, beeper.AuthError, OSError, ValueError):
return chat_id, {}
workers = min(beeper.SNIPPET_WORKERS, len(chats))
with ThreadPoolExecutor(max_workers=workers) as pool:
return dict(pool.map(work, chats))
def cmd_list(page_token: str) -> None:
started = time.monotonic()
token = _token()
per = max_chats()
start = 0
if page_token:
try:
start = max(0, int(page_token))
except ValueError:
start = 0
try:
chats, truncated = beeper.unread_chats(token, cap=FETCH_CAP, budget=LIST_BUDGET * 0.5)
accounts = beeper.accounts(token)
except Exception as exc: # noqa: BLE001 - mapped to the JSON error contract
_fail(exc)
return
unread = len(chats)
chunk = chats[start : start + per]
deadline = started + LIST_BUDGET
previews = _snippets(token, chunk, deadline)
rows = [beeper.chat_row(chat, previews.get(str(chat.get("id") or ""))) for chat in chunk]
# A page past the pile would always be empty, so do not offer one.
next_start = start + per
next_page = str(next_start) if next_start < unread else ""
out = {
"ok": True,
"unread": unread,
"accountCount": len(accounts),
"nextPage": next_page,
"thisPage": str(start),
"inboxes": _inboxes(accounts, chats),
"messages": rows,
}
if truncated:
out["warning"] = f"showing the newest {FETCH_CAP} unread chats"
emit(out)
def cmd_read(opaque: str) -> None:
chat_id = decode_id(opaque)
token = _token()
try:
beeper.mark_read(token, chat_id)
except Exception as exc: # noqa: BLE001 - mapped to the JSON error contract
_fail(exc)
return
emit({"ok": True})
def cmd_read_all() -> None:
started = time.monotonic()
deadline = started + READ_ALL_BUDGET
token = _token()
try:
# Snapshot first: marking changes what an unread query would return.
chats, _ = beeper.unread_chats(token, cap=READ_ALL_CAP, budget=READ_ALL_BUDGET * 0.3)
except Exception as exc: # noqa: BLE001 - mapped to the JSON error contract
_fail(exc)
return
ids = [str(chat.get("id") or "") for chat in chats]
ids = [chat_id for chat_id in ids if chat_id]
if not ids:
emit({"ok": True, "marked": 0})
return
marked = 0
errors: list[str] = []
ran_out = False
def work(chat_id: str) -> str:
remaining = deadline - time.monotonic()
if remaining <= 1:
return "budget"
try:
beeper.mark_read(token, chat_id, timeout=int(min(beeper.REQUEST_TIMEOUT, remaining)))
except Exception as exc: # noqa: BLE001 - one chat failing is not fatal
return str(exc) or "could not mark as read"
return ""
with ThreadPoolExecutor(max_workers=READ_ALL_WORKERS) as pool:
for result in pool.map(work, ids):
if result == "":
marked += 1
elif result == "budget":
ran_out = True
elif len(errors) < 3:
errors.append(result)
if ran_out and not errors:
errors.append(f"stopped after {int(READ_ALL_BUDGET)}s")
if errors and marked == 0:
emit({"ok": False, "marked": 0, "error": "; ".join(errors)})
return
if errors:
emit({"ok": False, "marked": marked, "error": "; ".join(errors)})
return
emit({"ok": True, "marked": marked})
def cmd_open(opaque: str) -> None:
chat_id = decode_id(opaque) if opaque else ""
token = _token()
try:
beeper.focus(token, chat_id)
except Exception as exc: # noqa: BLE001 - mapped to the JSON error contract
_fail(exc)
return
emit({"ok": True})
AUTH_HELP = """\
Create an access token in Beeper Desktop:
Settings -> Integrations -> create a token (read access is enough)
Then paste it here. It is stored in
"""
def cmd_auth() -> None:
# A token typed into a pipe would end up in a shell history or a log.
if not (sys.stdin.isatty() and sys.stderr.isatty()):
sys.stderr.write("omarchy-beeper auth needs a terminal; see docs/SETUP.md\n")
raise SystemExit(1)
try:
info = beeper.server_info()
except (beeper.UnreachableError, beeper.ApiError):
sys.stderr.write("Beeper Desktop is not running on localhost:23373; start it first.\n")
raise SystemExit(1) from None
app = info.get("app") if isinstance(info.get("app"), dict) else {}
sys.stderr.write(f"Found {app.get('name', 'Beeper')} {app.get('version', '')}".rstrip() + "\n")
sys.stderr.write(AUTH_HELP + f"{TOKEN_FILE} with mode 600.\n")
token = getpass.getpass("Access token: ").strip()
if not token:
sys.stderr.write("No token entered; nothing was saved.\n")
raise SystemExit(1)
try:
beeper.probe(token)
except beeper.AuthError:
sys.stderr.write("Beeper rejected that token; nothing was saved.\n")
raise SystemExit(1) from None
except (beeper.ApiError, beeper.UnreachableError) as exc:
sys.stderr.write(f"Could not verify the token: {exc}\n")
raise SystemExit(1) from None
save_token(token)
sys.stdout.write(f"Token saved to {TOKEN_FILE}. The panel picks it up on the next refresh.\n")
def main(argv: list[str] | None = None) -> None:
args = list(sys.argv[1:] if argv is None else argv)
if not args or args[0] == "list":
page = ""
rest = args[1:] if args else []
while rest:
if rest[0] == "--page" and len(rest) > 1:
page = rest[1]
rest = rest[2:]
elif rest[0] == "--limit" and len(rest) > 1:
os.environ["OMARCHY_BEEPER_MAX"] = rest[1]
rest = rest[2:]
else:
rest = rest[1:]
cmd_list(page)
return
if args[0] == "read":
if len(args) < 2:
die("usage: omarchy-beeper read <id>")
cmd_read(args[1])
return
if args[0] == "read-all":
cmd_read_all()
return
if args[0] == "open":
cmd_open(args[1] if len(args) > 1 else "")
return
if args[0] == "auth":
cmd_auth()
return
if args[0] in ("-h", "--help", "help"):
sys.stdout.write(HELP)
return
die("unknown command; try: omarchy-beeper --help")
if __name__ == "__main__":
main()
+283
View File
@@ -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
+47
View File
@@ -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
}
]
}
}
+186
View File
@@ -0,0 +1,186 @@
"""Shared helpers for mocked contract tests. Never talks to Beeper."""
from __future__ import annotations
import json
import sys
import urllib.error
import urllib.parse
from contextlib import contextmanager, redirect_stdout
from io import BytesIO, StringIO
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
LIB = ROOT / "lib"
if str(LIB) not in sys.path:
sys.path.insert(0, str(LIB))
def capture_json(fn, *args, **kwargs) -> dict:
buf = StringIO()
with redirect_stdout(buf):
try:
fn(*args, **kwargs)
except SystemExit:
pass
text = buf.getvalue().strip()
if not text:
raise AssertionError("expected JSON on stdout")
return json.loads(text.splitlines()[-1])
class FakeResponse:
"""Enough of an http.client.HTTPResponse for read_http_body()."""
def __init__(self, payload: object, status: int = 200, content_length: object | None = None):
if isinstance(payload, (bytes, bytearray)):
raw = bytes(payload)
else:
raw = json.dumps(payload).encode("utf-8")
self._buf = BytesIO(raw)
self.status = status
declared = len(raw) if content_length is None else content_length
self.headers = {} if declared is False else {"Content-Length": str(declared)}
def read(self, n: int = -1) -> bytes:
return self._buf.read(n)
def __enter__(self) -> FakeResponse:
return self
def __exit__(self, *exc: object) -> bool:
return False
class Call:
def __init__(self, method: str, url: str, body: object) -> None:
parsed = urllib.parse.urlsplit(url)
self.method = method
self.url = url
self.host = parsed.netloc
self.path = parsed.path
self.query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
self.body = body
def one(self, key: str, default: str = "") -> str:
values = self.query.get(key) or []
return values[0] if values else default
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"<Call {self.method} {self.path} {self.query}>"
class FakeHttp:
"""Route (METHOD, path) to a callable or a fixed response."""
def __init__(self, routes: dict[tuple[str, str], object] | None = None) -> None:
self.routes = dict(routes or {})
self.calls: list[Call] = []
def route(self, method: str, path: str, handler: object) -> None:
self.routes[(method, path)] = handler
def open(self, req, timeout: int | None = None): # noqa: D102 - urlopen shape
body = None
if req.data:
body = json.loads(req.data.decode("utf-8"))
call = Call(req.get_method(), req.full_url, body)
self.calls.append(call)
handler = self.routes.get((call.method, call.path))
if handler is None:
raise urllib.error.HTTPError(call.url, 404, "Not Found", {}, BytesIO(b"{}"))
result = handler(call) if callable(handler) else handler
if isinstance(result, Exception):
raise result
if isinstance(result, FakeResponse):
return result
return FakeResponse(result)
def paths(self, method: str = "") -> list[str]:
return [c.path for c in self.calls if not method or c.method == method]
@contextmanager
def fake_api(http: FakeHttp):
import beeper
with patch.object(beeper, "_OPENER", http):
yield http
def http_error(status: int, message: str = "nope", url: str = "http://localhost:23373/v1/x"):
body = json.dumps({"message": message, "code": "error"}).encode("utf-8")
return urllib.error.HTTPError(url, status, message, {"Content-Length": str(len(body))}, BytesIO(body))
def url_error(reason: Exception | None = None):
return urllib.error.URLError(reason or ConnectionRefusedError(111, "Connection refused"))
def chat(
chat_id: str,
*,
title: str = "Ada Lovelace",
network: str = "WhatsApp",
account: str = "local-whatsapp_1",
last_activity: str = "2026-02-11T10:00:00.000Z",
unread: int = 2,
) -> dict:
return {
"id": chat_id,
"localChatID": "1",
"accountID": account,
"network": network,
"title": title,
"type": "single",
"participants": {"items": [], "hasMore": False, "total": 1},
"lastActivity": last_activity,
"unreadCount": unread,
"isArchived": False,
"isMuted": False,
"isPinned": False,
}
def message(
chat_id: str,
*,
text: str = "are you coming tonight?",
sender: str = "Ada",
timestamp: str = "2026-02-11T10:00:00.000Z",
kind: str = "TEXT",
sort_key: str = "100",
) -> dict:
return {
"id": "m1",
"chatID": chat_id,
"accountID": "local-whatsapp_1",
"senderID": "@ada:example",
"senderName": sender,
"timestamp": timestamp,
"sortKey": sort_key,
"type": kind,
"text": text,
}
def account(account_id: str, network: str) -> dict:
return {
"accountID": account_id,
"loginID": account_id,
"bridge": {"id": network.lower(), "type": network.lower(), "provider": "local"},
"network": network,
"user": {"id": "@me:example", "isSelf": True},
"status": "connected",
}
def search_page(chats: list[dict], has_more: bool = False, cursor: str | None = None) -> dict:
return {
"items": chats,
"hasMore": has_more,
"oldestCursor": cursor,
"newestCursor": None,
}
+493
View File
@@ -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()
+180
View File
@@ -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()
+251
View File
@@ -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()