diff --git a/.gitignore b/.gitignore index ce7c3c0..363fcfc 100644 --- a/.gitignore +++ b/.gitignore @@ -140,4 +140,6 @@ vite.config.ts.timestamp-* src/bot/.venv -__pycache__ \ No newline at end of file +__pycache__ + +src/api/build.sh \ No newline at end of file diff --git a/README.md b/README.md index c893c2b..37fdedc 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ # NewSharkBot SharkBotの書き直し + +# 招待する方法 + +まだ開発中です。公開までしばらくお待ちください。 \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index a748baa..cb246d6 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,15 +8,20 @@ services: DB_DSN: "host=postgres port=5432 user=sharkbot password=password dbname=sharkbot sslmode=disable" GIN_MODE: "debug" depends_on: - - postgres + postgres: + condition: service_healthy networks: - sharkbot_network bot: build: ./src/bot depends_on: - api + env_file: + - ./src/bot/.env networks: - sharkbot_network + environment: + RESOURCE_API_BASE_URL: "http://api:8080" postgres: image: postgres:18 environment: @@ -29,6 +34,11 @@ services: - postgres_data:/var/lib/postgresql networks: - sharkbot_network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U sharkbot"] + interval: 5s + timeout: 5s + retries: 5 volumes: postgres_data: networks: diff --git a/src/api/src/cmd/main.go b/src/api/src/cmd/main.go index 6d74515..b8906fb 100644 --- a/src/api/src/cmd/main.go +++ b/src/api/src/cmd/main.go @@ -50,7 +50,7 @@ func main() { } // マイグレート - err = db.AutoMigrate(&model.GuildSetting{}) + err = db.AutoMigrate(&model.GuildSetting{}, &model.EmbedSetting{}, &model.MessageSetting{}) if err != nil { panic("failed to migrate database") } @@ -73,6 +73,8 @@ func main() { // ルートグループを登録 router.RegisterGuildsRoutes(r.Group("/")) + router.RegisterEmbed(r.Group("/")) + router.RegisterMessageSettings(r.Group("/")) // シンプルなGETエンドポイントを定義 r.GET("/health", healthCheck) diff --git a/src/api/src/internal/dto/Guild.go b/src/api/src/internal/dto/Guild.go index 19f6df9..91494b5 100644 --- a/src/api/src/internal/dto/Guild.go +++ b/src/api/src/internal/dto/Guild.go @@ -9,3 +9,8 @@ type ListGuildsResponse struct { type CreateOrUpdateGuildSettingRequest struct { EnabledModules map[string]bool `json:"enabledModules"` } + +type UpdateModuleRequest struct { + Module string `json:"module"` + Enabled bool `json:"enabled"` +} diff --git a/src/api/src/internal/model/EmbedSetting.go b/src/api/src/internal/model/EmbedSetting.go new file mode 100644 index 0000000..a103285 --- /dev/null +++ b/src/api/src/internal/model/EmbedSetting.go @@ -0,0 +1,34 @@ +package model + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "time" +) + +type EmbedData map[string]interface{} + +func (ed *EmbedData) Scan(value interface{}) error { + bytes, ok := value.([]byte) + if !ok { + return errors.New("type assertion to []byte failed") + } + return json.Unmarshal(bytes, ed) +} + +func (ed EmbedData) Value() (driver.Value, error) { + if len(ed) == 0 { + return nil, nil + } + return json.Marshal(ed) +} + +type EmbedSetting struct { + ID uint `gorm:"primaryKey"` + GuildID string `gorm:"index;not null" json:"guild_id"` // どのサーバーの設定か + Name string `json:"name"` // "welcome_custom", "goodbye_v1" など + Data EmbedData `gorm:"type:jsonb" json:"data"` // Discord EmbedのDict + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/src/api/src/internal/model/MessageSetting.go b/src/api/src/internal/model/MessageSetting.go new file mode 100644 index 0000000..e1cd709 --- /dev/null +++ b/src/api/src/internal/model/MessageSetting.go @@ -0,0 +1,17 @@ +package model + +import "time" + +type MessageSetting struct { + GuildID string `gorm:"primaryKey" json:"guild_id"` + Type string `gorm:"primaryKey" json:"type"` // "welcome" or "goodbye" + + ChannelID string `json:"channel_id"` + Content string `json:"content"` + + EmbedID *uint `json:"embed_id"` + Embed EmbedSetting `gorm:"foreignKey:EmbedID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` + + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/src/api/src/internal/router/embed.go b/src/api/src/internal/router/embed.go new file mode 100644 index 0000000..4d64604 --- /dev/null +++ b/src/api/src/internal/router/embed.go @@ -0,0 +1,119 @@ +package router + +import ( + "net/http" + "strconv" + + "github.com/SharkBot-Dev/NewSharkBot/api/internal/model" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +func RegisterEmbed(router *gin.RouterGroup) { + guilds := router.Group("/guilds/embeds") + { + guilds.GET("/:id", getEmbedSettingList) // 一覧取得 + guilds.GET("/:id/:name", getEmbedSetting) // 個別取得 + guilds.POST("/:id", createOrUpdateEmbedSetting) // 作成・更新 + guilds.DELETE("/:id/:embed_id", deleteEmbedSetting) // 削除 + } +} + +// 全件取得 +func getEmbedSettingList(c *gin.Context) { + id := c.Param("id") + var settings []model.EmbedSetting + db := c.MustGet("db").(*gorm.DB) + + if err := db.Where("guild_id = ?", id).Find(&settings).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"}) + return + } + c.JSON(http.StatusOK, settings) +} + +// 個別取得 +func getEmbedSetting(c *gin.Context) { + id := c.Param("id") + name := c.Param("name") + var setting model.EmbedSetting + db := c.MustGet("db").(*gorm.DB) + + if err := db.Where("guild_id = ? AND name = ?", id, name).First(&setting).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Embed setting not found"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + } + return + } + c.JSON(http.StatusOK, setting) +} + +// 作成・更新 +func createOrUpdateEmbedSetting(c *gin.Context) { + id := c.Param("id") + db := c.MustGet("db").(*gorm.DB) + + // リクエストボディをパースするための構造体 + var input struct { + Name string `json:"name" binding:"required"` + Data model.EmbedData `json:"data" binding:"required"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var setting model.EmbedSetting + // 既存の設定があるか確認 + result := db.Where("guild_id = ? AND name = ?", id, input.Name).First(&setting) + + setting.GuildID = id + setting.Name = input.Name + setting.Data = input.Data + + if result.Error == gorm.ErrRecordNotFound { + // 新規作成 + if err := db.Create(&setting).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create"}) + return + } + } else { + // 更新 + if err := db.Save(&setting).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update"}) + return + } + } + + c.JSON(http.StatusOK, setting) +} + +// 削除 +func deleteEmbedSetting(c *gin.Context) { + guildID := c.Param("id") + embedID := c.Param("embed_id") + db := c.MustGet("db").(*gorm.DB) + + embedIdInt, err := strconv.Atoi(embedID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid embed_id format"}) + return + } + + result := db.Where("guild_id = ? AND id = ?", guildID, embedIdInt).Delete(&model.EmbedSetting{}) + + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete"}) + return + } + + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Setting not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Deleted successfully"}) +} diff --git a/src/api/src/internal/router/guilds.go b/src/api/src/internal/router/guilds.go index 066999b..b30352d 100644 --- a/src/api/src/internal/router/guilds.go +++ b/src/api/src/internal/router/guilds.go @@ -15,6 +15,8 @@ func RegisterGuildsRoutes(router *gin.RouterGroup) { guilds.GET("/", listGuilds) guilds.GET("/:id", getGuildSettingByID) guilds.PUT("/:id", createOrUpdateGuildSetting) + guilds.GET("/:id/module", isGuildModuleEnabled) + guilds.PATCH("/:id/module", updateGuildModuleSetting) } } @@ -124,3 +126,106 @@ func createOrUpdateGuildSetting(c *gin.Context) { } c.JSON(200, guildSetting) } + +// isGuildModuleEnabled godoc +// @Summary Check if a specific module is enabled +// @Tags Guilds +// @Param id path string true "Guild ID" +// @Param module query string true "Module Name" +// @Success 200 {object} map[string]bool +// @Router /guilds/{id}/module/check [get] +func isGuildModuleEnabled(c *gin.Context) { + id := c.Param("id") + moduleName := c.Query("module") + + if moduleName == "" { + c.JSON(400, gin.H{"error": "Module parameter is required"}) + return + } + + dbRaw, exists := c.Get("db") + if !exists { + c.JSON(500, gin.H{"error": "Database connection not found"}) + return + } + db := dbRaw.(*gorm.DB) + + var guildSetting model.GuildSetting + result := db.First(&guildSetting, "guild_id = ?", id) + + if result.Error != nil { + if result.Error == gorm.ErrRecordNotFound { + c.JSON(200, gin.H{"module": moduleName, "enabled": false}) + } else { + log.Printf("Error: %s", result.Error.Error()) + c.JSON(500, gin.H{"error": "Internal server error"}) + } + return + } + + enabled := false + if guildSetting.EnabledModules != nil { + enabled = guildSetting.EnabledModules[moduleName] + } + + c.JSON(200, gin.H{ + "guild_id": id, + "module": moduleName, + "enabled": enabled, + }) +} + +func updateGuildModuleSetting(c *gin.Context) { + id := c.Param("id") + + var req dto.UpdateModuleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + + dbRaw, exists := c.Get("db") + if !exists { + c.JSON(500, gin.H{"error": "Database connection not found"}) + return + } + db := dbRaw.(*gorm.DB) + + var guildSetting model.GuildSetting + result := db.First(&guildSetting, "guild_id = ?", id) + + if result.Error != nil { + if result.Error == gorm.ErrRecordNotFound { + guildSetting = model.GuildSetting{ + GuildID: id, + EnabledModules: map[string]bool{ + req.Module: req.Enabled, + }, + } + + if err := db.Create(&guildSetting).Error; err != nil { + log.Printf("Error: %s", err.Error()) + c.JSON(500, gin.H{"error": "Internal server error"}) + return + } + } else if result.Error != nil { + log.Printf("Error: %s", result.Error.Error()) + c.JSON(500, gin.H{"error": "Internal server error"}) + return + } + } else { + if guildSetting.EnabledModules == nil { + guildSetting.EnabledModules = make(map[string]bool) + } + + guildSetting.EnabledModules[req.Module] = req.Enabled + + if err := db.Save(&guildSetting).Error; err != nil { + log.Printf("Error: %s", err.Error()) + c.JSON(500, gin.H{"error": "Internal server error"}) + return + } + } + + c.JSON(200, guildSetting) +} diff --git a/src/api/src/internal/router/message.go b/src/api/src/internal/router/message.go new file mode 100644 index 0000000..859be3d --- /dev/null +++ b/src/api/src/internal/router/message.go @@ -0,0 +1,108 @@ +package router + +import ( + "log" + "net/http" + + "github.com/SharkBot-Dev/NewSharkBot/api/internal/model" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +func RegisterMessageSettings(router *gin.RouterGroup) { + // /guilds/message/welcome や /guilds/message/goodbye でアクセス + guilds := router.Group("/guilds/message") + { + guilds.GET("/:id/:type", getMessageSetting) // 取得 + guilds.POST("/:id/:type", saveMessageSetting) // 作成・更新 (Upsert) + guilds.DELETE("/:id/:type", deleteMessageSetting) // 削除 + } +} + +// 取得 (Welcome または Goodbye) +func getMessageSetting(c *gin.Context) { + id := c.Param("id") + msgType := c.Param("type") // "welcome" or "goodbye" + + db := c.MustGet("db").(*gorm.DB) + var setting model.MessageSetting + + // Embedデータも一緒に取得するために Preload を使用 + result := db.Preload("Embed").Where("guild_id = ? AND type = ?", id, msgType).First(&setting) + + if result.Error != nil { + if result.Error == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Setting not found"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + } + return + } + c.JSON(http.StatusOK, setting) +} + +// 保存 (作成・更新) +func saveMessageSetting(c *gin.Context) { + id := c.Param("id") + msgType := c.Param("type") + db := c.MustGet("db").(*gorm.DB) + + var input struct { + ChannelID string `json:"channel_id"` + Content string `json:"content"` + EmbedID *uint `json:"embed_id"` // nullを許容 + } + + if err := c.ShouldBindJSON(&input); err != nil { + log.Printf("BindError: %x", err.Error()) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var setting model.MessageSetting + // 既存レコードを探す + result := db.Where("guild_id = ? AND type = ?", id, msgType).First(&setting) + + // フィールド更新 + setting.GuildID = id + setting.Type = msgType + setting.ChannelID = input.ChannelID + setting.Content = input.Content + setting.EmbedID = input.EmbedID + + if result.Error == gorm.ErrRecordNotFound { + // 新規作成 + if err := db.Create(&setting).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create setting"}) + return + } + } else { + // 更新 + if err := db.Save(&setting).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update setting"}) + return + } + } + + c.JSON(http.StatusOK, setting) +} + +// 削除 +func deleteMessageSetting(c *gin.Context) { + id := c.Param("id") + msgType := c.Param("type") + db := c.MustGet("db").(*gorm.DB) + + result := db.Where("guild_id = ? AND type = ?", id, msgType).Delete(&model.MessageSetting{}) + + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Setting not found"}) + return + } + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": msgType + " setting deleted successfully"}) +} diff --git a/src/bot/cogs/batch.py b/src/bot/cogs/batch.py new file mode 100644 index 0000000..19f6c6b --- /dev/null +++ b/src/bot/cogs/batch.py @@ -0,0 +1,22 @@ +import discord +from discord.ext import commands, tasks +from main import NewSharkBot +from lib.command import Command + +class BatchCog(commands.Cog): + def __init__(self, bot: NewSharkBot): + self.bot = bot + print("init -> BatchCog") + + @commands.Cog.listener() + async def on_ready(self): + if not self.batch_change_activity.is_running(): + self.batch_change_activity.start() + + @tasks.loop(seconds=30) + async def batch_change_activity(self): + activity = discord.CustomActivity(name="ダッシュボードから設定できます。") + await self.bot.change_presence(activity=activity) + +async def setup(bot): + await bot.add_cog(BatchCog(bot)) \ No newline at end of file diff --git a/src/bot/cogs/help.py b/src/bot/cogs/help.py index 197bc28..939e8e5 100644 --- a/src/bot/cogs/help.py +++ b/src/bot/cogs/help.py @@ -10,13 +10,13 @@ def __init__(self, bot: NewSharkBot): self.bot = bot help_cmd = Command( - name="help", description="Botのコマンド一覧や詳細を表示します。" + name="help", description="Botのコマンド一覧や詳細を表示します。", module_name="ヘルプ" ) help_cmd.execute = self.help_command self.bot.add_slashcommand(help_cmd) dashboard_cmd = Command( - name="dashboard", description="ダッシュボードの案内を表示します。" + name="dashboard", description="ダッシュボードの案内を表示します。", module_name="ヘルプ" ) dashboard_cmd.execute = self.dashboard_command self.bot.add_slashcommand(dashboard_cmd) @@ -47,7 +47,7 @@ async def help_command(self, interaction: discord.Interaction, **kwargs): else: embed.title = "コマンド一覧" cmd_list = [] - for cmd in self.bot.slashcommands.values(): + for cmd in await self.bot.tree.fetch_commands(guild=interaction.guild): cmd_list.append(f"`/{cmd.name}` - {cmd.description}") embed.description = ( diff --git a/src/bot/cogs/reaction_role.py b/src/bot/cogs/reaction_role.py new file mode 100644 index 0000000..9d44299 --- /dev/null +++ b/src/bot/cogs/reaction_role.py @@ -0,0 +1,55 @@ +from discord.ext import commands +import discord +import asyncio + +from main import NewSharkBot + + +class ReactionRoleCog(commands.Cog): + def __init__(self, bot: NewSharkBot): + self.bot = bot + print("init -> ReactionRoleCog") + + @commands.Cog.listener("on_interaction") + async def on_interaction_reaction_role(self, interaction: discord.Interaction): + if interaction.type != discord.InteractionType.component: + return + + custom_id = interaction.data.get('custom_id', "") + if not custom_id.startswith("reaction_role:"): + return + + if not interaction.guild: + return + + await interaction.response.defer(ephemeral=True, thinking=True) + + role_id = int(custom_id.split(":")[1]) + role = interaction.guild.get_role(role_id) + + if not role: + await interaction.followup.send("指定されたロールが見つかりませんでした。", ephemeral=True) + return + + member = interaction.user + try: + if role in member.roles: + await member.remove_roles(role) + action_text = "を解除しました。" + else: + await member.add_roles(role) + action_text = "を付与しました。" + + await interaction.followup.send(f"{role.mention} {action_text}", ephemeral=True) + + except discord.Forbidden: + await interaction.followup.send( + "ボットに権限がないため、ロールを操作できませんでした。\n(ロールの順序を確認してください)", + ephemeral=True + ) + except Exception as e: + await interaction.followup.send(f"エラーが発生しました: {e}", ephemeral=True) + + +async def setup(bot): + await bot.add_cog(ReactionRoleCog(bot)) diff --git a/src/bot/cogs/search.py b/src/bot/cogs/search.py new file mode 100644 index 0000000..d5e7105 --- /dev/null +++ b/src/bot/cogs/search.py @@ -0,0 +1,49 @@ +import os + +import aiohttp +from discord.ext import commands +import discord + +from main import NewSharkBot +from lib.command import Command + +from typing import Dict + + +class HelpCog(commands.Cog): + def __init__(self, bot: NewSharkBot): + self.bot = bot + + help_cmd = Command( + name="imgur", description="Imgurで画像を検索します。", module_name="検索" + ) + help_cmd.execute = self.imgur_command + self.bot.add_slashcommand(help_cmd) + + async def imgur_command(self, interaction: discord.Interaction, **kwargs): + search = kwargs.get('search') + + token = os.environ.get('IMGUR_CLIENTID') + + await interaction.response.defer() + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"https://api.imgur.com/3/gallery/search", + params={"q": search}, + headers={"Authorization": f"Client-ID {token}"}, + ) as resp: + data = await resp.json() + + if data and "data" in data: + for item in data["data"]: + return await interaction.followup.send(f"{item['link']}") + + return await interaction.followup.send( + f"結果が見つかりませんでした。" + ) + except: + return await interaction.followup.send(f"検索に失敗しました。") + +async def setup(bot): + await bot.add_cog(HelpCog(bot)) diff --git a/src/bot/cogs/test.py b/src/bot/cogs/test.py index db2b7b0..e0ac19e 100644 --- a/src/bot/cogs/test.py +++ b/src/bot/cogs/test.py @@ -11,7 +11,7 @@ def __init__(self, bot: NewSharkBot): self.bot = bot # コマンドをここに - test = Command(name="test", description="テストコマンドです。") + test = Command(name="test", description="テストコマンドです。", module_name="テストモジュール") test.execute = self.test_command self.bot.add_slashcommand(test) diff --git a/src/bot/cogs/welcome.py b/src/bot/cogs/welcome.py index e011d3e..6b2123d 100644 --- a/src/bot/cogs/welcome.py +++ b/src/bot/cogs/welcome.py @@ -1,3 +1,5 @@ +import logging + from discord.ext import commands import discord @@ -15,86 +17,55 @@ def __init__(self, bot: NewSharkBot): def welcome_parse(self, template: str, member: discord.Member) -> str: return template.replace("{ユーザー名}", member.display_name).replace("{ユーザーID}", str(member.id)).replace("{メンション}", member.mention).replace("{サーバー名}", member.guild.name) - @commands.Cog.listener() - async def on_member_join(self, member: discord.Member): - guild_id = str(member.guild.id) - - data = await self.bot.async_db["SharkBot"]["welcome_setting"].find_one({"guildId": guild_id}) - if not data or "welcome" not in data: - return - - if data["welcome"].get("enabled", False) == False: + async def _handle_member_event(self, member: discord.Member, event_type: str): + if not self.bot.api: return - message = self.welcome_parse(data["welcome"].get("message", "ようこそ、{ユーザー名}!"), member) - - channel = member.guild.get_channel(int(data["welcome"]["channelId"])) - if not channel: - return + guild_id = str(member.guild.id) - embed_name = data["welcome"].get('embed', None) - if not embed_name: - if message == "": + try: + setting = await self.bot.api.fetch_message_setting(guild_id, event_type) + if not setting: return - await channel.send(content=message) - return - embed_data = await self.bot.embed.getEmbed(guild_id, embed_name) - if not embed_data: - if message == "": + content = self.welcome_parse(setting.get("content", ""), member) + channel_id = setting.get("channel_id") + + if not channel_id: + return + + channel = member.guild.get_channel(int(channel_id)) + if not channel: return - await channel.send(content=message) - return - - embed_data = copy.deepcopy(embed_data) - embed_data["description"] = self.welcome_parse(embed_data.get("description", ""), member) - embed_data["title"] = self.welcome_parse(embed_data.get("title", ""), member) - - embed = discord.Embed.from_dict(embed_data) - if message == "": - await channel.send(embed=embed) - return - await channel.send(content=message, embed=embed) - - @commands.Cog.listener() - async def on_member_remove(self, member: discord.Member): - guild_id = str(member.guild.id) - - data = await self.bot.async_db["SharkBot"]["welcome_setting"].find_one({"guildId": guild_id}) - if not data or "goodbye" not in data: - return - - if data["goodbye"].get("enabled", False) == False: - return - - message = self.welcome_parse(data["goodbye"].get("message", "{ユーザー名}が退出しました。"), member) - - channel = member.guild.get_channel(int(data["goodbye"]["channelId"])) - if not channel: - return - - embed_name = data["goodbye"].get('embed', None) - if not embed_name: - if message == "": + embed = None + embed_setting = setting.get("Embed") + + if embed_setting: + embed_data = copy.deepcopy(embed_setting.get("data", {})) + + if "description" in embed_data: + embed_data["description"] = self.welcome_parse(embed_data["description"], member) + if "title" in embed_data: + embed_data["title"] = self.welcome_parse(embed_data["title"], member) + + embed = discord.Embed.from_dict(embed_data) + + if not content and not embed: return - await channel.send(content=message) - return + await channel.send(content=content or None, embed=embed) - embed_data = await self.bot.embed.getEmbed(guild_id, embed_name) - if not embed_data: - if message == "": - return - await channel.send(content=message) - return + except Exception as e: + logging.error(f"Error in {event_type} event: {e}") - embed_data = copy.deepcopy(embed_data) - embed_data["description"] = self.welcome_parse(embed_data.get("description", ""), member) - embed_data["title"] = self.welcome_parse(embed_data.get("title", ""), member) + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member): + await self._handle_member_event(member, "welcome") - embed = discord.Embed.from_dict(embed_data) - await channel.send(content=self.welcome_parse(data["goodbye"].get("message", "{ユーザー名}が退出しました。"), member), embed=embed) + @commands.Cog.listener() + async def on_member_remove(self, member: discord.Member): + await self._handle_member_event(member, "goodbye") async def setup(bot): await bot.add_cog(WelcomeCog(bot)) diff --git a/src/bot/lib/api.py b/src/bot/lib/api.py new file mode 100644 index 0000000..f9dbb59 --- /dev/null +++ b/src/bot/lib/api.py @@ -0,0 +1,151 @@ +import aiohttp +import re +from typing import Optional, List, Dict, Any, Union, Literal +from dataclasses import dataclass, asdict + +# --- 型定義 --- + +@dataclass +class EmbedSetting: + guild_id: str + name: str + data: Dict[str, Any] + id: Optional[int] = None + updated_at: Optional[str] = None + +@dataclass +class MessageSetting: + guild_id: str + type: Literal['welcome', 'goodbye'] + channel_id: str + content: str + embed_id: Optional[int] + embed: Optional[EmbedSetting] = None + updated_at: Optional[str] = None + +# --- クラス実装 --- + +class ResourceAPIClient: + def __init__(self, session: aiohttp.ClientSession, base_url: str): + self.session = session + self.base_url = base_url.rstrip('/') + + def _is_valid_discord_id(self, discord_id: str) -> bool: + return bool(re.match(r'^\d{17,20}$', discord_id)) + + def _validate_guild_id(self, guild_id: str): + if not guild_id: + raise ValueError("Guild ID is required") + if not self._is_valid_discord_id(guild_id): + raise ValueError("Invalid Guild ID") + + async def create_guild_entry(self, guild_id: str) -> Dict[str, Any]: + self._validate_guild_id(guild_id) + + payload = { + "id": guild_id, + "EnabledModules": {"help": True} + } + + async with self.session.put( + f"{self.base_url}/guilds/{guild_id}", + json=payload + ) as resp: + if not resp.ok: + raise Exception(f"Failed to create guild entry: {resp.status} {await resp.text()}") + return await resp.json() + + async def fetch_guild_settings(self, guild_id: str) -> Optional[Dict[str, Any]]: + self._validate_guild_id(guild_id) + + async with self.session.get(f"{self.base_url}/guilds/{guild_id}") as resp: + if resp.status == 404: + return await self.create_guild_entry(guild_id) + if not resp.ok: + raise Exception(f"Failed to fetch guild settings: {resp.status}") + return await resp.json() + + async def is_module_enabled(self, guild_id: str, module_name: str) -> Dict[str, Any]: + self._validate_guild_id(guild_id) + + params = {"module": module_name} + async with self.session.get( + f"{self.base_url}/guilds/{guild_id}/module", + params=params + ) as resp: + if not resp.ok: + raise Exception(f"Failed to fetch module status: {resp.status}") + return await resp.json() + + async def set_module_status(self, guild_id: str, module_name: str) -> Dict[str, Any]: + self._validate_guild_id(guild_id) + + params = {"module": module_name} + async with self.session.patch( + f"{self.base_url}/guilds/{guild_id}/module", + params=params + ) as resp: + if not resp.ok: + raise Exception(f"Failed to update module status: {resp.status}") + return await resp.json() + + # --- Message Settings --- + + async def fetch_message_setting(self, guild_id: str, setting_type: Literal['welcome', 'goodbye']) -> Optional[Dict[str, Any]]: + self._validate_guild_id(guild_id) + + async with self.session.get(f"{self.base_url}/guilds/message/{guild_id}/{setting_type}") as resp: + if resp.status == 404: + return None + if not resp.ok: + raise Exception(f"Failed to fetch {setting_type} settings") + return await resp.json() + + async def save_message_setting(self, guild_id: str, setting_type: Literal['welcome', 'goodbye'], data: Dict[str, Any]) -> Dict[str, Any]: + self._validate_guild_id(guild_id) + + async with self.session.post( + f"{self.base_url}/guilds/message/{guild_id}/{setting_type}", + json=data + ) as resp: + if not resp.ok: + raise Exception(f"Failed to save {setting_type} settings") + return await resp.json() + + async def delete_message_setting(self, guild_id: str, setting_type: Literal['welcome', 'goodbye']) -> Dict[str, Any]: + self._validate_guild_id(guild_id) + + async with self.session.delete(f"{self.base_url}/guilds/message/{guild_id}/{setting_type}") as resp: + if not resp.ok: + raise Exception(f"Failed to delete {setting_type} settings") + return await resp.json() + + # --- Embed Settings --- + + async def fetch_embed_settings(self, guild_id: str) -> List[Dict[str, Any]]: + self._validate_guild_id(guild_id) + + async with self.session.get(f"{self.base_url}/guilds/embeds/{guild_id}") as resp: + if not resp.ok: + return [] + return await resp.json() + + async def save_embed_setting(self, guild_id: str, name: str, embed_data: Dict[str, Any]) -> Dict[str, Any]: + self._validate_guild_id(guild_id) + + payload = {"name": name, "data": embed_data} + async with self.session.post( + f"{self.base_url}/guilds/embeds/{guild_id}", + json=payload + ) as resp: + if not resp.ok: + raise Exception("Failed to save embed setting") + return await resp.json() + + async def delete_embed_setting(self, guild_id: str, name: str) -> Dict[str, Any]: + self._validate_guild_id(guild_id) + + async with self.session.delete(f"{self.base_url}/guilds/embeds/{guild_id}/{name}") as resp: + if not resp.ok: + raise Exception("Failed to delete embed setting") + return await resp.json() \ No newline at end of file diff --git a/src/bot/lib/command.py b/src/bot/lib/command.py index fe71e43..345939b 100644 --- a/src/bot/lib/command.py +++ b/src/bot/lib/command.py @@ -3,9 +3,10 @@ class Command: - def __init__(self, name: str, description: str): + def __init__(self, name: str, description: str, module_name: str = "その他"): self.name = name self.description = description + self.module_name = module_name self.parameters: Dict[str, Any] = {} async def execute(self, interaction: discord.Interaction, **kwargs): @@ -16,3 +17,6 @@ def get_name(self): def get_description(self): return self.description + + def get_module_name(self): + return self.module_name \ No newline at end of file diff --git a/src/bot/lib/embed.py b/src/bot/lib/embed.py index 44e84e3..f22b035 100644 --- a/src/bot/lib/embed.py +++ b/src/bot/lib/embed.py @@ -4,9 +4,9 @@ class Embed: def __init__(self, bot: commands.Bot): self.bot = bot - async def getEmbed(self, guild_id: str, title: str): - fetch = await self.bot.async_db["SharkBot"]["embed_setting"].find_one({"guildId": guild_id}) - if not fetch: - return None - - return fetch.get("embeds", {}).get(title, None) \ No newline at end of file + async def getEmbed(self, guild_id: str, embed_id: str): + embed = await self.bot.api.fetch_embed_settings(guild_id) + if embed: + for e in embed: + if e['ID'] == embed_id: + return e['data'] \ No newline at end of file diff --git a/src/bot/main.py b/src/bot/main.py index 89301db..aa00066 100644 --- a/src/bot/main.py +++ b/src/bot/main.py @@ -1,5 +1,6 @@ import os from typing import Dict +import aiohttp import dotenv from discord.ext import commands import discord @@ -7,6 +8,7 @@ from lib import tree from lib.command import Command from lib.embed import Embed as customEmbed +from lib.api import ResourceAPIClient dotenv.load_dotenv() @@ -21,6 +23,8 @@ def __init__(self): ) print("InitDone") + self.api = None + self.slashcommands: Dict[str, Command] = {} self.embed = customEmbed(self) @@ -48,6 +52,15 @@ async def load_cogs(bot: commands.Bot, base_folder="cogs"): async def setup_hook() -> None: await load_cogs(bot) + bot.session = aiohttp.ClientSession() + + base_url = os.environ.get("RESOURCE_API_BASE_URL", "http://localhost:8080") + bot.api = ResourceAPIClient(bot.session, base_url) + +@bot.event +async def on_ready(): + print(f"Logged in as {bot.user} (ID: {bot.user.id})") + print("------") if __name__ == "__main__": bot.run(os.environ.get("DISCORD_TOKEN")) diff --git a/src/dashboard/bun.lock b/src/dashboard/bun.lock index 2165a85..c8f2f28 100644 --- a/src/dashboard/bun.lock +++ b/src/dashboard/bun.lock @@ -6,6 +6,9 @@ "name": "newsharkbot", "dependencies": { "@better-auth/prisma-adapter": "^1.5.6", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@prisma/adapter-pg": "^7.5.0", "@prisma/client": "^7.5.0", "@types/tinycolor2": "^1.4.6", @@ -17,7 +20,7 @@ "tinycolor2": "^1.6.0", }, "devDependencies": { - "@biomejs/biome": "^2.4.8", + "@biomejs/biome": "^2.4.9", "@tailwindcss/postcss": "^4.2.2", "@types/node": "^20.19.37", "@types/react": "^19.2.14", @@ -75,6 +78,14 @@ "@chevrotain/utils": ["@chevrotain/utils@10.5.0", "", {}, "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@electric-sql/pglite": ["@electric-sql/pglite@0.3.15", "", {}, "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ=="], "@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.0.20", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.15" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg=="], diff --git a/src/dashboard/package.json b/src/dashboard/package.json index 3b9af86..fc72ae5 100644 --- a/src/dashboard/package.json +++ b/src/dashboard/package.json @@ -11,6 +11,9 @@ }, "dependencies": { "@better-auth/prisma-adapter": "^1.5.6", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@prisma/adapter-pg": "^7.5.0", "@prisma/client": "^7.5.0", "@types/tinycolor2": "^1.4.6", diff --git a/src/dashboard/src/app/api/guilds/[guildId]/channels/route.tsx b/src/dashboard/src/app/api/guilds/[guildId]/channels/route.tsx index f020bb6..2de6b84 100644 --- a/src/dashboard/src/app/api/guilds/[guildId]/channels/route.tsx +++ b/src/dashboard/src/app/api/guilds/[guildId]/channels/route.tsx @@ -1,7 +1,8 @@ import { headers } from "next/headers"; import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; -import { checkAdminPermission, getGuildChannels } from "@/lib/discord"; +import { checkAdminPermission } from "@/lib/Discord/User"; +import { getGuildChannels } from "@/lib/Discord/Bot"; export async function GET( _request: Request, diff --git a/src/dashboard/src/app/api/guilds/[guildId]/commands/route.tsx b/src/dashboard/src/app/api/guilds/[guildId]/commands/route.tsx index f8e2bf9..2ca50c8 100644 --- a/src/dashboard/src/app/api/guilds/[guildId]/commands/route.tsx +++ b/src/dashboard/src/app/api/guilds/[guildId]/commands/route.tsx @@ -26,7 +26,7 @@ export async function POST( const { action, command, commandId } = await req.json(); - if (!action || (["add", "delete"].includes(action) && !command)) { + if (!action || action == "add" && !command) { return NextResponse.json( { error: "Missing required fields" }, { status: 400 }, diff --git a/src/dashboard/src/app/api/guilds/[guildId]/modules/embed/route.ts b/src/dashboard/src/app/api/guilds/[guildId]/modules/embed/route.ts index 0db5d72..c46a9f7 100644 --- a/src/dashboard/src/app/api/guilds/[guildId]/modules/embed/route.ts +++ b/src/dashboard/src/app/api/guilds/[guildId]/modules/embed/route.ts @@ -1,204 +1,116 @@ import { auth } from "@/lib/auth"; -import { checkAdminPermission } from "@/lib/discord"; -import clientPromise from "@/lib/mongodb"; +import { checkAdminPermission } from "@/lib/Discord/User"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; -export async function GET( - _request: Request, - { params }: { params: Promise<{ guildId: string }> }, -) { - try { - const { guildId } = await params; - let discordToken: { - accessToken: string; - accessTokenExpiresAt: Date | undefined; - scopes: string[]; - idToken: string | undefined; - }; - try { - const allLinkedAccounts = await auth.api.listUserAccounts({ +const BACKEND_URL = process.env.BACKEND_API_URL || "http://localhost:8080"; + +/** + * 共通の認証・権限チェック関数 + */ +async function validateAdmin(guildId: string) { + const allLinkedAccounts = await auth.api.listUserAccounts({ headers: await headers(), - }); - const discordAccountData = allLinkedAccounts.find( - (account) => account.providerId === "discord", - ); - if (!discordAccountData) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - discordToken = await auth.api.getAccessToken({ + }); + const discordAccountData = allLinkedAccounts.find( + (account) => account.providerId === "discord" + ); + + if (!discordAccountData) throw new Error("Unauthorized"); + + const discordToken = await auth.api.getAccessToken({ headers: await headers(), body: { providerId: "discord", accountId: discordAccountData.accountId, userId: discordAccountData.userId, }, - }); - } catch (e) { - console.log("Error fetching access token:", e); - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + }); - if ( - !discordToken.accessTokenExpiresAt || - Date.now() >= new Date(discordToken.accessTokenExpiresAt).getTime() - ) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); + if (!discordToken.accessToken || !discordToken.accessTokenExpiresAt || + Date.now() >= new Date(discordToken.accessTokenExpiresAt).getTime()) { + throw new Error("Unauthorized"); } - const hasPermission = await checkAdminPermission( - guildId, - discordToken.accessToken, - ); - if (!hasPermission) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } + const hasPermission = await checkAdminPermission(guildId, discordToken.accessToken); + if (!hasPermission) throw new Error("Forbidden"); - const client = await clientPromise; - const db = client.db("SharkBot"); - const settings = await db.collection("embed_setting").findOne({ guildId }); - - return NextResponse.json({ enabled: !!settings, settings: settings?.embeds || {} }); - } catch (error) { - return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); - } + return discordToken; } +export async function GET( + _request: Request, + { params }: { params: Promise<{ guildId: string }> } +) { + try { + const { guildId } = await params; + await validateAdmin(guildId); + + // Goサーバーから一覧を取得 + const res = await fetch(`${BACKEND_URL}/guilds/embeds/${guildId}`, { + cache: 'no-store' + }); + + if (!res.ok) return NextResponse.json({ error: "Backend error" }, { status: res.status }); + + const data = await res.json(); + return NextResponse.json({ enabled: true, settings: data }); + } catch (error: any) { + const status = error.message === "Forbidden" ? 403 : 401; + return NextResponse.json({ error: error.message }, { status }); + } +} export async function POST( - request: Request, - { params }: { params: Promise<{ guildId: string }> } + request: Request, + { params }: { params: Promise<{ guildId: string }> } ) { - try { - const { guildId } = await params; - let discordToken: { - accessToken: string; - accessTokenExpiresAt: Date | undefined; - scopes: string[]; - idToken: string | undefined; - }; try { - const allLinkedAccounts = await auth.api.listUserAccounts({ - headers: await headers(), + const { guildId } = await params; + await validateAdmin(guildId); + + const body = await request.json(); + + // Goサーバーへ保存リクエスト + const res = await fetch(`${BACKEND_URL}/guilds/embeds/${guildId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: body.title, // Go側は Name フィールド + data: body // Dictデータとして全体を渡す + }), }); - const discordAccountData = allLinkedAccounts.find( - (account) => account.providerId === "discord", - ); - if (!discordAccountData) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - discordToken = await auth.api.getAccessToken({ - headers: await headers(), - body: { - providerId: "discord", - accountId: discordAccountData.accountId, - userId: discordAccountData.userId, - }, - }); - } catch (e) { - console.log("Error fetching access token:", e); - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - if ( - !discordToken.accessTokenExpiresAt || - Date.now() >= new Date(discordToken.accessTokenExpiresAt).getTime() - ) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } + if (!res.ok) return NextResponse.json({ error: "Failed to save to backend" }, { status: res.status }); - const hasPermission = await checkAdminPermission( - guildId, - discordToken.accessToken, - ); - if (!hasPermission) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + return NextResponse.json({ success: true, message: "Settings saved!" }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); } - - const body = await request.json(); - - const client = await clientPromise; - const db = client.db("SharkBot"); - - await db - .collection("embed_setting") - .updateOne( - { guildId }, - { $set: { [`embeds.${body.title}`]: body } }, - { upsert: true }, - ); - - return NextResponse.json({ success: true, message: "Settings saved!" }); - } catch (error) { - return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); - } } export async function DELETE( - request: Request, - { params }: { params: Promise<{ guildId: string }> } + request: Request, + { params }: { params: Promise<{ guildId: string }> } ) { - try { - const { guildId } = await params; - let discordToken: { - accessToken: string; - accessTokenExpiresAt: Date | undefined; - scopes: string[]; - idToken: string | undefined; - }; try { - const allLinkedAccounts = await auth.api.listUserAccounts({ - headers: await headers(), - }); - const discordAccountData = allLinkedAccounts.find( - (account) => account.providerId === "discord", - ); - if (!discordAccountData) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - discordToken = await auth.api.getAccessToken({ - headers: await headers(), - body: { - providerId: "discord", - accountId: discordAccountData.accountId, - userId: discordAccountData.userId, - }, - }); - } catch (e) { - console.log("Error fetching access token:", e); - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + const { guildId } = await params; + await validateAdmin(guildId); - if ( - !discordToken.accessTokenExpiresAt || - Date.now() >= new Date(discordToken.accessTokenExpiresAt).getTime() - ) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } + const body = await request.json(); // { title: "..." } - const hasPermission = await checkAdminPermission( - guildId, - discordToken.accessToken, - ); + if (!body.embed_id) return NextResponse.json({ error: "Invalid request" }, { status: 400 }); + if (/^\d+$/.test(body.embed_id) === false) return NextResponse.json({ error: "Invalid request" }, { status: 400 }); - if (!hasPermission) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - - const body = await request.json(); - - const client = await clientPromise; - const db = client.db("SharkBot"); + // Goサーバーへ削除リクエスト + const res = await fetch(`${BACKEND_URL}/guilds/embeds/${guildId}/${body.embed_id}`, { + method: "DELETE", + }); - await db - .collection("embed_setting") - .updateOne( - { guildId }, - { $unset: { [`embeds.${body.title}`]: "" } }, - ); + if (!res.ok) return NextResponse.json({ error: "Failed to delete from backend" }, { status: res.status }); - return NextResponse.json({ success: true, message: "Embed deleted!" }); - } catch (error) { - return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); - } + return NextResponse.json({ success: true, message: "Embed deleted!" }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } } \ No newline at end of file diff --git a/src/dashboard/src/app/api/guilds/[guildId]/modules/goodbye/route.ts b/src/dashboard/src/app/api/guilds/[guildId]/modules/goodbye/route.ts new file mode 100644 index 0000000..e922d2f --- /dev/null +++ b/src/dashboard/src/app/api/guilds/[guildId]/modules/goodbye/route.ts @@ -0,0 +1,107 @@ +import { deleteMessageSetting, fetchMessageSetting, saveMessageSetting } from "@/lib/api/requests"; +import { auth } from "@/lib/auth"; +import { checkAdminPermission } from "@/lib/Discord/User"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +const BACKEND_URL = process.env.BACKEND_API_URL || "http://localhost:8080"; + +async function validateAdmin(guildId: string) { + const allLinkedAccounts = await auth.api.listUserAccounts({ + headers: await headers(), + }); + const discordAccountData = allLinkedAccounts.find( + (account) => account.providerId === "discord" + ); + + if (!discordAccountData) throw new Error("Unauthorized"); + + const discordToken = await auth.api.getAccessToken({ + headers: await headers(), + body: { + providerId: "discord", + accountId: discordAccountData.accountId, + userId: discordAccountData.userId, + }, + }); + + if (!discordToken.accessToken || !discordToken.accessTokenExpiresAt || + Date.now() >= new Date(discordToken.accessTokenExpiresAt).getTime()) { + throw new Error("Unauthorized"); + } + + const hasPermission = await checkAdminPermission(guildId, discordToken.accessToken); + if (!hasPermission) throw new Error("Forbidden"); + + return discordToken; +} + +/** + * GET: GoサーバーからWelcomeとGoodbyeの設定を取得して整形 + */ +export async function GET( + _request: Request, + { params }: { params: Promise<{ guildId: string }> } +) { + try { + const { guildId } = await params; + await validateAdmin(guildId); + + const goodbye = await fetchMessageSetting(guildId, "goodbye"); + + // フロントエンドの既存インターフェースに合わせる + const fixedSettings = { + channel_id: goodbye?.channel_id || "", + content: goodbye?.content || "", + embed_id: goodbye?.embed_id || null, + enabled: !!goodbye + }; + + return NextResponse.json({ success: true, settings: fixedSettings }); + } catch (error: any) { + const status = error.message === "Forbidden" ? 403 : 401; + return NextResponse.json({ error: error.message }, { status }); + } +} + +/** + * POST: フロントからのリクエストをGoサーバーへ転送 + */ + +export async function POST( + request: Request, + { params }: { params: Promise<{ guildId: string }> } +) { + try { + const { guildId } = await params; + await validateAdmin(guildId); + + const body = await request.json(); + + await saveMessageSetting(guildId, "goodbye", body.goodbye); + + return NextResponse.json({ success: true, message: "Settings synced successfully" }); + } catch (error: any) { + console.error("Settings POST Error:", error); + const status = error.message === "Forbidden" ? 403 : 401; + return NextResponse.json({ error: error.message }, { status: status || 500 }); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ guildId: string }> } +) { + try { + const { guildId } = await params; + await validateAdmin(guildId); + + await deleteMessageSetting(guildId, "goodbye"); + + return NextResponse.json({ success: true, message: "Settings synced successfully" }); + } catch (error: any) { + console.error("Settings POST Error:", error); + const status = error.message === "Forbidden" ? 403 : 401; + return NextResponse.json({ error: error.message }, { status: status || 500 }); + } +} \ No newline at end of file diff --git a/src/dashboard/src/app/api/guilds/[guildId]/modules/reactionRole/button/route.ts b/src/dashboard/src/app/api/guilds/[guildId]/modules/reactionRole/button/route.ts new file mode 100644 index 0000000..a85ccb5 --- /dev/null +++ b/src/dashboard/src/app/api/guilds/[guildId]/modules/reactionRole/button/route.ts @@ -0,0 +1,106 @@ +import { deleteMessageSetting, fetchEmbedSettings, fetchMessageSetting, saveMessageSetting } from "@/lib/api/requests"; +import { auth } from "@/lib/auth"; +import { getValidatedChannelInServer, sendMessage } from "@/lib/Discord/Bot"; +import { buildActionRowsFromMap } from "@/lib/Discord/RolePanel"; +import { checkAdminPermission } from "@/lib/Discord/User"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +async function validateAdmin(guildId: string) { + const allLinkedAccounts = await auth.api.listUserAccounts({ + headers: await headers(), + }); + const discordAccountData = allLinkedAccounts.find( + (account) => account.providerId === "discord" + ); + + if (!discordAccountData) throw new Error("Unauthorized"); + + const discordToken = await auth.api.getAccessToken({ + headers: await headers(), + body: { + providerId: "discord", + accountId: discordAccountData.accountId, + userId: discordAccountData.userId, + }, + }); + + if (!discordToken.accessToken || !discordToken.accessTokenExpiresAt || + Date.now() >= new Date(discordToken.accessTokenExpiresAt).getTime()) { + throw new Error("Unauthorized"); + } + + const hasPermission = await checkAdminPermission(guildId, discordToken.accessToken); + if (!hasPermission) throw new Error("Forbidden"); + + return discordToken; +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ guildId: string }> } +) { + try { + const { guildId } = await params; + + await validateAdmin(guildId); + + const { channelId, content, embedId, roles } = await request.json(); + + if (!channelId || !roles) { + return NextResponse.json({ error: "Missing channelId or roles" }, { status: 400 }); + } + + const validatedChannel = await getValidatedChannelInServer(guildId, channelId); + if (!validatedChannel) { + return NextResponse.json({ error: "Invalid channelId" }, { status: 400 }); + } + + const components = buildActionRowsFromMap(roles); + + if (embedId) { + const embeds = await fetchEmbedSettings(guildId); + const embedData = embeds.find((embed) => embed.ID === Number(embedId)); + + if (!embedData) { + return NextResponse.json({ error: "Embed not found" }, { status: 404 }); + } + + const result = await sendMessage( + channelId, + content || "", + embedData["data"], + components + ); + + return NextResponse.json({ + success: true, + message: "Sent.", + data: result + }); + } else { + const result = await sendMessage( + channelId, + content || "", + null, + components + ); + + return NextResponse.json({ + success: true, + message: "Sent.", + data: result + }); + } + + } catch (error: any) { + console.error("Settings POST Error:", error); + + let status = 500; + if (error.message === "Forbidden") status = 403; + if (error.message === "Unauthorized") status = 401; + if (error.message === "Invalid Channel ID") status = 400; + + return NextResponse.json({ error: error.message }, { status }); + } +} \ No newline at end of file diff --git a/src/dashboard/src/app/api/guilds/[guildId]/modules/route.ts b/src/dashboard/src/app/api/guilds/[guildId]/modules/route.ts index 1eb3f83..f75a681 100644 --- a/src/dashboard/src/app/api/guilds/[guildId]/modules/route.ts +++ b/src/dashboard/src/app/api/guilds/[guildId]/modules/route.ts @@ -73,14 +73,14 @@ export async function POST( try { const response = await fetch( - `${RESOURCE_API_BASE_URL}/guilds/${guildId}/modules`, + `${RESOURCE_API_BASE_URL}/guilds/${guildId}/module`, { - method: "POST", + method: "PATCH", headers: { "Content-Type": "application/json", }, body: JSON.stringify( - serializeRequest({ enabledModules: { [moduleId]: enabled } }), + serializeRequest({ enabled: enabled, module: moduleId }), ), }, ); diff --git a/src/dashboard/src/app/api/guilds/[guildId]/modules/welcome/route.ts b/src/dashboard/src/app/api/guilds/[guildId]/modules/welcome/route.ts index 62e064e..2e5661d 100644 --- a/src/dashboard/src/app/api/guilds/[guildId]/modules/welcome/route.ts +++ b/src/dashboard/src/app/api/guilds/[guildId]/modules/welcome/route.ts @@ -1,166 +1,106 @@ +import { deleteMessageSetting, fetchMessageSetting, saveMessageSetting } from "@/lib/api/requests"; +import { auth } from "@/lib/auth"; +import { checkAdminPermission } from "@/lib/Discord/User"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; -import { auth } from "@/lib/auth"; -import { checkAdminPermission } from "@/lib/discord"; -import clientPromise from "@/lib/mongodb"; - -interface WelcomeSetting { - guildId: string; - welcome: { - channelId: string; - message: string; - embed: any; - enabled: boolean; - }, - goodbye: { - channelId: string; - message: string; - embed: any; - enabled: boolean; - }; -} -export async function GET( - request: Request, - { params }: { params: Promise<{ guildId: string }> }, -) { - try { - const { guildId } = await params; - let discordToken: { - accessToken: string; - accessTokenExpiresAt: Date | undefined; - scopes: string[]; - idToken: string | undefined; - }; - try { - const allLinkedAccounts = await auth.api.listUserAccounts({ +const BACKEND_URL = process.env.BACKEND_API_URL || "http://localhost:8080"; + +async function validateAdmin(guildId: string) { + const allLinkedAccounts = await auth.api.listUserAccounts({ headers: await headers(), - }); - const discordAccountData = allLinkedAccounts.find( - (account) => account.providerId === "discord", - ); - if (!discordAccountData) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - discordToken = await auth.api.getAccessToken({ + }); + const discordAccountData = allLinkedAccounts.find( + (account) => account.providerId === "discord" + ); + + if (!discordAccountData) throw new Error("Unauthorized"); + + const discordToken = await auth.api.getAccessToken({ headers: await headers(), body: { providerId: "discord", accountId: discordAccountData.accountId, userId: discordAccountData.userId, }, - }); - } catch (e) { - console.log("Error fetching access token:", e); - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + }); - if ( - !discordToken.accessTokenExpiresAt || - Date.now() >= new Date(discordToken.accessTokenExpiresAt).getTime() - ) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); + if (!discordToken.accessToken || !discordToken.accessTokenExpiresAt || + Date.now() >= new Date(discordToken.accessTokenExpiresAt).getTime()) { + throw new Error("Unauthorized"); } - const hasPermission = await checkAdminPermission( - guildId, - discordToken.accessToken, - ); - if (!hasPermission) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } + const hasPermission = await checkAdminPermission(guildId, discordToken.accessToken); + if (!hasPermission) throw new Error("Forbidden"); - const client = await clientPromise; - const db = client.db("SharkBot"); - const settings = await db.collection("welcome_setting").findOne({ guildId }); - - const fixedSettings = { - welcome: { - channelId: settings?.welcome?.channelId || "", - message: settings?.welcome?.message || "", - embed: settings?.welcome?.embed || null, - enabled: settings?.welcome?.enabled || false, - }, - goodbye: { - channelId: settings?.goodbye?.channelId || "", - message: settings?.goodbye?.message || "", - embed: settings?.goodbye?.embed || null, - enabled: settings?.goodbye?.enabled || false, - }, - }; - return NextResponse.json({ success: true, settings: fixedSettings }); - } catch (error) { - return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); - } + return discordToken; } -export async function POST( - request: Request, - { params }: { params: { guildId: string } }, +/** + * GET: GoサーバーからWelcomeとGoodbyeの設定を取得して整形 + */ +export async function GET( + _request: Request, + { params }: { params: Promise<{ guildId: string }> } ) { - try { - const { guildId } = await params; - let discordToken: { - accessToken: string; - accessTokenExpiresAt: Date | undefined; - scopes: string[]; - idToken: string | undefined; - }; try { - const allLinkedAccounts = await auth.api.listUserAccounts({ - headers: await headers(), - }); - const discordAccountData = allLinkedAccounts.find( - (account) => account.providerId === "discord", - ); - if (!discordAccountData) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - discordToken = await auth.api.getAccessToken({ - headers: await headers(), - body: { - providerId: "discord", - accountId: discordAccountData.accountId, - userId: discordAccountData.userId, - }, - }); - } catch (e) { - console.log("Error fetching access token:", e); - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + const { guildId } = await params; + await validateAdmin(guildId); + + const welcome = await fetchMessageSetting(guildId, "welcome"); + + const fixedSettings = { + channel_id: welcome?.channel_id || "", + content: welcome?.content || "", + embed_id: welcome?.embed_id || null, + enabled: !!welcome + }; - if ( - !discordToken.accessTokenExpiresAt || - Date.now() >= new Date(discordToken.accessTokenExpiresAt).getTime() - ) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); + return NextResponse.json({ success: true, settings: fixedSettings }); + } catch (error: any) { + const status = error.message === "Forbidden" ? 403 : 401; + return NextResponse.json({ error: error.message }, { status }); } +} - const hasPermission = await checkAdminPermission( - guildId, - discordToken.accessToken, - ); - if (!hasPermission) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); +/** + * POST: フロントからのリクエストをGoサーバーへ転送 + */ + +export async function POST( + request: Request, + { params }: { params: Promise<{ guildId: string }> } +) { + try { + const { guildId } = await params; + await validateAdmin(guildId); + + const body = await request.json(); + + await saveMessageSetting(guildId, "welcome", body.welcome); + + return NextResponse.json({ success: true, message: "Settings synced successfully" }); + } catch (error: any) { + console.error("Settings POST Error:", error); + const status = error.message === "Forbidden" ? 403 : 401; + return NextResponse.json({ error: error.message }, { status: status || 500 }); } +} - const body = await request.json(); - const settung: WelcomeSetting = { - guildId, - ...body - }; - - const client = await clientPromise; - const db = client.db("SharkBot"); - await db.collection("welcome_setting").updateOne( - { guildId }, - { $set: { ...settung } }, - { upsert: true }, - ) - - return NextResponse.json({ success: true, message: "Settings saved!" }); - - } catch (error) { - return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); - } +export async function DELETE( + request: Request, + { params }: { params: Promise<{ guildId: string }> } +) { + try { + const { guildId } = await params; + await validateAdmin(guildId); + + await deleteMessageSetting(guildId, "welcome"); + + return NextResponse.json({ success: true, message: "Settings synced successfully" }); + } catch (error: any) { + console.error("Settings POST Error:", error); + const status = error.message === "Forbidden" ? 403 : 401; + return NextResponse.json({ error: error.message }, { status: status || 500 }); + } } \ No newline at end of file diff --git a/src/dashboard/src/app/api/guilds/[guildId]/roles/route.tsx b/src/dashboard/src/app/api/guilds/[guildId]/roles/route.tsx new file mode 100644 index 0000000..41a177a --- /dev/null +++ b/src/dashboard/src/app/api/guilds/[guildId]/roles/route.tsx @@ -0,0 +1,63 @@ +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { checkAdminPermission } from "@/lib/Discord/User"; +import { getGuildChannels, getGuildRoles } from "@/lib/Discord/Bot"; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ guildId: string }> }, +) { + const { guildId } = await params; + let discordToken: { + accessToken: string; + accessTokenExpiresAt: Date | undefined; + scopes: string[]; + idToken: string | undefined; + }; + try { + const allLinkedAccounts = await auth.api.listUserAccounts({ + headers: await headers(), + }); + const discordAccountData = allLinkedAccounts.find( + (account) => account.providerId === "discord", + ); + if (!discordAccountData) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + discordToken = await auth.api.getAccessToken({ + headers: await headers(), + body: { + providerId: "discord", + accountId: discordAccountData.accountId, + userId: discordAccountData.userId, + }, + }); + } catch (e) { + console.log("Error fetching access token:", e); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if ( + !discordToken.accessTokenExpiresAt || + Date.now() >= new Date(discordToken.accessTokenExpiresAt).getTime() + ) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const hasPermission = await checkAdminPermission( + guildId, + discordToken.accessToken, + ); + if (!hasPermission) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const channels = await getGuildRoles(guildId); + + return NextResponse.json(channels); + } catch { + return NextResponse.json({ error: "DB接続エラー" }, { status: 500 }); + } +} diff --git a/src/dashboard/src/app/dashboard/[guildId]/client.tsx b/src/dashboard/src/app/dashboard/[guildId]/client.tsx new file mode 100644 index 0000000..6d98743 --- /dev/null +++ b/src/dashboard/src/app/dashboard/[guildId]/client.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { NoIconModuleSetting, type ModuleSetting } from "@/lib/modules"; +import ModuleCard from "@/components/ModuleCard"; +import { modules as modules_list } from "@/lib/modules"; + +export default function ModuleList({ + guildId, + initialModules +}: { + guildId: string, + initialModules: NoIconModuleSetting[] +}) { + const [modules, setModules] = useState(initialModules); + + const modulesWithIcons = useMemo(() => { + return modules.map(m => { + const original = modules_list.get(m.id); + return { + ...m, + icon: original?.icon + }; + }); + }, [modules]); + + const groupedModules = useMemo(() => { + return modulesWithIcons.reduce((acc, mod) => { + const group = mod.group || "その他"; + if (!acc[group]) acc[group] = []; + acc[group].push(mod); + return acc; + }, {} as Record); + }, [modulesWithIcons]); + + const toggleModule = async (moduleId: string) => { + const target = modules.find((m) => m.id === moduleId); + if (!target) return; + + const newState = !target.enabled; + + setModules((prev) => + prev.map((m) => (m.id === moduleId ? { ...m, enabled: newState } : m)) + ); + + try { + const res = await fetch(`/api/guilds/${guildId}/modules`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + moduleId: moduleId, + enabled: newState + }), + }); + + if (!res.ok) throw new Error("保存失敗"); + + } catch (error) { + console.error("Failed to update module:", error); + alert("設定の保存に失敗しました"); + + setModules((prev) => + prev.map((m) => (m.id === moduleId ? { ...m, enabled: !newState } : m)) + ); + } + }; + + return ( + <> + {Object.entries(groupedModules).map(([groupName, items]) => ( +
+

+ {groupName} +

+
+ {items.map((mod) => ( + + ))} +
+
+ ))} + + ); +} \ No newline at end of file diff --git a/src/dashboard/src/app/dashboard/[guildId]/embed/EmbedEditorClient.tsx b/src/dashboard/src/app/dashboard/[guildId]/embed/EmbedEditorClient.tsx new file mode 100644 index 0000000..62c548b --- /dev/null +++ b/src/dashboard/src/app/dashboard/[guildId]/embed/EmbedEditorClient.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { Terminal, Save } from "lucide-react"; +import { useState } from "react"; +import DiscordEmbedBuilder from "@/components/EmbedBuilder"; +import CollapsibleSection from "@/components/CollapsibleSection"; +import { EmbedSetting } from "@/lib/api/requests"; // 型定義 + +interface Props { + guildId: string; + initialEmbeds: EmbedSetting[]; // すでにGoバックエンドから取得済みのリスト +} + +export default function EmbedEditorClient({ guildId, initialEmbeds }: Props) { + const [savedEmbeds, setSavedEmbeds] = useState(initialEmbeds); + const [currentEmbedData, setCurrentEmbedData] = useState(null); + const [saving, setSaving] = useState(false); + + // 保存処理 (Next.jsのAPI Route /api/guilds/[id]/modules/embed を叩く) + const handleSave = async () => { + // Builder側で入力されたタイトルをNameとして扱う + const name = currentEmbedData?.title; + if (!name) { + alert("埋め込みのタイトルを入力してください。"); + return; + } + + setSaving(true); + try { + // 修正したNext.jsのAPI RouteへPOST + const response = await fetch(`/api/guilds/${guildId}/modules/embed`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(currentEmbedData), // Titleを含むEmbed Dict全体を送信 + }); + + if (!response.ok) throw new Error("Failed to save"); + + // Go側から返ってくる構造に合わせてリストを更新 + const newSetting: EmbedSetting = { + guild_id: guildId, + name: name, + data: currentEmbedData, + }; + + setSavedEmbeds((prev) => { + const filtered = prev.filter((e) => e.name !== name); + return [...filtered, newSetting]; + }); + + alert("埋め込みを保存しました!"); + } catch (error) { + console.error(error); + alert("保存中にエラーが発生しました。"); + } finally { + setSaving(false); + } + }; + + // 削除処理 + const handleDelete = async (name: string, id: string) => { + if (!confirm(`埋め込み「${name}」を削除してもよろしいですか?`)) return; + + try { + const response = await fetch(`/api/guilds/${guildId}/modules/embed`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ embed_id: Number(id) }), + }); + + if (response.ok) { + setSavedEmbeds((prev) => prev.filter((e) => String(e.ID) !== id)); + } else { + alert("削除に失敗しました。"); + } + } catch (error) { + console.error("Delete error:", error); + } + }; + + return ( +
+
+ +
+ +
+
+ +

