mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 15:07:10 +02:00
Add actions support to chat adapter protocol (#67)
* 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 * feat: add autocomplete for slash commands Extends the chat system with autocomplete UI for slash commands, making actions discoverable and easier to execute. New features: - SlashCommandSuggestionList component with keyboard navigation - Slash command search integrated into MentionEditor - Autocomplete popup shows command name and description - Arrow keys for navigation, Enter to select, Escape to close - Filters available commands based on typed text When typing "/" in chat, users now see: /join Request to join the group /leave Leave the group The autocomplete uses the same TipTap suggestion system as @mentions and :emoji:, providing a consistent UX across all autocomplete features. Tests: All 804 tests passing Build: Successful Lint: 1 warning fixed (unused parameter) * feat: execute slash commands immediately on autocomplete selection When selecting a slash command from the autocomplete menu, the action now executes immediately and clears the input, providing a more streamlined UX. Changes: - Added onCommandExecute prop to MentionEditor - Modified slash command suggestion to call onCommandExecute on selection - Clears editor content immediately after selection - Added handleCommandExecute callback in ChatViewer - Executes action and shows toast notifications Before: Type "/" → select "/join" → press Enter → executes After: Type "/" → select "/join" → executes immediately This matches the expected behavior for command selection - when you choose a command from autocomplete, you want to execute it, not just insert it into the text field. Tests: All 804 tests passing Build: Successful Lint: No new errors * feat: make chat actions context-aware and filter by membership status Actions are now intelligently filtered based on the user's membership in the conversation, providing a cleaner and more intuitive UX. Changes: - Added GetActionsOptions type with conversation and activePubkey - Updated base adapter getActions() to accept optional context - Modified NIP-29 adapter to filter actions based on membership: - /join: only shown when user is NOT a member/admin - /leave: only shown when user IS a member - Updated ChatViewer to pass conversation and user context to searchCommands - Moved searchCommands callback after conversation is defined This prevents showing irrelevant commands like "/join" when you're already a member, or "/leave" when you haven't joined yet. The autocomplete menu now only displays actions that are actually executable in the current context. Implementation notes: - NIP-29 uses getAllActions() fallback when context unavailable - Membership determined by checking conversation.participants array - Other protocols return empty array by default (no actions yet) Tests: All 804 tests passing Build: Successful Lint: No new errors --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,8 @@ 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 type { ChatAction } from "@/types/chat-actions";
|
||||
import { parseSlashCommand } from "@/lib/chat/slash-command-parser";
|
||||
import { UserName } from "./nostr/UserName";
|
||||
import { RichText } from "./nostr/RichText";
|
||||
import Timestamp from "./Timestamp";
|
||||
@@ -363,6 +365,22 @@ export function ChatViewer({
|
||||
? conversationResult.conversation
|
||||
: null;
|
||||
|
||||
// Slash command search for action autocomplete
|
||||
// Context-aware: only shows relevant actions based on membership status
|
||||
const searchCommands = useCallback(
|
||||
async (query: string) => {
|
||||
const availableActions = adapter.getActions({
|
||||
conversation: conversation || undefined,
|
||||
activePubkey: activeAccount?.pubkey,
|
||||
});
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return availableActions.filter((action) =>
|
||||
action.name.toLowerCase().includes(lowerQuery),
|
||||
);
|
||||
},
|
||||
[adapter, conversation, activeAccount],
|
||||
);
|
||||
|
||||
// Cleanup subscriptions when conversation changes or component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -437,6 +455,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, {
|
||||
@@ -455,6 +502,36 @@ export function ChatViewer({
|
||||
}
|
||||
};
|
||||
|
||||
// Handle command execution from autocomplete
|
||||
const handleCommandExecute = useCallback(
|
||||
async (action: ChatAction) => {
|
||||
if (!conversation || !hasActiveAccount || isSending) return;
|
||||
|
||||
setIsSending(true);
|
||||
try {
|
||||
const result = await adapter.executeAction(action.name, {
|
||||
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);
|
||||
}
|
||||
},
|
||||
[conversation, hasActiveAccount, isSending, adapter, activeAccount],
|
||||
);
|
||||
|
||||
// Handle reply button click
|
||||
const handleReply = useCallback((messageId: string) => {
|
||||
setReplyTo(messageId);
|
||||
@@ -770,6 +847,8 @@ export function ChatViewer({
|
||||
placeholder="Type a message..."
|
||||
searchProfiles={searchProfiles}
|
||||
searchEmojis={searchEmojis}
|
||||
searchCommands={searchCommands}
|
||||
onCommandExecute={handleCommandExecute}
|
||||
onSubmit={(content, emojiTags) => {
|
||||
if (content.trim()) {
|
||||
handleSend(content, replyTo, emojiTags);
|
||||
|
||||
@@ -23,8 +23,13 @@ import {
|
||||
EmojiSuggestionList,
|
||||
type EmojiSuggestionListHandle,
|
||||
} from "./EmojiSuggestionList";
|
||||
import {
|
||||
SlashCommandSuggestionList,
|
||||
type SlashCommandSuggestionListHandle,
|
||||
} from "./SlashCommandSuggestionList";
|
||||
import type { ProfileSearchResult } from "@/services/profile-search";
|
||||
import type { EmojiSearchResult } from "@/services/emoji-search";
|
||||
import type { ChatAction } from "@/types/chat-actions";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
/**
|
||||
@@ -50,6 +55,8 @@ export interface MentionEditorProps {
|
||||
onSubmit?: (content: string, emojiTags: EmojiTag[]) => void;
|
||||
searchProfiles: (query: string) => Promise<ProfileSearchResult[]>;
|
||||
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
|
||||
searchCommands?: (query: string) => Promise<ChatAction[]>;
|
||||
onCommandExecute?: (action: ChatAction) => Promise<void>;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -152,6 +159,8 @@ export const MentionEditor = forwardRef<
|
||||
onSubmit,
|
||||
searchProfiles,
|
||||
searchEmojis,
|
||||
searchCommands,
|
||||
onCommandExecute,
|
||||
autoFocus = false,
|
||||
className = "",
|
||||
},
|
||||
@@ -335,6 +344,101 @@ export const MentionEditor = forwardRef<
|
||||
[searchEmojis],
|
||||
);
|
||||
|
||||
// Create slash command suggestion configuration for / commands
|
||||
const slashCommandSuggestion: Omit<SuggestionOptions, "editor"> | null =
|
||||
useMemo(
|
||||
() =>
|
||||
searchCommands
|
||||
? {
|
||||
char: "/",
|
||||
allowSpaces: false,
|
||||
items: async ({ query }) => {
|
||||
return await searchCommands(query);
|
||||
},
|
||||
render: () => {
|
||||
let component: ReactRenderer<SlashCommandSuggestionListHandle>;
|
||||
let popup: TippyInstance[];
|
||||
let editorRef: any;
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
editorRef = props.editor;
|
||||
component = new ReactRenderer(
|
||||
SlashCommandSuggestionList,
|
||||
{
|
||||
props: {
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
onClose: () => {
|
||||
popup[0]?.hide();
|
||||
},
|
||||
},
|
||||
editor: props.editor,
|
||||
},
|
||||
);
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect:
|
||||
props.clientRect as () => DOMRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "top-start",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps({
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup[0]?.setProps({
|
||||
getReferenceClientRect:
|
||||
props.clientRect as () => DOMRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup[0]?.hide();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+Enter submits the message
|
||||
if (
|
||||
props.event.key === "Enter" &&
|
||||
(props.event.ctrlKey || props.event.metaKey)
|
||||
) {
|
||||
popup[0]?.hide();
|
||||
handleSubmitRef.current(editorRef);
|
||||
return true;
|
||||
}
|
||||
|
||||
return component.ref?.onKeyDown(props.event) ?? false;
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0]?.destroy();
|
||||
component.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
: null,
|
||||
[searchCommands],
|
||||
);
|
||||
|
||||
// Helper function to serialize editor content with mentions and emojis
|
||||
const serializeContent = useCallback(
|
||||
(editorInstance: any): SerializedContent => {
|
||||
@@ -503,8 +607,49 @@ export const MentionEditor = forwardRef<
|
||||
);
|
||||
}
|
||||
|
||||
// Add slash command extension if search is provided
|
||||
if (slashCommandSuggestion) {
|
||||
const SlashCommand = Mention.extend({
|
||||
name: "slashCommand",
|
||||
});
|
||||
|
||||
exts.push(
|
||||
SlashCommand.configure({
|
||||
HTMLAttributes: {
|
||||
class: "slash-command",
|
||||
},
|
||||
suggestion: {
|
||||
...slashCommandSuggestion,
|
||||
command: ({ editor, props }: any) => {
|
||||
// props is the ChatAction
|
||||
// Execute the command immediately and clear the editor
|
||||
editor.commands.clearContent();
|
||||
if (onCommandExecute) {
|
||||
// Execute action asynchronously
|
||||
onCommandExecute(props).catch((error) => {
|
||||
console.error(
|
||||
"[MentionEditor] Command execution failed:",
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
renderLabel({ node }) {
|
||||
return `/${node.attrs.label}`;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return exts;
|
||||
}, [mentionSuggestion, emojiSuggestion, placeholder]);
|
||||
}, [
|
||||
mentionSuggestion,
|
||||
emojiSuggestion,
|
||||
slashCommandSuggestion,
|
||||
onCommandExecute,
|
||||
placeholder,
|
||||
]);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
|
||||
112
src/components/editor/SlashCommandSuggestionList.tsx
Normal file
112
src/components/editor/SlashCommandSuggestionList.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { ChatAction } from "@/types/chat-actions";
|
||||
import { Terminal } from "lucide-react";
|
||||
|
||||
export interface SlashCommandSuggestionListProps {
|
||||
items: ChatAction[];
|
||||
command: (item: ChatAction) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export interface SlashCommandSuggestionListHandle {
|
||||
onKeyDown: (event: KeyboardEvent) => boolean;
|
||||
}
|
||||
|
||||
export const SlashCommandSuggestionList = forwardRef<
|
||||
SlashCommandSuggestionListHandle,
|
||||
SlashCommandSuggestionListProps
|
||||
>(({ items, command, onClose }, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keyboard navigation
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: (event: KeyboardEvent) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
setSelectedIndex((prev) => (prev + items.length - 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
setSelectedIndex((prev) => (prev + 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && !event.ctrlKey && !event.metaKey) {
|
||||
if (items[selectedIndex]) {
|
||||
command(items[selectedIndex]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
onClose?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
const selectedElement = listRef.current?.children[selectedIndex];
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Reset selected index when items change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="border border-border/50 bg-popover p-4 text-sm text-muted-foreground shadow-md">
|
||||
No commands available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
className="max-h-[300px] w-[320px] overflow-y-auto border border-border/50 bg-popover shadow-md"
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.name}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
onClick={() => command(item)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`flex w-full items-center gap-3 px-3 py-2 text-left transition-colors ${
|
||||
index === selectedIndex ? "bg-muted/60" : "hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<Terminal className="size-4 flex-shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium font-mono">
|
||||
/{item.name}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SlashCommandSuggestionList.displayName = "SlashCommandSuggestionList";
|
||||
@@ -10,6 +10,12 @@ import type {
|
||||
CreateConversationParams,
|
||||
} from "@/types/chat";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import type {
|
||||
ChatAction,
|
||||
ChatActionContext,
|
||||
ChatActionResult,
|
||||
GetActionsOptions,
|
||||
} from "@/types/chat-actions";
|
||||
|
||||
/**
|
||||
* Options for sending a message
|
||||
@@ -141,4 +147,38 @@ 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.
|
||||
* Can be filtered based on conversation and user context
|
||||
* Returns empty array by default
|
||||
*/
|
||||
getActions(_options?: GetActionsOptions): ChatAction[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a chat action by name
|
||||
* Returns error if action not found
|
||||
*/
|
||||
async executeAction(
|
||||
actionName: string,
|
||||
context: ChatActionContext,
|
||||
): Promise<ChatActionResult> {
|
||||
// Get actions with context for validation
|
||||
const action = this.getActions({
|
||||
conversation: context.conversation,
|
||||
activePubkey: context.activePubkey,
|
||||
}).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, GetActionsOptions } from "@/types/chat-actions";
|
||||
import eventStore from "@/services/event-store";
|
||||
import pool from "@/services/relay-pool";
|
||||
import { publishEventToRelays } from "@/services/hub";
|
||||
@@ -490,6 +491,130 @@ export class Nip29Adapter extends ChatProtocolAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available actions for NIP-29 groups
|
||||
* Filters actions based on user's membership status:
|
||||
* - /join: only shown when user is NOT a member/admin
|
||||
* - /leave: only shown when user IS a member
|
||||
*/
|
||||
getActions(options?: GetActionsOptions): ChatAction[] {
|
||||
const actions: ChatAction[] = [];
|
||||
|
||||
// Check if we have context to filter actions
|
||||
if (!options?.conversation || !options?.activePubkey) {
|
||||
// No context - return all actions
|
||||
return this.getAllActions();
|
||||
}
|
||||
|
||||
const { conversation, activePubkey } = options;
|
||||
|
||||
// Find user's participant info
|
||||
const userParticipant = conversation.participants.find(
|
||||
(p) => p.pubkey === activePubkey,
|
||||
);
|
||||
|
||||
const isMember = !!userParticipant;
|
||||
|
||||
// Add /join if user is NOT a member
|
||||
if (!isMember) {
|
||||
actions.push({
|
||||
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",
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add /leave if user IS a member
|
||||
if (isMember) {
|
||||
actions.push({
|
||||
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",
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all possible actions (used when no context available)
|
||||
* @private
|
||||
*/
|
||||
private getAllActions(): 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,
|
||||
};
|
||||
}
|
||||
48
src/types/chat-actions.ts
Normal file
48
src/types/chat-actions.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
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>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for filtering available actions
|
||||
*/
|
||||
export interface GetActionsOptions {
|
||||
/** Current conversation */
|
||||
conversation?: Conversation;
|
||||
|
||||
/** Active user's pubkey */
|
||||
activePubkey?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user