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)