Skip to content

Instantly share code, notes, and snippets.

@notnotrishi
Last active December 30, 2025 19:54
Show Gist options
  • Select an option

  • Save notnotrishi/0f2c92f48f55c19c7feb6b81f767b634 to your computer and use it in GitHub Desktop.

Select an option

Save notnotrishi/0f2c92f48f55c19c7feb6b81f767b634 to your computer and use it in GitHub Desktop.
Lightweight C daemon to watch Beeper Desktop for new messages on macOS/Linux (hack around missing webhooks). Polls the local API, keeps a low RSS (<1MB), reads token/optional hook from env or ini, and can fire any shell command on new unread (e.g., curl). Run directly, under nohup, or via launchd/systemd for persistence.
#include <errno.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <sys/resource.h>
#include <sys/time.h>
#define HOST "::1"
#define PORT 23373
#define CHECK_INTERVAL 1
#define READ_TIMEOUT_SEC 1
#define CONFIG_FILE "beeper_watcher.ini"
#define TOKEN_ENV "BEEPER_TOKEN"
#define HOOK_ENV "BEEPER_HOOK"
#define TOKEN_MAX 256
#define HOOK_MAX 256
#define PRINT_UPDATES 0 // set to 1 to print total unread count on change
/*
Usage:
Prefer env vars:
export BEEPER_TOKEN="your-token"
export BEEPER_HOOK='echo "hook fired" >> /tmp/beeper_hook.log'
Or place beeper_watcher.ini next to the binary with lines:
BEEPER_TOKEN=your-token
BEEPER_HOOK=echo "hook fired" >> /tmp/beeper_hook.log
Build (macOS/Linux):
cc beeper_watcher.c -o beeper_watcher
Run:
./beeper_watcher
Run detached (keeps running after terminal closes):
nohup ./beeper_watcher >/tmp/beeper_watcher.out 2>&1 &
# Optional: disown
Notes:
- Hook runs when unread count increases; stdout/stderr go with the watcher.
- Redirect inside the hook (as above) if you want a persistent log.
- Use absolute paths in the hook for tools like curl, e.g. /usr/bin/curl.
Note:
The hook command is executed whenever the unread count increases.
Hook can be any shell command or curl etc.
*/
static void log_error(const char *label) {
char msg[128];
const char *err = strerror(errno);
size_t len = 0;
// Fixed-format: "label: errmsg\n"
while (label[len] && len < sizeof(msg) - 1) {
msg[len] = label[len];
len++;
}
if (len + 2 < sizeof(msg)) {
msg[len++] = ':';
msg[len++] = ' ';
}
size_t i = 0;
while (err[i] && len < sizeof(msg) - 1) {
msg[len++] = err[i++];
}
if (len < sizeof(msg) - 1) {
msg[len++] = '\n';
}
write(STDERR_FILENO, msg, len);
}
static void log_status(int code) {
char msg[64];
const char prefix[] = "http status: ";
size_t len = 0;
memcpy(msg, prefix, sizeof(prefix) - 1);
len += sizeof(prefix) - 1;
int v = code;
if (v == 0) {
msg[len++] = '0';
} else {
char digits[12];
size_t d = 0;
while (v > 0 && d < sizeof(digits)) {
digits[d++] = (char)('0' + (v % 10));
v /= 10;
}
while (d > 0 && len < sizeof(msg) - 1) {
msg[len++] = digits[--d];
}
}
if (len < sizeof(msg) - 1) { msg[len++] = '\n'; }
write(STDERR_FILENO, msg, len);
}
static void run_hook(const char *cmd) {
if (!cmd || !cmd[0]) { return; }
pid_t pid = fork();
if (pid == 0) {
execl("/bin/sh", "sh", "-c", cmd, (char *)0);
_exit(127);
}
}
static void load_ini(char *token, size_t tmax, char *hook, size_t hmax) {
int fd = open(CONFIG_FILE, O_RDONLY);
if (fd < 0) { return; }
char buf[512];
ssize_t n = read(fd, buf, sizeof(buf) - 1);
close(fd);
if (n <= 0) { return; }
buf[n] = '\0';
char *line = buf;
while (line < buf + n) {
char *end = strchr(line, '\n');
if (!end) { end = buf + n; }
*end = '\0';
char *eq = strchr(line, '=');
if (eq) {
*eq = '\0';
char *key = line;
char *val = eq + 1;
size_t vlen = strlen(val);
if (vlen && val[vlen - 1] == '\r') { val[--vlen] = '\0'; }
if (!token[0] && (strcmp(key, "token") == 0 || strcmp(key, TOKEN_ENV) == 0)) {
if (vlen >= tmax) { vlen = tmax - 1; }
memcpy(token, val, vlen);
token[vlen] = '\0';
} else if (!hook[0] && (strcmp(key, "command") == 0 || strcmp(key, HOOK_ENV) == 0)) {
if (vlen >= hmax) { vlen = hmax - 1; }
memcpy(hook, val, vlen);
hook[vlen] = '\0';
}
}
line = end + 1;
}
}
static ssize_t write_int_line(int value) {
char buf[64];
const char prefix[] = "Total unread messages: ";
size_t len = 0;
memcpy(buf + len, prefix, sizeof(prefix) - 1);
len += sizeof(prefix) - 1;
// Convert integer to decimal (no stdio required)
char digits[16];
size_t dlen = 0;
int v = value;
if (v == 0) {
digits[dlen++] = '0';
} else {
while (v > 0 && dlen < sizeof(digits)) {
digits[dlen++] = (char)('0' + (v % 10));
v /= 10;
}
// reverse digits into place
for (size_t i = 0; i < dlen / 2; i++) {
char tmp = digits[i];
digits[i] = digits[dlen - 1 - i];
digits[dlen - 1 - i] = tmp;
}
}
if (len + dlen + 1 > sizeof(buf)) { // +1 for newline
return -1;
}
memcpy(buf + len, digits, dlen);
len += dlen;
buf[len++] = '\n';
return write(STDOUT_FILENO, buf, len);
}
int main() {
char token[TOKEN_MAX] = {0};
char hook_cmd[HOOK_MAX] = {0};
const char *env_token = getenv(TOKEN_ENV);
if (env_token && env_token[0]) {
size_t l = strlen(env_token);
if (l >= sizeof(token)) { l = sizeof(token) - 1; }
memcpy(token, env_token, l);
token[l] = '\0';
}
const char *env_hook = getenv(HOOK_ENV);
if (env_hook && env_hook[0]) {
size_t l = strlen(env_hook);
if (l >= sizeof(hook_cmd)) { l = sizeof(hook_cmd) - 1; }
memcpy(hook_cmd, env_hook, l);
hook_cmd[l] = '\0';
}
load_ini(token, sizeof(token), hook_cmd, sizeof(hook_cmd));
if (!token[0]) {
const char msg[] = "missing token (env BEEPER_TOKEN or beeper_watcher.ini)\n";
write(STDERR_FILENO, msg, sizeof(msg) - 1);
return 1;
}
const char pre[] =
"GET /v1/chats/search HTTP/1.1\r\n"
"Host: localhost\r\n"
"Authorization: Bearer ";
const char post[] = "\r\nConnection: keep-alive\r\n\r\n";
char request[512];
size_t pre_len = sizeof(pre) - 1;
size_t tok_len = strlen(token);
size_t post_len = sizeof(post) - 1;
if (pre_len + tok_len + post_len >= sizeof(request)) {
const char msg[] = "token too long\n";
write(STDERR_FILENO, msg, sizeof(msg) - 1);
return 1;
}
memcpy(request, pre, pre_len);
memcpy(request + pre_len, token, tok_len);
memcpy(request + pre_len + tok_len, post, post_len);
size_t request_len = pre_len + tok_len + post_len;
struct rlimit lim = { .rlim_cur = 5 * 1024 * 1024, .rlim_max = 5 * 1024 * 1024 };
setrlimit(RLIMIT_AS, &lim); // fail fast if memory creeps above 5MB
int sock = -1;
struct sockaddr_in6 server;
memset(&server, 0, sizeof(server));
server.sin6_family = AF_INET6;
server.sin6_port = htons(PORT);
inet_pton(AF_INET6, HOST, &server.sin6_addr);
for (;;) {
if (sock < 0) {
sock = socket(AF_INET6, SOCK_STREAM, 0);
if (sock < 0) { log_error("socket"); sleep(CHECK_INTERVAL); continue; }
struct timeval tv = { .tv_sec = READ_TIMEOUT_SEC, .tv_usec = 0 };
setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
setsockopt(sock, SOL_SOCKET, SO_KEEPALIVE, &(int){1}, sizeof(int));
if (connect(sock, (struct sockaddr*)&server, sizeof(server)) < 0) {
log_error("connect");
close(sock);
sock = -1;
sleep(CHECK_INTERVAL);
continue;
}
}
send(sock, request, request_len, 0);
char buffer[4096];
int totalUnread = 0;
static int last_unread = -1;
int status_code = 0;
int first_chunk = 1;
for (;;) {
int bytes = (int)read(sock, buffer, sizeof(buffer) - 1);
if (bytes <= 0) {
if (bytes < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
break; // timeout, fall through to next poll without reconnect
}
close(sock);
sock = -1; // force reconnect
break;
}
buffer[bytes] = '\0';
if (first_chunk) {
first_chunk = 0;
if (bytes >= 12 && memcmp(buffer, "HTTP/1.1 ", 9) == 0) {
status_code = atoi(buffer + 9);
if (status_code != 200) {
log_status(status_code);
totalUnread = last_unread >= 0 ? last_unread : 0;
break;
}
}
}
char *p = buffer;
while ((p = strstr(p, "\"unreadCount\":")) != NULL) {
totalUnread += atoi(p + 14);
p += 14;
}
}
if (PRINT_UPDATES && totalUnread != last_unread) {
write_int_line(totalUnread);
}
if (hook_cmd[0] && last_unread >= 0 && totalUnread > last_unread) {
run_hook(hook_cmd);
}
last_unread = totalUnread;
sleep(CHECK_INTERVAL);
}
return 0;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment