Skip to content

Commit 335a18b

Browse files
authored
Merge pull request #385 from ably/feature/implement-get-message-command
[DX-1285] Implement `get-message` to retrieve latest version by serial
2 parents 8872beb + 65d2c0e commit 335a18b

7 files changed

Lines changed: 701 additions & 10 deletions

File tree

src/base-command.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1740,6 +1740,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
17401740
flags: BaseFlags,
17411741
component: string,
17421742
context?: Record<string, unknown>,
1743+
hint?: string,
17431744
): never {
17441745
// If error was already handled by a prior fail() call, re-throw it.
17451746
// This prevents double error output when fail() is called inside a try
@@ -1763,12 +1764,18 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
17631764
},
17641765
);
17651766

1766-
const friendlyHint = getFriendlyAblyErrorHint(
1767-
cmdError.code ??
1768-
(typeof cmdError.context.errorCode === "number"
1769-
? cmdError.context.errorCode
1770-
: undefined),
1771-
);
1767+
// A command-specific hint passed by the caller takes precedence over the
1768+
// global registry. Use this when an Ably error code is too generic to
1769+
// attach a universally-applicable hint (e.g. 40400 means different things
1770+
// in `channels get-message` vs `apps`/`keys` lookups).
1771+
const friendlyHint =
1772+
hint ??
1773+
getFriendlyAblyErrorHint(
1774+
cmdError.code ??
1775+
(typeof cmdError.context.errorCode === "number"
1776+
? cmdError.context.errorCode
1777+
: undefined),
1778+
);
17721779

17731780
if (this.shouldOutputJson(flags)) {
17741781
const jsonData = cmdError.toJsonData(friendlyHint);

src/commands/channels/annotations/get.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ export default class ChannelsAnnotationsGet extends AblyBaseCommand {
2626
}),
2727
};
2828

29-
static override description = "Get annotations for a channel message";
29+
static override description =
30+
"List individual annotation events published for a given channel message";
3031