エディター

+
+ + +
+ + +
+ {savedEmbeds.length === 0 ? ( +
+

保存されたテンプレートはありません。

+
+ ) : ( + savedEmbeds.map((embed) => ( +
+
+

{embed.name}

+

+ {typeof embed.data.description === 'string' ? embed.data.description : "説明なし"} +

+
+
+ +
+
+ )) + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/dashboard/src/app/dashboard/[guildId]/embed/page.tsx b/src/dashboard/src/app/dashboard/[guildId]/embed/page.tsx index 5487123..ec480e0 100644 --- a/src/dashboard/src/app/dashboard/[guildId]/embed/page.tsx +++ b/src/dashboard/src/app/dashboard/[guildId]/embed/page.tsx @@ -1,180 +1,54 @@ -"use client"; - -import { Terminal, Save } from "lucide-react"; -import { useParams, useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import DiscordEmbedBuilder from "@/components/EmbedBuilder"; -import CollapsibleSection from "@/components/CollapsibleSection"; - -export default function WelcomeGoodbyeModulePage() { - const params = useParams(); - const router = useRouter(); - const guildId = params.guildId as string; - - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - - // 現在ビルダーで編集中のデータ - const [currentEmbed, setCurrentEmbed] = useState(null); - // 保存済みの埋め込みリスト - const [savedEmbeds, setSavedEmbeds] = useState([]); - - useEffect(() => { - async function init() { - try { - // モジュール有効化チェック - const statusRes = await fetch(`/api/guilds/${guildId}/modules/isEnabled?module=embed`); - const statusData = await statusRes.json(); - - if (!statusData.enabled) { - alert("このサーバーではモジュールが有効になっていません。"); - router.push(`/dashboard/${guildId}`); - return; - } - - // 埋め込みデータの取得 - const res = await fetch(`/api/guilds/${guildId}/modules/embed`); - const data = await res.json(); - - if (res.ok) { - setSavedEmbeds(Object.values(data.settings || {})); - } - } catch (e) { - console.error("Failed to load settings", e); - } finally { - setLoading(false); - } - } - init(); - }, [guildId, router]); - - const handleSave = async () => { - if (!currentEmbed || !currentEmbed.title) { - alert("埋め込みのタイトルを入力してください。"); - return; - } - - setSaving(true); - try { - const response = await fetch(`/api/guilds/${guildId}/modules/embed`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(currentEmbed), - }); - - if (response.ok) { - setSavedEmbeds((prev) => { - const filtered = prev.filter((e) => e.title !== currentEmbed.title); - return [...filtered, currentEmbed]; - }); - alert("埋め込みを保存しました!"); - } else { - throw new Error("Failed to save"); - } - } catch (error) { - alert("保存中にエラーが発生しました。"); - } finally { - setSaving(false); - } - }; - - const handleEmbedDelete = async (title: string) => { - if (!confirm(`埋め込み「${title}」を削除してもよろしいですか?`)) return; - - try { - const response = await fetch(`/api/guilds/${guildId}/modules/embed`, { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title }), - }); - - if (response.ok) { - setSavedEmbeds((prev) => prev.filter((e) => e.title !== title)); - } else { - alert("削除に失敗しました。"); - } - } catch (error) { - console.error("Delete error:", error); +import { Suspense } from "react"; +import { fetchEmbedSettings, isModuleEnabled } from "@/lib/api/requests"; // ラッパー関数の場所 +import EmbedEditorClient from "./EmbedEditorClient"; +import LoadingSkeleton from "@/components/LoadingSkeleton"; +import Alert from "@/components/Alert"; +import { redirect } from "next/navigation"; + +interface Props { + params: { guildId: string }; +} + +export default async function WelcomeGoodbyeModulePage({ params }: Props) { + const { guildId } = await params; + try { + const data = await isModuleEnabled(guildId, "embed"); + + if (!data.enabled) { + return ; } - }; - - const handleEditClick = (embed: any) => { - setCurrentEmbed(embed); - window.scrollTo({ top: 0, behavior: "smooth" }); - }; - - if (loading) { - return ( -
-
-
- ); + } catch (error) { + redirect(`/dashboard/${guildId}`); } return (
-
-
-

埋め込み作成モジュール

-

特定のタイトルで埋め込みを保存・管理できます。

-
- - +
+

埋め込み作成モジュール

+

特定のタイトルで埋め込みを保存・管理できます。

-
-
-
- -

エディター

-
- - -
- - -
- {savedEmbeds.length === 0 ? ( -
-

保存された埋め込みはまだありません。

-
- ) : ( - savedEmbeds.map((embed: any) => ( -
-
-

{embed.title}

-

{embed.description || "説明なし"}

-
-
- -
-
- )) - )} -
-
-
+ {/* データの取得とエディター部分を分離 */} + }> + +

- ※ 同じタイトルの埋め込みを保存すると、上書きされます。 + ※ 同じ名前の埋め込みを保存すると、上書きされます。

); +} + +async function EmbedContent({ guildId }: { guildId: string }) { + const initialEmbeds = await fetchEmbedSettings(guildId); + + return ( + + ); } \ No newline at end of file diff --git a/src/dashboard/src/app/dashboard/[guildId]/help/page.tsx b/src/dashboard/src/app/dashboard/[guildId]/help/page.tsx index a48d523..63fb385 100644 --- a/src/dashboard/src/app/dashboard/[guildId]/help/page.tsx +++ b/src/dashboard/src/app/dashboard/[guildId]/help/page.tsx @@ -1,9 +1,11 @@ -"use client"; - +import { Suspense } from "react"; +import { redirect } from "next/navigation"; import { Terminal } from "lucide-react"; -import { useParams, useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; import CommandsControl from "@/components/commands"; +import LoadingSkeleton from "@/components/LoadingSkeleton"; + +import { isModuleEnabled } from "@/lib/api/requests"; +import Alert from "@/components/Alert"; const commands = [ { @@ -24,50 +26,27 @@ const commands = [ }, ]; -export default function HelpModuleSetting() { - const params = useParams(); - const router = useRouter(); - const guildId = params.guildId as string; +export default async function HelpModuleSetting({ params }: { params: { guildId: string } }) { + const { guildId } = await params; + try { + const data = await isModuleEnabled(guildId, "help"); - const [loading, setLoading] = useState(true); - - useEffect(() => { - async function init() { - (await fetch(`/api/guilds/${guildId}/modules/isEnabled?module=help`)) - .json() - .then((data) => { - if (data.enabled) { - setLoading(false); - } else { - alert("このサーバーではモジュールが有効になっていません。"); - router.push(`/dashboard/${guildId}`); - } - }); + if (!data.enabled) { + return ; } - init(); - }, [guildId, router]); - - if (loading) { - return ( -
-
-
- ); + } catch (error) { + redirect(`/dashboard/${guildId}`); } return (
-
-
-

- ヘルプモジュール -

-

- サーバーで使用するスラッシュコマンドの有効・無効を切り替えます。 -

-
-
+
+

ヘルプモジュール

+

+ サーバーで使用するスラッシュコマンドの有効・無効を切り替えます。 +

+

@@ -80,17 +59,18 @@ export default function HelpModuleSetting() {
-
- -
+ }> +
+ +
+

- ※ - 反映まで数分かかる場合があります。同期が完了しない場合はページを更新してください。 + ※ 反映まで数分かかる場合があります。同期が完了しない場合はページを更新してください。

); -} +} \ No newline at end of file diff --git a/src/dashboard/src/app/dashboard/[guildId]/page.tsx b/src/dashboard/src/app/dashboard/[guildId]/page.tsx index afd5d45..fd5e3d7 100644 --- a/src/dashboard/src/app/dashboard/[guildId]/page.tsx +++ b/src/dashboard/src/app/dashboard/[guildId]/page.tsx @@ -1,180 +1,53 @@ -"use client"; +import { Suspense } from "react"; +import { redirect } from "next/navigation"; +import { modules as modules_list } from "@/lib/modules"; +import ModuleCard from "@/components/ModuleCard"; +import { fetchGuildSettings } from "@/lib/api/requests"; +import ModuleList from "./client"; +import LoadingSkeleton from "@/components/LoadingSkeleton"; -import { useParams, useRouter } from "next/navigation"; -import { useEffect, useState, useMemo } from "react"; -import { type ModuleSetting, modules as modules_list } from "@/lib/modules"; +export default async function DashboardPage({ params }: { params: { guildId: string } }) { + const { guildId } = await params; -const ADMIN_PERMISSION = 0x8; - -export default function DashboardPage() { - const params = useParams(); - const router = useRouter(); - const guildId = params.guildId as string; - - const [loading, setLoading] = useState(true); - const [modules, setModules] = useState( - Array.from(modules_list.values()) - ); - - const groupedModules = useMemo(() => { - return modules.reduce((acc, mod) => { - const group = mod.group || "その他"; - if (!acc[group]) acc[group] = []; - acc[group].push(mod); - return acc; - }, {} as Record); - }, [modules]); - - useEffect(() => { - async function init() { - setLoading(true); - try { - const guildRes = await fetch("/api/discord/guilds"); - if (!guildRes.ok) throw new Error("認証に失敗しました"); - - const userGuilds = await guildRes.json(); - const targetGuild = userGuilds.find((g: any) => g.id === guildId); - - const hasPermission = - targetGuild && - (BigInt(targetGuild.permissions) & BigInt(ADMIN_PERMISSION)) === - BigInt(ADMIN_PERMISSION); - - if (!hasPermission) { - alert("このサーバーを管理する権限がありません。"); - router.push("/dashboard"); - return; - } - - const res = await fetch(`/api/guilds/${guildId}/modules`); - if (!res.ok) throw new Error("データの取得に失敗しました"); - - const data = await res.json(); - - if (data.modules) { - setModules((prev) => - prev.map((m) => ({ - ...m, - enabled: !!data.modules[m.id], - })) - ); - } - } catch (error) { - console.error("Initialization error:", error); - router.push("/dashboard"); - } finally { - setLoading(false); - } - } - - init(); - }, [guildId, router]); - - const toggleModule = async (moduleId: string) => { - const target = modules.find((m) => m.id === moduleId); - if (!target) return; - - const newState = !target.enabled; - - setModules((prev) => - prev.map((m) => (m.id === moduleId ? { ...m, enabled: newState } : m)) - ); - - try { - const res = await fetch(`/api/guilds/${guildId}/modules`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ moduleId, enabled: newState }), - }); - - if (!res.ok) throw new Error("保存失敗"); - } catch { - alert("設定の保存に失敗しました"); - setModules((prev) => - prev.map((m) => (m.id === moduleId ? { ...m, enabled: !newState } : m)) - ); - } - }; - - if (loading) { - return ( -
-
-
- ); - } + const dataPromise = fetchGuildSettings(guildId); return (
-
+

サーバー管理

ID: {guildId}

- -
- - {Object.entries(groupedModules).map(([groupName, items]) => ( -
-

- - {items.length} - - {groupName} -

- -
- {items.map((mod) => ( -
- -
-
- ))} -
- - ))} + }> + +
); +} + +async function ModuleListWrapper({ guildId, dataPromise }: { guildId: string, dataPromise: Promise }) { + const data = await dataPromise; + + if (!data) { + redirect("/dashboard"); + } + + const initialModules = Array.from(modules_list.values()).map(m => ({ + id: m.id, + name: m.name, + description: m.description, + group: m.group, + enabled: !!(data.EnabledModules && data.EnabledModules[m.id]) + })); + + return ( + + ); } \ No newline at end of file diff --git a/src/dashboard/src/app/dashboard/[guildId]/reaction_role/Client.tsx b/src/dashboard/src/app/dashboard/[guildId]/reaction_role/Client.tsx new file mode 100644 index 0000000..132127c --- /dev/null +++ b/src/dashboard/src/app/dashboard/[guildId]/reaction_role/Client.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { Save } from "lucide-react"; +import { useState } from "react"; +import ChannelSelecter from "@/components/channel-selecter"; +import EmbedSelecter from "@/components/EmbedSelecter"; +import ToggleSwitch from "@/components/toggleSwitch"; +import CollapsibleSection from "@/components/CollapsibleSection"; +import { ButtonRoleMap } from "@/constants/reaction_role/rolesmap"; +import ButtonRoleSelector from "@/components/ButtonRole"; + +interface Props { + guildId: string; + roles: any[]; +} + +export default function ReactionRoleClient({ guildId, roles }: Props) { + const [saving, setSaving] = useState(false); + + const [reactionType, setReactionType] = useState("button"); + + const [ButtonRoleMap, setButtonRoleMap] = useState({}); + + const [channel, setChannel] = useState(); + const [embed, setEmbed] = useState(); + const [content, setContent] = useState(""); + + const sendButtonRolePanel = async () => { + if (!channel) { + alert("チャンネルを選択してください。"); + return; + } + + if (Object.keys(ButtonRoleMap).length === 0) { + alert("ロールを最低でも一つ選択してください。"); + return; + } + + setSaving(true); + try { + const body = { + reaction_type: reactionType, + roles: ButtonRoleMap, + channelId: channel, + embedId: embed, + content: content, + } + + const response = await fetch(`/api/guilds/${guildId}/modules/reactionRole/button`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) throw new Error(); + alert("送信しました!"); + + } catch (e) { + alert("送信に失敗しました。"); + } finally { + setSaving(false); + } + } + + return ( +
+ +
+
+
+ + setChannel(id as any)} + /> +
+
+ + setEmbed(val as any)} + /> +
+
+
+ +