feat: add protocol-specific actions to chat adapters

Extends the chat adapter system with support for slash commands and
protocol-specific actions without parameters.

New features:
- ChatAction type system for defining simple commands
- Base adapter getActions() and executeAction() methods
- NIP-29 /join and /leave slash commands
- Slash command parser for detecting /commands
- ChatViewer integration with toast notifications

Example usage in NIP-29 groups:
  /join  - Request to join the group
  /leave - Leave the group

The action system is extensible and can be enhanced with parameterized
actions (e.g., /kick <user>, /ban <user>) in future iterations.

Tests: All 804 tests passing
Build: Successful
Lint: No errors
This commit is contained in:
Claude
2026-01-12 20:36:55 +00:00
parent 9657ec635f
commit bf1ffe5b0f
5 changed files with 190 additions and 0 deletions

View File

@@ -18,6 +18,7 @@ import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter";
import { Nip53Adapter } from "@/lib/chat/adapters/nip-53-adapter";
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
import type { Message } from "@/types/chat";
import { parseSlashCommand } from "@/lib/chat/slash-command-parser";
import { UserName } from "./nostr/UserName";
import { RichText } from "./nostr/RichText";
import Timestamp from "./Timestamp";
@@ -437,6 +438,35 @@ export function ChatViewer({
) => {
if (!conversation || !hasActiveAccount || isSending) return;
// Check if this is a slash command
const slashCmd = parseSlashCommand(content);
if (slashCmd) {
// Execute action instead of sending message
setIsSending(true);
try {
const result = await adapter.executeAction(slashCmd.command, {
activePubkey: activeAccount.pubkey,
activeSigner: activeAccount.signer,
conversation,
});
if (result.success) {
toast.success(result.message || "Action completed");
} else {
toast.error(result.message || "Action failed");
}
} catch (error) {
console.error("[Chat] Failed to execute action:", error);
const errorMessage =
error instanceof Error ? error.message : "Action failed";
toast.error(errorMessage);
} finally {
setIsSending(false);
}
return;
}
// Regular message sending
setIsSending(true);
try {
await adapter.sendMessage(conversation, content, {

View File

@@ -10,6 +10,11 @@ import type {
CreateConversationParams,
} from "@/types/chat";
import type { NostrEvent } from "@/types/nostr";
import type {
ChatAction,
ChatActionContext,
ChatActionResult,
} from "@/types/chat-actions";
/**
* Options for sending a message
@@ -141,4 +146,33 @@ export abstract class ChatProtocolAdapter {
* Optional - only for protocols with leave semantics (groups)
*/
leaveConversation?(conversation: Conversation): Promise<void>;
/**
* Get available actions for this protocol
* Actions are protocol-specific slash commands like /join, /leave, etc.
* Returns empty array by default
*/
getActions(): ChatAction[] {
return [];
}
/**
* Execute a chat action by name
* Returns error if action not found
*/
async executeAction(
actionName: string,
context: ChatActionContext,
): Promise<ChatActionResult> {
const action = this.getActions().find((a) => a.name === actionName);
if (!action) {
return {
success: false,
message: `Unknown action: /${actionName}`,
};
}
return action.handler(context);
}
}

View File

@@ -13,6 +13,7 @@ import type {
ParticipantRole,
} from "@/types/chat";
import type { NostrEvent } from "@/types/nostr";
import type { ChatAction } from "@/types/chat-actions";
import eventStore from "@/services/event-store";
import pool from "@/services/relay-pool";
import { publishEventToRelays } from "@/services/hub";
@@ -490,6 +491,55 @@ export class Nip29Adapter extends ChatProtocolAdapter {
};
}
/**
* Get available actions for NIP-29 groups
* Returns simple join/leave commands without parameters
*/
getActions(): ChatAction[] {
return [
{
name: "join",
description: "Request to join the group",
handler: async (context) => {
try {
await this.joinConversation(context.conversation);
return {
success: true,
message: "Join request sent",
};
} catch (error) {
return {
success: false,
message:
error instanceof Error ? error.message : "Failed to join group",
};
}
},
},
{
name: "leave",
description: "Leave the group",
handler: async (context) => {
try {
await this.leaveConversation(context.conversation);
return {
success: true,
message: "You left the group",
};
} catch (error) {
return {
success: false,
message:
error instanceof Error
? error.message
: "Failed to leave group",
};
}
},
},
];
}
/**
* Load a replied-to message
* First checks EventStore, then fetches from group relay if needed

View File

@@ -0,0 +1,39 @@
/**
* Parsed slash command result
*/
export interface ParsedSlashCommand {
/** Command name (without the leading slash) */
command: string;
}
/**
* Parse a slash command from message text
* Returns null if text is not a slash command
*
* Examples:
* "/join" -> { command: "join" }
* "/leave" -> { command: "leave" }
* "hello" -> null
* "not a /command" -> null
*/
export function parseSlashCommand(text: string): ParsedSlashCommand | null {
// Trim whitespace
const trimmed = text.trim();
// Must start with slash
if (!trimmed.startsWith("/")) {
return null;
}
// Extract command (everything after the slash)
const command = trimmed.slice(1).trim();
// Must have a command name
if (!command) {
return null;
}
return {
command,
};
}

37
src/types/chat-actions.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { Conversation } from "./chat";
/**
* Context passed to action handlers
*/
export interface ChatActionContext {
/** Active user's pubkey */
activePubkey: string;
/** Active user's signer */
activeSigner: any;
/** Conversation being acted upon */
conversation: Conversation;
}
/**
* Result from executing an action
*/
export interface ChatActionResult {
success: boolean;
message?: string;
}
/**
* Simple chat action without parameters
*/
export interface ChatAction {
/** Command name (e.g., "join", "leave") */
name: string;
/** Human-readable description */
description: string;
/** Handler function */
handler: (context: ChatActionContext) => Promise<ChatActionResult>;
}