Skip to content
Draft
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
1,279 changes: 1,279 additions & 0 deletions ANNOTATION_IMPL_V2.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion docs/Project-Structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ This document outlines the directory structure of the Ably CLI project.
│ │ ├── auth/ # Authentication (keys, tokens)
│ │ ├── bench/ # Benchmarking (publisher, subscriber)
│ │ ├── channel-rule/ # Channel rules / namespaces
│ │ ├── channels/ # Pub/Sub channels (publish, subscribe, presence, history, etc.)
│ │ ├── channels/ # Pub/Sub channels (publish, subscribe, presence, history, annotations, etc.)
│ │ ├── config/ # CLI config management (show, path)
│ │ ├── connections/ # Client connections (test)
│ │ ├── integrations/ # Integration rules
Expand Down Expand Up @@ -69,6 +69,7 @@ This document outlines the directory structure of the Ably CLI project.
│ ├── types/
│ │ └── cli.ts # General CLI type definitions
│ └── utils/
│ ├── annotations.ts # Annotation type validation utilities
│ ├── channel-rule-display.ts # Channel rule human-readable display
│ ├── chat-constants.ts # Shared Chat SDK constants (REACTION_TYPE_MAP)
│ ├── errors.ts # Error utilities (errorMessage)
Expand Down
18 changes: 12 additions & 6 deletions src/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "./utils/long-running.js";
import isTestMode from "./utils/test-mode.js";
import isWebCliMode from "./utils/web-mode.js";
import { enhanceErrorMessage, errorMessage } from "./utils/errors.js";

// List of commands not allowed in web CLI mode - EXPORTED
export const WEB_CLI_RESTRICTED_COMMANDS = [
Expand Down Expand Up @@ -805,7 +806,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
} catch (error) {
// Fallback to regular JSON.stringify
this.debug(
`Error using color-json: ${error instanceof Error ? error.message : String(error)}. Falling back to regular JSON.`,
`Error using color-json: ${errorMessage(error)}. Falling back to regular JSON.`,
);
return JSON.stringify(data, null, 2);
}
Expand Down Expand Up @@ -1446,7 +1447,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
try {
return JSON.parse(value.trim());
} catch (error) {
const errorMsg = `Invalid ${flagName} JSON: ${error instanceof Error ? error.message : String(error)}`;
const errorMsg = `Invalid ${flagName} JSON: ${errorMessage(error)}`;
if (this.shouldOutputJson(flags)) {
this.jsonError({ error: errorMsg, success: false }, flags);
} else {
Expand All @@ -1471,13 +1472,18 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
component: string,
context?: Record<string, unknown>,
): void {
const errorMsg = error instanceof Error ? error.message : String(error);
this.logCliEvent(flags, component, "fatalError", `Error: ${errorMsg}`, {
error: errorMsg,
const baseErrorMsg = errorMessage(error);
// Enhance error message with CLI-specific hints for known Ably error codes
const errorMsg = enhanceErrorMessage(error, baseErrorMsg);
this.logCliEvent(flags, component, "fatalError", `Error: ${baseErrorMsg}`, {
error: baseErrorMsg,
...context,
});
if (this.shouldOutputJson(flags)) {
this.jsonError({ error: errorMsg, success: false, ...context }, flags);
this.jsonError(
{ error: baseErrorMsg, success: false, ...context },
flags,
);
} else {
this.error(errorMsg);
}
Expand Down
15 changes: 15 additions & 0 deletions src/commands/channels/annotations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { BaseTopicCommand } from "../../base-topic-command.js";

export default class ChannelsAnnotations extends BaseTopicCommand {
protected topicName = "channels:annotations";
protected commandGroup = "channel annotations";

static override description = "Manage annotations on Ably channel messages";

static override examples = [
"$ ably channels annotations publish my-channel msg-serial-123 reactions:flag.v1",
"$ ably channels annotations delete my-channel msg-serial-123 reactions:flag.v1",
"$ ably channels annotations get my-channel msg-serial-123",
"$ ably channels annotations subscribe my-channel",
];
}
103 changes: 103 additions & 0 deletions src/commands/channels/annotations/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Args, Flags } from "@oclif/core";
import * as Ably from "ably";

import { AblyBaseCommand } from "../../../base-command.js";
import { clientIdFlag, productApiFlags } from "../../../flags.js";
import {
extractSummarizationType,
validateAnnotationParams,
} from "../../../utils/annotations.js";
import { resource, success } from "../../../utils/output.js";

export default class ChannelsAnnotationsDelete extends AblyBaseCommand {
static override description = "Delete an annotation from a message";

static override examples = [
"$ ably channels annotations delete my-channel msg-serial-123 reactions:flag.v1",
"$ ably channels annotations delete my-channel msg-serial-123 reactions:distinct.v1 --name thumbsup",
"$ ably channels annotations delete my-channel msg-serial-123 reactions:flag.v1 --json",
];

static override args = {
channel: Args.string({ description: "Channel name", required: true }),
msgSerial: Args.string({
description: "Message serial of the annotated message",
required: true,
}),
annotationType: Args.string({
description: "Annotation type (e.g., reactions:flag.v1)",
required: true,
}),
};

static override flags = {
...productApiFlags,
...clientIdFlag,
name: Flags.string({
description:
"Annotation name (required for distinct/unique/multiple types)",
}),
data: Flags.string({ description: "Optional data payload (JSON string)" }),
};

async run(): Promise<void> {
const { args, flags } = await this.parse(ChannelsAnnotationsDelete);

try {
// 1. Validate (same as publish, but count not needed for delete)
const summarization = extractSummarizationType(args.annotationType);
const errors = validateAnnotationParams(summarization, {
name: flags.name,
isDelete: true,
});
if (errors.length > 0) {
this.error(errors.join("\n"));
}

// 2. Build OutboundAnnotation
const annotation: Ably.OutboundAnnotation = {
type: args.annotationType,
};
if (flags.name) annotation.name = flags.name;
if (flags.data) {
const parsed = this.parseJsonFlag(flags.data, "--data", flags);
if (!parsed) return;
annotation.data = parsed;
}

// 3. Create client and delete
const client = await this.createAblyRealtimeClient(flags);
if (!client) return;

const channel = client.channels.get(args.channel);
await channel.annotations.delete(args.msgSerial, annotation);

// 4. Output success
if (this.shouldOutputJson(flags)) {
this.log(
this.formatJsonOutput(
{
success: true,
channel: args.channel,
messageSerial: args.msgSerial,
annotationType: args.annotationType,
name: flags.name || null,
},
flags,
),
);
} else {
this.log(
success(`Annotation deleted from channel ${resource(args.channel)}.`),
);
}

client.close();
} catch (error) {
this.handleCommandError(error, flags, "annotations:delete", {
channel: args.channel,
messageSerial: args.msgSerial,
});
}
}
}
130 changes: 130 additions & 0 deletions src/commands/channels/annotations/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { Args, Flags } from "@oclif/core";
import * as Ably from "ably";
import chalk from "chalk";

import { AblyBaseCommand } from "../../../base-command.js";
import { productApiFlags } from "../../../flags.js";
import {
formatMessageTimestamp,
formatTimestamp,
limitWarning,
resource,
} from "../../../utils/output.js";

export default class ChannelsAnnotationsGet extends AblyBaseCommand {
static override description = "Get annotations for a message";

static override examples = [
"$ ably channels annotations get my-channel msg-serial-123",
"$ ably channels annotations get my-channel msg-serial-123 --limit 50",
"$ ably channels annotations get my-channel msg-serial-123 --json",
];

static override args = {
channel: Args.string({ description: "Channel name", required: true }),
msgSerial: Args.string({
description: "Message serial to get annotations for",
required: true,
}),
};

static override flags = {
...productApiFlags,
limit: Flags.integer({
default: 100,
description: "Maximum number of results to return (default: 100)",
}),
};

async run(): Promise<void> {
const { args, flags } = await this.parse(ChannelsAnnotationsGet);

try {
// 1. Create REST client (get is a REST operation)
const client = await this.createAblyRestClient(flags);
if (!client) return;

// 2. Get channel and fetch annotations
const channel = client.channels.get(args.channel);
const params: Ably.GetAnnotationsParams = {};
if (flags.limit !== undefined) {
params.limit = flags.limit;
}

const result = await channel.annotations.get(args.msgSerial, params);
const annotations = result.items;

// 3. Output results
if (this.shouldOutputJson(flags)) {
this.log(
this.formatJsonOutput(
annotations.map((annotation) => ({
id: annotation.id,
action: annotation.action,
type: annotation.type,
name: annotation.name || null,
clientId: annotation.clientId || null,
count: annotation.count ?? null,
data: annotation.data ?? null,
messageSerial: annotation.messageSerial,
serial: annotation.serial,
timestamp: annotation.timestamp,
})),
flags,
),
);
} else {
if (annotations.length === 0) {
this.log(
`No annotations found for message ${resource(args.msgSerial)} on channel ${resource(args.channel)}.`,
);
return;
}

this.log(
`Annotations for message ${resource(args.msgSerial)} on channel ${resource(args.channel)}:\n`,
);

for (const [index, annotation] of annotations.entries()) {
const timestamp = formatMessageTimestamp(annotation.timestamp);
this.log(
`${chalk.dim(`[${index + 1}]`)} ${formatTimestamp(timestamp)}`,
);
this.log(
` ${chalk.dim("Action:")} ${annotation.action === "annotation.create" ? "ANNOTATION.CREATE" : "ANNOTATION.DELETE"}`,
);
this.log(` ${chalk.dim("Type:")} ${annotation.type}`);
this.log(` ${chalk.dim("Name:")} ${annotation.name || "(none)"}`);
this.log(
` ${chalk.dim("Client ID:")} ${annotation.clientId ? chalk.blue(annotation.clientId) : "(none)"}`,
);
this.log(
` ${chalk.dim("Message Serial:")} ${annotation.messageSerial}`,
);
this.log(` ${chalk.dim("Timestamp:")} ${annotation.timestamp}`);
if (annotation.count !== undefined) {
this.log(` ${chalk.dim("Count:")} ${annotation.count}`);
}
if (annotation.data) {
this.log(
` ${chalk.dim("Data:")} ${JSON.stringify(annotation.data)}`,
);
}
this.log(""); // Blank line between annotations
}

const warning = limitWarning(
annotations.length,
flags.limit,
"annotations",
);
if (warning) this.log(warning);
}
} catch (error) {
this.handleCommandError(error, flags, "annotations:get", {
channel: args.channel,
messageSerial: args.msgSerial,
});
}
}
}
Loading