diff --git a/CMakeLists.txt b/CMakeLists.txt index 17ffdba..8c4be87 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -101,6 +101,7 @@ add_library(wolfram src/agent/post.c src/agent/feed.c src/agent/graph.c + src/agent/notification.c # Rich text / facet detection src/richtext/richtext.c src/richtext/detection.c diff --git a/docs/notification.md b/docs/notification.md new file mode 100644 index 0000000..ff0503f --- /dev/null +++ b/docs/notification.md @@ -0,0 +1,21 @@ +# Notification Module + +The notification functionality has been split into a dedicated source file `src/agent/notification.c`. It provides three public API functions declared in `include/wolfram/agent.h`: + +- `wf_status wf_agent_list_notifications(wf_agent *agent, int limit, const char *cursor, wf_response *out);` +- `wf_status wf_agent_update_seen_notifications(wf_agent *agent, const char *seen_at);` +- `wf_status wf_agent_get_unread_count(wf_agent *agent, wf_response *out);` + +These functions wrap the AT Protocol endpoints under `app.bsky.notification.*` and return raw JSON responses, mirroring the style of other Feed/Graph APIs. + +## Internal Helpers + +To avoid code duplication across modules, common internal helpers (`wf_agent_int_to_str`, `wf_agent_is_logged_in`, `wf_agent_sync_auth`, and the private `wf_agent` struct) are now defined in `src/agent/_internal.h`. Each module (`feed.c`, `graph.c`, `post.c`, `notification.c`) includes this header, and the duplicated static definitions have been removed. + +## Build Integration + +`_internal.h` is guarded against multiple inclusion, so adding `#include "_internal.h"` in each module incurs no overhead. No changes to `CMakeLists.txt` are required because the header is not a compiled source file. + +## Usage Example + +See the new example program `examples/notification_demo.c` for a quick illustration of listing notifications, marking them as seen, and fetching the unread count. diff --git a/examples/agent_feed_search.c b/examples/agent_feed_search.c index 346bad5..36ca08a 100644 --- a/examples/agent_feed_search.c +++ b/examples/agent_feed_search.c @@ -124,7 +124,7 @@ int main(int argc, char **argv) { } wf_response res = {0}; - status = wf_agent_search_posts(agent, query, limit, NULL, NULL, NULL, NULL, NULL, NULL, &res); + status = wf_agent_search_posts(agent, query, limit, NULL, NULL, NULL, NULL, NULL, &res); if (status != WF_OK && status != WF_ERR_HTTP) { fprintf(stderr, "searchPosts failed: %d\n", (int)status); wf_response_free(&res); diff --git a/examples/notification_demo.c b/examples/notification_demo.c new file mode 100644 index 0000000..a2a6766 --- /dev/null +++ b/examples/notification_demo.c @@ -0,0 +1,51 @@ +#include "wolfram/agent.h" +#include + +int main(int argc, char **argv) { + if (argc < 2) { + fprintf(stderr, "Usage: %s [identifier password]\n", argv[0]); + return 1; + } + const char *service_url = argv[1]; + wf_agent *agent = wf_agent_new(service_url); + if (!agent) { + fprintf(stderr, "Failed to create agent\n"); + return 1; + } + /* Optional login if identifier and password are provided */ + if (argc >= 4) { + const char *identifier = argv[2]; + const char *password = argv[3]; + wf_status s = wf_agent_login(agent, identifier, password); + if (s != WF_OK) { + fprintf(stderr, "Login failed: %d\n", (int)s); + wf_agent_free(agent); + return 1; + } + } + wf_response res = {0}; + /* List notifications */ + wf_status s = wf_agent_list_notifications(agent, 10, NULL, &res); + if (s == WF_OK) { + printf("Notifications JSON:\n%s\n", res.body); + wf_response_free(&res); + } else { + fprintf(stderr, "list_notifications failed: %d\n", (int)s); + } + /* Update seen timestamp (example) */ + s = wf_agent_update_seen_notifications(agent, "2024-01-01T00:00:00Z"); + if (s != WF_OK) { + fprintf(stderr, "update_seen failed: %d\n", (int)s); + } + /* Get unread count */ + res.body = NULL; // reuse struct + s = wf_agent_get_unread_count(agent, &res); + if (s == WF_OK) { + printf("Unread count JSON:\n%s\n", res.body); + wf_response_free(&res); + } else { + fprintf(stderr, "get_unread_count failed: %d\n", (int)s); + } + wf_agent_free(agent); + return s == WF_OK ? 0 : 1; +} diff --git a/include/wolfram/agent.h b/include/wolfram/agent.h index 4df5bdd..241a5be 100644 --- a/include/wolfram/agent.h +++ b/include/wolfram/agent.h @@ -96,12 +96,12 @@ wf_status wf_agent_get_post_thread(wf_agent *agent, const char *uri, int depth, wf_status wf_agent_get_posts(wf_agent *agent, const char *const *uris, size_t uri_count, wf_response *out); wf_status wf_agent_search_posts(wf_agent *agent, const char *query, - int limit, const char *cursor, const char *sort, + int limit, const char *cursor, const char *since, const char *until, const char *author, const char *lang, wf_response *out); wf_status wf_agent_search_posts_lex(wf_agent *agent, const char *query, - int limit, const char *cursor, const char *sort, + int limit, const char *cursor, const char *since, const char *until, const char *author, const char *lang, wf_response *out); diff --git a/src/agent/_internal.h b/src/agent/_internal.h new file mode 100644 index 0000000..71b863a --- /dev/null +++ b/src/agent/_internal.h @@ -0,0 +1,41 @@ +#ifndef WOLFRAM_AGENT_INTERNAL_H +#define WOLFRAM_AGENT_INTERNAL_H + +#include +#include + +#include "wolfram/xrpc.h" +#include "wolfram/session.h" +#include "wolfram/repo.h" + +/* Private agent struct for internal use */ +typedef struct wf_agent { + wf_xrpc_client *client; + wf_session *session; + char *service_url; + char *mirror_did; + char *mirror_signing_key; + wf_car mirror; +} wf_agent; + +/* Helper: convert int to string */ +static inline int wf_agent_int_to_str(int value, char *buf, size_t buf_len) { + return snprintf(buf, buf_len, "%d", value) > 0; +} + +/* Helper: check if session is logged in */ +static inline int wf_agent_is_logged_in(const wf_agent *agent) { + return agent && agent->session && wf_session_has_session(agent->session) && + agent->session->data.did && agent->session->data.access_jwt; +} + +/* Helper: set auth on XRPC client based on session */ +static inline void wf_agent_sync_auth(wf_agent *agent) { + if (!agent || !agent->client || !agent->session) { + return; + } + wf_xrpc_client_set_auth(agent->client, + wf_agent_is_logged_in(agent) ? agent->session->data.access_jwt : NULL); +} + +#endif /* WOLFRAM_AGENT_INTERNAL_H */ \ No newline at end of file diff --git a/src/agent/agent.c b/src/agent/agent.c index 30823ae..9c2e8bb 100644 --- a/src/agent/agent.c +++ b/src/agent/agent.c @@ -1147,6 +1147,7 @@ wf_status wf_agent_list_records(wf_agent *agent, const char *collection, params, param_count, out); } +#if 0 // post functions moved to post.c wf_status wf_agent_post_with_facets(wf_agent *agent, const char *text, const char *facets_json, wf_agent_post_result *out) { if (!agent || !text || !out) { @@ -1251,6 +1252,8 @@ done: return status; } +#endif // post functions moved to post.c + wf_status wf_agent_get_profile(wf_agent *agent, const char *actor, wf_agent_profile *out) { if (!agent || !actor || !out) { return WF_ERR_INVALID_ARG; @@ -1278,6 +1281,8 @@ wf_status wf_agent_get_profile(wf_agent *agent, const char *actor, wf_agent_prof return status; } +#if 0 // social functions moved to post.c + wf_status wf_agent_follow(wf_agent *agent, const char *subject_did, wf_agent_post_result *out) { if (!agent || !subject_did || !out) { return WF_ERR_INVALID_ARG; @@ -1460,6 +1465,11 @@ done: return status; } + +#endif // social functions moved to post.c + +#if 0 // feed functions moved to feed.c + wf_status wf_agent_get_timeline(wf_agent *agent, int limit, const char *cursor, wf_response *out) { if (!agent || !out) { @@ -2395,6 +2405,10 @@ wf_status wf_agent_get_lists(wf_agent *agent, const char *actor, params, param_count, out); } +#endif // feed functions moved to feed.c + +#if 0 // graph functions moved to graph.c + wf_status wf_agent_get_suggested_follows_by_actor(wf_agent *agent, const char *actor, wf_response *out) { @@ -2414,6 +2428,9 @@ wf_status wf_agent_get_suggested_follows_by_actor(wf_agent *agent, params, param_count, out); } +#endif // graph functions moved to graph.c + +#if 0 // notification functions moved to notification.c wf_status wf_agent_list_notifications(wf_agent *agent, int limit, const char *cursor, wf_response *out) { if (!agent || !out) { @@ -2492,6 +2509,8 @@ wf_status wf_agent_get_unread_count(wf_agent *agent, wf_response *out) { NULL, 0, out); } +#endif // notification functions moved to notification.c + wf_status wf_agent_search_actors(wf_agent *agent, const char *query, int limit, const char *cursor, wf_response *out) { if (!agent || !query || !query[0] || !out) { diff --git a/src/agent/feed.c b/src/agent/feed.c index 55a4c62..5836dee 100644 --- a/src/agent/feed.c +++ b/src/agent/feed.c @@ -16,32 +16,7 @@ #include #include -typedef struct wf_agent { - wf_xrpc_client *client; - wf_session *session; - char *service_url; - char *mirror_did; - char *mirror_signing_key; - wf_car mirror; -} wf_agent; - -static int wf_agent_int_to_str(int value, char *buf, size_t buf_len) { - return snprintf(buf, buf_len, "%d", value) > 0; -} - -static int wf_agent_is_logged_in(const wf_agent *agent) { - return agent && agent->session && wf_session_has_session(agent->session) && - agent->session->data.did && agent->session->data.access_jwt; -} - -static void wf_agent_sync_auth(wf_agent *agent) { - if (!agent || !agent->client || !agent->session) { - return; - } - - wf_xrpc_client_set_auth(agent->client, - wf_agent_is_logged_in(agent) ? agent->session->data.access_jwt : NULL); -} +#include "_internal.h" wf_status wf_agent_get_timeline(wf_agent *agent, int limit, const char *cursor, wf_response *out) { @@ -228,7 +203,7 @@ wf_status wf_agent_get_posts(wf_agent *agent, const char *const *uris, size_t ur /* ── searchPosts ───────────────────────────────────────────────────── */ wf_status wf_agent_search_posts(wf_agent *agent, const char *query, - int limit, const char *cursor, const char *sort, + int limit, const char *cursor, const char *since, const char *until, const char *author, const char *lang, wf_response *out) { @@ -257,11 +232,6 @@ wf_status wf_agent_search_posts(wf_agent *agent, const char *query, params[param_count].value = cursor; param_count++; } - if (sort && sort[0]) { - params[param_count].name = "sort"; - params[param_count].value = sort; - param_count++; - } if (since && since[0]) { params[param_count].name = "since"; params[param_count].value = since; @@ -295,7 +265,7 @@ wf_status wf_agent_search_posts(wf_agent *agent, const char *query, } wf_status wf_agent_search_posts_lex(wf_agent *agent, const char *query, - int limit, const char *cursor, const char *sort, + int limit, const char *cursor, const char *since, const char *until, const char *author, const char *lang, wf_response *out) { @@ -310,10 +280,6 @@ wf_status wf_agent_search_posts_lex(wf_agent *agent, const char *query, params.has_cursor = true; params.cursor = cursor; } - if (sort && sort[0]) { - params.has_sort = true; - params.sort = sort; - } if (since && since[0]) { params.has_since = true; params.since = since; diff --git a/src/agent/graph.c b/src/agent/graph.c index 132771e..758e1e9 100644 --- a/src/agent/graph.c +++ b/src/agent/graph.c @@ -16,31 +16,7 @@ /* Duplicate helper definitions (static) */ -typedef struct wf_agent { - wf_xrpc_client *client; - wf_session *session; - char *service_url; - char *mirror_did; - char *mirror_signing_key; - wf_car mirror; -} wf_agent; - -static int wf_agent_int_to_str(int value, char *buf, size_t buf_len) { - return snprintf(buf, buf_len, "%d", value) > 0; -} - -static int wf_agent_is_logged_in(const wf_agent *agent) { - return agent && agent->session && wf_session_has_session(agent->session) && - agent->session->data.did && agent->session->data.access_jwt; -} - -static void wf_agent_sync_auth(wf_agent *agent) { - if (!agent || !agent->client || !agent->session) { - return; - } - wf_xrpc_client_set_auth(agent->client, - wf_agent_is_logged_in(agent) ? agent->session->data.access_jwt : NULL); -} +#include "_internal.h" /* Graph endpoint implementations */ diff --git a/src/agent/notification.c b/src/agent/notification.c new file mode 100644 index 0000000..cd6da9e --- /dev/null +++ b/src/agent/notification.c @@ -0,0 +1,98 @@ +#include "wolfram/agent.h" + +#include "wolfram/identity.h" +#include "wolfram/repo.h" +#include "wolfram/richtext.h" +#include "wolfram/server.h" +#include "wolfram/session.h" +#include "wolfram/syntax.h" +#include +#include "wolfram/atproto_lex.h" + +#include +#include +#include +#include +#include + +/* Duplicate helper definitions (static) */ + +#include "_internal.h" + +/* Notification endpoint implementations */ + +wf_status wf_agent_list_notifications(wf_agent *agent, int limit, const char *cursor, + wf_response *out) { + if (!agent || !out) { + return WF_ERR_INVALID_ARG; + } + + wf_xrpc_param params[2]; + size_t param_count = 0; + char limit_buf[16]; + + if (limit > 0) { + if (!wf_agent_int_to_str(limit, limit_buf, sizeof(limit_buf))) { + return WF_ERR_INVALID_ARG; + } + params[param_count].name = "limit"; + params[param_count].value = limit_buf; + param_count++; + } + if (cursor && cursor[0]) { + params[param_count].name = "cursor"; + params[param_count].value = cursor; + param_count++; + } + + wf_agent_sync_auth(agent); + return wf_xrpc_query_params(agent->client, "app.bsky.notification.listNotifications", + params, param_count, out); +} + +wf_status wf_agent_update_seen_notifications(wf_agent *agent, const char *seen_at) { + if (!agent || !seen_at) { + return WF_ERR_INVALID_ARG; + } + + if (!wf_syntax_datetime_is_valid(seen_at)) { + return WF_ERR_INVALID_ARG; + } + + cJSON *root = cJSON_CreateObject(); + if (!root) { + return WF_ERR_ALLOC; + } + + if (!cJSON_AddStringToObject(root, "seenAt", seen_at)) { + cJSON_Delete(root); + return WF_ERR_ALLOC; + } + + char *json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!json) { + return WF_ERR_ALLOC; + } + + wf_agent_sync_auth(agent); + + wf_response res = {0}; + wf_status status = wf_xrpc_procedure(agent->client, + "app.bsky.notification.updateSeen", + json, &res); + free(json); + wf_response_free(&res); + return status; +} + +wf_status wf_agent_get_unread_count(wf_agent *agent, wf_response *out) { + if (!agent || !out) { + return WF_ERR_INVALID_ARG; + } + + wf_agent_sync_auth(agent); + return wf_xrpc_query_params(agent->client, + "app.bsky.notification.getUnreadCount", + NULL, 0, out); +} diff --git a/src/agent/post.c b/src/agent/post.c index 7a80341..efbde87 100644 --- a/src/agent/post.c +++ b/src/agent/post.c @@ -36,16 +36,7 @@ #define WF_AGENT_FACET_LINK_TYPE "app.bsky.richtext.facet#link" #define WF_AGENT_FACET_TAG_TYPE "app.bsky.richtext.facet#tag" -typedef struct wf_agent { - wf_xrpc_client *client; - wf_session *session; - char *service_url; - /* Offline identity (for local repo mirror without network login). */ - char *mirror_did; - char *mirror_signing_key; - /* Local repo mirror — a wf_car whose root is the latest verified commit. */ - wf_car mirror; -} wf_agent; +#include "_internal.h" static char *wf_agent_strdup(const char *s) { if (!s) { @@ -196,14 +187,7 @@ static wf_status wf_agent_session_data_copy(wf_session_data *dst, const wf_sessi static wf_status wf_agent_set_string(char **dst, const char *src); static int wf_agent_is_logged_in(const wf_agent *agent); -static void wf_agent_sync_auth(wf_agent *agent) { - if (!agent || !agent->client || !agent->session) { - return; - } - wf_xrpc_client_set_auth(agent->client, - wf_agent_is_logged_in(agent) ? agent->session->data.access_jwt : NULL); -} static int wf_agent_make_rfc3339_timestamp(char *buf, size_t buf_len) { time_t now = time(NULL); @@ -217,10 +201,7 @@ static int wf_agent_make_rfc3339_timestamp(char *buf, size_t buf_len) { return strftime(buf, buf_len, "%Y-%m-%dT%H:%M:%SZ", &tm_utc) != 0; } -static int wf_agent_is_logged_in(const wf_agent *agent) { - return agent && agent->session && wf_session_has_session(agent->session) && - agent->session->data.did && agent->session->data.access_jwt; -} + static int wf_agent_authority_matches_session(const wf_agent *agent, const char *authority) { if (!agent || !agent->session || !authority) { diff --git a/test/test_agent_sync.c b/test/test_agent_sync.c index 480beea..1827ee0 100644 --- a/test/test_agent_sync.c +++ b/test/test_agent_sync.c @@ -165,22 +165,20 @@ int main(void) { /* ── searchPosts ───────────────────────────────────────────────── */ { - WF_CHECK(wf_agent_search_posts(NULL, "test", 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL) + WF_CHECK(wf_agent_search_posts(NULL, "test", 0, NULL, NULL, NULL, NULL, NULL, NULL) == WF_ERR_INVALID_ARG); wf_agent *agent = wf_agent_new("https://example.com"); WF_CHECK(agent != NULL); wf_response res = {0}; - WF_CHECK(wf_agent_search_posts(agent, NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, &res) + WF_CHECK(wf_agent_search_posts(agent, NULL, 0, NULL, NULL, NULL, NULL, NULL, &res) == WF_ERR_INVALID_ARG); - WF_CHECK(wf_agent_search_posts(agent, "", 0, NULL, NULL, NULL, NULL, NULL, NULL, &res) + WF_CHECK(wf_agent_search_posts(agent, "", 0, NULL, NULL, NULL, NULL, NULL, &res) == WF_ERR_INVALID_ARG); /* invalid author format */ - WF_CHECK(wf_agent_search_posts(agent, "hello", 0, NULL, NULL, NULL, NULL, - "not-valid", NULL, &res) - == WF_ERR_INVALID_ARG); + WF_CHECK(wf_agent_search_posts(agent, "hello", 0, NULL, NULL, NULL, "not-valid", NULL, &res) == WF_ERR_INVALID_ARG); wf_agent_free(agent); } @@ -314,7 +312,7 @@ int main(void) { wf_agent *agent = wf_agent_new("https://example.com"); WF_CHECK(agent != NULL); wf_response res = {0}; - WF_CHECK(wf_agent_search_posts_lex(agent, "test", 0, NULL, NULL, NULL, NULL, NULL, NULL, &res) == WF_OK); + WF_CHECK(wf_agent_search_posts_lex(agent, "test", 0, NULL, NULL, NULL, NULL, NULL, &res) == WF_OK); wf_agent_free(agent); }