Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://\<host\>:\<port\>/WickrIO/V1/Apps/\<API Key\>/Messages
Get Message Status | GET | https://\<host\>:\<port\>/WickrIO/V1/Apps/\<API Key\>/MessageStatus/\<status_id\>
Set Message URL Callback | POST | https://\<host\>:\<port\>/WickrIO/V1/Apps/\<API Key\>/MsgRecvCallback?callbackurl=\<url\>
Get Message URL Callback | GET | https://\<host\>:\<port\>/WickrIO/V1/Apps/\<API Key\>/MsgRecvCallback
Delete Message URL Callback | DELETE | https://\<host\>:\<port\>/WickrIO/V1/Apps/\<API Key\>/MsgRecvCallback
Expand All @@ -64,6 +65,71 @@ Get Directory | GET | https://\<host\>:\<port\>/WickrIO/V1/Apps/\<API Key\>/Dire

The \<API Key\> 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://<host>:<port>/WickrIO/V1/Apps/<API Key>/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://<host>:<port>/WickrIO/V1/Apps/<API Key>/MessageStatus/<status_id>
```

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

Expand Down
60 changes: 58 additions & 2 deletions src/web_interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -221,6 +222,19 @@ async function main(): Promise<void> {
} 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) : ''
Expand Down Expand Up @@ -268,16 +282,23 @@ async function main(): Promise<void> {
} 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)
Expand Down Expand Up @@ -319,16 +340,23 @@ async function main(): Promise<void> {
} 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)
Expand Down Expand Up @@ -736,6 +764,19 @@ async function main(): Promise<void> {
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) {
Expand Down Expand Up @@ -795,6 +836,21 @@ async function main(): Promise<void> {
})
}

//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<string> {
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 {
Expand Down
166 changes: 166 additions & 0 deletions tests/messages.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
})
10 changes: 10 additions & 0 deletions tests/setup/mocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading