diff --git a/cmd/server/main.go b/cmd/server/main.go index 17fec85..1651933 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -128,6 +128,11 @@ func main() { gateway.ServeWS(database.DB, hub, logger, w, r, cfg.Session.CookieName) }) + // Bot WebSocket endpoint (auth via ?token= query param) + r.Get("/ws/bot", func(w http.ResponseWriter, r *http.Request) { + gateway.ServeBotWS(database.DB, hub, logger, w, r) + }) + // API routes r.Route("/api/v1", func(r chi.Router) { // Auth (public: register, login, logout) with strict rate limiting @@ -343,13 +348,9 @@ func main() { reaction.NewHandler(database.DB, hub).RegisterRoutes(r) }) - // Bots + // Bots + slash commands r.Route("/bots", func(r chi.Router) { bot.NewHandler(database.DB).RegisterRoutes(r) - }) - - // Bot slash commands - r.Route("/bots/{botID}/commands", func(r chi.Router) { bot.NewCommandHandler(database.DB).RegisterCommandRoutes(r) }) diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index e7c10cb..a9511bf 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -22,6 +22,7 @@ func NewHandler(db *sql.DB) *Handler { // RegisterRoutes registers authenticated bot routes under the given router. func (h *Handler) RegisterRoutes(r chi.Router) { + r.Get("/store", h.Store) r.Post("/", h.Create) r.Get("/", h.List) r.Get("/{botID}", h.Get) @@ -547,3 +548,54 @@ func (h *Handler) RegenerateToken(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, b) } + +// ---- Store (public listing) ---- + +type storeBotResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Avatar *string `json:"avatar"` + Description string `json:"description"` + ServerCount int `json:"server_count"` + OwnerID string `json:"owner_id"` + CreatedAt string `json:"created_at"` +} + +// Store returns all bots with their server count (visible to any authenticated user). +func (h *Handler) Store(w http.ResponseWriter, r *http.Request) { + rows, err := h.db.QueryContext(r.Context(), ` + SELECT b.id, b.name, b.avatar, b.description, b.owner_id, b.created_at::text, + COUNT(bs.server_id) AS server_count + FROM bots b + LEFT JOIN bot_servers bs ON bs.bot_id = b.id + GROUP BY b.id, b.name, b.avatar, b.description, b.owner_id, b.created_at + ORDER BY server_count DESC, b.name + `) + if err != nil { + writeErr(w, http.StatusInternalServerError, "server error") + return + } + defer rows.Close() + + bots := make([]storeBotResponse, 0) + for rows.Next() { + var b storeBotResponse + var avatar sql.NullString + var createdAt sql.NullString + if err := rows.Scan(&b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt, &b.ServerCount); err != nil { + writeErr(w, http.StatusInternalServerError, "server error") + return + } + if avatar.Valid { + b.Avatar = &avatar.String + } + b.CreatedAt = createdAt.String + bots = append(bots, b) + } + if err := rows.Err(); err != nil { + writeErr(w, http.StatusInternalServerError, "server error") + return + } + + writeJSON(w, http.StatusOK, bots) +} diff --git a/internal/db/db.go b/internal/db/db.go index ef58a31..a78124e 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -565,5 +565,9 @@ CREATE TABLE IF NOT EXISTS feature_request_votes ( PRIMARY KEY (feature_request_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_feature_request_votes_fr ON feature_request_votes(feature_request_id); + +-- Bot messages: track which bot authored a message +ALTER TABLE messages ADD COLUMN IF NOT EXISTS bot_id UUID REFERENCES bots(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_messages_bot ON messages(bot_id) WHERE bot_id IS NOT NULL; ` diff --git a/internal/gateway/client.go b/internal/gateway/client.go index 6b39c8c..375cafd 100644 --- a/internal/gateway/client.go +++ b/internal/gateway/client.go @@ -2,7 +2,9 @@ package gateway import ( "context" + "crypto/sha256" "database/sql" + "encoding/hex" "encoding/json" "log/slog" "net/http" @@ -106,6 +108,9 @@ type Client struct { Conn *websocket.Conn UserID string Username string + IsBot bool + BotID string + BotName string send chan []byte } @@ -184,6 +189,18 @@ func (c *Client) readPump() { }) } } + case BotSendMessage: + if !c.IsBot { + c.Hub.logger.Warn("non-bot client sent SEND_MESSAGE", "user_id", c.UserID) + continue + } + c.handleBotSendMessage(event.Data) + case BotDeleteMessage: + if !c.IsBot { + c.Hub.logger.Warn("non-bot client sent DELETE_MESSAGE", "user_id", c.UserID) + continue + } + c.handleBotDeleteMessage(event.Data) default: c.Hub.logger.Info("received event from client", "type", event.Type, "user_id", c.UserID) } @@ -300,3 +317,195 @@ func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r go client.writePump() go client.readPump() } + +// ServeBotWS handles websocket requests from bot clients. +// Authenticates via ?token= query param (bot token, hashed lookup). +func ServeBotWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + if token == "" { + http.Error(w, `{"error":"token query param required"}`, http.StatusBadRequest) + return + } + + // Hash the token and look up the bot. + tokenHash := hashToken(token) + + var botID, botName, ownerID string + err := db.QueryRowContext(r.Context(), + `SELECT id, name, owner_id FROM bots WHERE token = $1`, + tokenHash, + ).Scan(&botID, &botName, &ownerID) + if err != nil { + logger.Warn("bot ws auth: invalid token") + http.Error(w, `{"error":"invalid bot token"}`, http.StatusUnauthorized) + return + } + + // Verify the bot is added to at least one server. + var serverCount int + err = db.QueryRowContext(r.Context(), + `SELECT COUNT(*) FROM bot_servers WHERE bot_id = $1`, botID, + ).Scan(&serverCount) + if err != nil || serverCount == 0 { + logger.Warn("bot ws auth: bot not added to any server", "bot_id", botID) + http.Error(w, `{"error":"bot not added to any server"}`, http.StatusForbidden) + return + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + logger.Error("bot websocket upgrade failed", "error", err) + return + } + + // Load bot server memberships into hub so BroadcastToServer works. + hub.RefreshUserServers(ownerID) + + conn.SetReadDeadline(time.Time{}) + conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"ready","bot_id":"`+botID+`"}`)) + + client := &Client{ + Hub: hub, + Conn: conn, + UserID: ownerID, + Username: botName, + IsBot: true, + BotID: botID, + BotName: botName, + send: make(chan []byte, 256), + } + + hub.Register(client) + + go client.writePump() + go client.readPump() +} + +// hashToken returns the SHA-256 hex digest of a token. +func hashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} + +// handleBotSendMessage processes a SEND_MESSAGE action from a bot client. +func (c *Client) handleBotSendMessage(data interface{}) { + var payload struct { + ChannelID string `json:"channel_id"` + Content string `json:"content"` + } + raw, ok := data.(json.RawMessage) + if !ok { + return + } + if err := json.Unmarshal(raw, &payload); err != nil || payload.ChannelID == "" || payload.Content == "" { + c.Hub.logger.Warn("bot SEND_MESSAGE: invalid payload") + return + } + if len(payload.Content) > 4000 { + payload.Content = payload.Content[:4000] + } + + // Verify the bot is in the server that owns this channel. + serverID, err := c.Hub.ServerIDForChannel(context.Background(), payload.ChannelID) + if err != nil { + c.Hub.logger.Warn("bot SEND_MESSAGE: channel not found", "channel_id", payload.ChannelID) + return + } + var inServer bool + err = c.Hub.db.QueryRowContext(context.Background(), + `SELECT EXISTS(SELECT 1 FROM bot_servers WHERE bot_id = $1 AND server_id = $2)`, + c.BotID, serverID, + ).Scan(&inServer) + if err != nil || !inServer { + c.Hub.logger.Warn("bot SEND_MESSAGE: bot not in server", "bot_id", c.BotID, "server_id", serverID) + return + } + + // Insert the message with bot_id set. + var msgID, createdAt string + err = c.Hub.db.QueryRowContext(context.Background(), + `INSERT INTO messages (channel_id, author_id, content, bot_id) + VALUES ($1, $2, $3, $4) + RETURNING id, created_at::text`, + payload.ChannelID, c.UserID, payload.Content, c.BotID, + ).Scan(&msgID, &createdAt) + if err != nil { + c.Hub.logger.Error("bot SEND_MESSAGE: insert failed", "error", err) + return + } + + // Broadcast MESSAGE_CREATE to the server. + c.Hub.BroadcastToServer(serverID, Event{ + Type: EventMessageCreate, + Data: map[string]interface{}{ + "id": msgID, + "channel_id": payload.ChannelID, + "author_id": c.UserID, + "author_username": c.BotName, + "author_display_name": nil, + "author_bot": true, + "bot_id": c.BotID, + "bot_name": c.BotName, + "content": payload.Content, + "reply_to": nil, + "edited_at": nil, + "pinned": false, + "created_at": createdAt, + "embeds": []interface{}{}, + "reactions": []interface{}{}, + }, + }) +} + +// handleBotDeleteMessage processes a DELETE_MESSAGE action from a bot client. +func (c *Client) handleBotDeleteMessage(data interface{}) { + var payload struct { + ChannelID string `json:"channel_id"` + MessageID string `json:"message_id"` + } + raw, ok := data.(json.RawMessage) + if !ok { + return + } + if err := json.Unmarshal(raw, &payload); err != nil || payload.ChannelID == "" || payload.MessageID == "" { + c.Hub.logger.Warn("bot DELETE_MESSAGE: invalid payload") + return + } + + // Verify the bot is in the server that owns this channel. + serverID, err := c.Hub.ServerIDForChannel(context.Background(), payload.ChannelID) + if err != nil { + return + } + var inServer bool + err = c.Hub.db.QueryRowContext(context.Background(), + `SELECT EXISTS(SELECT 1 FROM bot_servers WHERE bot_id = $1 AND server_id = $2)`, + c.BotID, serverID, + ).Scan(&inServer) + if err != nil || !inServer { + return + } + + // Delete the message (only if it exists in this channel). + result, err := c.Hub.db.ExecContext(context.Background(), + `DELETE FROM messages WHERE id = $1 AND channel_id = $2`, + payload.MessageID, payload.ChannelID, + ) + if err != nil { + c.Hub.logger.Error("bot DELETE_MESSAGE: delete failed", "error", err) + return + } + rows, _ := result.RowsAffected() + if rows == 0 { + return + } + + // Broadcast MESSAGE_DELETE. + c.Hub.BroadcastToServer(serverID, Event{ + Type: EventMessageDelete, + Data: map[string]string{ + "id": payload.MessageID, + "channel_id": payload.ChannelID, + }, + }) +} diff --git a/internal/gateway/events.go b/internal/gateway/events.go index 22223b4..63e73ec 100644 --- a/internal/gateway/events.go +++ b/internal/gateway/events.go @@ -23,6 +23,10 @@ const ( EventVoiceMute = "VOICE_MUTE" EventVoiceDeafen = "VOICE_DEAFEN" EventVoiceWhisper = "VOICE_WHISPER" + + // Bot action events (sent by bot clients) + BotSendMessage = "SEND_MESSAGE" + BotDeleteMessage = "DELETE_MESSAGE" ) // Event represents a WebSocket event sent to clients. diff --git a/internal/message/handlers.go b/internal/message/handlers.go index a27083a..171a442 100644 --- a/internal/message/handlers.go +++ b/internal/message/handlers.go @@ -159,6 +159,9 @@ type messageResponse struct { AuthorID string `json:"author_id"` AuthorName string `json:"author_username"` DisplayName *string `json:"author_display_name"` + AuthorBot bool `json:"author_bot"` + BotID *string `json:"bot_id,omitempty"` + BotName *string `json:"bot_name,omitempty"` Content string `json:"content"` ReplyTo *string `json:"reply_to,omitempty"` EditedAt *string `json:"edited_at"` @@ -505,18 +508,20 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { var err error if before != "" { rows, err = h.db.QueryContext(r.Context(), ` - SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text + SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text, m.bot_id, b.name FROM messages m JOIN users u ON m.author_id = u.id + LEFT JOIN bots b ON m.bot_id = b.id WHERE m.channel_id = $1 AND m.created_at < (SELECT created_at FROM messages WHERE id = $2) ORDER BY m.created_at DESC LIMIT $3 `, channelID, before, limit) } else { rows, err = h.db.QueryContext(r.Context(), ` - SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text + SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text, m.bot_id, b.name FROM messages m JOIN users u ON m.author_id = u.id + LEFT JOIN bots b ON m.bot_id = b.id WHERE m.channel_id = $1 ORDER BY m.created_at DESC LIMIT $2 @@ -534,7 +539,9 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { var editedAt sql.NullString var createdAt sql.NullString var replyTo sql.NullString - err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt) + var botID sql.NullString + var botName sql.NullString + err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, &botID, &botName) if err != nil { continue } @@ -544,6 +551,13 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { if editedAt.Valid { msg.EditedAt = &editedAt.String } + if botID.Valid { + msg.BotID = &botID.String + msg.AuthorBot = true + } + if botName.Valid { + msg.BotName = &botName.String + } msg.CreatedAt = createdAt.String messages = append(messages, msg) } @@ -802,9 +816,10 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { rows, err := h.db.QueryContext(r.Context(), ` SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text, - ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank + ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank, m.bot_id, b.name FROM messages m JOIN users u ON m.author_id = u.id + LEFT JOIN bots b ON m.bot_id = b.id WHERE m.channel_id = $1 AND m.search_vector @@ plainto_tsquery('english', $2) ORDER BY rank DESC, m.created_at DESC LIMIT $3 @@ -822,7 +837,9 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { var createdAt sql.NullString var replyTo sql.NullString var rank float64 - err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, &rank) + var botID sql.NullString + var botName sql.NullString + err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, &rank, &botID, &botName) if err != nil { continue } @@ -832,6 +849,13 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { if editedAt.Valid { msg.EditedAt = &editedAt.String } + if botID.Valid { + msg.BotID = &botID.String + msg.AuthorBot = true + } + if botName.Valid { + msg.BotName = &botName.String + } msg.CreatedAt = createdAt.String messages = append(messages, msg) } @@ -1059,9 +1083,10 @@ func (h *Handler) ListPinned(w http.ResponseWriter, r *http.Request) { } rows, err := h.db.QueryContext(r.Context(), ` - SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text + SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text, m.bot_id, b.name FROM messages m JOIN users u ON m.author_id = u.id + LEFT JOIN bots b ON m.bot_id = b.id WHERE m.channel_id = $1 AND m.pinned = TRUE ORDER BY m.created_at DESC `, channelID) @@ -1077,7 +1102,9 @@ func (h *Handler) ListPinned(w http.ResponseWriter, r *http.Request) { var editedAt sql.NullString var createdAt sql.NullString var replyTo sql.NullString - err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt) + var botID sql.NullString + var botName sql.NullString + err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, &botID, &botName) if err != nil { continue } @@ -1087,6 +1114,13 @@ func (h *Handler) ListPinned(w http.ResponseWriter, r *http.Request) { if editedAt.Valid { msg.EditedAt = &editedAt.String } + if botID.Valid { + msg.BotID = &botID.String + msg.AuthorBot = true + } + if botName.Valid { + msg.BotName = &botName.String + } msg.CreatedAt = createdAt.String messages = append(messages, msg) } diff --git a/web/src/App.tsx b/web/src/App.tsx index 5a1da80..703fcfc 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import { Layout } from './components/Layout.tsx'; import { ChatArea } from './components/ChatArea.tsx'; import { UserSettings } from './components/UserSettings.tsx'; import { BotManager } from './components/BotManager.tsx'; +import { BotStore } from './components/BotStore.tsx'; import { CommandManager } from './components/CommandManager.tsx'; import { RoleManager } from './components/RoleManager.tsx'; import { JoinServer } from './components/JoinServer.tsx'; @@ -69,11 +70,23 @@ function App() { /> +
+
+ +
+
+ + } + /> +
- + ← [BACK] BOT MANAGER @@ -91,7 +104,7 @@ function App() {
- + ← [BACK] SLASH COMMANDS diff --git a/web/src/components/BotManager.tsx b/web/src/components/BotManager.tsx index b5c142e..c0beb1c 100644 --- a/web/src/components/BotManager.tsx +++ b/web/src/components/BotManager.tsx @@ -348,8 +348,8 @@ export function BotManager() { {/* Footer */}
- - {'<'} [BACK TO CHAT] + + {'<'} [BACK TO STORE]
diff --git a/web/src/components/BotStore.tsx b/web/src/components/BotStore.tsx new file mode 100644 index 0000000..70c533d --- /dev/null +++ b/web/src/components/BotStore.tsx @@ -0,0 +1,187 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useBotStore, type StoreBot } from '../stores/bot.ts'; +import { useServerStore } from '../stores/server.ts'; +import { useAuthStore } from '../stores/auth.ts'; + +export function BotStore() { + const storeBots = useBotStore((s) => s.storeBots); + const loading = useBotStore((s) => s.loading); + const error = useBotStore((s) => s.error); + const fetchStoreBots = useBotStore((s) => s.fetchStoreBots); + const addToServer = useBotStore((s) => s.addToServer); + + const servers = useServerStore((s) => s.servers); + const fetchServers = useServerStore((s) => s.fetchServers); + const currentUserId = useAuthStore((s) => s.user?.id); + + const [addBotId, setAddBotId] = useState(null); + const [selectedServer, setSelectedServer] = useState(''); + const [adding, setAdding] = useState(false); + const [search, setSearch] = useState(''); + + useEffect(() => { + fetchStoreBots(); + fetchServers(); + }, [fetchStoreBots, fetchServers]); + + const handleAdd = async () => { + if (!addBotId || !selectedServer) return; + setAdding(true); + try { + await addToServer(addBotId, selectedServer); + setAddBotId(null); + setSelectedServer(''); + fetchStoreBots(); // refresh counts + } catch { + // error in store + } finally { + setAdding(false); + } + }; + + const filtered = storeBots.filter((b) => + b.name.toLowerCase().includes(search.toLowerCase()) || + b.description.toLowerCase().includes(search.toLowerCase()) + ); + + return ( +
+
+
+
+            {'┌──────────────────────────────────┐\n'}
+            {'│       === BOT STORE ===          │\n'}
+            {'└──────────────────────────────────┘'}
+          
+

+ browse and add bots to your servers +

+ + {error && ( +

ERR: {error}

+ )} + + {/* Search */} +
+ setSearch(e.target.value)} + placeholder="search bots..." + className="terminal-input w-full" + /> +
+ + {/* Bot list */} + {loading && storeBots.length === 0 && ( +

[loading...]

+ )} + {!loading && filtered.length === 0 && ( +

[no bots found]

+ )} + +
+ {filtered.map((bot) => ( + { setAddBotId(bot.id); setSelectedServer(''); }} + /> + ))} +
+ + {/* Add to server modal */} + {addBotId && ( +
+
+

+ {'>'} ADD BOT TO SERVER +

+ +
+ + +
+
+
+ )} + + {/* Footer */} +
+ + {'<'} [BACK TO CHAT] + + + [MY BOTS] + +
+
+
+
+ ); +} + +function StoreBotCard({ bot, isOwner, onAdd }: { bot: StoreBot; isOwner: boolean; onAdd: () => void }) { + return ( +
+
+
+

+ {bot.avatar && ( + + )} + [{bot.name}] + {isOwner && ( + (yours) + )} +

+ {bot.description && ( +

{bot.description}

+ )} +
+
+

+ {bot.server_count} {bot.server_count === 1 ? 'server' : 'servers'} +

+
+
+
+ +
+
+ ); +} diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx index 95608d4..4b70e50 100644 --- a/web/src/components/ChatArea.tsx +++ b/web/src/components/ChatArea.tsx @@ -216,12 +216,14 @@ const MessageItem = memo(({ )} {formatTime(message.created_at)}{' '} {message.pinned && [PIN]} - { + { e.stopPropagation(); - onAuthorClick(message.author_id); + if (!message.author_bot) onAuthorClick(message.author_id); }}> - <{members.find((m) => m.id === message.author_id)?.nickname || message.author_username}> - {" "} + <{message.author_bot ? (message.bot_name || message.author_username) : (members.find((m) => m.id === message.author_id)?.nickname || message.author_username)}> + + {message.author_bot && BOT} + {" "} {renderContent(message.content, memberUsernames)} {renderEmbeds(message.embeds)} {message.poll && } diff --git a/web/src/stores/bot.ts b/web/src/stores/bot.ts index 6e9914f..cbe43ac 100644 --- a/web/src/stores/bot.ts +++ b/web/src/stores/bot.ts @@ -18,12 +18,24 @@ export interface SlashCommand { description: string; } +export interface StoreBot { + id: string; + name: string; + avatar: string | null; + description: string; + server_count: number; + owner_id: string; + created_at: string; +} + interface BotState { bots: Bot[]; + storeBots: StoreBot[]; loading: boolean; error: string | null; fetchBots: () => Promise; + fetchStoreBots: () => Promise; createBot: (name: string, description: string) => Promise; updateBot: (id: string, data: { name?: string; description?: string; avatar?: string }) => Promise; deleteBot: (id: string) => Promise; @@ -39,6 +51,7 @@ interface BotState { export const useBotStore = create((set) => ({ bots: [], + storeBots: [], loading: false, error: null, @@ -55,6 +68,19 @@ export const useBotStore = create((set) => ({ } }, + fetchStoreBots: async () => { + set({ loading: true, error: null }); + try { + const storeBots = await api.get('/bots/store'); + set({ storeBots, loading: false }); + } catch (error) { + set({ + loading: false, + error: error instanceof Error ? error.message : 'Failed to fetch bot store', + }); + } + }, + createBot: async (name, description) => { set({ loading: true, error: null }); try { @@ -189,7 +215,7 @@ export const useBotStore = create((set) => ({ fetchServerCommands: async (serverId) => { try { - return await api.get(`/servers/${serverId}/commands`); + return await api.get(`/bots/servers/${serverId}/commands`); } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to fetch server commands', diff --git a/web/src/stores/message.ts b/web/src/stores/message.ts index 95dcf8c..987486a 100644 --- a/web/src/stores/message.ts +++ b/web/src/stores/message.ts @@ -38,6 +38,9 @@ export interface Message { author_id: string; author_username: string; author_display_name: string | null; + author_bot?: boolean; + bot_id?: string | null; + bot_name?: string | null; content: string; reply_to?: string | null; embeds?: MessageEmbed[]; diff --git a/web/tsconfig.app.tsbuildinfo b/web/tsconfig.app.tsbuildinfo index 1e8cade..6d9d7d8 100644 --- a/web/tsconfig.app.tsbuildinfo +++ b/web/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AudioRenderers.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandDropdown.tsx","./src/components/CommandManager.tsx","./src/components/ConnectionStatus.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DeviceSettingsModal.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ExpandableImage.tsx","./src/components/FeatureRequestsPanel.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/FormatToolbar.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallBanner.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/ListView.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageInput.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/NotificationPrompt.tsx","./src/components/PinnedMessages.tsx","./src/components/Poll.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ResetPasswordPage.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/kaomojiData.ts","./src/lib/slashCommands.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/featureRequest.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/voicePresence.ts","./src/stores/ws.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AudioRenderers.tsx","./src/components/BotManager.tsx","./src/components/BotStore.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandDropdown.tsx","./src/components/CommandManager.tsx","./src/components/ConnectionStatus.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DeviceSettingsModal.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ExpandableImage.tsx","./src/components/FeatureRequestsPanel.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/FormatToolbar.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallBanner.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/ListView.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageInput.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/NotificationPrompt.tsx","./src/components/PinnedMessages.tsx","./src/components/Poll.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ResetPasswordPage.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/kaomojiData.ts","./src/lib/slashCommands.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/featureRequest.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/voicePresence.ts","./src/stores/ws.ts"],"version":"5.9.3"} \ No newline at end of file