diff --git a/CLAUDE.md b/CLAUDE.md index 5298c3a..18f3ca4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ ## アーキテクチャ ### 採用アーキテクチャ -クリーンアーキテクチャの軽量版(entities/usecases/interfaces) +クリーンアーキテクチャ(entities/usecases/handlers/gateways) ### ディレクトリ構成 @@ -36,9 +36,11 @@ reaction-bot/ ├── entities/ # エンティティ層 │ └── config.go # 設定とドメインモデル ├── usecases/ # ユースケース層 - │ └── transfer_message.go # メッセージ転送のビジネスロジック - └── interfaces/ # インターフェース層 - └── discord_handler.go # Discordイベントハンドラー + │ └── transfer_message.go # ビジネスロジック + インターフェース定義 + ├── handlers/ # ハンドラー層(Controller) + │ └── discord_handler.go # Discordイベントハンドラー + └── gateways/ # ゲートウェイ層(外部APIとの橋渡し) + └── discord_gateway.go # Discord API実装 ``` ### 各レイヤーの責務 @@ -48,21 +50,33 @@ reaction-bot/ - ドメインモデルの定義 - 環境変数の読み込みとバリデーション - 外部ライブラリに依存しない純粋なビジネスルール +- **依存**: なし(何も知らない) #### usecases/ - メッセージ転送のビジネスロジック -- Discord APIを使ったメッセージ取得・送信 -- 転送メッセージの整形 -- entitiesに依存、interfacesからは独立 - -#### interfaces/ -- Discordイベントのハンドリング -- 外部ライブラリ(discordgo)とのやり取り -- usecasesの呼び出し +- 外部APIのインターフェース定義(DiscordClient) +- ビジネスルールの実装 +- **依存**: entities のみ +- **重要**: 外部ライブラリ(discordgo)に直接依存しない + +#### handlers/(Interface Adapters - Controller) +- 外部からの入力を受け取る(Discordイベントのハンドリング) +- UseCaseの呼び出しを統制(orchestrate) +- イベントをビジネスロジックに変換 +- **依存**: usecases, gateways, entities +- **役割**: 入力側のアダプター + +#### gateways/(Interface Adapters - Gateway) +- 外部APIとの橋渡し(Discord API実装) +- usecasesで定義されたインターフェースを実装 +- 外部ライブラリ(discordgo)の具体的な呼び出し +- **依存**: usecases(インターフェース), discordgo +- **役割**: 出力側のアダプター #### main.go - アプリケーションのエントリーポイント - 依存関係の注入(DI) +- 依存関係の組み立て順序: entities → usecases → gateway → handler - Discord botの起動とシャットダウン処理 ## 環境変数 @@ -90,7 +104,7 @@ reaction-bot/ ### Go言語ベストプラクティス #### 命名規則 -- **パッケージ名**: 小文字、単数形、短く簡潔(`entities`, `usecases`, `interfaces`) +- **パッケージ名**: 小文字、単数形、短く簡潔(`entities`, `usecases`, `handlers`, `gateways`) - **変数名**: キャメルケース、明示的で説明的な名前 - 一般的で伝わる略語はOK: `cfg`, `msg`, `ch`, `ctx`, `err`, `id` - 伝わりにくい略語はNG: `fwdCh`, `rctMap` など diff --git a/docs/architecture.md b/docs/architecture.md index 59de895..583cd91 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,12 +8,14 @@ graph TB MAIN[main.go
エントリーポイント
DI・起動・シャットダウン] end - subgraph "Interface Layer" - HANDLER[DiscordHandler
Discord イベントハンドリング
HandleReactionAdd
HandleReactionRemove] + subgraph "Interface Adapters Layer" + HANDLER[DiscordHandler
イベントハンドリング
HandleReactionAdd
HandleReactionRemove] + GATEWAY[DiscordGateway
外部API橋渡し
GetMessage
SendMessageWithReference
DeleteMessage] end subgraph "Use Cases Layer" - TRANSFER[TransferMessageUseCase
メッセージ転送ロジック
TransferMessage
DeleteTransferredMessage] + TRANSFER[TransferMessageUseCase
ビジネスロジック
TransferMessage
DeleteTransferredMessage] + INTERFACE[DiscordClient
インターフェース定義] end subgraph "Entities Layer" @@ -26,27 +28,31 @@ graph TB end MAIN -->|creates & injects| HANDLER + MAIN -->|creates & injects| GATEWAY MAIN -->|creates & injects| TRANSFER MAIN -->|loads| CONFIG HANDLER -->|uses| TRANSFER + HANDLER -->|uses| GATEWAY HANDLER -->|references| CONFIG + GATEWAY -.->|implements| INTERFACE + TRANSFER -->|uses| INTERFACE TRANSFER -->|references| CONFIG CONFIG -->|reads| ENV - HANDLER <-->|API calls| DISCORD - TRANSFER <-->|API calls| DISCORD + HANDLER <-->|discordgo| DISCORD + GATEWAY <-->|discordgo| DISCORD classDef presentation fill:#ff6b6b,stroke:#c92a2a,color:#fff - classDef interface fill:#ffd93d,stroke:#f08700,color:#000 + classDef adapter fill:#ffd93d,stroke:#f08700,color:#000 classDef usecase fill:#4ecdc4,stroke:#0a9396,color:#fff classDef entity fill:#95e1d3,stroke:#38b000,color:#000 classDef external fill:#e0e0e0,stroke:#666,color:#000 class MAIN presentation - class HANDLER interface - class TRANSFER usecase + class HANDLER,GATEWAY adapter + class TRANSFER,INTERFACE usecase class CONFIG entity class DISCORD,ENV external ``` @@ -54,13 +60,16 @@ graph TB ## レイヤー責務 ### Presentation Layer -- **main.go**: アプリケーションのエントリーポイント、依存関係の注入(DI)、Bot起動・シャットダウン +- **main.go**: エントリーポイント、依存関係の注入(DI)、Bot起動・シャットダウン -### Interface Layer -- **DiscordHandler**: Discordイベントのハンドリング、外部ライブラリ(discordgo)との接続 +### Interface Adapters Layer +- **DiscordHandler (handlers/)**: 入力側アダプター - Discordイベントのハンドリング、UseCaseの呼び出し +- **DiscordGateway (gateways/)**: 出力側アダプター - 外部API(discordgo)との橋渡し ### Use Cases Layer -- **TransferMessageUseCase**: メッセージ転送のビジネスロジック、Discord API操作のカプセル化 +- **TransferMessageUseCase**: メッセージ転送のビジネスロジック +- **DiscordClient**: Discord API通信のインターフェース定義 +- **重要**: 外部ライブラリ(discordgo)に直接依存しない ### Entities Layer - **Config**: 設定とドメインモデル、環境変数の読み込みとバリデーション @@ -68,7 +77,12 @@ graph TB ## 依存関係の方向 ``` -Presentation → Interface → Use Cases → Entities +Presentation → Interface Adapters → Use Cases → Entities + (handlers, gateways) ``` -各レイヤーは内側(下位)のレイヤーのみに依存し、外側(上位)のレイヤーに依存しない(クリーンアーキテクチャの原則) \ No newline at end of file +### クリーンアーキテクチャの原則 +- 各レイヤーは内側のレイヤーのみに依存 +- UseCasesは**インターフェース**のみ定義、Gatewaysが**実装** +- HandlersとGatewaysは同じInterface Adapters層(同等の立場) +- Handlersがビジネスフローを統制(orchestrate)、Gatewaysを呼び出す \ No newline at end of file diff --git a/docs/flow.md b/docs/flow.md index bb488d9..00b5a0b 100644 --- a/docs/flow.md +++ b/docs/flow.md @@ -7,24 +7,29 @@ sequenceDiagram actor User as ユーザー participant Discord as Discord API participant Handler as DiscordHandler + participant Gateway as DiscordGateway participant UseCase as TransferMessageUseCase participant Config as Config User->>Discord: カスタム絵文字リアクションを追加 Discord->>Handler: MessageReactionAdd イベント Handler->>Handler: isTriggerReactionEmoji()
対象絵文字かチェック - Handler->>Discord: 元メッセージを取得 - Discord-->>Handler: メッセージデータ + Handler->>Gateway: GetMessage() + Gateway->>Discord: 元メッセージを取得 + Discord-->>Gateway: メッセージデータ + Gateway-->>Handler: Message Handler->>Handler: getTriggerReactionCount()
重複チェック alt 既に転送済み (reactionCount > 1) Handler->>Handler: 転送をスキップ else 初回のリアクション (reactionCount == 1) - Handler->>UseCase: TransferMessage() + Handler->>UseCase: TransferMessage(gateway, message) UseCase->>Config: 転送先チャンネルID取得 - UseCase->>Discord: メッセージ転送 - Discord-->>UseCase: 転送メッセージID + UseCase->>Gateway: SendMessageWithReference() + Gateway->>Discord: メッセージ転送 + Discord-->>Gateway: 転送メッセージID + Gateway-->>UseCase: 転送メッセージID UseCase->>UseCase: transferMsgMapping に保存
(元ID → 転送ID) - UseCase->>Handler: 成功 + UseCase-->>Handler: 成功 end ``` @@ -46,6 +51,7 @@ sequenceDiagram actor User as ユーザー participant Discord as Discord API participant Handler as DiscordHandler + participant Gateway as DiscordGateway participant UseCase as TransferMessageUseCase User->>Discord: カスタム絵文字リアクションを削除 @@ -57,12 +63,14 @@ sequenceDiagram alt まだリアクションが残っている (count > 0) Handler->>Handler: 削除をスキップ else 全てのリアクションが削除された (count == 0) - Handler->>UseCase: DeleteTransferredMessage() + Handler->>UseCase: DeleteTransferredMessage(gateway, messageID) UseCase->>UseCase: transferMsgMapping から
転送メッセージID取得 - UseCase->>Discord: 転送メッセージを削除 - Discord-->>UseCase: 削除完了 + UseCase->>Gateway: DeleteMessage() + Gateway->>Discord: 転送メッセージを削除 + Discord-->>Gateway: 削除完了 + Gateway-->>UseCase: 成功 UseCase->>UseCase: transferMsgMapping から削除 - UseCase->>Handler: 成功 + UseCase-->>Handler: 成功 end ``` diff --git a/internal/gateways/discord_gateway.go b/internal/gateways/discord_gateway.go new file mode 100644 index 0000000..cd653f3 --- /dev/null +++ b/internal/gateways/discord_gateway.go @@ -0,0 +1,59 @@ +package gateways + +import ( + "reaction/internal/usecases" + + "github.com/bwmarrin/discordgo" +) + +// DiscordGateway - Discord APIとの通信ゲートウェイ +type DiscordGateway struct { + session *discordgo.Session +} + +// NewDiscordGateway - 新しいDiscordGatewayを作成 +func NewDiscordGateway(session *discordgo.Session) *DiscordGateway { + return &DiscordGateway{ + session: session, + } +} + +// GetMessage - メッセージを取得 +func (g *DiscordGateway) GetMessage(channelID, messageID string) (*usecases.Message, error) { + msg, err := g.session.ChannelMessage(channelID, messageID) + if err != nil { + return nil, err + } + + return &usecases.Message{ + GuildID: msg.GuildID, + ChannelID: msg.ChannelID, + ID: msg.ID, + Content: msg.Content, + }, nil +} + +// SendMessageWithReference - メッセージ参照を使って転送 +func (g *DiscordGateway) SendMessageWithReference(channelID string, ref *usecases.MessageReference) (string, error) { + discordRef := &discordgo.MessageReference{ + GuildID: ref.GuildID, + ChannelID: ref.ChannelID, + MessageID: ref.MessageID, + } + + transferMsgSend := &discordgo.MessageSend{ + Reference: discordRef, + } + + transferredMsg, err := g.session.ChannelMessageSendComplex(channelID, transferMsgSend) + if err != nil { + return "", err + } + + return transferredMsg.ID, nil +} + +// DeleteMessage - メッセージを削除 +func (g *DiscordGateway) DeleteMessage(channelID, messageID string) error { + return g.session.ChannelMessageDelete(channelID, messageID) +} diff --git a/internal/interfaces/discord_handler.go b/internal/handlers/discord_handler.go similarity index 62% rename from internal/interfaces/discord_handler.go rename to internal/handlers/discord_handler.go index 1782a09..4322d78 100644 --- a/internal/interfaces/discord_handler.go +++ b/internal/handlers/discord_handler.go @@ -1,4 +1,4 @@ -package interfaces +package handlers import ( "log" @@ -12,21 +12,25 @@ import ( // DiscordHandler - Discordイベントを処理するハンドラー type DiscordHandler struct { transferUseCase *usecases.TransferMessageUseCase + gateway usecases.DiscordClient config *entities.Config } -// NewDiscordHandler - 新しいDiscordHandlerを作成する -// WHY: UseCase を注入することで、DiscordHandler の責務を分離する -func NewDiscordHandler(transferUseCase *usecases.TransferMessageUseCase, config *entities.Config) *DiscordHandler { +// NewDiscordHandler - 新しいDiscordHandlerを作成 +func NewDiscordHandler( + transferUseCase *usecases.TransferMessageUseCase, + gateway usecases.DiscordClient, + config *entities.Config, +) *DiscordHandler { return &DiscordHandler{ transferUseCase: transferUseCase, + gateway: gateway, config: config, } } -// HandleReactionAdd - リアクション追加イベントを処理する +// HandleReactionAdd - リアクション追加イベントを処理 func (h *DiscordHandler) HandleReactionAdd(s *discordgo.Session, r *discordgo.MessageReactionAdd) { - // WHY: Bot自身のリアクションは処理しない(無限ループ防止) if r.UserID == s.State.User.ID { return } @@ -35,36 +39,38 @@ func (h *DiscordHandler) HandleReactionAdd(s *discordgo.Session, r *discordgo.Me return } - // WHY: 転送メッセージへのリアクションは無視する(転送の連鎖を防ぐ) if h.transferUseCase.IsTransferredMessage(r.MessageID) { log.Printf("メッセージ %s は転送メッセージのため、リアクション追加を無視します", r.MessageID) return } - originalMsg, err := s.ChannelMessage(r.ChannelID, r.MessageID) + originalMsg, err := h.gateway.GetMessage(r.ChannelID, r.MessageID) if err != nil { log.Printf("メッセージ取得に失敗: %v", err) return } - // WHY: すでにトリガーリアクションが付与されている場合は転送しない(重複転送を防ぐ) - reactionCount := h.getTriggerReactionCount(originalMsg) + discordMsg, err := s.ChannelMessage(r.ChannelID, r.MessageID) + if err != nil { + log.Printf("リアクション確認のためのメッセージ取得に失敗: %v", err) + return + } + + reactionCount := h.getTriggerReactionCount(discordMsg) if reactionCount > 1 { log.Printf("メッセージ %s には既にトリガーリアクションが %d 個ついているため、転送をスキップします", r.MessageID, reactionCount) return } - // 転送メッセージを作成し、転送メッセージIDを保存 - err = h.transferUseCase.TransferMessage(s, originalMsg) + err = h.transferUseCase.TransferMessage(h.gateway, originalMsg) if err != nil { log.Printf("メッセージ転送に失敗: %v", err) return } } -// HandleReactionRemove - リアクション削除イベントを処理する +// HandleReactionRemove - リアクション削除イベントを処理 func (h *DiscordHandler) HandleReactionRemove(s *discordgo.Session, r *discordgo.MessageReactionRemove) { - // WHY: Bot自身のリアクションは処理しない(無限ループ防止) if r.UserID == s.State.User.ID { return } @@ -73,27 +79,24 @@ func (h *DiscordHandler) HandleReactionRemove(s *discordgo.Session, r *discordgo return } - // WHY: 転送メッセージへのリアクションは無視する(転送の連鎖を防ぐ) if h.transferUseCase.IsTransferredMessage(r.MessageID) { log.Printf("メッセージ %s は転送メッセージのため、リアクション削除を無視します", r.MessageID) return } - originalMsg, err := s.ChannelMessage(r.ChannelID, r.MessageID) + discordMsg, err := s.ChannelMessage(r.ChannelID, r.MessageID) if err != nil { log.Printf("メッセージ取得に失敗: %v", err) return } - // WHY: トリガーリアクションが0個になった場合のみ転送メッセージを削除 - triggerReactionCount := h.getTriggerReactionCount(originalMsg) + triggerReactionCount := h.getTriggerReactionCount(discordMsg) if triggerReactionCount > 0 { log.Printf("メッセージ %s にはまだトリガーリアクションが %d 個残っているため、削除をスキップします", r.MessageID, triggerReactionCount) return } - // 転送メッセージを削除(保存してあった、転送メッセージIDをキーにして削除) - err = h.transferUseCase.DeleteTransferredMessage(s, r.MessageID) + err = h.transferUseCase.DeleteTransferredMessage(h.gateway, r.MessageID) if err != nil { log.Printf("転送メッセージの削除に失敗: %v", err) return diff --git a/internal/usecases/transfer_message.go b/internal/usecases/transfer_message.go index 80b6579..4e24ec0 100644 --- a/internal/usecases/transfer_message.go +++ b/internal/usecases/transfer_message.go @@ -5,18 +5,38 @@ import ( "sync" "reaction/internal/entities" - - "github.com/bwmarrin/discordgo" ) -// TransferMessageUseCase - メッセージ転送のビジネスロジックを担当する +// MessageReference - メッセージ参照情報 +type MessageReference struct { + GuildID string + ChannelID string + MessageID string +} + +// Message - メッセージ情報 +type Message struct { + GuildID string + ChannelID string + ID string + Content string +} + +// DiscordClient - Discord APIとの通信インターフェース +type DiscordClient interface { + GetMessage(channelID, messageID string) (*Message, error) + SendMessageWithReference(channelID string, ref *MessageReference) (string, error) + DeleteMessage(channelID, messageID string) error +} + +// TransferMessageUseCase - メッセージ転送のビジネスロジック type TransferMessageUseCase struct { config *entities.Config - transferMsgMapping map[string]string // 元メッセージID と 転送メッセージID のマッピング - mappingMutex sync.RWMutex // マッピング操作の排他制御(並行処理を防ぐ) + transferMsgMapping map[string]string + mappingMutex sync.RWMutex } -// NewTransferMessageUseCase - 新しいTransferMessageUseCaseを作成する +// NewTransferMessageUseCase - 新しいTransferMessageUseCaseを作成 func NewTransferMessageUseCase(config *entities.Config) *TransferMessageUseCase { return &TransferMessageUseCase{ config: config, @@ -24,40 +44,36 @@ func NewTransferMessageUseCase(config *entities.Config) *TransferMessageUseCase } } -// TransferMessage - 転送メッセージを作成し、転送メッセージIDを保存 +// TransferMessage - メッセージを転送し、マッピングを保存 func (uc *TransferMessageUseCase) TransferMessage( - session *discordgo.Session, - originalMsg *discordgo.Message, + client DiscordClient, + originalMsg *Message, ) error { - // WHY: Discord APIのメッセージ転送機能を使用するため、元メッセージへの参照を作成 - transferRef := originalMsg.Forward() - - transferMsgSend := &discordgo.MessageSend{ - Reference: transferRef, + transferRef := &MessageReference{ + GuildID: originalMsg.GuildID, + ChannelID: originalMsg.ChannelID, + MessageID: originalMsg.ID, } - // 指定されたチャンネルに転送 - transferredMsg, err := session.ChannelMessageSendComplex(uc.config.TransferChannelID, transferMsgSend) + transferredMsgID, err := client.SendMessageWithReference(uc.config.TransferChannelID, transferRef) if err != nil { log.Printf("メッセージ転送に失敗: %v", err) return err } - // 転送メッセージIDを保存 uc.mappingMutex.Lock() - uc.transferMsgMapping[originalMsg.ID] = transferredMsg.ID + uc.transferMsgMapping[originalMsg.ID] = transferredMsgID uc.mappingMutex.Unlock() - log.Printf("メッセージ %s をチャンネル %s に転送しました (転送メッセージID: %s)", originalMsg.ID, uc.config.TransferChannelID, transferredMsg.ID) + log.Printf("メッセージ %s をチャンネル %s に転送しました (転送メッセージID: %s)", originalMsg.ID, uc.config.TransferChannelID, transferredMsgID) return nil } -// DeleteTransferredMessage - 転送されたメッセージを削除する +// DeleteTransferredMessage - 転送メッセージを削除 func (uc *TransferMessageUseCase) DeleteTransferredMessage( - session *discordgo.Session, + client DiscordClient, originalMsgID string, ) error { - // マッピングから転送メッセージIDを取得 uc.mappingMutex.RLock() transferredMsgID, exists := uc.transferMsgMapping[originalMsgID] uc.mappingMutex.RUnlock() @@ -67,14 +83,12 @@ func (uc *TransferMessageUseCase) DeleteTransferredMessage( return nil } - // 転送メッセージを削除 - err := session.ChannelMessageDelete(uc.config.TransferChannelID, transferredMsgID) + err := client.DeleteMessage(uc.config.TransferChannelID, transferredMsgID) if err != nil { log.Printf("転送メッセージの削除に失敗: %v", err) return err } - // マッピングから削除 uc.mappingMutex.Lock() delete(uc.transferMsgMapping, originalMsgID) uc.mappingMutex.Unlock() @@ -83,12 +97,11 @@ func (uc *TransferMessageUseCase) DeleteTransferredMessage( return nil } -// IsTransferredMessage - 指定されたメッセージIDが転送メッセージかどうかを判定する +// IsTransferredMessage - メッセージが転送先かどうかを判定 func (uc *TransferMessageUseCase) IsTransferredMessage(msgID string) bool { uc.mappingMutex.RLock() defer uc.mappingMutex.RUnlock() - // WHY: transferMsgMappingの値として存在する場合、そのメッセージは転送メッセージ for _, transferredMsgID := range uc.transferMsgMapping { if transferredMsgID == msgID { return true diff --git a/main.go b/main.go index 7b43892..c4e4150 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,8 @@ import ( "syscall" "reaction/internal/entities" - "reaction/internal/interfaces" + "reaction/internal/gateways" + "reaction/internal/handlers" "reaction/internal/usecases" "github.com/bwmarrin/discordgo" @@ -20,16 +21,21 @@ func main() { log.Fatal("設定の読み込みに失敗:", err) } - // UseCase を作成し、DiscordHandler に渡す - transferUseCase := usecases.NewTransferMessageUseCase(cfg) - discordHandler := interfaces.NewDiscordHandler(transferUseCase, cfg) - // Discord Bot 接続 dg, err := discordgo.New("Bot " + cfg.DiscordBotToken) if err != nil { log.Fatal("Discordセッションの作成に失敗:", err) } + // Gateway を作成(外部APIとの橋渡し) + discordGateway := gateways.NewDiscordGateway(dg) + + // UseCase を作成 + transferUseCase := usecases.NewTransferMessageUseCase(cfg) + + // Handler を作成し、UseCase と Gateway を注入 + discordHandler := handlers.NewDiscordHandler(transferUseCase, discordGateway, cfg) + dg.AddHandler(discordHandler.HandleReactionAdd) dg.AddHandler(discordHandler.HandleReactionRemove)