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:
parent
6a46cb0bb6
commit
411df971ec
5 changed files with 16 additions and 285 deletions
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue