Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/internal/bot/command/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import (
var Commands = []*discordgo.ApplicationCommand{
general.LoadPingCommandContext(),
general.LoadAboutCommandContext(),
general.LoadPinCommandContext(),
general.LoadUnpinCommandContext(),
general.LoadPinSelectCommandContext(),
general.LoadTtsCommandContext(),
general.LoadHelpCommandContext(),
admin.LoadMaintenanceCommandContext(),
Expand Down
88 changes: 88 additions & 0 deletions src/internal/bot/command/general/pin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package general

import (
"time"
"unibot/internal"

"github.com/bwmarrin/discordgo"
)

func LoadPinCommandContext() *discordgo.ApplicationCommand {
perm := int64(discordgo.PermissionManageMessages)
dm := false
contexts := []discordgo.InteractionContextType{discordgo.InteractionContextGuild}
return &discordgo.ApplicationCommand{
Name: "pin",
Description: "メッセージをピン留めします。",
DefaultMemberPermissions: &perm,
DMPermission: &dm,
Contexts: &contexts,
}
}

func Pin(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) {
config := ctx.Config

if !hasPinPermission(s, i) {
replyPinError(s, i, config, "権限がありません", "この操作を実行する権限がありません。")
return
}

showPinModal(s, i)
}

func showPinModal(s *discordgo.Session, i *discordgo.InteractionCreate) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseModal,
Data: &discordgo.InteractionResponseData{
CustomID: "pin_message",
Title: "メッセージのピン留め",
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "message",
Label: "投稿内容",
Style: discordgo.TextInputParagraph,
Placeholder: "投稿内容を入力してください。すでにPinされたメッセージがある場合は上書きされます。",
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The modal placeholder text states "すでにPinされたメッセージがある場合は上書きされます" (existing pinned messages will be overwritten), but this contradicts the actual behavior in pin_select.go (lines 61-64) which prevents pinning when a message already exists. This discrepancy in the user-facing message creates confusion about the expected behavior. Ensure the placeholder text accurately reflects the actual implementation, or align the implementation with this stated behavior by allowing overwrites.

Copilot uses AI. Check for mistakes.
Required: true,
},
}},
},
},
})
}

func hasPinPermission(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
if i.Member == nil || i.Member.User == nil {
return false
}
perms, err := s.UserChannelPermissions(i.Member.User.ID, i.ChannelID)
if err != nil {
return false
}
return perms&discordgo.PermissionManageMessages != 0
}

func replyPinError(s *discordgo.Session, i *discordgo.InteractionCreate, config *internal.Config, title, description string) {
footer := &discordgo.MessageEmbedFooter{Text: "Requested by Unknown"}
if i.Member != nil {
footer.Text = "Requested by " + i.Member.DisplayName()
footer.IconURL = i.Member.AvatarURL("")
} else if i.User != nil {
footer.Text = "Requested by " + i.User.Username
footer.IconURL = i.User.AvatarURL("")
}

_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Embeds: &[]*discordgo.MessageEmbed{
{
Title: title,
Description: description,
Color: config.Colors.Error,
Footer: footer,
Timestamp: time.Now().Format(time.RFC3339),
},
},
Flags: discordgo.MessageFlagsEphemeral,
})
}
124 changes: 124 additions & 0 deletions src/internal/bot/command/general/pin_modal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package general

import (
"unibot/internal"
"unibot/internal/model"
"unibot/internal/repository"

"github.com/bwmarrin/discordgo"
)

// HandlePinModalSubmit はピン留めモーダルの送信を処理する
func HandlePinModalSubmit(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) bool {
data := i.ModalSubmitData()
if data.CustomID != "pin_message" {
return false
}

_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
},
})

config := ctx.Config
if !hasPinPermission(s, i) {
replyPinError(s, i, config, "権限がありません", "この操作を実行する権限がありません。")
return true
}

message := getPinModalValue(data, "message")
if message == "" {
replyPinError(s, i, config, "入力エラー", "投稿内容を入力してください。")
return true
}

channel, err := s.State.Channel(i.ChannelID)
if err != nil {
channel, _ = s.Channel(i.ChannelID)
}
if channel == nil || channel.Type == discordgo.ChannelTypeDM || channel.Type == discordgo.ChannelTypeGroupDM {
replyPinError(s, i, config, "エラー", "このチャンネルではメッセージを送信できません。")
return true
}