3132
static override examples = [
3233
'$ ably channels annotations get my-channel "01234567890:0"',
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { Args, Flags } from "@oclif/core";
2+
import * as Ably from "ably";
3+
4+
import { AblyBaseCommand } from "../../base-command.js";
5+
import { CommandError } from "../../errors/command-error.js";
6+
import { productApiFlags } from "../../flags.js";
7+
import {
8+
formatMessageTimestamp,
9+
formatMessagesOutput,
10+
formatResource,
11+
} from "../../utils/output.js";
12+
import type { MessageDisplayFields } from "../../utils/output.js";
13+
14+
const MUTABLE_MESSAGES_HINT =
15+
"Ensure the channel has a `mutableMessages` rule enabled (run `ably apps rules list`) and that the message serial is correct.";
16+
17+
export default class ChannelsGetMessage extends AblyBaseCommand {
18+
static override args = {
19+
channelName: Args.string({
20+
description: "The channel name",
21+
required: true,
22+
}),
23+
messageSerial: Args.string({
24+
description: "The serial of the message to retrieve",
25+
required: true,
26+
}),
27+
};
28+
29+
static override description =
30+
"Get the latest version of a message on an Ably channel. Requires `mutableMessages` enabled on the channel rule.";
31+
32+
static override examples = [
33+
'$ ably channels get-message my-channel "01234567890:0"',
34+
'$ ably channels get-message my-channel "01234567890:0" --json',
35+
'$ ably channels get-message my-channel "01234567890:0" --pretty-json',
36+
'$ ably channels get-message my-channel "01234567890:0" --cipher YOUR_CIPHER_KEY',
37+
];
38+
39+
static override flags = {
40+
...productApiFlags,
41+
cipher: Flags.string({
42+
description:
43+
"Decryption key for encrypted messages (base64-encoded or hex-encoded, supports AES-128-CBC and AES-256-CBC)",
44+
}),
45+
};
46+
47+
async run(): Promise<void> {
48+
const { args, flags } = await this.parse(ChannelsGetMessage);
49+
const channelName = args.channelName;
50+
const serial = args.messageSerial;
51+
52+
try {
53+
const rest = await this.createAblyRestClient(flags);
54+
if (!rest) return;
55+
56+
const channelOptions: Ably.ChannelOptions = {};
57+
if (flags.cipher) {
58+
channelOptions.cipher = { key: flags.cipher };
59+
}
60+
61+
const channel = rest.channels.get(channelName, channelOptions);
62+
63+
this.logProgress(
64+
`Fetching message ${formatResource(serial)} on channel ${formatResource(channelName)}`,
65+
flags,
66+
);
67+
68+
const message = await channel.getMessage(serial);
69+
70+
const tracePayload = {
71+
id: message.id,
72+
timestamp: formatMessageTimestamp(message.timestamp),
73+
channel: channelName,
74+
event: message.name || undefined,
75+
clientId: message.clientId,
76+
connectionId: message.connectionId,
77+
data: message.data as unknown,
78+
encoding: message.encoding,
79+
extras: message.extras as unknown,
80+
action:
81+
message.action === undefined ? undefined : String(message.action),
82+
serial: message.serial,
83+
version: message.version,
84+
annotations: message.annotations,
85+
};
86+
this.logCliEvent(
87+
flags,
88+
"channelGetMessage",
89+
"messageRetrieved",
90+
`Retrieved message ${message.serial ?? serial} on channel ${channelName}`,
91+
tracePayload,
92+
);
93+
94+
if (this.shouldOutputJson(flags)) {
95+
this.logJsonResult(
96+
{
97+
message: {
98+
...message,
99+
// Stringify action for predictable JSON typing across commands
100+
// (matches `channels subscribe`'s explicit normalisation).
101+
action:
102+
message.action === undefined
103+
? undefined
104+
: String(message.action),
105+
// Nullish-aware: a legitimate epoch-zero timestamp must not be
106+
// dropped to undefined.
107+
timestamp:
108+
message.timestamp == null
109+
? undefined
110+
: new Date(message.timestamp).toISOString(),
111+
},
112+
},
113+
flags,
114+
);
115+
} else {
116+
const display: MessageDisplayFields = {
117+
action:
118+
message.action === undefined ? undefined : String(message.action),
119+
channel: channelName,
120+
clientId: message.clientId,
121+
data: message.data,
122+
event: message.name || undefined,
123+
id: message.id,
124+
serial: message.serial,
125+
timestamp: message.timestamp ?? Date.now(),
126+
version: message.version,
127+
annotations: message.annotations,
128+
};
129+
this.log(formatMessagesOutput([display]));
130+
}
131+
} catch (error) {
132+
const cmdError = CommandError.from(error);
133+
const hint = cmdError.code === 40400 ? MUTABLE_MESSAGES_HINT : undefined;
134+
this.fail(
135+
error,
136+
flags,
137+
"channelGetMessage",
138+
{ channel: channelName, serial },
139+
hint,
140+
);
141+
}
142+
}
143+
}

src/utils/output.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,16 +202,19 @@ export function formatMessagesOutput(messages: MessageDisplayFields[]): string {
202202
}
203203

204204
if (msg.annotations && Object.keys(msg.annotations.summary).length > 0) {
205-
lines.push(`${formatLabel("Annotations")}`);
205+
lines.push(
206+
`${formatLabel("Annotations")}`,
207+
` ${formatLabel("Summary")}`,
208+
);
206209
for (const [annotationType, value] of Object.entries(
207210
msg.annotations.summary,
208211
)) {
209212
const formattedValue = formatMessageData(value)
210213
.split("\n")
211-
.map((line) => ` ${line}`)
214+
.map((line) => ` ${line}`)
212215
.join("\n");
213216

214-
lines.push(` ${formatLabel(annotationType)}`, formattedValue);
217+
lines.push(` ${formatLabel(annotationType)}`, formattedValue);
215218
}
216219
}
217220

test/e2e/channels/channel-message-ops-e2e.test.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,164 @@ describe.skipIf(SHOULD_SKIP_E2E || SHOULD_SKIP_MUTABLE_TESTS)(
113113
},
114114
);
115115

