From 476af9f257afb5310697ef4d328d2402b37d1b56 Mon Sep 17 00:00:00 2001 From: Diaconu Radu-Mihai <52667211+countradooku@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:10:09 +0200 Subject: [PATCH] Expose message annotations in the server SDK Add Sockudo 4.4 message annotation request and response surfaces for publishing, deleting, and listing raw annotation events through trusted backend SDKs. Constraint: Annotation REST calls use the existing signed app HTTP API Confidence: medium Scope-risk: moderate Tested: git diff --check Not-tested: SDK-specific test suite --- index.d.ts | 66 +++++++++++++++++++++++++++++++ src/sockudo.ts | 55 ++++++++++++++++++++++++++ src/types.ts | 49 +++++++++++++++++++++++ tests/integration/sockudo/get.js | 42 ++++++++++++++++++++ tests/integration/sockudo/post.js | 63 +++++++++++++++++++++++++++++ 5 files changed, 275 insertions(+) diff --git a/index.d.ts b/index.d.ts index 068ed91..27ce0d3 100644 --- a/index.d.ts +++ b/index.d.ts @@ -22,6 +22,7 @@ declare class Sockudo { triggerBatch(events: Array): Promise; get(opts: Sockudo.GetOptions): Promise; + delete(opts: Sockudo.GetOptions): Promise; channelHistory( channel: string, params?: Sockudo.HistoryParams, @@ -50,6 +51,22 @@ declare class Sockudo { messageSerial: string, body: { data: string; [key: string]: unknown }, ): Promise; + publishAnnotation( + channel: string, + messageSerial: string, + body: Sockudo.PublishAnnotationBody, + ): Promise; + deleteAnnotation( + channel: string, + messageSerial: string, + annotationSerial: string, + params?: { socket_id?: string }, + ): Promise; + listAnnotations( + channel: string, + messageSerial: string, + params?: Sockudo.AnnotationEventsParams, + ): Promise; channelPresenceHistory( channel: string, params?: Sockudo.PresenceHistoryParams, @@ -201,6 +218,55 @@ declare namespace Sockudo { cursor?: string; } + export interface PublishAnnotationBody { + type: string; + name?: string; + clientId?: string; + socketId?: string; + count?: number; + data?: any; + encoding?: string; + } + + export interface PublishAnnotationResponse { + annotationSerial: string; + } + + export interface DeleteAnnotationResponse { + annotationSerial: string; + deletedAnnotationSerial: string; + } + + export interface AnnotationEventsParams extends Params { + type?: string; + from_serial?: string; + limit?: number; + socket_id?: string; + } + + export interface AnnotationEvent { + action: "annotation.create" | "annotation.delete"; + id?: string | null; + serial: string; + messageSerial: string; + type: string; + name?: string | null; + clientId?: string | null; + count?: number | null; + data?: any; + encoding?: string | null; + timestamp?: number | null; + } + + export interface AnnotationEventsResponse { + channel: string; + messageSerial: string; + limit: number; + hasMore: boolean; + nextCursor?: string | null; + items: Array; + } + export type HistoryDirection = "newest_first" | "oldest_first"; export interface PresenceHistoryParams extends HistoryParams { diff --git a/src/sockudo.ts b/src/sockudo.ts index f885835..0202cd5 100644 --- a/src/sockudo.ts +++ b/src/sockudo.ts @@ -9,7 +9,10 @@ import Token = require("./token"); import WebHook = require("./webhook"); import type { BatchEvent, + AnnotationEventsParams, + AnnotationEventsResponse, ChannelAuthResponse, + DeleteAnnotationResponse, GetMessageResponse, GetOptions, HistoryPage, @@ -23,6 +26,8 @@ import type { Options, PostOptions, PresenceChannelData, + PublishAnnotationBody, + PublishAnnotationResponse, ResponseWithIdempotency, SignedQueryStringOptions, TriggerParams, @@ -325,6 +330,13 @@ class Sockudo { }) as Promise; } + delete(options: GetOptions): Promise { + return requests.send(this.config, { + ...options, + method: "DELETE", + }) as Promise; + } + channelHistory( channel: string, params: GetOptions["params"] = {}, @@ -396,6 +408,49 @@ class Sockudo { }).then((response) => response.json() as Promise); } + publishAnnotation( + channel: string, + messageSerial: string, + body: PublishAnnotationBody, + ): Promise { + validateChannel(channel); + return this.post({ + path: `/channels/${channel}/messages/${messageSerial}/annotations`, + body, + }).then( + (response) => response.json() as Promise, + ); + } + + deleteAnnotation( + channel: string, + messageSerial: string, + annotationSerial: string, + params: { socket_id?: string } = {}, + ): Promise { + validateChannel(channel); + return this.delete({ + path: `/channels/${channel}/messages/${messageSerial}/annotations/${annotationSerial}`, + params, + }).then( + (response) => response.json() as Promise, + ); + } + + listAnnotations( + channel: string, + messageSerial: string, + params: AnnotationEventsParams = {}, + ): Promise { + validateChannel(channel); + return this.get({ + path: `/channels/${channel}/messages/${messageSerial}/annotations`, + params, + }).then( + (response) => response.json() as Promise, + ); + } + channelPresenceHistory( channel: string, params: PresenceHistoryParams = {}, diff --git a/src/types.ts b/src/types.ts index 199042d..f143e6f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -253,6 +253,55 @@ export interface MessageVersionsParams extends RequestParams { cursor?: string; } +export interface PublishAnnotationBody { + type: string; + name?: string; + clientId?: string; + socketId?: string; + count?: number; + data?: unknown; + encoding?: string; +} + +export interface PublishAnnotationResponse { + annotationSerial: string; +} + +export interface DeleteAnnotationResponse { + annotationSerial: string; + deletedAnnotationSerial: string; +} + +export interface AnnotationEventsParams extends RequestParams { + type?: string; + from_serial?: string; + limit?: number; + socket_id?: string; +} + +export interface AnnotationEvent { + action: "annotation.create" | "annotation.delete"; + id?: string | null; + serial: string; + messageSerial: string; + type: string; + name?: string | null; + clientId?: string | null; + count?: number | null; + data?: unknown; + encoding?: string | null; + timestamp?: number | null; +} + +export interface AnnotationEventsResponse { + channel: string; + messageSerial: string; + limit: number; + hasMore: boolean; + nextCursor?: string | null; + items: AnnotationEvent[]; +} + export interface PostOptions extends RequestOptions { body: unknown; } diff --git a/tests/integration/sockudo/get.js b/tests/integration/sockudo/get.js index d601896..48348cb 100644 --- a/tests/integration/sockudo/get.js +++ b/tests/integration/sockudo/get.js @@ -241,6 +241,48 @@ describe("Sockudo", function () { }); }); + describe("#listAnnotations", function () { + it("should call the annotation events endpoint with expected query params", function (done) { + nock("http://localhost") + .filteringPath(function (path) { + return path + .replace(/auth_timestamp=[0-9]+/, "auth_timestamp=X") + .replace(/auth_signature=[0-9a-f]{64}/, "auth_signature=Y"); + }) + .get( + "/apps/999/channels/chat:room-1/messages/msg:1/annotations?auth_key=111111&auth_timestamp=X&auth_version=1.0&cursor=abc&direction=oldest_first&limit=10&type=reaction%3Adistinct.v1&auth_signature=Y", + ) + .reply(200, { + channel: "chat:room-1", + message_serial: "msg:1", + direction: "oldest_first", + limit: 10, + has_more: false, + items: [ + { + annotation_serial: "ann:1", + type: "reaction:distinct.v1", + name: "like", + client_id: "alice", + }, + ], + }); + + sockudo + .listAnnotations("chat:room-1", "msg:1", { + limit: 10, + direction: "oldest_first", + cursor: "abc", + type: "reaction:distinct.v1", + }) + .then((payload) => { + expect(payload.items[0].annotation_serial).to.equal("ann:1"); + done(); + }) + .catch(done); + }); + }); + describe("#channelPresenceHistory", function () { it("should call the presence history endpoint with expected query params", function (done) { nock("http://localhost") diff --git a/tests/integration/sockudo/post.js b/tests/integration/sockudo/post.js index c0d5320..b2a7937 100644 --- a/tests/integration/sockudo/post.js +++ b/tests/integration/sockudo/post.js @@ -273,4 +273,67 @@ describe("Sockudo", function () { .catch(done); }); }); + + describe("#publishAnnotation", function () { + it("should call the annotation publish endpoint", function (done) { + nock("http://localhost") + .filteringPath(function (path) { + return path + .replace(/auth_timestamp=[0-9]+/, "auth_timestamp=X") + .replace(/auth_signature=[0-9a-f]{64}/, "auth_signature=Y"); + }) + .post( + "/apps/10000/channels/chat:room-1/messages/msg:1/annotations?auth_key=aaaa&auth_timestamp=X&auth_version=1.0&body_md5=6212b152d0b9bfe76ea0cd5e73367410&auth_signature=Y", + '{"type":"reaction:distinct.v1","name":"like","client_id":"alice"}', + ) + .reply(200, { + channel: "chat:room-1", + message_serial: "msg:1", + annotation_serial: "ann:1", + accepted: true, + }); + + sockudo + .publishAnnotation("chat:room-1", "msg:1", { + type: "reaction:distinct.v1", + name: "like", + client_id: "alice", + }) + .then((payload) => { + expect(payload.annotation_serial).to.equal("ann:1"); + done(); + }) + .catch(done); + }); + }); + + describe("#deleteAnnotation", function () { + it("should call the annotation delete endpoint", function (done) { + nock("http://localhost") + .filteringPath(function (path) { + return path + .replace(/auth_timestamp=[0-9]+/, "auth_timestamp=X") + .replace(/auth_signature=[0-9a-f]{64}/, "auth_signature=Y"); + }) + .delete( + "/apps/10000/channels/chat:room-1/messages/msg:1/annotations/ann:1?auth_key=aaaa&auth_timestamp=X&auth_version=1.0&socket_id=123.456&auth_signature=Y", + ) + .reply(200, { + channel: "chat:room-1", + message_serial: "msg:1", + annotation_serial: "ann:1", + deleted: true, + }); + + sockudo + .deleteAnnotation("chat:room-1", "msg:1", "ann:1", { + socket_id: "123.456", + }) + .then((payload) => { + expect(payload.deleted).to.equal(true); + done(); + }) + .catch(done); + }); + }); });