Last active
December 30, 2025 19:54
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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