116+
it(
117+
"should retrieve a message via channels get-message",
118+
{ timeout: 60000 },
119+
async () => {
120+
setupTestFailureHandler(
121+
"should retrieve a message via channels get-message",
122+
);
123+
124+
// Use a fresh channel/serial so we don't see updates from other tests
125+
const getChannel = getMutableChannelName("msg-get");
126+
const serial = await publishAndGetSerial(getChannel, "fresh-message");
127+
128+
const result = await runCommand(
129+
["channels", "get-message", getChannel, serial, "--json"],
130+
{
131+
env: { ABLY_API_KEY: E2E_API_KEY || "" },
132+
timeoutMs: 30000,
133+
},
134+
);
135+
136+
expect(result.exitCode).toBe(0);
137+
138+
const records = parseNdjsonLines(result.stdout);
139+
const parsed = records.find((r) => r.type === "result") ?? records[0];
140+
expect(parsed.success).toBe(true);
141+
expect(parsed.message).toBeDefined();
142+
const message = parsed.message as Record<string, unknown>;
143+
expect(message.serial).toBe(serial);
144+
expect(message.data).toBe("fresh-message");
145+
// Timestamp must be ISO 8601 (history-style normalisation)
146+
expect(message.timestamp).toMatch(
147+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
148+
);
149+
},
150+
);
151+
152+
it(
153+
"should return the latest version after an update via channels get-message",
154+
{ timeout: 60000 },
155+
async () => {
156+
setupTestFailureHandler(
157+
"should return the latest version after an update via channels get-message",
158+
);
159+
160+
// Publish, update, then verify get-message returns the updated payload
161+
const updateChannel = getMutableChannelName("msg-get-after-update");
162+
const serial = await publishAndGetSerial(updateChannel, "original");
163+
164+
const updateResult = await runCommand(
165+
[
166+
"channels",
167+
"update",
168+
updateChannel,
169+
serial,
170+
"edited-text",
171+
"--json",
172+
],
173+
{
174+
env: { ABLY_API_KEY: E2E_API_KEY || "" },
175+
timeoutMs: 30000,
176+
},
177+
);
178+
expect(updateResult.exitCode).toBe(0);
179+
180+
// Retry get-message — update is eventually consistent
181+
let latestMessage: Record<string, unknown> | undefined;
182+
for (let attempt = 0; attempt < 10; attempt++) {
183+
const getResult = await runCommand(
184+
["channels", "get-message", updateChannel, serial, "--json"],
185+
{
186+
env: { ABLY_API_KEY: E2E_API_KEY || "" },
187+
timeoutMs: 30000,
188+
},
189+
);
190+
if (getResult.exitCode === 0) {
191+
const records = parseNdjsonLines(getResult.stdout);
192+
const parsed =
193+
records.find((r) => r.type === "result") ?? records[0];
194+
latestMessage = parsed.message as
195+
| Record<string, unknown>
196+
| undefined;
197+
if (latestMessage?.data === "edited-text") break;
198+
}
199+
await new Promise((resolve) => setTimeout(resolve, 1000));
200+
}
201+
202+
expect(latestMessage).toBeDefined();
203+
expect(latestMessage!.data).toBe("edited-text");
204+
// The action must reflect that this is an update, not the original create
205+
expect(latestMessage!.action).toBe("message.update");
206+
// The version block must be populated and differ from the message serial
207+
expect(latestMessage!.version).toBeDefined();
208+
const version = latestMessage!.version as Record<string, unknown>;
209+
expect(version.serial).toBeDefined();
210+
expect(version.serial).not.toBe(serial);
211+
},
212+
);
213+
214+
it(
215+
"should render human-readable output without --json",
216+
{ timeout: 60000 },
217+
async () => {
218+
setupTestFailureHandler(
219+
"should render human-readable output without --json",
220+
);
221+
222+
const humanChannel = getMutableChannelName("msg-get-human");
223+
const serial = await publishAndGetSerial(humanChannel, "human-text");
224+
225+
const result = await runCommand(
226+
["channels", "get-message", humanChannel, serial],
227+
{
228+
env: { ABLY_API_KEY: E2E_API_KEY || "" },
229+
timeoutMs: 30000,
230+
},
231+
);
232+
233+
expect(result.exitCode).toBe(0);
234+
// Field labels rendered by formatMessagesOutput must appear
235+
expect(result.stdout).toContain("Channel");
236+
expect(result.stdout).toContain("Serial");
237+
expect(result.stdout).toContain(serial);
238+
expect(result.stdout).toContain("Data");
239+
expect(result.stdout).toContain("human-text");
240+
},
241+
);
242+
243+
it(
244+
"should fail with a non-zero exit code for an unknown serial",
245+
{ timeout: 60000 },
246+
async () => {
247+
setupTestFailureHandler(
248+
"should fail with a non-zero exit code for an unknown serial",
249+
);
250+
251+
const result = await runCommand(
252+
[
253+
"channels",
254+
"get-message",
255+
channelName,
256+
"0000000000-000@deadbeef:000",
257+
"--json",
258+
],
259+
{
260+
env: { ABLY_API_KEY: E2E_API_KEY || "" },
261+
timeoutMs: 30000,
262+
},
263+
);
264+
265+
expect(result.exitCode).not.toBe(0);
266+
267+
const records = parseNdjsonLines(result.stdout);
268+
const errorRecord = records.find((r) => r.type === "error");
269+
expect(errorRecord).toBeDefined();
270+
expect(errorRecord!.success).toBe(false);
271+
},
272+
);
273+
116274
it(
117275
"should delete a message via channels delete",
118276
{ timeout: 60000 },

0 commit comments

Comments
 (0)