refactor: remove Groq voice transcription dependency

Groq Whisper APIによる音声文字起こし機能を削除。
各チャンネル(Discord/Telegram/Slack)の音声添付処理を
汎用メディア処理に簡素化し、pkg/voiceパッケージを削除。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
KoheiYamashita 2026-02-24 15:13:27 +09:00
parent 6a46cb0bb6
commit 411df971ec
5 changed files with 16 additions and 285 deletions

View file

@ -12,11 +12,9 @@ import (
"github.com/KarakuriAgent/clawdroid/pkg/config" "github.com/KarakuriAgent/clawdroid/pkg/config"
"github.com/KarakuriAgent/clawdroid/pkg/logger" "github.com/KarakuriAgent/clawdroid/pkg/logger"
"github.com/KarakuriAgent/clawdroid/pkg/utils" "github.com/KarakuriAgent/clawdroid/pkg/utils"
"github.com/KarakuriAgent/clawdroid/pkg/voice"
) )
const ( const (
transcriptionTimeout = 30 * time.Second
sendTimeout = 10 * time.Second sendTimeout = 10 * time.Second
) )
@ -24,7 +22,6 @@ type DiscordChannel struct {
*BaseChannel *BaseChannel
session *discordgo.Session session *discordgo.Session
config config.DiscordConfig config config.DiscordConfig
transcriber *voice.GroqTranscriber
ctx context.Context ctx context.Context
} }
@ -40,15 +37,10 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
BaseChannel: base, BaseChannel: base,
session: session, session: session,
config: cfg, config: cfg,
transcriber: nil,
ctx: context.Background(), ctx: context.Background(),
}, nil }, nil
} }
func (c *DiscordChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
c.transcriber = transcriber
}
func (c *DiscordChannel) getContext() context.Context { func (c *DiscordChannel) getContext() context.Context {
if c.ctx == nil { if c.ctx == nil {
return context.Background() return context.Background()
@ -319,48 +311,13 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
}() }()
for _, attachment := range m.Attachments { for _, attachment := range m.Attachments {
isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType)
if isAudio {
localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
if localPath != "" {
localFiles = append(localFiles, localPath)
transcribedText := ""
if c.transcriber != nil && c.transcriber.IsAvailable() {
ctx, cancel := context.WithTimeout(c.getContext(), transcriptionTimeout)
result, err := c.transcriber.Transcribe(ctx, localPath)
cancel() // 立即释放context资源避免在for循环中泄漏
if err != nil {
logger.ErrorCF("discord", "Voice transcription failed", map[string]any{
"error": err.Error(),
})
transcribedText = fmt.Sprintf("[audio: %s (transcription failed)]", attachment.Filename)
} else {
transcribedText = fmt.Sprintf("[audio transcription: %s]", result.Text)
logger.DebugCF("discord", "Audio transcribed successfully", map[string]any{
"text": result.Text,
})
}
} else {
transcribedText = fmt.Sprintf("[audio: %s]", attachment.Filename)
}
content = appendContent(content, transcribedText)
} else {
logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{
"url": attachment.URL,
"filename": attachment.Filename,
})
}
} else {
localPath := c.downloadAttachment(attachment.URL, attachment.Filename) localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
if localPath != "" { if localPath != "" {
localFiles = append(localFiles, localPath) localFiles = append(localFiles, localPath)
if dataURL := utils.EncodeFileToDataURL(localPath); dataURL != "" { if dataURL := utils.EncodeFileToDataURL(localPath); dataURL != "" {
mediaPaths = append(mediaPaths, dataURL) mediaPaths = append(mediaPaths, dataURL)
} } else {
content = appendContent(content, fmt.Sprintf("[audio: %s]", attachment.Filename))
} }
} }
} }

View file