repo := repository.NewPinSettingRepository(ctx.DB)
existing, err := repo.GetByChannelID(i.ChannelID)
if err != nil {
replyPinError(s, i, config, "エラー", "ピン留めの取得に失敗しました。")
return true
}
if len(existing) > 0 {
replyPinError(s, i, config, "エラー", "このチャンネルには既にピン留めされたメッセージがあります。\n最初にそれを`/unpin`で解除してください。")
return true
}

embed := &discordgo.MessageEmbed{
Description: message,
Color: config.Colors.Success,
Footer: &discordgo.MessageEmbedFooter{
Text: "Pinned Message",
},
}

sentMessage, err := s.ChannelMessageSendEmbed(i.ChannelID, embed)
if err != nil {
replyPinError(s, i, config, "エラー", "メッセージの送信に失敗しました。")
return true
}
Comment on lines +65 to +69
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When updating or creating a pinned message, the code does not delete the previous pinned message from the channel. In pin_select.go lines 74-78 and resendPinnedMessage lines 111-113, the old message is deleted before sending a new one. This inconsistency will leave old pinned messages in the channel when using the /pin modal. Add code to delete the previous message (if it exists) before sending the new one, similar to the pattern in resendPinnedMessage.

Copilot uses AI. Check for mistakes.

setting := &model.PinSetting{
ID: i.ChannelID,
URL: sentMessage.ID,
Title: "Pinned Message",
Content: message,
GuildID: i.GuildID,
ChannelID: i.ChannelID,
}

if err := repo.Create(setting); err != nil {
replyPinError(s, i, config, "エラー", "ピン留めの保存に失敗しました。")
return true
}

content := "メッセージをピン留めしました: `" + message + "`"
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: &content,
})

return true
}

func getPinModalValue(data discordgo.ModalSubmitInteractionData, customID string) string {
for _, comp := range data.Components {
switch row := comp.(type) {
case *discordgo.ActionsRow:
if value := getTextInputValue(row.Components, customID); value != "" {
return value
}
case discordgo.ActionsRow:
if value := getTextInputValue(row.Components, customID); value != "" {
return value
}
}
}

return ""
}

func getTextInputValue(components []discordgo.MessageComponent, customID string) string {
for _, component := range components {
switch input := component.(type) {
case *discordgo.TextInput:
if input.CustomID == customID {
return input.Value
}
case discordgo.TextInput:
if input.CustomID == customID {
return input.Value
}
}
}
return ""
}
Comment on lines 109 to 124
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The replyPinSuccess function is defined but never used anywhere in the codebase. Consider removing this unused function to reduce code clutter and maintenance burden.

Suggested change
func replyPinSuccess(s *discordgo.Session, i *discordgo.InteractionCreate, config *internal.Config, title string) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Embeds: []*discordgo.MessageEmbed{
{
Title: title,
Color: config.Colors.Success,
Timestamp: time.Now().Format(time.RFC3339),
},
},
Flags: discordgo.MessageFlagsEphemeral,
},
})
}

Copilot uses AI. Check for mistakes.
106 changes: 106 additions & 0 deletions src/internal/bot/command/general/pin_select.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package general

import (
"time"
"unibot/internal"
"unibot/internal/model"
"unibot/internal/repository"

"github.com/bwmarrin/discordgo"
)

func LoadPinSelectCommandContext() *discordgo.ApplicationCommand {
contexts := []discordgo.InteractionContextType{discordgo.InteractionContextGuild}
return &discordgo.ApplicationCommand{
Name: "Pinするメッセージを選択",
Type: discordgo.MessageApplicationCommand,
Contexts: &contexts,
}
}

