From bf1ffe5b0f6a74d0435d4e6eb5972199aaa764f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Jan 2026 20:36:55 +0000 Subject: [PATCH] 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 , /ban ) in future iterations. Tests: All 804 tests passing Build: Successful Lint: No errors --- src/components/ChatViewer.tsx | 30 +++++++++++++++ src/lib/chat/adapters/base-adapter.ts | 34 +++++++++++++++++ src/lib/chat/adapters/nip-29-adapter.ts | 50 +++++++++++++++++++++++++ src/lib/chat/slash-command-parser.ts | 39 +++++++++++++++++++ src/types/chat-actions.ts | 37 ++++++++++++++++++ 5 files changed, 190 insertions(+) create mode 100644 src/lib/chat/slash-command-parser.ts create mode 100644 src/types/chat-actions.ts diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index c5710ac..8fa4eee 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -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, { diff --git a/src/lib/chat/adapters/base-adapter.ts b/src/lib/chat/adapters/base-adapter.ts index badea44..b949061 100644 --- a/src/lib/chat/adapters/base-adapter.ts +++ b/src/lib/chat/adapters/base-adapter.ts @@ -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; + + /** + * 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 { + const action = this.getActions().find((a) => a.name === actionName); + + if (!action) { + return { + success: false, + message: `Unknown action: /${actionName}`, + }; + } + + return action.handler(context); + } } diff --git a/src/lib/chat/adapters/nip-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts index 1f1bc80..445a1d4 100644 --- a/src/lib/chat/adapters/nip-29-adapter.ts +++ b/src/lib/chat/adapters/nip-29-adapter.ts @@ -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 diff --git a/src/lib/chat/slash-command-parser.ts b/src/lib/chat/slash-command-parser.ts new file mode 100644 index 0000000..d11d2d0 --- /dev/null +++ b/src/lib/chat/slash-command-parser.ts @@ -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, + }; +} diff --git a/src/types/chat-actions.ts b/src/types/chat-actions.ts new file mode 100644 index 0000000..53a55b0 --- /dev/null +++ b/src/types/chat-actions.ts @@ -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; +}