@ -6,7 +6,6 @@ import (
"os" "os"
"strings" "strings"
"sync" "sync"
"time"
"github.com/slack-go/slack" "github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents" "github.com/slack-go/slack/slackevents"
@ -16,7 +15,6 @@ import (
"github.com/KarakuriAgent/clawdroid/pkg/config" "github.com/KarakuriAgent/clawdroid/pkg/config"
"github.com/KarakuriAgent/clawdroid/pkg/logger" "github.com/KarakuriAgent/clawdroid/pkg/logger"
"github.com/KarakuriAgent/clawdroid/pkg/utils" "github.com/KarakuriAgent/clawdroid/pkg/utils"
"github.com/KarakuriAgent/clawdroid/pkg/voice"
) )
type SlackChannel struct { type SlackChannel struct {
@ -25,7 +23,6 @@ type SlackChannel struct {
api *slack.Client api *slack.Client
socketClient *socketmode.Client socketClient *socketmode.Client
botUserID string botUserID string
transcriber *voice.GroqTranscriber
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
pendingAcks sync.Map pendingAcks sync.Map
@ -58,10 +55,6 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack
}, nil }, nil
} }
func (c *SlackChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
c.transcriber = transcriber
}
func (c *SlackChannel) Start(ctx context.Context) error { func (c *SlackChannel) Start(ctx context.Context) error {
logger.InfoC("slack", "Starting Slack channel (Socket Mode)") logger.InfoC("slack", "Starting Slack channel (Socket Mode)")
@ -252,21 +245,10 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
} }
localFiles = append(localFiles, localPath) localFiles = append(localFiles, localPath)
if utils.IsAudioFile(file.Name, file.Mimetype) && c.transcriber != nil && c.transcriber.IsAvailable() { if dataURL := utils.EncodeFileToDataURL(localPath); dataURL != "" {
ctx, cancel := context.WithTimeout(c.ctx, 30*time.Second)
defer cancel()
result, err := c.transcriber.Transcribe(ctx, localPath)
if err != nil {
logger.ErrorCF("slack", "Voice transcription failed", map[string]interface{}{"error": err.Error()})
content += fmt.Sprintf("\n[audio: %s (transcription failed)]", file.Name)
} else {
content += fmt.Sprintf("\n[voice transcription: %s]", result.Text)
}
} else if dataURL := utils.EncodeFileToDataURL(localPath); dataURL != "" {
mediaPaths = append(mediaPaths, dataURL) mediaPaths = append(mediaPaths, dataURL)
} else { } else {
content += fmt.Sprintf("\n[file: %s]", file.Name) content += fmt.Sprintf("\n[audio: %s]", file.Name)
} }
} }
} }

View file

@ -21,7 +21,6 @@ import (
"github.com/KarakuriAgent/clawdroid/pkg/config" "github.com/KarakuriAgent/clawdroid/pkg/config"
"github.com/KarakuriAgent/clawdroid/pkg/logger" "github.com/KarakuriAgent/clawdroid/pkg/logger"
"github.com/KarakuriAgent/clawdroid/pkg/utils" "github.com/KarakuriAgent/clawdroid/pkg/utils"
"github.com/KarakuriAgent/clawdroid/pkg/voice"
) )
type TelegramChannel struct { type TelegramChannel struct {
@ -30,7 +29,6 @@ type TelegramChannel struct {
commands TelegramCommander commands TelegramCommander
config *config.Config config *config.Config
chatIDs map[string]int64 chatIDs map[string]int64
transcriber *voice.GroqTranscriber
placeholders sync.Map // chatID -> messageID placeholders sync.Map // chatID -> messageID
stopThinking sync.Map // chatID -> thinkingCancel stopThinking sync.Map // chatID -> thinkingCancel
} }
@ -74,16 +72,11 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
bot: bot, bot: bot,
config: cfg, config: cfg,
chatIDs: make(map[string]int64), chatIDs: make(map[string]int64),
transcriber: nil,
placeholders: sync.Map{}, placeholders: sync.Map{},
stopThinking: sync.Map{}, stopThinking: sync.Map{},
}, nil }, nil
} }
func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
c.transcriber = transcriber
}
func (c *TelegramChannel) Start(ctx context.Context) error { func (c *TelegramChannel) Start(ctx context.Context) error {
logger.InfoC("telegram", "Starting Telegram bot (polling mode)...") logger.InfoC("telegram", "Starting Telegram bot (polling mode)...")
@ -256,32 +249,10 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
localFiles = append(localFiles, voicePath) localFiles = append(localFiles, voicePath)
mediaPaths = append(mediaPaths, voicePath) mediaPaths = append(mediaPaths, voicePath)
transcribedText := ""
if c.transcriber != nil && c.transcriber.IsAvailable() {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
result, err := c.transcriber.Transcribe(ctx, voicePath)
if err != nil {
logger.ErrorCF("telegram", "Voice transcription failed", map[string]interface{}{
"error": err.Error(),
"path": voicePath,
})
transcribedText = "[voice (transcription failed)]"
} else {
transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text)
logger.InfoCF("telegram", "Voice transcribed successfully", map[string]interface{}{
"text": result.Text,
})
}
} else {
transcribedText = "[voice]"
}
if content != "" { if content != "" {
content += "\n" content += "\n"
} }
content += transcribedText content += "[voice]"
} }
} }

View file