func PinSelect(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) {
config := ctx.Config

if !hasPinPermission(s, i) {
replyPinError(s, i, config, "権限がありません", "この操作を実行する権限がありません。")
return
}

data := i.ApplicationCommandData()
if data.Resolved == nil || data.Resolved.Messages == nil {
replyPinError(s, i, config, "エラー", "メッセージの取得に失敗しました。")
return
}

targetMsg, ok := data.Resolved.Messages[data.TargetID]
if !ok || targetMsg == nil {
replyPinError(s, i, config, "エラー", "メッセージの取得に失敗しました。")
return
}

if targetMsg.Author != nil && targetMsg.Author.Bot {
replyPinError(s, i, config, "エラー", "ボットのメッセージはピン留めできません。")
return
}

channel, err := s.State.Channel(i.ChannelID)
if err != nil {
channel, _ = s.Channel(i.ChannelID)
}
if channel == nil || channel.Type == discordgo.ChannelTypeDM || channel.Type == discordgo.ChannelTypeGroupDM {
replyPinError(s, i, config, "エラー", "このチャンネルではメッセージをピン留めできません。")
return
}

repo := repository.NewPinSettingRepository(ctx.DB)
settings, err := repo.GetByChannelID(i.ChannelID)
if err != nil {
replyPinError(s, i, config, "エラー", "ピン留めの取得に失敗しました。")
return
}
if len(settings) > 0 {
replyPinError(s, i, config, "エラー", "このチャンネルには既にピン留めされたメッセージがあります。\n最初にそれを`/unpin`で解除してください。")
return
}

embed := &discordgo.MessageEmbed{
Description: targetMsg.Content,
Color: config.Colors.Success,
Footer: &discordgo.MessageEmbedFooter{
Text: "Pinned Message",
},
}

sentMessage, err := s.ChannelMessageSendEmbed(i.ChannelID, embed)
if err != nil {
replyPinError(s, i, config, "エラー", "メッセージの送信に失敗しました。")
return
}

setting := &model.PinSetting{
ID: i.ChannelID,
URL: sentMessage.ID,
Title: "Pinned Message",
Content: targetMsg.Content,
GuildID: i.GuildID,
ChannelID: i.ChannelID,
}

err = repo.Create(setting)
if err != nil {
replyPinError(s, i, config, "エラー", "ピン留めの保存に失敗しました。")
return
}
Comment on lines +74 to +93
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the pin message context menu handler, after successfully pinning a message, there is no cleanup of the old pinned message if one exists. Unlike the modal handler and resendPinnedMessage function, this code doesn't check for or delete existing pinned messages before creating a new one. This is inconsistent with the error check at lines 61-64 which prevents creating a second pin. If that check were ever removed or if there's a race condition, this could result in multiple pinned messages accumulating in the channel. Consider deleting the old pinned message if settings already exist before the check at line 61.

Copilot uses AI. Check for mistakes.

successEmbed := &discordgo.MessageEmbed{
Title: "メッセージをピン留めしました",
Description: "このメッセージは今後ピン留めされます。\nファイルは保存されないのでご注意ください。",
Color: config.Colors.Success,
Timestamp: time.Now().Format(time.RFC3339),
}

_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Embeds: &[]*discordgo.MessageEmbed{successEmbed},
Flags: discordgo.MessageFlagsEphemeral,
})
}
63 changes: 63 additions & 0 deletions src/internal/bot/command/general/unpin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package general

import (
"time"
"unibot/internal"
"unibot/internal/repository"

"github.com/bwmarrin/discordgo"
)

func LoadUnpinCommandContext() *discordgo.ApplicationCommand {
perm := int64(discordgo.PermissionManageMessages)
dm := false
contexts := []discordgo.InteractionContextType{discordgo.InteractionContextGuild}
return &discordgo.ApplicationCommand{
Name: "unpin",
Description: "ピン留めを解除します。",
DefaultMemberPermissions: &perm,
DMPermission: &dm,
Contexts: &contexts,
}
}

func Unpin(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) {
config := ctx.Config

if !hasPinPermission(s, i) {
replyPinError(s, i, config, "権限がありません", "この操作を実行する権限がありません。")
return
}

repo := repository.NewPinSettingRepository(ctx.DB)
settings, err := repo.GetByChannelID(i.ChannelID)
if err != nil {
replyPinError(s, i, config, "エラーが発生しました", "ピン留めの解除中にエラーが発生しました。")
return
}
if len(settings) == 0 {
content := "このチャンネルにはピン留めされたメッセージがありません。"
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: &content,
Flags: discordgo.MessageFlagsEphemeral,
})
return
}

err = repo.DeleteByChannelID(i.ChannelID)
if err != nil {
replyPinError(s, i, config, "エラーが発生しました", "ピン留めの解除中にエラーが発生しました。")
return
}
Comment on lines +47 to +51
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unpin command deletes the pin setting from the database but does not delete the actual pinned message from the Discord channel. Users will see the pinned message remain in the channel even after unpinning. Before deleting from the database (line 49), iterate through the settings and delete each message using s.ChannelMessageDelete(i.ChannelID, setting.URL) to clean up the Discord channel as well.

Copilot uses AI. Check for mistakes.

successEmbed := &discordgo.MessageEmbed{
Title: "ピン留めを解除しました",
Color: config.Colors.Success,
Timestamp: time.Now().Format(time.RFC3339),
}

_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Embeds: &[]*discordgo.MessageEmbed{successEmbed},
Flags: discordgo.MessageFlagsEphemeral,
})
}
Loading