mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-13 00:46:54 +02:00
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:
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
39
src/lib/chat/slash-command-parser.ts
Normal file
39
src/lib/chat/slash-command-parser.ts
Normal 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
37
src/types/chat-actions.ts
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user