@ -68,26 +68,6 @@ func EncodeFileToDataURL(path string) string {
return "data:" + mime + ";base64," + encoded return "data:" + mime + ";base64," + encoded
} }
// IsAudioFile checks if a file is an audio file based on its filename extension and content type.
func IsAudioFile(filename, contentType string) bool {
audioExtensions := []string{".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma"}
audioTypes := []string{"audio/", "application/ogg", "application/x-ogg"}
for _, ext := range audioExtensions {
if strings.HasSuffix(strings.ToLower(filename), ext) {
return true
}
}
for _, audioType := range audioTypes {
if strings.HasPrefix(strings.ToLower(contentType), audioType) {
return true
}
}
return false
}
// SanitizeFilename removes potentially dangerous characters from a filename // SanitizeFilename removes potentially dangerous characters from a filename
// and returns a safe version for local filesystem storage. // and returns a safe version for local filesystem storage.
func SanitizeFilename(filename string) string { func SanitizeFilename(filename string) string {

View file

@ -1,159 +0,0 @@
package voice
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
"github.com/KarakuriAgent/clawdroid/pkg/logger"
"github.com/KarakuriAgent/clawdroid/pkg/utils"
)
type GroqTranscriber struct {
apiKey string
apiBase string
httpClient *http.Client
}
type TranscriptionResponse struct {
Text string `json:"text"`
Language string `json:"language,omitempty"`
Duration float64 `json:"duration,omitempty"`
}
func NewGroqTranscriber(apiKey string) *GroqTranscriber {
logger.DebugCF("voice", "Creating Groq transcriber", map[string]interface{}{"has_api_key": apiKey != ""})
apiBase := "https://api.groq.com/openai/v1"
return &GroqTranscriber{
apiKey: apiKey,
apiBase: apiBase,
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
}
}
func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) {
logger.InfoCF("voice", "Starting transcription", map[string]interface{}{"audio_file": audioFilePath})
audioFile, err := os.Open(audioFilePath)
if err != nil {
logger.ErrorCF("voice", "Failed to open audio file", map[string]interface{}{"path": audioFilePath, "error": err})
return nil, fmt.Errorf("failed to open audio file: %w", err)
}
defer audioFile.Close()
fileInfo, err := audioFile.Stat()
if err != nil {
logger.ErrorCF("voice", "Failed to get file info", map[string]interface{}{"path": audioFilePath, "error": err})
return nil, fmt.Errorf("failed to get file info: %w", err)
}
logger.DebugCF("voice", "Audio file details", map[string]interface{}{
"size_bytes": fileInfo.Size(),
"file_name": filepath.Base(audioFilePath),
})
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath))
if err != nil {
logger.ErrorCF("voice", "Failed to create form file", map[string]interface{}{"error": err})
return nil, fmt.Errorf("failed to create form file: %w", err)
}
copied, err := io.Copy(part, audioFile)
if err != nil {
logger.ErrorCF("voice", "Failed to copy file content", map[string]interface{}{"error": err})
return nil, fmt.Errorf("failed to copy file content: %w", err)
}
logger.DebugCF("voice", "File copied to request", map[string]interface{}{"bytes_copied": copied})
if err := writer.WriteField("model", "whisper-large-v3"); err != nil {
logger.ErrorCF("voice", "Failed to write model field", map[string]interface{}{"error": err})
return nil, fmt.Errorf("failed to write model field: %w", err)
}
if err := writer.WriteField("response_format", "json"); err != nil {
logger.ErrorCF("voice", "Failed to write response_format field", map[string]interface{}{"error": err})
return nil, fmt.Errorf("failed to write response_format field: %w", err)
}
if err := writer.Close(); err != nil {
logger.ErrorCF("voice", "Failed to close multipart writer", map[string]interface{}{"error": err})
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
}
url := t.apiBase + "/audio/transcriptions"
req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody)
if err != nil {
logger.ErrorCF("voice", "Failed to create request", map[string]interface{}{"error": err})
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+t.apiKey)
logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]interface{}{
"url": url,
"request_size_bytes": requestBody.Len(),
"file_size_bytes": fileInfo.Size(),
})
resp, err := t.httpClient.Do(req)
if err != nil {
logger.ErrorCF("voice", "Failed to send request", map[string]interface{}{"error": err})
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
logger.ErrorCF("voice", "Failed to read response", map[string]interface{}{"error": err})
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
logger.ErrorCF("voice", "API error", map[string]interface{}{
"status_code": resp.StatusCode,
"response": string(body),
})
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
logger.DebugCF("voice", "Received response from Groq API", map[string]interface{}{
"status_code": resp.StatusCode,
"response_size_bytes": len(body),
})
var result TranscriptionResponse
if err := json.Unmarshal(body, &result); err != nil {
logger.ErrorCF("voice", "Failed to unmarshal response", map[string]interface{}{"error": err})
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
logger.InfoCF("voice", "Transcription completed successfully", map[string]interface{}{
"text_length": len(result.Text),
"language": result.Language,
"duration_seconds": result.Duration,
"transcription_preview": utils.Truncate(result.Text, 50),
})
return &result, nil
}
func (t *GroqTranscriber) IsAvailable() bool {
available := t.apiKey != ""
logger.DebugCF("voice", "Checking transcriber availability", map[string]interface{}{"available": available})
return available
}