-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.go
More file actions
1103 lines (932 loc) · 33.1 KB
/
Copy pathmain.go
File metadata and controls
1103 lines (932 loc) · 33.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// ConsoNance - Audio Stream Bot for Discord
// Copyright (C) 2025 Kazuki F.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"bufio"
"context"
"fmt"
"io"
"log"
"log/slog"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/disgoorg/disgo"
"github.com/disgoorg/disgo/bot"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/events"
"github.com/disgoorg/disgo/gateway"
"github.com/disgoorg/disgo/voice"
"github.com/disgoorg/godave/golibdave"
"github.com/disgoorg/snowflake/v2"
"github.com/gen2brain/malgo"
"gopkg.in/yaml.v3"
"layeh.com/gopus"
)
// Bot state management
type BotState struct {
sync.RWMutex
voiceConn voice.Conn // interface: always assign nil directly to avoid interface-nil pitfall
guildID snowflake.ID
channelID snowflake.ID
audioDeviceName string
isStreaming bool
stopStreaming chan bool // buffered(1): non-blocking send to stop streaming safely
manualDisconnect bool // set to true on manual disconnect to prevent reconnection
}
var (
botState *BotState
config *Config
client *bot.Client
botUserID snowflake.ID
)
// Config structure
type Config struct {
DiscordToken string `yaml:"discord_token"`
ChannelID string `yaml:"channel_id"`
GuildID string `yaml:"guild_id"`
AudioDeviceName string `yaml:"audio_device_name"`
AudioBufferPeriods int `yaml:"audio_buffer_periods"` // 0 = use default
}
// setupLogFile creates a log file and configures logging to both file and console
func setupLogFile() (*os.File, error) {
logsDir := "logs"
if err := os.MkdirAll(logsDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create logs directory: %w", err)
}
timestamp := time.Now().Format("20060102_150405")
logFileName := filepath.Join(logsDir, fmt.Sprintf("consonance_%s.log", timestamp))
logFile, err := os.OpenFile(logFileName, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, fmt.Errorf("failed to create log file: %w", err)
}
multiWriter := io.MultiWriter(os.Stdout, logFile)
log.SetOutput(multiWriter)
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
// disgo の内部ログ (slog.Default) を LevelWarn に設定して DEBUG/INFO を抑制する
// slog.SetDefault は内部で log.SetOutput を上書きするため、直後に再設定する
slogHandler := slog.NewTextHandler(multiWriter, &slog.HandlerOptions{Level: slog.LevelWarn})
slog.SetDefault(slog.New(slogHandler))
// slog.SetDefault に上書きされた log パッケージの設定を復元する(自前のログを守る)
log.SetOutput(multiWriter)
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
return logFile, nil
}
func main() {
fmt.Println("==========================================")
fmt.Printf(" %s\n", GetVersionString())
fmt.Println("==========================================")
fmt.Println()
logFile, err := setupLogFile()
if err != nil {
log.Printf("Warning: Failed to setup log file: %v (continuing with console only)", err)
} else {
defer logFile.Close()
log.Println("Log file created successfully")
}
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC: %v", r)
log.Println("Application terminated abnormally")
waitForEnter()
}
}()
config, err = loadOrCreateConfig()
if err != nil {
exitWithError("Failed to load config: %v", err)
}
if config.DiscordToken == "" {
token, err := promptForDiscordToken()
if err != nil {
exitWithError("Failed to get Discord token: %v", err)
}
config.DiscordToken = token
log.Println("✓ Discord token saved to config.yaml")
}
tokenPreview := config.DiscordToken
if len(tokenPreview) > 20 {
tokenPreview = tokenPreview[:10] + "..." + tokenPreview[len(tokenPreview)-10:]
}
log.Printf("Using Discord token: %s", tokenPreview)
selectedDevice := config.AudioDeviceName
if selectedDevice == "" {
device, err := selectAudioDevice()
if err != nil {
exitWithError("Failed to select audio device: %v", err)
}
selectedDevice = device
log.Printf("Selected audio device: %s", selectedDevice)
} else {
log.Printf("Using audio device from config: %s", selectedDevice)
}
botState = &BotState{
audioDeviceName: selectedDevice,
stopStreaming: make(chan bool, 1),
}
// libdave が正常にロードされているか確認(0 なら DAVE 無効 → 4022 の原因になる)
daveVersionCheck := golibdave.NewSession(slog.Default(), "version-check", nil)
log.Printf("libdave MaxSupportedProtocolVersion: %d", daveVersionCheck.MaxSupportedProtocolVersion())
client, err = disgo.New(config.DiscordToken,
bot.WithGatewayConfigOpts(gateway.WithIntents(
gateway.IntentGuildVoiceStates,
gateway.IntentGuilds,
gateway.IntentGuildMessages,
gateway.IntentMessageContent,
)),
bot.WithEventListenerFunc(onReady),
bot.WithEventListenerFunc(messageCreate),
bot.WithVoiceManagerConfigOpts(
voice.WithDaveSessionCreateFunc(golibdave.NewSession),
// AutoReconnect を無効化し、再接続ロジックを一本化する。
// disgo v0.19.2 は 4022 (ボイスサーバー移行) をクローズコードマップに持たないため
// Unknown (Reconnect: true) にフォールバックし、旧セッションへの再接続を試み続ける。
// これが monitorVoiceConnection と競合して二重ストームを引き起こすため、
// disgo 側の自動再接続を切り、monitorVoiceConnection に一本化する。
voice.WithConnConfigOpts(
voice.WithConnGatewayConfigOpts(
voice.WithGatewayAutoReconnect(false),
),
),
),
)
if err != nil {
exitWithError("Failed to create Discord client: %v", err)
}
defer client.Close(context.Background())
log.Println("Connecting to Discord...")
if err := client.OpenGateway(context.Background()); err != nil {
log.Printf("Failed to open Discord gateway: %v", err)
log.Println("")
log.Println("=== Troubleshooting Authentication Error ===")
log.Println("If you see '4004: Authentication failed', check the following:")
log.Println("1. Verify your bot token is correct in config.yaml")
log.Println("2. Go to Discord Developer Portal (https://discord.com/developers/applications)")
log.Println("3. Select your application → Bot")
log.Println("4. Under 'Privileged Gateway Intents', enable:")
log.Println(" - MESSAGE CONTENT INTENT (required!)")
log.Println(" - SERVER MEMBERS INTENT")
log.Println(" - PRESENCE INTENT")
log.Println("5. Save changes and try again")
log.Println("6. If still failing, try resetting your bot token")
log.Println("")
waitForEnter()
os.Exit(1)
}
log.Println("Bot is now running. Mention me with commands!")
log.Println("Commands: @Bot join #channel-name, @Bot leave, @Bot status, @Bot help")
sc := make(chan os.Signal, 1)
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
<-sc
log.Println("Bot is shutting down...")
if botState.voiceConn != nil {
leaveVoiceChannel()
}
}
// onReady handles the Ready event
func onReady(e *events.Ready) {
botUserID = e.User.ID
log.Printf("Bot ready as %s (%d)", e.User.Username, e.User.ID)
inviteURL := fmt.Sprintf("https://discord.com/api/oauth2/authorize?client_id=%s&scope=bot&permissions=3215376", e.Application.ID)
fmt.Println("")
fmt.Println("==========================================")
fmt.Println(" Bot Invite Link:")
fmt.Printf(" %s\n", inviteURL)
fmt.Println("==========================================")
fmt.Println("")
if config.ChannelID != "" && config.GuildID != "" {
guildID, err := snowflake.Parse(config.GuildID)
if err != nil {
log.Printf("Invalid guild_id in config: %v", err)
return
}
channelID, err := snowflake.Parse(config.ChannelID)
if err != nil {
log.Printf("Invalid channel_id in config: %v", err)
return
}
log.Printf("Auto-connecting to channel %s...", config.ChannelID)
go func() {
if err := joinVoiceChannel(guildID, channelID); err != nil {
log.Printf("Failed to auto-connect: %v", err)
}
}()
}
}
// messageCreate handles incoming messages
func messageCreate(e *events.MessageCreate) {
if e.Message.Author.Bot {
return
}
mentioned := false
for _, user := range e.Message.Mentions {
if user.ID == botUserID {
mentioned = true
break
}
}
if !mentioned {
return
}
content := e.Message.Content
for _, user := range e.Message.Mentions {
if user.ID == botUserID {
content = strings.ReplaceAll(content, "<@"+user.ID.String()+">", "")
content = strings.ReplaceAll(content, "<@!"+user.ID.String()+">", "")
}
}
content = strings.TrimSpace(content)
parts := strings.Fields(content)
if len(parts) == 0 {
sendMsg(e, "コマンドを指定してください! `@Bot help` でヘルプを表示できます。")
return
}
command := strings.ToLower(parts[0])
// イベントハンドラ内でブロックすると listen goroutine が止まり
// VOICE_STATE_UPDATE 等を受信できなくなるため、必ず goroutine で実行する
switch command {
case "join":
go handleJoinCommand(e, parts[1:])
case "leave":
go handleLeaveCommand(e)
case "status":
go handleStatusCommand(e)
case "help":
go handleHelpCommand(e)
default:
go sendMsg(e, fmt.Sprintf("不明なコマンド: `%s`\n`@Bot help` でヘルプを表示できます。", command))
}
}
// sendMsg sends a plain text message to the channel where the event occurred
func sendMsg(e *events.MessageCreate, content string) {
_, _ = e.Client().Rest.CreateMessage(e.ChannelID, discord.MessageCreate{Content: content})
}
// handleJoinCommand handles the join command
func handleJoinCommand(e *events.MessageCreate, args []string) {
if len(args) == 0 {
sendMsg(e, "チャンネル名またはメンションを指定してください!\n例: `@Bot join #雑談部屋`")
return
}
if e.GuildID == nil {
sendMsg(e, "このコマンドはサーバーでのみ使用できます。")
return
}
guildID := *e.GuildID
var targetChannelID snowflake.ID
var channelName string
if strings.HasPrefix(args[0], "<#") && strings.HasSuffix(args[0], ">") {
idStr := strings.TrimSuffix(strings.TrimPrefix(args[0], "<#"), ">")
id, err := snowflake.Parse(idStr)
if err != nil {
sendMsg(e, "チャンネルIDが無効です。")
return
}
targetChannelID = id
} else {
targetName := strings.TrimPrefix(strings.Join(args, " "), "#")
channels, err := e.Client().Rest.GetGuildChannels(guildID)
if err != nil {
sendMsg(e, fmt.Sprintf("チャンネル一覧の取得に失敗しました: %v", err))
return
}
for _, ch := range channels {
if ch.Type() == discord.ChannelTypeGuildVoice && strings.EqualFold(ch.Name(), targetName) {
targetChannelID = ch.ID()
channelName = ch.Name()
break
}
}
if targetChannelID == 0 {
sendMsg(e, fmt.Sprintf("ボイスチャンネル `%s` が見つかりませんでした。", targetName))
return
}
}
if channelName == "" {
ch, err := e.Client().Rest.GetChannel(targetChannelID)
if err == nil {
channelName = ch.Name()
} else {
channelName = targetChannelID.String()
}
}
if err := joinVoiceChannel(guildID, targetChannelID); err != nil {
sendMsg(e, fmt.Sprintf("ボイスチャンネルへの接続に失敗しました: %v", err))
return
}
sendMsg(e, fmt.Sprintf("✅ ボイスチャンネル `%s` に接続しました!", channelName))
}
// handleLeaveCommand handles the leave command
func handleLeaveCommand(e *events.MessageCreate) {
botState.RLock()
connected := botState.voiceConn != nil
botState.RUnlock()
if !connected {
sendMsg(e, "現在、どのボイスチャンネルにも接続していません。")
return
}
leaveVoiceChannel()
sendMsg(e, "✅ ボイスチャンネルから退出しました。")
}
// handleStatusCommand handles the status command
func handleStatusCommand(e *events.MessageCreate) {
botState.RLock()
voiceConn := botState.voiceConn
channelID := botState.channelID
isStreaming := botState.isStreaming
audioDeviceName := botState.audioDeviceName
botState.RUnlock()
if voiceConn == nil {
sendMsg(e, "📊 **Status**: ボイスチャンネルに接続していません")
return
}
channelName := channelID.String()
if ch, err := e.Client().Rest.GetChannel(channelID); err == nil {
channelName = ch.Name()
}
status := fmt.Sprintf("📊 **Status**\n"+
"接続中: `%s`\n"+
"ストリーミング: %v\n"+
"オーディオデバイス: `%s`",
channelName,
isStreaming,
audioDeviceName)
sendMsg(e, status)
}
// handleHelpCommand handles the help command
func handleHelpCommand(e *events.MessageCreate) {
helpText := fmt.Sprintf("**%s - Commands**\n\n", GetVersionString()) +
"`@Bot join #チャンネル名` - 指定したボイスチャンネルに接続します\n" +
"`@Bot join チャンネル名` - チャンネル名で検索して接続します\n" +
"`@Bot leave` - 現在のボイスチャンネルから退出します\n" +
"`@Bot status` - 現在の接続状態を表示します\n" +
"`@Bot help` - このヘルプを表示します"
sendMsg(e, helpText)
}
// makeVoiceGWLogger returns a voice gateway event handler that logs all DAVE-related events in detail.
func makeVoiceGWLogger(prefix string) voice.EventHandlerFunc {
return func(gw voice.Gateway, op voice.Opcode, seq int, data voice.GatewayMessageData) {
if op == voice.OpcodeHeartbeatACK {
return
}
log.Printf("%s op=%d seq=%d type=%T", prefix, op, seq, data)
switch d := data.(type) {
case voice.GatewayMessageDataSessionDescription:
log.Printf("[DAVE] SessionDescription.DaveProtocolVersion=%d (0=disabled, >0=enabled)", d.DaveProtocolVersion)
case voice.GatewayMessageDataDaveProtocolExecuteTransition:
log.Printf("[DAVE] ExecuteTransition: DAVE E2EE is now active (transitionID=%d)", d.TransitionID)
case voice.GatewayMessageDataDaveMLSAnnounceCommitTransition:
log.Printf("[DAVE] AnnounceCommitTransition: new MLS commit received (transitionID=%d, commit_size=%d bytes)", d.TransitionID, len(d.CommitMessage))
case voice.GatewayMessageDataDaveMLSWelcome:
log.Printf("[DAVE] MLSWelcome: Welcome message received (transitionID=%d, welcome_size=%d bytes)", d.TransitionID, len(d.WelcomeMessage))
case voice.GatewayMessageDataDaveMLSExternalSenderPackage:
log.Printf("[DAVE] ExternalSenderPackage: received (%d bytes)", len(d))
case voice.GatewayMessageDataDaveProtocolPrepareTransition:
log.Printf("[DAVE] PrepareTransition: protocolVersion=%d transitionID=%d", d.ProtocolVersion, d.TransitionID)
}
}
}
// stopStreaming sends a non-blocking stop signal to the streaming goroutine
func signalStopStreaming() {
select {
case botState.stopStreaming <- true:
default:
}
}
// joinVoiceChannel joins a voice channel and starts streaming
func joinVoiceChannel(guildID, channelID snowflake.ID) error {
botState.Lock()
defer botState.Unlock()
botState.manualDisconnect = false
if botState.voiceConn != nil {
log.Println("Already connected, disconnecting first...")
if botState.isStreaming {
botState.isStreaming = false
signalStopStreaming()
}
oldConn := botState.voiceConn
botState.voiceConn = nil
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
oldConn.Close(ctx)
cancel()
}
conn := client.VoiceManager.CreateConn(guildID)
// 音声ゲートウェイイベントを詳細ログ出力(接続断の診断用)
conn.SetEventHandlerFunc(makeVoiceGWLogger("[VoiceGW]"))
log.Printf("Sending VoiceStateUpdate to join guild=%d channel=%d", guildID, channelID)
// タイムアウトを30秒に設定(Discord の応答が10秒を超えるケースがある)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := conn.Open(ctx, channelID, false, false); err != nil {
// タイムアウト等で失敗した場合、VoiceManager から Conn を削除する。
// conn.Close() は leave VoiceStateUpdate を Discord に送ってしまい、
// join→leave→join の連鎖で Discord 側の処理渋滞を悪化させるため使わない。
log.Printf("conn.Open failed (%v), removing conn from VoiceManager without leave", err)
client.VoiceManager.RemoveConn(guildID)
return fmt.Errorf("failed to join voice channel: %w", err)
}
botState.voiceConn = conn
botState.channelID = channelID
botState.guildID = guildID
botState.isStreaming = true
go func() {
if err := streamSystemAudio(conn, botState.audioDeviceName); err != nil {
log.Printf("Streaming stopped with error: %v", err)
}
botState.Lock()
botState.isStreaming = false
botState.Unlock()
}()
go monitorVoiceConnection(guildID, channelID)
log.Printf("Successfully connected to voice channel: %d", channelID)
return nil
}
// leaveVoiceChannel disconnects from the current voice channel
func leaveVoiceChannel() {
botState.Lock()
if botState.voiceConn == nil {
botState.Unlock()
return
}
log.Println("Disconnecting from voice channel...")
botState.manualDisconnect = true
if botState.isStreaming {
botState.isStreaming = false
signalStopStreaming()
}
conn := botState.voiceConn
botState.voiceConn = nil
botState.channelID = 0
botState.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn.Close(ctx)
log.Println("Disconnected from voice channel")
}
// monitorVoiceConnection monitors the voice connection and reconnects on disconnection
func monitorVoiceConnection(guildID, channelID snowflake.ID) {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
<-ticker.C
botState.RLock()
if botState.manualDisconnect || botState.voiceConn == nil {
botState.RUnlock()
log.Println("Voice connection monitor stopped (manual disconnect or no connection)")
return
}
botState.RUnlock()
conn := client.VoiceManager.GetConn(guildID)
if conn != nil && conn.ChannelID() != nil {
continue
}
// disgo の closeHandlerFunc が先に conn を VoiceManager から除去した場合(AutoReconnect=false)と、
// ChannelID が nil になった場合(VOICE_STATE_UPDATE nil 受信)の両方を検知する。
if conn == nil {
log.Println("Voice connection removed by disgo (e.g. close code 4022 voice server change), will rejoin...")
} else {
log.Println("Voice connection lost (ChannelID became nil), attempting to reconnect...")
}
botState.Lock()
if botState.isStreaming {
botState.isStreaming = false
signalStopStreaming()
}
botState.voiceConn = nil
botState.Unlock()
for i := range 3 {
botState.RLock()
abort := botState.manualDisconnect
botState.RUnlock()
if abort {
return
}
// 4022 (DAVE ハンドシェイクタイムアウト) 後に Discord の MLS グループが
// 安定するまで十分な時間を確保する。短すぎると再接続時も同じタイムアウトが繰り返される。
waitTime := time.Duration(10*(i+1)) * time.Second
log.Printf("Waiting %v before reconnection attempt %d/3...", waitTime, i+1)
time.Sleep(waitTime)
if err := reconnectVoiceChannel(guildID, channelID); err == nil {
log.Println("Successfully reconnected and resumed streaming!")
return
} else {
log.Printf("Reconnection attempt %d/3 failed: %v", i+1, err)
}
}
log.Printf("Failed to reconnect after 3 attempts, giving up.")
return
}
}
// reconnectVoiceChannel reconnects without spawning a new monitor goroutine
func reconnectVoiceChannel(guildID, channelID snowflake.ID) error {
botState.Lock()
defer botState.Unlock()
if botState.manualDisconnect {
return fmt.Errorf("manual disconnect flag is set, aborting reconnection")
}
if botState.voiceConn != nil {
if botState.isStreaming {
botState.isStreaming = false
signalStopStreaming()
}
oldConn := botState.voiceConn
botState.voiceConn = nil
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
oldConn.Close(ctx)
cancel()
}
conn := client.VoiceManager.CreateConn(guildID)
conn.SetEventHandlerFunc(makeVoiceGWLogger("[VoiceGW/reconnect]"))
log.Printf("Sending VoiceStateUpdate to reconnect guild=%d channel=%d", guildID, channelID)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := conn.Open(ctx, channelID, false, false); err != nil {
log.Printf("reconnect conn.Open failed (%v), removing conn from VoiceManager without leave", err)
client.VoiceManager.RemoveConn(guildID)
return fmt.Errorf("failed to join voice channel: %w", err)
}
botState.voiceConn = conn
botState.channelID = channelID
botState.guildID = guildID
botState.isStreaming = true
go func() {
if err := streamSystemAudio(conn, botState.audioDeviceName); err != nil {
log.Printf("Streaming stopped with error: %v", err)
}
botState.Lock()
botState.isStreaming = false
botState.Unlock()
}()
go monitorVoiceConnection(guildID, channelID)
log.Printf("Successfully reconnected to voice channel: %d", channelID)
return nil
}
// streamSystemAudio captures system audio (loopback) and streams it to Discord
func streamSystemAudio(conn voice.Conn, deviceName string) error {
const (
sampleRate = 48000
channels = 2
frameSize = 960 // 20ms at 48kHz
)
encoder, err := gopus.NewEncoder(sampleRate, channels, gopus.Audio)
if err != nil {
return fmt.Errorf("failed to create opus encoder: %v", err)
}
ctx, err := malgo.InitContext(nil, malgo.ContextConfig{}, nil)
if err != nil {
return fmt.Errorf("failed to initialize malgo context: %v", err)
}
defer func() {
_ = ctx.Uninit()
ctx.Free()
}()
deviceConfig := malgo.DefaultDeviceConfig(malgo.Loopback)
deviceConfig.Capture.Format = malgo.FormatS16
deviceConfig.Capture.Channels = uint32(channels)
deviceConfig.SampleRate = uint32(sampleRate)
deviceConfig.Alsa.NoMMap = 1
deviceConfig.PeriodSizeInFrames = uint32(frameSize)
bufferPeriods := config.AudioBufferPeriods
if bufferPeriods == 0 {
bufferPeriods = 4
}
deviceConfig.Periods = uint32(bufferPeriods)
log.Printf("Audio buffer periods: %d (latency: ~%dms)", bufferPeriods, bufferPeriods*20)
if deviceName != "" {
deviceInfo, err := findDeviceByName(ctx, deviceName)
if err != nil {
return fmt.Errorf("failed to find device '%s': %v", deviceName, err)
}
deviceConfig.Capture.DeviceID = deviceInfo.ID.Pointer()
log.Printf("Using audio device: %s", deviceName)
} else {
log.Println("Using default loopback device")
}
pcmBuffer := make([]int16, 0, frameSize*channels*2)
conversionBuffer := make([]int16, frameSize*channels*2)
pcmFrameChan := make(chan []int16, 5)
encodeCtx, encodeCancel := context.WithCancel(context.Background())
defer encodeCancel()
encodeDone := make(chan struct{})
go func() {
defer close(encodeDone)
for {
select {
case <-encodeCtx.Done():
return
case frame := <-pcmFrameChan:
if conn.ChannelID() == nil {
log.Println("Voice connection lost, stopping encoder goroutine")
signalStopStreaming()
return
}
bufferLen := len(pcmFrameChan)
if bufferLen > 3 {
skippedFrames := 0
for len(pcmFrameChan) > 1 {
<-pcmFrameChan
skippedFrames++
}
if skippedFrames > 0 {
log.Printf("Latency recovery: skipped %d old frames (buffer was %d)", skippedFrames, bufferLen)
}
select {
case frame = <-pcmFrameChan:
default:
}
}
opusData, err := encoder.Encode(frame, frameSize, 1000)
if err != nil {
log.Printf("Failed to encode audio: %v", err)
continue
}
if _, err := conn.UDP().Write(opusData); err != nil {
log.Printf("UDP write failed (voice connection likely lost): %v", err)
signalStopStreaming()
return
}
}
}
}()
speakCtx, speakCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer speakCancel()
if err := conn.SetSpeaking(speakCtx, voice.SpeakingFlagMicrophone); err != nil {
return fmt.Errorf("failed to set speaking state: %v", err)
}
defer func() {
stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second)
defer stopCancel()
_ = conn.SetSpeaking(stopCtx, voice.SpeakingFlagNone)
}()
log.Println("Starting system audio capture (loopback mode)...")
var captureCallbacks = malgo.DeviceCallbacks{
Data: func(pOutputSample, pInputSamples []byte, framecount uint32) {
sampleCount := len(pInputSamples) / 2
if sampleCount > len(conversionBuffer) {
conversionBuffer = make([]int16, sampleCount)
}
for i := 0; i < sampleCount; i++ {
conversionBuffer[i] = int16(pInputSamples[i*2]) | int16(pInputSamples[i*2+1])<<8
}
pcmBuffer = append(pcmBuffer, conversionBuffer[:sampleCount]...)
for len(pcmBuffer) >= frameSize*channels {
frameCopy := make([]int16, frameSize*channels)
copy(frameCopy, pcmBuffer[:frameSize*channels])
pcmBuffer = pcmBuffer[frameSize*channels:]
select {
case pcmFrameChan <- frameCopy:
default:
}
}
},
}
device, err := malgo.InitDevice(ctx.Context, deviceConfig, captureCallbacks)
if err != nil {
return fmt.Errorf("failed to initialize capture device: %v", err)
}
if err := device.Start(); err != nil {
device.Uninit()
return fmt.Errorf("failed to start capture device: %v", err)
}
log.Println("System audio streaming started!")
<-botState.stopStreaming
encodeCancel()
device.Stop()
device.Uninit()
<-encodeDone
log.Println("System audio streaming stopped.")
return nil
}
// selectAudioDevice displays available audio devices and lets the user select one
func selectAudioDevice() (string, error) {
ctx, err := malgo.InitContext(nil, malgo.ContextConfig{}, nil)
if err != nil {
return "", fmt.Errorf("failed to initialize malgo context: %v", err)
}
defer func() {
_ = ctx.Uninit()
ctx.Free()
}()
infos, err := ctx.Devices(malgo.Playback)
if err != nil {
return "", fmt.Errorf("failed to get playback devices: %v", err)
}
if len(infos) == 0 {
return "", fmt.Errorf("no playback devices found")
}
fmt.Println("\n=== Select Audio Device ===")
fmt.Println("Available audio devices for loopback capture:")
fmt.Println()
for i, info := range infos {
defaultMark := ""
if info.IsDefault > 0 {
defaultMark = " (Default)"
}
fmt.Printf("[%d] %s%s\n", i+1, info.Name(), defaultMark)
}
fmt.Println()
fmt.Print("Enter device number: ")
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString('\n')
if err != nil {
return "", fmt.Errorf("failed to read input: %v", err)
}
input = strings.TrimSpace(input)
selection, err := strconv.Atoi(input)
if err != nil {
return "", fmt.Errorf("invalid input: please enter a number")
}
if selection < 1 || selection > len(infos) {
return "", fmt.Errorf("invalid selection: please enter a number between 1 and %d", len(infos))
}
selectedDevice := infos[selection-1].Name()
fmt.Printf("\n✓ Selected: %s\n", selectedDevice)
fmt.Print("\nSave this device as default in config.yaml? (y/n): ")
saveInput, err := reader.ReadString('\n')
if err != nil {
log.Printf("Warning: Failed to read input: %v", err)
fmt.Println()
return selectedDevice, nil
}
saveInput = strings.TrimSpace(strings.ToLower(saveInput))
if saveInput == "y" || saveInput == "yes" {
if err := saveDeviceToConfig(selectedDevice); err != nil {
log.Printf("Warning: Failed to save device to config: %v", err)
fmt.Println("Device selection will be used for this session only.")
} else {
fmt.Println("✓ Device saved to config.yaml")
}
}
fmt.Println()
return selectedDevice, nil
}
// saveDeviceToConfig saves the selected audio device to config.yaml
func saveDeviceToConfig(deviceName string) error {
data, err := os.ReadFile("config.yaml")
if err != nil {
return fmt.Errorf("failed to read config.yaml: %w", err)
}
lines := strings.Split(string(data), "\n")
updated := false
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "audio_device_name:") ||
strings.HasPrefix(trimmed, "# audio_device_name:") {
lines[i] = fmt.Sprintf("audio_device_name: \"%s\"", deviceName)
updated = true
break
}
}
if !updated {
lines = append(lines, fmt.Sprintf("audio_device_name: \"%s\"", deviceName))
}
output := strings.Join(lines, "\n")
if err := os.WriteFile("config.yaml", []byte(output), 0644); err != nil {
return fmt.Errorf("failed to write config.yaml: %w", err)
}
return nil
}
// findDeviceByName finds a device by its name
func findDeviceByName(ctx *malgo.AllocatedContext, deviceName string) (*malgo.DeviceInfo, error) {
infos, err := ctx.Devices(malgo.Playback)
if err != nil {
return nil, fmt.Errorf("failed to get devices: %v", err)
}
for _, info := range infos {
if info.Name() == deviceName {
return &info, nil
}
}
return nil, fmt.Errorf("device not found: %s", deviceName)
}
// waitForEnter waits for the user to press Enter before exiting
func waitForEnter() {
fmt.Println("")
fmt.Print("Press Enter to exit...")
reader := bufio.NewReader(os.Stdin)
reader.ReadString('\n')
}
// exitWithError logs an error message and waits for Enter before exiting
func exitWithError(format string, args ...interface{}) {
log.Printf(format, args...)
waitForEnter()
os.Exit(1)
}
// loadOrCreateConfig loads config.yaml or creates it if it doesn't exist
func loadOrCreateConfig() (*Config, error) {
if _, err := os.Stat("config.yaml"); os.IsNotExist(err) {
log.Println("config.yaml not found. Creating a new one...")
if err := createDefaultConfig(); err != nil {
return nil, fmt.Errorf("failed to create config.yaml: %w", err)
}
log.Println("✓ Created config.yaml")
}
configFile, err := os.Open("config.yaml")
if err != nil {
return nil, fmt.Errorf("failed to open config.yaml: %w", err)
}
defer configFile.Close()
cfg := &Config{}
decoder := yaml.NewDecoder(configFile)
if err := decoder.Decode(cfg); err != nil {
return nil, fmt.Errorf("failed to parse config.yaml: %w", err)
}