diff --git a/README.md b/README.md index 35f4d08..b9538d5 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ This section describes the REST APIs that are supported by the 2.x version of th API | HTTP | URL ----|------|----- Send Message | POST | https://\:\/WickrIO/V1/Apps/\/Messages +Get Message Status | GET | https://\:\/WickrIO/V1/Apps/\/MessageStatus/\ Set Message URL Callback | POST | https://\:\/WickrIO/V1/Apps/\/MsgRecvCallback?callbackurl=\ Get Message URL Callback | GET | https://\:\/WickrIO/V1/Apps/\/MsgRecvCallback Delete Message URL Callback | DELETE | https://\:\/WickrIO/V1/Apps/\/MsgRecvCallback @@ -64,6 +65,71 @@ Get Directory | GET | https://\:\/WickrIO/V1/Apps/\/Dire The \ value is the value you entered during the configuration of the Wickr Web Interface intagration. +## Message Status Tracking + +The Send Message endpoint supports optional delivery-status tracking. When you add the `?status=true` query parameter to a `POST .../Messages` request, the Web Interface will: + +1. Generate a unique `status_id` (a UUID). +2. Register that `status_id` with the WickrIO engine so the message's per-recipient delivery status can be tracked. +3. Send the message, associating it with the generated `status_id`. +4. Return the `status_id` in the response so you can query the status later. + +### Sending a message with status tracking + +Request: +``` +POST https://:/WickrIO/V1/Apps//Messages?status=true + +{ + "users": [{ "name": "alice@example.com" }], + "message": "Hello!" +} +``` + +Successful response (`200`): +```json +{ "success": true, "status_id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301" } +``` + +Notes: +- Status tracking works for both 1-to-1 messages (`users`) and room/group messages (`vgroupid`). +- Status tracking is **not** supported for attachments. Requesting `?status=true` together with an `attachment` returns a `400` with a JSON error body: + ```json + { "success": false, "error": { "message": "Message status tracking cannot be applied to attachments." } } + ``` +- Without `?status=true`, the endpoint behaves exactly as before and returns the raw engine response. + +### Retrieving message status + +Use the returned `status_id` to look up the current delivery status. The response type defaults to `full`. + +Request: +``` +GET https://:/WickrIO/V1/Apps//MessageStatus/ +``` + +The response is the JSON status payload returned by the WickrIO engine. If the lookup fails (for example, an unknown `status_id`), a `400` is returned. + +Example response (`200`): +```json +[ + { + "sent_datetime": "2026-08-13 20:47:03 UTC", + "message_id": "23522be0975811f1a31f07808894dd38", + "status": 1, + "status_string": "sent", + "user": "alice@example.com" + }, + { + "sent_datetime": "2026-08-13 20:47:03 UTC", + "message_id": "23522be0975811f1a31f07808894dd38", + "status": 1, + "status_string": "sent", + "user": "bob@example.com" + } +] +``` + ## Usage: * For full documentation and for information on all of the endpoints and requirements visit: https://wickrinc.github.io/wickrio-docs/#web-interface-rest-api diff --git a/src/web_interface.ts b/src/web_interface.ts index 5a7a1ef..de8a97b 100644 --- a/src/web_interface.ts +++ b/src/web_interface.ts @@ -6,6 +6,7 @@ import fs from 'fs' import logger from './logger' import multer from 'multer' import path from 'node:path' +import { randomUUID } from 'node:crypto' import morgan from 'morgan' import { GroupConvoRequest, MessageBody, RoomRequest, Tokens, UserEntry } from './types' @@ -221,6 +222,19 @@ async function main(): Promise { } else if (!body.message && !body.attachment) { return res.send('Need a message OR an attachment to send a message.') } + + // Opt-in message status tracking via ?status=true + const wantsStatus = req.query.status === 'true' + if (wantsStatus && body.attachment) { + return res + .status(400) + .type('json') + .send({ + success: false, + error: { message: 'Message status tracking cannot be applied to attachments.' }, + }) + } + const ttl = body.ttl ? body.ttl.toString() : '' const bor = body.bor ? body.bor.toString() : '' const messagemeta = body.messagemeta ? JSON.stringify(body.messagemeta) : '' @@ -268,16 +282,23 @@ async function main(): Promise { } else { const message = body.message! try { + let messageID = '' + if (wantsStatus) { + messageID = await addMessageStatusId(users.join(','), message) + } const csm = await WickrIOAPI.cmdSend1to1Message( users, message, ttl, bor, - '', + messageID, [], messagemeta ) logger.info('1to1 message sent') + if (wantsStatus) { + return res.type('json').send({ success: true, status_id: messageID }) + } res.send(csm) } catch (err) { console.log(err) @@ -319,16 +340,23 @@ async function main(): Promise { } else { const message = body.message! try { + let messageID = '' + if (wantsStatus) { + messageID = await addMessageStatusId(vGroupID, message) + } const csrm = await WickrIOAPI.cmdSendRoomMessage( vGroupID, message, ttl, bor, - '', + messageID, [], messagemeta ) logger.info('Room message sent') + if (wantsStatus) { + return res.type('json').send({ success: true, status_id: messageID }) + } res.send(csrm) } catch (err) { console.log(err) @@ -736,6 +764,19 @@ async function main(): Promise { res.end() }) + app + .route([xapiEndpoint + '/MessageStatus/:status_id', endpoint + '/MessageStatus/:status_id']) + .get(async function (req: Request, res: Response) { + const statusId = req.params.status_id as string + try { + const status = await WickrIOAPI.cmdGetMessageStatus(statusId, 'full') + res.type('json').send(status) + } catch (err) { + console.log(err) + return res.status(400).type('txt').send('Failed to retrieve message status') + } + }) + app .route([xapiEndpoint + '/MsgRecvCallback', endpoint + '/MsgRecvCallback']) .post(async function (req: Request, res: Response) { @@ -795,6 +836,21 @@ async function main(): Promise { }) } +//Registers a unique status/tracking ID with the engine so the message's +//delivery status can later be queried via cmdGetMessageStatus. Returns the +//generated status ID, which is passed as the message ID to the send calls. +async function addMessageStatusId(target: string, message: string): Promise { + const statusId = randomUUID() + await WickrIOAPI.cmdAddMessageID( + statusId, + bot_username, + target, + new Date().toISOString(), + message + ) + return statusId +} + //Basic function to validate credentials for example function checkCreds(authToken: string): boolean { try { diff --git a/tests/messages.test.js b/tests/messages.test.js index 69bb284..a8ffb12 100644 --- a/tests/messages.test.js +++ b/tests/messages.test.js @@ -390,4 +390,170 @@ describe('Messages API Tests', () => { expect(mockWickrIOAPI.cmdSendRecallMessage).toHaveBeenCalledWith('room123', 'msg456') }) }) + + describe('POST /Messages - Status Tracking (?status=true)', () => { + const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + + it('should register a status ID and return it for a 1-to-1 message', async () => { + const response = await request(app) + .post('/WickrIO/V2/Apps/Messages?status=true') + .set('Authorization', authHeader) + .set('x-api-key', apiKey) + .send({ + users: [{ name: 'user1' }, { name: 'user2' }], + message: 'Tracked message', + }) + + expect(response.status).toBe(200) + expect(response.body.success).toBe(true) + expect(response.body.status_id).toMatch(UUID_REGEX) + + const statusId = response.body.status_id + + // cmdAddMessageID(messageID, sender, target, dateSent, message) + expect(mockWickrIOAPI.cmdAddMessageID).toHaveBeenCalledTimes(1) + const addArgs = mockWickrIOAPI.cmdAddMessageID.mock.calls[0] + expect(addArgs[0]).toBe(statusId) + expect(addArgs[1]).toBe('testbot') // bot username as sender + expect(addArgs[2]).toBe('user1,user2') // target stashes recipients + expect(typeof addArgs[3]).toBe('string') // dateSent + expect(addArgs[4]).toBe('Tracked message') + + // Status ID is passed as the 5th argument (messageID) to the send call + expect(mockWickrIOAPI.cmdSend1to1Message).toHaveBeenCalledWith( + ['user1', 'user2'], + 'Tracked message', + '', + '', + statusId, + [], + '' + ) + }) + + it('should register a status ID and return it for a room message', async () => { + const response = await request(app) + .post('/WickrIO/V2/Apps/Messages?status=true') + .set('Authorization', authHeader) + .set('x-api-key', apiKey) + .send({ + vgroupid: 'room123', + message: 'Tracked room message', + }) + + expect(response.status).toBe(200) + expect(response.body.success).toBe(true) + expect(response.body.status_id).toMatch(UUID_REGEX) + + const statusId = response.body.status_id + + const addArgs = mockWickrIOAPI.cmdAddMessageID.mock.calls[0] + expect(addArgs[0]).toBe(statusId) + expect(addArgs[1]).toBe('testbot') + expect(addArgs[2]).toBe('room123') // target stashes the vGroupID + expect(addArgs[4]).toBe('Tracked room message') + + expect(mockWickrIOAPI.cmdSendRoomMessage).toHaveBeenCalledWith( + 'room123', + 'Tracked room message', + '', + '', + statusId, + [], + '' + ) + }) + + it('should reject status tracking on an attachment with a 400 JSON error', async () => { + const response = await request(app) + .post('/WickrIO/V2/Apps/Messages?status=true') + .set('Authorization', authHeader) + .set('x-api-key', apiKey) + .send({ + users: [{ name: 'user1' }], + attachment: { + url: 'https://example.com/file.pdf', + displayname: 'document.pdf', + }, + }) + + expect(response.status).toBe(400) + expect(response.body.success).toBe(false) + expect(response.body.error.message).toContain('attachment') + expect(mockWickrIOAPI.cmdAddMessageID).not.toHaveBeenCalled() + expect(mockWickrIOAPI.cmdSend1to1Attachment).not.toHaveBeenCalled() + }) + + it('should not register a status ID when status is not requested', async () => { + const response = await request(app) + .post('/WickrIO/V2/Apps/Messages') + .set('Authorization', authHeader) + .set('x-api-key', apiKey) + .send({ + users: [{ name: 'user1' }], + message: 'Untracked message', + }) + + expect(response.status).toBe(200) + expect(mockWickrIOAPI.cmdAddMessageID).not.toHaveBeenCalled() + // 5th argument (messageID) remains empty + expect(mockWickrIOAPI.cmdSend1to1Message).toHaveBeenCalledWith( + ['user1'], + 'Untracked message', + '', + '', + '', + [], + '' + ) + }) + + it('should not treat status values other than "true" as opt-in', async () => { + const response = await request(app) + .post('/WickrIO/V2/Apps/Messages?status=1') + .set('Authorization', authHeader) + .set('x-api-key', apiKey) + .send({ + users: [{ name: 'user1' }], + message: 'Untracked message', + }) + + expect(response.status).toBe(200) + expect(mockWickrIOAPI.cmdAddMessageID).not.toHaveBeenCalled() + }) + }) + + describe('GET /MessageStatus/:status_id - Retrieve Message Status', () => { + it('should retrieve message status defaulting type to "full" (V1)', async () => { + const response = await request(app) + .get('/WickrIO/V1/Apps/test-api-key/MessageStatus/status-uuid-123') + .set('Authorization', authHeader) + + expect(response.status).toBe(200) + expect(response.body).toMatchObject(mockResponses.messageStatus) + expect(mockWickrIOAPI.cmdGetMessageStatus).toHaveBeenCalledWith('status-uuid-123', 'full') + }) + + it('should retrieve message status (V2)', async () => { + const response = await request(app) + .get('/WickrIO/V2/Apps/MessageStatus/status-uuid-123') + .set('Authorization', authHeader) + .set('x-api-key', apiKey) + + expect(response.status).toBe(200) + expect(mockWickrIOAPI.cmdGetMessageStatus).toHaveBeenCalledWith('status-uuid-123', 'full') + }) + + it('should return 400 when the status lookup fails', async () => { + mockWickrIOAPI.cmdGetMessageStatus.mockRejectedValueOnce(new Error('not found')) + + const response = await request(app) + .get('/WickrIO/V2/Apps/MessageStatus/does-not-exist') + .set('Authorization', authHeader) + .set('x-api-key', apiKey) + + expect(response.status).toBe(400) + expect(response.text).toContain('Failed to retrieve message status') + }) + }) }) diff --git a/tests/setup/mocks.js b/tests/setup/mocks.js index 07ac231..2669601 100644 --- a/tests/setup/mocks.js +++ b/tests/setup/mocks.js @@ -46,6 +46,14 @@ export const mockResponses = { directory: { users: [{ name: 'user1' }, { name: 'user2' }, { name: 'user3' }], }, + messageStatus: { + message_id: 'status-uuid-123', + type: 'full', + statuses: [ + { user: 'user1', status: 'delivered' }, + { user: 'user2', status: 'read' }, + ], + }, } // Create mock WickrIOAPI @@ -77,6 +85,8 @@ export const mockWickrIOAPI = { cmdGetMsgCallback: vi.fn().mockResolvedValue('http://example.com/callback'), cmdDeleteMsgCallback: vi.fn().mockResolvedValue(mockResponses.success), cmdGetDirectory: vi.fn().mockResolvedValue(JSON.stringify(mockResponses.directory)), + cmdAddMessageID: vi.fn().mockResolvedValue(mockResponses.success), + cmdGetMessageStatus: vi.fn().mockResolvedValue(JSON.stringify(mockResponses.messageStatus)), } // Mock logger