-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathfeed.xml
1060 lines (776 loc) · 116 KB
/
feed.xml
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
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Pepabo Tech Portal</title>
<id>https://tech.pepabo.com/</id>
<link href="https://tech.pepabo.com/"/>
<link href="https://tech.pepabo.com/feed.xml" rel="self"/>
<updated>2025-02-12T15:00:00+00:00</updated>
<author>
<name>GMO Pepabo, Inc.</name>
</author>
<entry>
<title>ロリポップ for Gamers におけるフロントエンドテストの拡充</title>
<link rel="alternate" href="https://tech.pepabo.com/2025/02/13/gamers-frontend-component-test/"/>
<id>https://tech.pepabo.com/2025/02/13/gamers-frontend-component-test/</id>
<published>2025-02-12T15:00:00+00:00</published>
<updated>2025-02-14T00:04:44+00:00</updated>
<author>
<name>kinosuke01</name>
</author>
<content type="html"><p>こんにちは。Webアプリケーションエンジニアの<a href="https://x.com/kinosuke01">きのすけ</a>です。</p>
<p>フロントエンド開発において、適切なテスト戦略を選択し実装することは、品質を担保する上で重要な課題です。この記事では、ロリポップ for Gamersにおけるフロントエンドのテスト拡充の取り組みについて紹介したいと思います。</p>
<h2 id="背景">背景</h2>
<p>GMOペパボでは、2024年に「<a href="https://gamers.lolipop.jp/">ロリポップ for Gamers</a>」というサービスをリリースしました。これは、VPSをベースに「ゲームのマルチプレイが簡単にできる環境」を提供するサービスです。技術スタックとしては、フロントエンドにNext.js、バックエンドにGoを採用しています。</p>
<p><img src="https://files.speakerdeck.com/presentations/57ee0628a8174d5094b9a249a3c14441/slide_39.jpg" /></p>
<p>プロジェクト初期において、フロントエンドの経験が豊富なチームメンバーはいませんでした。しかし、迅速な市場参入を実現するために、非常に限られた時間の中で、可能な限りフロントエンドのキャッチアップを行い実装を行いました。</p>
<p>結果として、プロジェクトの立ち上げから13営業日で初期リリースを実現することができました。</p>
<h2 id="課題">課題</h2>
<p>プロジェクト初期において、フロントエンドのテストは表示やフックに関わらないロジック部分のユニットテストのみを実装していました。しかし、実際の開発を進める中で、以下のような課題が見えてきました。</p>
<ol>
<li>ユーザーの実際の操作(ボタンクリックなど)に対する動作確認ができていない</li>
<li>APIリクエストの成功/失敗時の挙動を確認できていない</li>
<li>画面表示が意図通りに更新されるかを確認できていない</li>
</ol>
<p>非常に簡略化をすると以下のようなイメージです。</p>
<div class="highlight"><pre class="highlight tsx"><code><span class="c1">// sample.tsx</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">useState</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react</span><span class="dl">'</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">wrapText</span> <span class="o">=</span> <span class="p">(</span><span class="nx">text</span><span class="p">:</span> <span class="kr">string</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
<span class="k">return</span> <span class="s2">`</span><span class="p">${</span><span class="nx">text</span><span class="p">}</span><span class="s2">!!!!!!!`</span>
<span class="p">}</span>
<span class="kd">const</span> <span class="nx">reqApi</span> <span class="o">=</span> <span class="p">():</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="kr">string</span><span class="o">&gt;</span> <span class="o">=&gt;</span> <span class="p">{</span>
<span class="c1">// サンプルのため、API呼び出しをPromiseで代替</span>
<span class="k">return</span> <span class="k">new</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="kr">string</span><span class="o">&gt;</span><span class="p">((</span><span class="nx">resolve</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
<span class="nx">setTimeout</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
<span class="nx">resolve</span><span class="p">(</span><span class="dl">'</span><span class="s1">Hello, World</span><span class="dl">'</span><span class="p">)</span>
<span class="p">},</span> <span class="mi">100</span><span class="p">)</span>
<span class="p">})</span>
<span class="p">}</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">Sample</span> <span class="o">=</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
<span class="kd">const</span> <span class="p">[</span><span class="nx">value</span><span class="p">,</span> <span class="nx">setValue</span><span class="p">]</span> <span class="o">=</span> <span class="nx">useState</span><span class="p">(</span><span class="dl">''</span><span class="p">)</span>
<span class="kd">const</span> <span class="nx">handleClick</span> <span class="o">=</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
<span class="nx">reqApi</span><span class="p">().</span><span class="nx">then</span><span class="p">((</span><span class="nx">v</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
<span class="nx">setValue</span><span class="p">(</span><span class="nx">wrapText</span><span class="p">(</span><span class="nx">v</span><span class="p">))</span>
<span class="p">})</span>
<span class="p">}</span>
<span class="k">return</span> <span class="p">(</span>
<span class="p">&lt;</span><span class="nt">div</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">button</span> <span class="na">data-testid</span><span class="p">=</span><span class="s">'sample-button'</span> <span class="na">onClick</span><span class="p">=</span><span class="si">{</span><span class="nx">handleClick</span><span class="si">}</span><span class="p">&gt;</span>
Fetch
<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">span</span> <span class="na">data-testid</span><span class="p">=</span><span class="s">'sample-text'</span><span class="p">&gt;</span><span class="si">{</span><span class="nx">value</span><span class="si">}</span><span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
<span class="p">)</span>
<span class="p">}</span>
</code></pre></div>
<div class="highlight"><pre class="highlight typescript"><code><span class="c1">// api.ts</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">reqApi</span> <span class="o">=</span> <span class="p">():</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="kr">string</span><span class="o">&gt;</span> <span class="o">=&gt;</span> <span class="p">{</span>
<span class="c1">// サンプルのため、API呼び出しをPromiseで代替</span>
<span class="k">return</span> <span class="k">new</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="kr">string</span><span class="o">&gt;</span><span class="p">((</span><span class="nx">resolve</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
<span class="nx">setTimeout</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
<span class="nx">resolve</span><span class="p">(</span><span class="dl">'</span><span class="s1">Hello, World</span><span class="dl">'</span><span class="p">)</span>
<span class="p">},</span> <span class="mi">100</span><span class="p">)</span>
<span class="p">})</span>
<span class="p">}</span>
</code></pre></div>
<div class="highlight"><pre class="highlight typescript"><code><span class="c1">// sample.test.ts</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">describe</span><span class="p">,</span> <span class="nx">expect</span><span class="p">,</span> <span class="nx">it</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">vitest</span><span class="dl">'</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">wrapText</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">./sample</span><span class="dl">'</span>
<span class="c1">// このくらいしか書けない...</span>
<span class="nx">describe</span><span class="p">(</span><span class="dl">'</span><span class="s1">wrapText</span><span class="dl">'</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
<span class="nx">it</span><span class="p">(</span><span class="dl">'</span><span class="s1">should wrap text</span><span class="dl">'</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
<span class="nx">expect</span><span class="p">(</span><span class="nx">wrapText</span><span class="p">(</span><span class="dl">'</span><span class="s1">Hello, World</span><span class="dl">'</span><span class="p">)).</span><span class="nx">toBe</span><span class="p">(</span><span class="dl">'</span><span class="s1">Hello, World!!!!!!!</span><span class="dl">'</span><span class="p">)</span>
<span class="p">})</span>
<span class="p">})</span>
</code></pre></div>
<p>このテストコードでは、単純な文字列処理(wrapText関数)の検証しかできていません。実際のアプリケーションでは</p>
<ul>
<li>ボタンをクリックしたときの動作</li>
<li>データ取得後の画面表示の更新</li>
<li>エラー発生時のエラーハンドリング</li>
</ul>
<p>といった重要な部分が検証できておらず、品質を担保するには不十分な状態でした。</p>
<p>しかし、テストの方針について再考するほど余裕もありません。一旦はそのまま実装を進め、後ほど検討することとしました。</p>
<h2 id="対応したこと">対応したこと</h2>
<p>リリースラッシュが一区切りついたタイミングで、チーム内の有志にてフロントエンドの課題を棚卸ししました。この時期にはフロントエンドの経験が豊富なメンバーがジョインしてくれたため、相談をしつつテストコードのあり方についての議論も行いました。</p>
<h3 id="テスト戦略を決める">テスト戦略を決める</h3>
<p>結論から言うと「testing-libraryを用いたコンポーネントテスト」に重点を置く方針としました。jsdomのようなライブラリを使用してメモリ上にコンポーネントをレンダリングし、イベントを発火することで、DOMにどのような変化が発生したかをチェックするテストになります。</p>
<p>テストコードの例は以下のようになります。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="c1">// sample.test.ts</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">fireEvent</span><span class="p">,</span> <span class="nx">render</span><span class="p">,</span> <span class="nx">screen</span><span class="p">,</span> <span class="nx">waitFor</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@testing-library/react</span><span class="dl">'</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">describe</span><span class="p">,</span> <span class="nx">expect</span><span class="p">,</span> <span class="nx">it</span><span class="p">,</span> <span class="nx">vi</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">vitest</span><span class="dl">'</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">reqApi</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">./api</span><span class="dl">'</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">Sample</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">./sample</span><span class="dl">'</span>
<span class="c1">// APIをモック</span>
<span class="nx">vi</span><span class="p">.</span><span class="nx">mock</span><span class="p">(</span><span class="dl">'</span><span class="s1">./api</span><span class="dl">'</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">({</span>
<span class="na">reqApi</span><span class="p">:</span> <span class="nx">vi</span><span class="p">.</span><span class="nx">fn</span><span class="p">(),</span>
<span class="p">}))</span>
<span class="nx">describe</span><span class="p">(</span><span class="dl">'</span><span class="s1">Sample</span><span class="dl">'</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
<span class="c1">// 各テストの前にモックをリセット</span>
<span class="nx">beforeEach</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
<span class="nx">vi</span><span class="p">.</span><span class="nx">clearAllMocks</span><span class="p">()</span>
<span class="p">})</span>
<span class="nx">it</span><span class="p">(</span><span class="dl">'</span><span class="s1">正常系のメッセージが表示されること</span><span class="dl">'</span><span class="p">,</span> <span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
<span class="c1">// APIの成功レスポンスをモック</span>
<span class="nx">vi</span><span class="p">.</span><span class="nx">mocked</span><span class="p">(</span><span class="nx">reqApi</span><span class="p">).</span><span class="nx">mockResolvedValue</span><span class="p">(</span><span class="dl">'</span><span class="s1">Hello, World</span><span class="dl">'</span><span class="p">)</span>
<span class="c1">// レンダリングする</span>
<span class="nx">render</span><span class="p">(</span><span class="o">&lt;</span><span class="nx">Sample</span> <span class="o">/&gt;</span><span class="p">)</span>
<span class="c1">// レンダリングされた要素を取得する</span>
<span class="kd">const</span> <span class="nx">button</span> <span class="o">=</span> <span class="nx">screen</span><span class="p">.</span><span class="nx">getByTestId</span><span class="p">(</span><span class="dl">'</span><span class="s1">sample-button</span><span class="dl">'</span><span class="p">)</span>
<span class="kd">const</span> <span class="nx">text</span> <span class="o">=</span> <span class="nx">screen</span><span class="p">.</span><span class="nx">getByTestId</span><span class="p">(</span><span class="dl">'</span><span class="s1">sample-text</span><span class="dl">'</span><span class="p">)</span>
<span class="c1">// クリックする前の表示</span>
<span class="nx">expect</span><span class="p">(</span><span class="nx">text</span><span class="p">).</span><span class="nx">toHaveTextContent</span><span class="p">(</span><span class="dl">''</span><span class="p">)</span>
<span class="c1">// クリックする</span>
<span class="nx">fireEvent</span><span class="p">.</span><span class="nx">click</span><span class="p">(</span><span class="nx">button</span><span class="p">)</span>
<span class="c1">// クリック後の表示</span>
<span class="k">await</span> <span class="nx">waitFor</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
<span class="nx">expect</span><span class="p">(</span><span class="nx">text</span><span class="p">).</span><span class="nx">toHaveTextContent</span><span class="p">(</span><span class="dl">'</span><span class="s1">Hello, World</span><span class="dl">'</span><span class="p">)</span>
<span class="p">})</span>
<span class="p">})</span>
<span class="nx">it</span><span class="p">(</span><span class="dl">'</span><span class="s1">APIがエラーの場合、エラーメッセージが表示されること</span><span class="dl">'</span><span class="p">,</span> <span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
<span class="c1">// APIのエラーレスポンスをモック</span>
<span class="kd">const</span> <span class="nx">errorMessage</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">API Error</span><span class="dl">'</span>
<span class="nx">vi</span><span class="p">.</span><span class="nx">mocked</span><span class="p">(</span><span class="nx">reqApi</span><span class="p">).</span><span class="nx">mockRejectedValue</span><span class="p">(</span><span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="nx">errorMessage</span><span class="p">))</span>
<span class="c1">// レンダリング</span>
<span class="nx">render</span><span class="p">(</span><span class="o">&lt;</span><span class="nx">Sample</span> <span class="o">/&gt;</span><span class="p">)</span>
<span class="c1">// ボタンをクリック</span>
<span class="kd">const</span> <span class="nx">button</span> <span class="o">=</span> <span class="nx">screen</span><span class="p">.</span><span class="nx">getByTestId</span><span class="p">(</span><span class="dl">'</span><span class="s1">sample-button</span><span class="dl">'</span><span class="p">)</span>
<span class="nx">fireEvent</span><span class="p">.</span><span class="nx">click</span><span class="p">(</span><span class="nx">button</span><span class="p">)</span>
<span class="c1">// エラーメッセージが表示されることを確認</span>
<span class="k">await</span> <span class="nx">waitFor</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">text</span> <span class="o">=</span> <span class="nx">screen</span><span class="p">.</span><span class="nx">getByTestId</span><span class="p">(</span><span class="dl">'</span><span class="s1">sample-text</span><span class="dl">'</span><span class="p">)</span>
<span class="nx">expect</span><span class="p">(</span><span class="nx">text</span><span class="p">).</span><span class="nx">toHaveTextContent</span><span class="p">(</span><span class="s2">`Error: </span><span class="p">${</span><span class="nx">errorMessage</span><span class="p">}</span><span class="s2">`</span><span class="p">)</span>
<span class="p">})</span>
<span class="p">})</span>
<span class="p">})</span>
</code></pre></div>
<p>ユーザーのアクションを起点に、画面にどのように反映されるかまで、コンポーネント単位で網羅的にテストができているのではないでしょうか。</p>
<p>コンポーネントテストは 2024年10月の <a href="https://www.thoughtworks.com/radar/techniques/component-testing">Technology Radar で Adopt</a> となっており、トレンドに即した選定でもあったかと思います。</p>
<p>なお、他に「<strong>hook,viewで独立したユニットテストに重きを置く</strong>」という手法を検討しましたが、以下の理由のためお見送りしました。</p>
<ul>
<li>ふるまいではなく実装をテストしてしまう傾向がありそう。そのため、コンポーネントのリファクタリングに伴いテストも書き換える必要がでてきそう。</li>
<li><a href="https://www.react-hook-form.com/">React Hook Form</a> のように、簡単な設定で挙動が変わるコンポーネントのテストができない。</li>
<li>hooksをテストするライブラリが、<a href="https://github.com/testing-library/react-testing-library?tab=readme-ov-file#hooks">コンポーネントテストを推奨</a>していた。</li>
</ul>
<h3 id="いつやるかを決める">いつやるかを決める</h3>
<p>テスト戦略は決まりましたが、いつ実装するかという問題がありました。リリースして間もないサービスです。提供機能を充実させることが非常に重要なフェーズでもありました。</p>
<p>そのような状況下で私がとったアプローチは「まずは自主的な活動として、朝の30分時間を確保してテストコードを書く」ことでした。リリースして間もないサービスで、機能もそれほど多くありません。裁量が効く範囲で時間を確保すれば、おおむねテストコードの拡充ができるのではないかと考えました。また、AIバンドルエディタであるCursorも活用すれば、効率的にテストコードを拡充できる予感もありました。</p>
<h3 id="テストコードの生成">テストコードの生成</h3>
<p>Cursorによるテストコードの生成は、当初は試行錯誤でした。しかし最終的には以下の手法で、ほぼ手直しなく生成できることを確認しました。</p>
<ol>
<li>
<p>前準備として data-testid(テストに用いるタグの識別子)を付与するために、以下のようなプロンプトを使用。</p>
<div class="highlight"><pre class="highlight plaintext"><code> '@testing-library/react', 'vitest' を使用して、コンポーネントのテストを書きたいです。まずは xxx.tsx に data-testid を付与してください。
</code></pre></div> </li>
<li>
<p>テスト対象となるコードと、関連するコードをコンテキストとして追加する。</p>
<p><img src="/blog/2025/02/13/gamers-frontend-component-test/images/cursor-context.jpg" alt="Cursorにコンテキストを追加" /></p>
</li>
<li>
<p>Cursor Composer を用いて、以下のようなプロンプトでテストコード生成を指示する。</p>
<div class="highlight"><pre class="highlight plaintext"><code> '@testing-library/react', 'vitest' を使用して、コンポーネントのテストを書いてください。テストのファイルは xxx.test.tsx としてください。なお、hook は以下の例のように、xxxApi のメソッドをモックするようにしてください。
// ここに例となるコードを記載
</code></pre></div> </li>
<li>
<p>生成したテストケースが不十分だと感じるときは、以下のプロンプトを用いてチューニングする。</p>
<div class="highlight"><pre class="highlight plaintext"><code> では、この出力を60点とします。60点とした時に100点とはどのようなものですか?100点にするために足りないものを列挙した後に、100点の答えを生成してください。
</code></pre></div> </li>
</ol>
<p>このような取り組みを経て、効率よくテストコードの拡充を進めることができました。</p>
<p>※ なお、AIによるコード生成領域は進化が著しいため、読者の方がこの記事を目にされたときには、かなり古い手法になっているかもしれません。</p>
<h3 id="みんなで実装できるようにする">みんなで実装できるようにする</h3>
<p>ここまでの実装は主に私が個人で進めてきましたが、これではスケールしません。チーム全員が取り組めるようにする必要があります。</p>
<p>人が行動を起こす条件には<a href="https://note.com/fladdict/n/n723d55548d10">「動機」「能力」「きっかけ」の3つが必要</a> という考え方があります。チームメンバーにはテストコードを書いたほうがいいという思いはあるため「動機」は満たしていそうです。あとは「能力」と「きっかけ」を作ったらよいのではないかと考えました。</p>
<p>これらを満たすため、メンバーを集め、コンポーネントテストを書くハンズオンを実施することにしました。実際に手を動かしてコードを書くことで「能力」を醸成し、そしてハンズオンというイベント自体を「きっかけ」とするアプローチです。</p>
<p>ハンズオンは、以下の構成で実施しました。</p>
<ul>
<li><a href="https://www.robinwieruch.de/react-testing-library/">React Testing Library Tutorial</a> をベースとした座学</li>
<li>Cursorを使ってテストコードを生成する手法の紹介</li>
<li>実運用されているプロダクトのテストコードを各自で実装</li>
</ul>
<p>ハンズオンはメンバーからは好評で、各自がフロントエンドのテストを書く土壌を整えることができたのではないかと思います。現在は、わたし以外が書いたテストコードがどんどん増えています。</p>
<h2 id="残された課題">残された課題</h2>
<p>テストを書くのは「手早く安全にコードの変更を実現するため」と考えています。そのためには、複数のコンポーネントを統合したインテグレーションテストや、backendともつなぎ込んだE2Eテストの自動化も検討していく必要がありそうです。今後はそれらも視野にいれて、開発に取り組んでいきたいと思います。</p>
<p>なお、ロリポップ for Gamersの backendにおけるテストについては、「<a href="https://developers.gmo.jp/technology/57747/">短期間での新規プロダクト開発における「コスパの良い」Goのテスト戦略</a> 」に詳しく記載があります。興味のある方は是非ご覧ください。</p>
<h2 id="まとめ">まとめ</h2>
<p>以上、ロリポップ for Gamersにおけるフロントエンドのテスト拡充の取り組みについて紹介しました。コンポーネントテストを中心としたテスト戦略の選定から、AIを活用した効率的な実装、そしてチーム全体での取り組みへと発展させた経緯をお伝えしました。この事例が、フロントエンドのテスト導入を検討されている方々の参考になれば幸いです。</p>
</content>
</entry>
<entry>
<title>東京Ruby会議12でbuty4649、yumu、kenchanが登壇しました!</title>
<link rel="alternate" href="https://tech.pepabo.com/2025/02/12/tokyorubykaigi-report/"/>
<id>https://tech.pepabo.com/2025/02/12/tokyorubykaigi-report/</id>
<published>2025-02-11T15:00:00+00:00</published>
<updated>2025-02-14T00:04:44+00:00</updated>
<author>
<name>yumu</name>
</author>
<content type="html"><p>2025年1月18日(土)に開催された<a href="https://regional.rubykaigi.org/tokyo12/">東京Ruby会議12</a>で、GMOペパボの<a href="https://x.com/buty4649">@buty4649</a>、<a href="https://x.com/myumura3">@yumu</a>、<a href="https://x.com/kenchan">@kenchan</a>が登壇しました!</p>
<p>本記事では、発表の内容を振り返りつつ、当日十分に説明しきれなかった部分の補足や、後日談についてお話しします。</p>
<ol id="markdown-toc">
<li><a href="#yumu-rubyawsで作る動画変換システム" id="markdown-toc-yumu-rubyawsで作る動画変換システム">@yumu Ruby×AWSで作る動画変換システム</a> <ol>
<li><a href="#発表の概要" id="markdown-toc-発表の概要">発表の概要</a></li>
<li><a href="#内容の補足" id="markdown-toc-内容の補足">内容の補足</a></li>
<li><a href="#後日談shoryuken-gemの復活と今後の展開" id="markdown-toc-後日談shoryuken-gemの復活と今後の展開">後日談:Shoryuken gemの復活と今後の展開</a></li>
</ol>
</li>
<li><a href="#buty4649-mrubyでワンバイナリなテキストフィルタツールを使った" id="markdown-toc-buty4649-mrubyでワンバイナリなテキストフィルタツールを使った">@buty4649 mrubyでワンバイナリなテキストフィルタツールを使った</a></li>
<li><a href="#regionalrb-and-the-tokyo-metropolis" id="markdown-toc-regionalrb-and-the-tokyo-metropolis">Regional.rb and the Tokyo Metropolis</a> <ol>
<li><a href="#q-コミュニティを長く続けるコツは" id="markdown-toc-q-コミュニティを長く続けるコツは">Q. コミュニティを長く続けるコツは?</a></li>
<li><a href="#q-コミュニティを立ち上げたいと思っている人は何からするといい" id="markdown-toc-q-コミュニティを立ち上げたいと思っている人は何からするといい">Q. コミュニティを立ち上げたいと思っている人は何からするといい?</a></li>
</ol>
</li>
<li><a href="#おわりに" id="markdown-toc-おわりに">おわりに</a></li>
</ol>
<h2 id="yumu-rubyawsで作る動画変換システム">@yumu Ruby×AWSで作る動画変換システム</h2>
<h3 id="発表の概要">発表の概要</h3>
<iframe class="speakerdeck-iframe" frameborder="0" src="https://speakerdeck.com/player/e71eb9aaf4fe4060960f9480cfc01b02" title="Ruby×AWSで作る動画変換システム 東京Ruby会議12" allowfullscreen="true" style="border: 0px; background: padding-box padding-box rgba(0, 0, 0, 0.1); margin: 0px; padding: 0px; border-radius: 6px; box-shadow: rgba(0, 0, 0, 0.2) 0px 5px 40px; width: 100%; height: auto; aspect-ratio: 560 / 315;" data-ratio="1.7777777777777777"></iframe>
<p>発表では、minneにおける動画投稿機能の裏側で動作している動画変換システムについて、以下の3つの観点からお話ししました。</p>
<ul>
<li>技術選定:SaaSではなく自前実装を選んだ理由</li>
<li>実装方法:RubyとAWSの各サービスを組み合わせたシステム構築</li>
<li>開発・運用:ローカル開発環境の整備から本番環境での安定運用まで</li>
</ul>
<p>特に、<a href="https://github.com/streamio/streamio-ffmpeg">Streamio FFMPEG</a>と<a href="https://github.com/ruby-shoryuken/shoryuken">Shoryuken</a>という2つのgemを中心に、FFmpegを使った動画変換処理とAWS SQSを使った非同期処理の実装について詳しく説明しました。</p>
<p><img src="/blog/2025/02/12/tokyorubykaigi-report/images/yumu.png" alt="yumuが登壇している写真" /></p>
<h3 id="内容の補足">内容の補足</h3>
<p>発表後、いくつか質問をいただきました。ここでは、それらの質問について回答します。</p>
<h4 id="アプリケーション側から動画の変換が完了したかどうかをどうやって判別しているの">アプリケーション側から、動画の変換が完了したかどうかをどうやって判別しているの?</h4>
<p>変換後の動画の判別には、Active Storageの仕組みを活用しています。ActiveStorage::Blobで生成されるユニークなkeyを基にして、以下のような命名規則でファイルを管理しています。</p>
<div class="highlight"><pre class="highlight plaintext"><code>original/ # オリジナルファイル
└── {key}
large/ # 変換後ファイル・高画質版
└── {key}
small/ # 変換後ファイル・軽量版
└── {key}
</code></pre></div>
<p>この命名規則により、アプリケーション側では元の動画のkeyから各バージョンの動画パスを容易に特定できます。また、オリジナルのファイルにはタグとしてステータス(ウイルススキャン完了、変換完了など)を付与しており、これをアプリケーション側から参照することで変換状態の確認も可能です。</p>
<h4 id="別の似たライブラリと比べてshoryukenを選んだ理由は何">別の似たライブラリと比べて、Shoryukenを選んだ理由は何?</h4>
<p><a href="https://github.com/aws/aws-activejob-sqs-ruby">aws-activejob-sqs-ruby</a>の開発に関わっている方から、Shoryukenの選定理由について質問をいただきました。</p>
<p>今回の動画変換システムは本体のRailsアプリケーションから独立した形で構築したかったため、ActiveJobに依存しないShoryukenを選択しました。SidekiqもActiveJobに依存しませんが、発表でも触れた通り、インフラをAWSに統一したかったのでShoryukenを採用しています。</p>
<p>なお、動画変換システムとは別の機能で、ActiveJobのバックエンドとしてShoryukenを使用しているものもあります。</p>
<h4 id="hls変換はどうやって実現するの">HLS変換はどうやって実現するの?</h4>
<p>HLS(HTTP Live Streaming)への対応は今後の重要な課題の1つとして認識しています。すでにStreamio FFMPEGを利用して動画変換システムを構築していることから、HLSの実装でも引き続きこのgemを活用していく予定です。実際に以下のようなコードでHLS形式への変換が可能です。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="nb">require</span> <span class="s1">'streamio-ffmpeg'</span>
<span class="n">movie</span> <span class="o">=</span> <span class="no">FFMPEG</span><span class="o">::</span><span class="no">Movie</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="s1">'sample.mp4'</span><span class="p">)</span>
<span class="n">options</span> <span class="o">=</span> <span class="p">{</span>
<span class="ss">video_codec: </span><span class="s1">'libx264'</span><span class="p">,</span> <span class="c1"># H.264コーデックを使用</span>
<span class="ss">audio_codec: </span><span class="s1">'aac'</span><span class="p">,</span> <span class="c1"># AACコーデックを使用</span>
<span class="ss">custom: </span><span class="p">[</span>
<span class="s1">'-bsf:v'</span><span class="p">,</span> <span class="s1">'h264_mp4toannexb'</span><span class="p">,</span> <span class="c1"># HLSに必要なストリームフォーマット</span>
<span class="s1">'-f'</span><span class="p">,</span> <span class="s1">'segment'</span><span class="p">,</span> <span class="c1"># セグメント分割の指定</span>
<span class="s1">'-segment_format'</span><span class="p">,</span> <span class="s1">'mpegts'</span><span class="p">,</span> <span class="c1"># セグメントフォーマットをMPEG-TSに設定</span>
<span class="s1">'-segment_time'</span><span class="p">,</span> <span class="s1">'5'</span><span class="p">,</span> <span class="c1"># 各セグメントの長さを5秒に設定</span>
<span class="s1">'-segment_list'</span><span class="p">,</span> <span class="no">File</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s1">'stream'</span><span class="p">,</span> <span class="s1">'playlist.m3u8'</span><span class="p">)</span> <span class="c1"># m3u8プレイリストの出力先</span>
<span class="p">]</span>
<span class="p">}</span>
<span class="n">transcoder_options</span> <span class="o">=</span> <span class="p">{</span> <span class="ss">validate: </span><span class="kp">false</span> <span class="p">}</span>
<span class="n">output_path</span> <span class="o">=</span> <span class="no">File</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s1">'stream'</span><span class="p">,</span> <span class="s1">'a%03d.ts'</span><span class="p">)</span>
<span class="n">movie</span><span class="p">.</span><span class="nf">transcode</span><span class="p">(</span><span class="n">output_path</span><span class="p">,</span> <span class="n">options</span><span class="p">,</span> <span class="n">transcoder_options</span><span class="p">)</span>
</code></pre></div>
<p>ただし、HLS形式への変換は通常の動画変換よりも処理時間が長くなることが予想されます。そのため、処理の進捗状況をより細かく把握・通知できる仕組みの実装や、長時間処理に対応するためのワーカーの設定調整などの工夫が必要そうです。</p>
<h3 id="後日談shoryuken-gemの復活と今後の展開">後日談:Shoryuken gemの復活と今後の展開</h3>
<p>発表時点で、Shoryuken gemはアーカイブされていたのですが、発表時に「Shoryukenアーカイブされちゃったの!?」という声をたくさんいただき、gem作者の方に直接コンタクトを取ってみました。その結果、アーカイブが解除され、現在は開発が再開されています!</p>
<p>私自身もcontributorとして開発に参加させていただき、テストの改善のためにlocalstackを導入するなど、日々改善を進めています。</p>
<p>動画変換システムを作ってみて、RubyとAWSを組み合わせると複雑な機能もスムーズに実装できることを実感しました。また、これまでgemを使う側だった自分が、開発する側としても関われるようになったのは大きな成長につながったと感じています。</p>
<p>これからも、minneの動画機能の改善と、Shoryukenの開発の両面でRubyコミュニティに貢献していけたらと思います。</p>
<h2 id="buty4649-mrubyでワンバイナリなテキストフィルタツールを使った">@buty4649 mrubyでワンバイナリなテキストフィルタツールを使った</h2>
<p>本発表では、mrubyでワンバイナリなCLIツールを作るということをメインテーマに実例と開発手法について紹介しました。
実例においては、私がOSSとして開発しているテキストフィルタツールの<a href="https://github.com/buty4649/rf/">rfコマンド</a>を紹介しました。rfコマンドはgrepやsedやawk、jqやyqといったツールと同等の処理を、Rubyで書けてそして少ないタイピング数で実現することを目標に開発しています。もし本発表で気になったらぜひインストールしフィードバックをいただけたら幸いです。</p>
<p>開発手法の紹介は、rfコマンドの開発で得られた知見をもとにmrubyを使ったCLIツール開発のいろはについて解説しました。具体的には、mrubyを使った開発の始め方や、CLIツールを作るためのノウハウやバイナリのマルチプラットフォーム対応、そしてGitHub Actionsを使ったCI環境の構築手法やVSCodeのおすすめ設定といった内容でした。
本発表を通じて、mrubyへの関心やCLIツール作成の魅力を感じていただけたら幸いです。</p>
<h2 id="regionalrb-and-the-tokyo-metropolis">Regional.rb and the Tokyo Metropolis</h2>
<p>Shibuya.rbを代表して登壇した<a href="https://x.com/kenchan">@kenchan</a>です。本セッションは、合計16個のRubyコミュニティの代表が登壇し、それぞれのコミュニティの成り立ちや、印象に残っている出来事などを話し、その後会場からの質問に答えるという流れで進んでいきました。</p>
<p>各コミュニティからの発表で一番興味深かったのは、Tokyu.rbとAsakusa.rbという2つのコミュニティについてです。日本でSeattle.rbのようなコミュニティを作りたいと立ち上がった2つのコミュニティが、今はまったく別のものになっているということと、その理由としてTokyu.rbはみんなに求めらていることを探していった結果であるという点はとてもおもしろかったです。コミュニティとしてマーケットインを徹底した結果とも言えるのではないでしょうか。</p>
<p>さて、今回は時間の関係もあって、Shibuya.rb担当として会場からの質問に答えることができなかったので、この場を借りて回答できそうなものに勝手に回答しようと思います。</p>
<h3 id="q-コミュニティを長く続けるコツは">Q. コミュニティを長く続けるコツは?</h3>
<p>自分は「長く続けている」と言えるほど続けられていませんが、Shibuya.rb自体は「代替り」が何度か行なわれているコミュニティです。会場での回答でも「無理をしない」というお話がありましたが、オーガナイザーのエネルギーも無限ではないですし、休みたくなることもあると思います。オーガナイザーは困っていたら困っていると言う、コミュニティが続いて欲しい人はオーガナイザーに突撃する、そういう意識や行動がコミュニティが長く続くことに繋がるのではないかと思います。</p>
<h3 id="q-コミュニティを立ち上げたいと思っている人は何からするといい">Q. コミュニティを立ち上げたいと思っている人は何からするといい?</h3>
<p>運営を一緒にできる仲間を募りましょう。一人ではじめるよりも仲間で始めたほうが、運営そのものも楽める可能性が高いと思います。また、既にあるコミュニティに参加して、オーガナイザーや運営の仲間を作るのもオススメです。</p>
<h2 id="おわりに">おわりに</h2>
<p>発表を聞いてくださった皆さん、東京Ruby会議12の運営の皆さん、本当にありがとうございました。これからも、様々な関わりを通じてRubyコミュニティを盛り上げていきたいと思います。</p>
</content>
</entry>
<entry>
<title>BuriKaigiにtesuwo、donokun、ugo、doskoiが登壇します!</title>
<link rel="alternate" href="https://tech.pepabo.com/2025/01/31/burikaigi2025-entry/"/>
<id>https://tech.pepabo.com/2025/01/31/burikaigi2025-entry/</id>
<published>2025-01-30T15:00:00+00:00</published>
<updated>2025-02-14T00:04:44+00:00</updated>
<author>
<name>ugo</name>
</author>
<content type="html"><p>2025年2月1日(土)に開催される<a href="https://burikaigi.dev/">Burikaigi2025</a>にGMOペパボからは<a href="https://x.com/tetsuwo0717">@てつを。</a>、<a href="https://x.com/furudono2">@どのくん</a>、<a href="https://x.com/yukyu30">@ugo</a>、<a href="https://twitter.com/doskoi64">@どすこい</a>が登壇します!
去年のBurikaigiに、参加したパートナーがとても良かったと言っていたので、プロポーザルを提出しました!
どのメンバーも30分のセッションが初めてなので緊張していますが、精一杯準備するので、ぜひ足を運んでみてください!</p>
<h3 id="てつを-あなたの配信ワイワイたりていますか配信を盛り上げるaiwaiwai-aiを作った話">@てつを。 あなたの配信ワイワイたりていますか?? 配信を盛り上げるAI「waiwai-ai」を作った話</h3>
<p>新規配信者の課題である「コメントの少なさ」に対し、配信の盛り上げ役となるAI「waiwai-ai」を開発しました。本トークでは、waiwai-aiが視聴者のコメントを生成・投稿する仕組みを支える技術スタックについて解説します。具体的には、Pythonライブラリを用いた音声認識と仮想オーディオデバイスによるリアルタイム文字起こしから、その文字をDifyを活用して視聴者のようなコメントを生成するAI、そしてそのコメントを見える形で出力するために工夫した点を紹介します!!</p>
<h3 id="donokun-cliツール開発をprotocol-buffers-スキーマで駆動する">@donokun CLIツール開発をProtocol Buffers スキーマで駆動する</h3>
<p><a href="https://htdp.org/">How to Design Programs</a>を引用して、ぼくが思う上手で楽しいプログラミングとは何かを主張します。</p>
<p>次にWeb APIの開発にもこの手法は適用できると考え、スキーマ駆動開発でどのようにその考え方を適用できるかを紹介します。そのためにProtocol Buffersを用いる例を説明します。</p>
<p>さらにCLI開発にもこの方法を広げるために作成したツールを紹介します。</p>
<h3 id="ugo-3dモデル作成販売を行うwebアプリケーションの裏側">@ugo 3Dモデル作成、販売を行うWebアプリケーションの裏側</h3>
<p>SUZURIにて画像から3Dモデルを作成する「<a href="https://lp.suzuri.jp/3d-t-shirt">3Dグッズ作成機能</a>」が公開されました。
この機能は画像をアップロードすることで、3Dモデルの生成、販売をシームレスに行うことができる機能です。
本発表では3Dモデルの生成手法と、生成された3Dモデルをデジタルコンテンツとしての出品するための方法を、実例を交えて機能の裏側を解説します!</p>
<h3 id="どすこい-データサイエンスをするつもりがkpi数値算出がなーんできてないぜ新卒1年目が配属1ヶ月で挑んだサブスクサービスのkpi数値算出タスク">@どすこい データサイエンスをするつもりが、KPI数値算出がなーんできてないぜ!新卒1年目が配属1ヶ月で挑んだサブスクサービスのKPI数値算出タスク</h3>
<p>研修が終わって事業部に配属されてから今までで挑んだタスクの中で、最も大変だったタスクの話です!
サブスクサービスのKPI数値は、新規契約数、更新契約数、解約数、有効契約数、などがあります。
これらの算出は、ECサービスの売上高と違い、契約のイベントを追跡して状態を管理する必要があります。
また、契約のイベントと状態の種類は多岐にわたるため、テストケースも多くなりますし、テストケースが十分であることを担保するのも難しいです。
これらに対して、どのように対処したのかをお話しします。</p>
</content>
</entry>
<entry>
<title>2つの決裁書承認システムとSlackを連携させて効率化した話</title>
<link rel="alternate" href="https://tech.pepabo.com/2025/01/29/cn-xpoint/"/>
<id>https://tech.pepabo.com/2025/01/29/cn-xpoint/</id>
<published>2025-01-28T15:00:00+00:00</published>
<updated>2025-02-14T00:04:44+00:00</updated>
<author>
<name>よしだ</name>
</author>
<content type="html"><p>こんにちは!
GMO ペパボのグループ会社である GMO クリエイターズネットワークの<a href="https://twitter.com/theyoshida3">よしだ</a>です。</p>
<p>今回は、社内で使われている 2 つの決裁書承認システムと Slack を連携させて効率化した話をします。</p>
<h1 id="以前の状態">以前の状態</h1>
<p>フリーナンスでは、ユーザー様が登録した請求書の審査など、サービス運営業務に利用する管理画面が存在します。
サービス運営業務の中でも、管理画面へのアカウント追加といった上長の承認を必要とするものがあり、管理画面上で承認を行います。</p>
<p>また、サービス運営以外の決裁は X-point<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>という承認システムを導入していました。</p>
<h2 id="問題点">問題点</h2>
<p>以前の状態では下記の問題点がありました。</p>
<p><strong>アカウントの二重管理</strong></p>
<p>承認を扱うシステムが 2 つ存在することにより、それぞれのシステムにログインして承認する必要があります。</p>
<p><strong>承認者の二重管理</strong></p>
<p>承認を扱うシステムが 2 つ存在することにより、承認フローもそれぞれ管理する必要があります。
承認者の異動の際には、それぞれのシステムで反映する必要があります。</p>
<p><strong>承認作業時間</strong></p>
<p>また、管理画面では一件ずつの決裁書作成や決裁書承認しかできず、承認件数が増えてくると承認に時間がかかっていました。</p>
<h1 id="現在の状態">現在の状態</h1>
<p>現在の状態をフロー図にまとめました。
<img src="/blog/2025/01/29/cn_xpoint/images/mermaid.png" alt="現在のフロー" /></p>
<p>申請者は決裁書を提出する人です。
以後は、フローの流れを説明していきます。</p>
<h2 id="決裁書作成--決裁書提出">決裁書作成 ~ 決裁書提出</h2>
<p>フリーナンス管理画面から、 X-point の API を実行し、承認を取りたい内容の決裁書を下書き書類として作成します。</p>
<p>下書き書類作成後は、新しいタブで X-point の決裁書編集画面を開き、申請者は内容の確認と提出を行います。</p>
<h2 id="承認依頼-webhook--承認依頼の送信">承認依頼 Webhook ~ 承認依頼の送信</h2>
<p>決裁書提出時に X-point からフリーナンスへ承認依頼が送信されます。フリーナンスは、現在の X-point の承認フローから承認者を判断し、承認者毎の専用 Slack チャンネルに承認依頼を送信します。</p>
<h2 id="承認操作--承認実施">承認操作 ~ 承認実施</h2>
<p>承認依頼で送信された Slack は下記となります。
<img src="/blog/2025/01/29/cn_xpoint/images/slack.png" alt="承認依頼" /></p>
<p>「承認」ボタンを押下することで、フリーナンス側の承認操作が実行されます。
承認操作では、承認をした Slack ユーザーから X-point ユーザーを判断し、 X-point の承認 API を実行します。</p>
<p>「却下」ボタン却下の場合、決議書は却下として、フリーナンスから X-point へ API を実行します。</p>
<h2 id="承認完了-webhook">承認完了 Webhook</h2>
<p>承認操作が実行された際は、Webhook のステータスで承認フローが完了しているか判断できます。
承認フローが完了している場合、後続の処理を実施します。
承認フローがまだ残っている場合には、次の承認者の専用 Slack チャンネルへ承認依頼を送信します。</p>
<h1 id="改善された点">改善された点</h1>
<p>以前の状態で挙げてた問題点は、2 つの決裁書承認システムと Slack を連携することで改善されました。</p>
<p><strong>アカウントの二重管理</strong></p>
<p>決裁書承認は X-point の責務とすることで、承認者は X-point だけ確認すればよくなりました。</p>
<p><strong>承認者の二重管理</strong></p>
<p>承認フローも X-point の責務とすることで、承認者の異動の際には、X-point のみ反映すればよくなりました。</p>
<p><strong>承認作業時間</strong></p>
<p>Slack 上での一覧表示から決裁書承認ができ、承認件数が増えた場合にも時間がかかりにくくなりました。</p>
<h1 id="考慮した点">考慮した点</h1>
<p>連携の仲介役をフリーナンスの管理ユーザーに担わせました。</p>
<ul>
<li>X-point API を利用するために、フリーナンスの管理ユーザーと X-point ユーザーの紐づけ(X-point トークン保持)</li>
<li>Slack から承認操作をするために、フリーナンスの管理ユーザーと Slack ユーザーの紐づけ(Slack ユーザー ID 保持)</li>
</ul>
<p>Slack から X-point 上の決裁書を操作する際に、フリーナンスを経由することで「Slack ユーザー ID → フリーナンス管理ユーザー ID → X-point トークン」と X-point へのアクセスが可能になります。</p>
<h1 id="最後に">最後に</h1>
<p>承認フローを X-point の責務にすることで、承認者の変更に強いと述べました。
では、承認フローの階層が増えた場合、どうなるのでしょうか。</p>
<p>次回は、決裁書承認フローの階層が増えた話をします。</p>
<h2 id="gmo-クリエイターズネットワークではプロダクト開発職種の採用をおこなっています">GMO クリエイターズネットワークでは、プロダクト開発職種の採用をおこなっています!</h2>
<p>フリーナンスでは今、プロダクト開発組織を立ち上げていこうとしている最中です。</p>
<p>ソフトウェアエンジニアとして面白い部分は、フロントエンド〜バックエンド〜インフラを横断的に触る開発スタイルで開発している点です。
Go のコードを書いたり、Terraform を書いたりしながら開発を進めていることが多いです。</p>
<p>現在は Go や React を中心としたシステムへのリアーキテクチャを検討しています。
バックエンドアーキテクチャの設計やフレームワークの選定、検証とやれることは非常に多いです。</p>
<p>そんな技術課題の解消やユーザに価値を提供出来る仕組みづくりを私達と一緒に進めていってくださる方々を募集中です!以下の職種を積極採用しています!</p>
<ul>
<li><a href="https://open.talentio.com/r/1/c/gmo-cn/pages/78518">ソフトウェアエンジニア(バックエンド)</a></li>
</ul>
<p>また、<a href="https://open.talentio.com/r/1/c/gmo-cn/pages/78856">カジュアル面談</a>も大歓迎ですのでお気軽に申し込んでいただけると嬉しいです!</p>
<div class="footnotes" role="doc-endnotes">
<ol>
<li id="fn:1" role="doc-endnote">
<p>https://www.atled.jp/xpoint_cloud/ <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
</li>
</ol>
</div>
</content>
</entry>
<entry>
<title>視聴者と同様に配信をコメントで盛り上げるwaiwai-aiを作った話</title>
<link rel="alternate" href="https://tech.pepabo.com/2025/01/17/waiwai-ai/"/>
<id>https://tech.pepabo.com/2025/01/17/waiwai-ai/</id>
<published>2025-01-16T15:00:00+00:00</published>
<updated>2025-02-14T00:04:44+00:00</updated>
<author>
<name>どすこい</name>
</author>
<content type="html"><ol id="markdown-toc">
<li><a href="#はじめに" id="markdown-toc-はじめに">はじめに</a></li>
<li><a href="#技術選定" id="markdown-toc-技術選定">技術選定</a></li>
<li><a href="#任意の音声映像から文字起こしをする技術-てつを" id="markdown-toc-任意の音声映像から文字起こしをする技術-てつを">任意の音声/映像から文字起こしをする技術 @てつを。</a> <ol>
<li><a href="#音声入力部分" id="markdown-toc-音声入力部分">音声入力部分</a></li>
<li><a href="#映像入力部分" id="markdown-toc-映像入力部分">映像入力部分</a></li>
</ol>
</li>
<li><a href="#わいわいをaiで生成する部分-どすこい" id="markdown-toc-わいわいをaiで生成する部分-どすこい">わいわいをAIで生成する部分 @どすこい</a> <ol>
<li><a href="#生成aiアプリ開発基盤について" id="markdown-toc-生成aiアプリ開発基盤について">生成AIアプリ開発基盤について</a></li>
<li><a href="#入力部分とのインターフェースについて" id="markdown-toc-入力部分とのインターフェースについて">入力部分とのインターフェースについて</a></li>
<li><a href="#コメント生成部分のllmアプリケーションについて" id="markdown-toc-コメント生成部分のllmアプリケーションについて">コメント生成部分のLLMアプリケーションについて</a></li>
<li><a href="#コメント生成部分のハイパーパラメータのチューニングについて" id="markdown-toc-コメント生成部分のハイパーパラメータのチューニングについて">コメント生成部分のハイパーパラメータのチューニングについて</a></li>
</ol>
</li>
<li><a href="#生成した内容の出力部分-はるおつ" id="markdown-toc-生成した内容の出力部分-はるおつ">生成した内容の出力部分 @はるおつ</a> <ol>
<li><a href="#実装の手順" id="markdown-toc-実装の手順">実装の手順</a></li>
<li><a href="#コンソールへの出力" id="markdown-toc-コンソールへの出力">コンソールへの出力</a></li>
<li><a href="#slackの特定のチャンネルにそのまま出力する" id="markdown-toc-slackの特定のチャンネルにそのまま出力する">Slackの特定のチャンネルにそのまま出力する</a></li>
<li><a href="#slackの特定のメッセージに対してスレッドで出力する" id="markdown-toc-slackの特定のメッセージに対してスレッドで出力する">Slackの特定のメッセージに対してスレッドで出力する</a></li>
</ol>
</li>
<li><a href="#結果" id="markdown-toc-結果">結果</a></li>
<li><a href="#感想" id="markdown-toc-感想">感想</a> <ol>
<li><a href="#どすこい" id="markdown-toc-どすこい">どすこい</a></li>
<li><a href="#はるおつ" id="markdown-toc-はるおつ">はるおつ</a></li>
<li><a href="#てつを" id="markdown-toc-てつを">てつを</a></li>
</ol>
</li>
</ol>
<h2 id="はじめに">はじめに</h2>
<p>こんにちは。2024年度新卒エンジニアの<a href="https://x.com/hrt_ykym">はるおつ</a>、<a href="https://x.com/tetsuwo0717">てつを</a>、<a href="https://x.com/doskoi64">どすこい</a>です。普段の事業部の開発から離れて、事業部や職種を跨いでチームを組んで、2日間かけてプロダクトを作るイベントである、<a href="https://hr.pepabo.com/report/2023/04/06/8730">お産合宿</a>(こちらは2023年のものです)で開発したプロダクトについてブログを書きます!</p>
<p>今回のテーマは"配信"で、昨今伸びている配信業界にアプローチしたプロダクトを各々開発しました。</p>
<p>上記テーマのもとで私たちが開発したプロダクトを<strong>waiwai-ai</strong>と名付けました。視聴者と同様にコメント投稿することで配信を盛り上げるAI botです。新規の配信者は視聴者が少なく、配信が寂しくなってしまうという課題があります。waiwai-aiが配信を盛り上げることで、新規配信者でも配信を楽しめるようにすることが目的です。</p>
<h2 id="技術選定">技術選定</h2>
<p>本アプリケーションは以下のような技術を用いて実現しました。</p>
<p><strong>入力部分</strong></p>
<ul>
<li>pyaudio</li>
<li>speech_recognition</li>
<li>VB-Cable</li>
<li>OBS の仮想カメラ機能</li>
</ul>
<p><strong>わいわいをAIで生成する部分</strong></p>
<ul>
<li>Dify</li>
<li>LLMマルチエージェントモデル</li>
<li>ハイパーパラメータのチューニング</li>
</ul>
<p><strong>生成した内容の出力部分</strong></p>
<ul>
<li>Slack API</li>
<li>正規表現 (Python)</li>
</ul>
<p><img src="/blog/2025/01/17/waiwai-ai/images/waiwai-ai-image.png" alt="waiwai-aiのアーキテクチャ図" /></p>
<p>以降ではそれぞれが担当した、各コンポーネントの詳細を説明します。</p>
<h2 id="任意の音声映像から文字起こしをする技術-てつを">任意の音声/映像から文字起こしをする技術 @てつを。</h2>
<p>こんにちは!入力部分を担当した<a href="https://x.com/tetsuwo0717">てつを</a>です!!</p>
<p>はじめに、私が担当した <strong>waiwai-ai</strong> の「入力部分」の実装について、音声入力と映像キャプチャ機能を中心に解説します。特に、リアルタイムの音声からテキストの生成、そして映像のキャプチャ方法に至るまでのライブラリの選定理由と工夫点について触れていきます。</p>
<p>配信中は雑音やBGM、効果音などさまざまな音声が混在する環境であり、精度の高い文字起こしを実現するために<strong>pyaudio</strong>、<strong>speech_recognition</strong>、そして仮想オーディオデバイスとしての <strong>VB-Cable</strong> を組み合わせ、マルチデバイス対応とノイズの多い環境でも安定した文字起こしを目指しました。
これによってOBSからの音声をリアルタイムで取得し、音声データを統合的に扱うことができます。また配信者自体は話していなくても、流れている音楽やゲーム音に対してもコメントを返してくれるようになります。
また、今回採用したwaiwai-aiのLLMは動画を入力することができなかったためOBSのモニターを画像としてキャプチャすることでLLMに読み込ませました。画像をLLMに入力することでLLMはより的確なコメントを返してくれます。キャプチャをするためのライブラリには <strong>cv2</strong> を採用しました。</p>
<h3 id="音声入力部分">音声入力部分</h3>
<p>音声入力部分の目的は、配信者の音声やゲーム音をリアルタイムで取り込み、それをテキスト化することです。以下のツール、ライブラリを用いて実装し、以下の順で処理していきます。</p>
<h4 id="音声の取得">音声の取得</h4>
<p>配信者が使用するマイクやOBSからの音声出力を取得します。これは <a href="https://pypi.org/project/PyAudio/">pyaudio</a> を使用して任意のマイクデバイスを選択し、音声を録音することで実現します。</p>
<h4 id="obsとの連携">OBSとの連携</h4>
<p>OBSの音声出力(配信者の声、ゲーム音、BGM)を仮想オーディオデバイスとしてPythonに取り込むために <a href="https://vb-audio.com/Cable/">VB-Cable</a> を使用します。</p>
<ul>
<li>使用ツール: VB-Cable(仮想オーディオケーブル)</li>
</ul>
<h4 id="音声の文字起こし">音声の文字起こし</h4>
<p>取り込んだ音声を <a href="https://pypi.org/project/SpeechRecognition/">speech_recognition</a> を使ってリアルタイムで文字起こしします。Google Speech Recognition APIを活用し、日本語に対応しています。
以下はspeech_recognitionを使用した文字起こしのコード例です。</p>
<div class="highlight"><pre class="highlight python"><code><span class="c1"># 音声ファイルを文字起こしする関数
</span><span class="k">def</span> <span class="nf">transcribe_audio</span><span class="p">(</span><span class="n">file_path</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
<span class="n">recognizer</span> <span class="o">=</span> <span class="n">sr</span><span class="p">.</span><span class="n">Recognizer</span><span class="p">()</span>
<span class="k">with</span> <span class="n">sr</span><span class="p">.</span><span class="n">AudioFile</span><span class="p">(</span><span class="n">file_path</span><span class="p">)</span> <span class="k">as</span> <span class="n">source</span><span class="p">:</span>
<span class="n">audio</span> <span class="o">=</span> <span class="n">recognizer</span><span class="p">.</span><span class="n">record</span><span class="p">(</span><span class="n">source</span><span class="p">)</span>
<span class="k">try</span><span class="p">:</span>
<span class="n">text</span> <span class="o">=</span> <span class="n">recognizer</span><span class="p">.</span><span class="n">recognize_google</span><span class="p">(</span><span class="n">audio</span><span class="p">,</span> <span class="n">language</span><span class="o">=</span><span class="s">"ja-JP"</span><span class="p">)</span>
<span class="k">return</span> <span class="n">text</span>
<span class="k">except</span> <span class="n">sr</span><span class="p">.</span><span class="n">UnknownValueError</span><span class="p">:</span>
<span class="k">raise</span> <span class="n">AudioError</span><span class="p">(</span><span class="s">"Google Speech Recognition could not understand audio"</span><span class="p">)</span>
<span class="k">except</span> <span class="n">sr</span><span class="p">.</span><span class="n">RequestError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
<span class="k">raise</span> <span class="n">AudioError</span><span class="p">(</span><span class="s">"Could not request results from Google Speech Recognition service"</span><span class="p">)</span>
</code></pre></div>
<p>音声入力部分の処理は「音声/映像の取得 → 文字起こし → わいわいをAIで生成する部分へ渡す」のループを30秒単位で繰り返しています。</p>
<h3 id="映像入力部分">映像入力部分</h3>
<p>映像入力部分の目的は、配信映像をキャプチャしてフレーム単位で保存することです。これにより実況中の特定のシーンを自動的に記録できます。</p>
<h4 id="obsとの連携-1">OBSとの連携</h4>
<p>OBSの映像を仮想カメラ機能を使ってPythonに入力します。この仮想カメラ機能により、配信者が意図した画面レイアウトをそのまま処理可能です。</p>
<h4 id="フレームのキャプチャと保存">フレームのキャプチャと保存</h4>
<p><a href="https://pypi.org/project/opencv-python/">cv2</a> を使用してフレームをキャプチャし、任意のタイミングで画像として保存します。30秒間隔でのキャプチャを設定しています。</p>
<div class="highlight"><pre class="highlight python"><code><span class="kn">import</span> <span class="nn">cv2</span>
<span class="n">cap</span> <span class="o">=</span> <span class="n">cv2</span><span class="p">.</span><span class="n">VideoCapture</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
<span class="c1"># フレームをキャプチャ
</span><span class="n">ret</span><span class="p">,</span> <span class="n">frame</span> <span class="o">=</span> <span class="n">cap</span><span class="p">.</span><span class="n">read</span><span class="p">()</span>
<span class="k">if</span> <span class="n">ret</span><span class="p">:</span>
<span class="c1"># スクリーンショットを保存
</span> <span class="n">cv2</span><span class="p">.</span><span class="n">imwrite</span><span class="p">(</span><span class="s">'screenshot.png'</span><span class="p">,</span> <span class="n">frame</span><span class="p">)</span>
<span class="k">else</span><span class="p">:</span>
<span class="k">print</span><span class="p">(</span><span class="s">"フレームの取得に失敗しました"</span><span class="p">)</span>
<span class="n">cap</span><span class="p">.</span><span class="n">release</span><span class="p">()</span>
</code></pre></div>
<h2 id="わいわいをaiで生成する部分-どすこい">わいわいをAIで生成する部分 @どすこい</h2>
<p>こんにちは!AIが好き好き人間の<a href="https://x.com/doskoi64">どすこい</a>です!私はわいわいを生成する部分を担当しました。ここでいう”わいわい”とは、配信を盛り上げるコメントのことです。わいわい生成部は、入力部分で受け取った配信の情報を、LLM(大規模言語モデル)アプリケーションの入力として用いて”わいわい”を生成させ、出力部に渡すことが責務となっています。</p>
<p>ここでは、そのLLMアプリケーションの構成について説明します。</p>
<h3 id="生成aiアプリ開発基盤について">生成AIアプリ開発基盤について</h3>
<p>配信を盛り上げる”わいわい”を生み出すために、LLMを用いることにしました。そこで、当時少しずつ盛り上がり始めていたDifyを用いてLLMアプリを開発することにしました。<a href="https://dify.ai/jp">Dify</a>はオープンソースのLLMアプリ開発プラットフォームで、LLMの設定や処理、ハイパーパラメータ設定をGUI上で簡単に操作することができます。</p>
<p>今回の話とは別に、DifyとSlackbotの連携についてはこのブログでまとめて書いたので、ぜひみてください。
https://zenn.dev/ctk64/articles/f7e6cf44bf4be7</p>
<h3 id="入力部分とのインターフェースについて">入力部分とのインターフェースについて</h3>
<p>入力部分から受け取るデータは、<strong>配信者の発言内容を抽出したテキストデータ</strong>を、用いることにしました。具体的には、配信者の発言やそれに関連する情報をテキスト形式で受け取ることを想定しています。この仕様を早い段階で定義したことで、わいわい生成部ではダミーデータを用いた開発が可能となり、チームメンバーが独立かつ並列して作業を進められる体制が構築できました。</p>
<p>また、このテキストデータはノイズ処理などをせずにそのまま用いることにしました。この判断の理由として、以下の点が挙げられます:</p>
<ol>
<li>入力部分で取得されるテキストデータの精度が実証済みで十分高いこと。</li>
<li>使用するLLMアプリケーションが、ある程度のノイズを処理できる性能を有していること。</li>
</ol>
<p>このため、入力部分から受け取ったテキストデータをそのままLLMに渡して処理する設計としました。</p>
<p>なお、将来的に以下の状況が生じた場合には、入力部分とわいわい生成部分の間にデータの前処理を行う仕組みが必要になると考えられます:</p>
<ul>
<li>入力部分から送られるデータにノイズが多く含まれる場合。</li>
<li>使用するLLMアプリケーションがノイズに対して脆弱な場合。</li>
</ul>
<p>出力部分とのインターフェースに関しては、別途「出力部分の仕様」で詳述します。</p>
<h3 id="コメント生成部分のllmアプリケーションについて">コメント生成部分のLLMアプリケーションについて</h3>
<p>LLMのユーモアに関する研究はいくつかあり、その中で手軽で効果の高そうな手法を応用することにしました。それは、異なる人格のLLMをいくつか用意して、ランダムなLLMにユーモアを生成させるものです。</p>
<p>今回の開発では、3種類の異なるキャラクターを持つLLMを用意しました。それぞれ、
①視聴者を積極的に褒めてくれるLLM
②状況を整理して褒めるLLM
③自分のことを面白いと思っている少し自信過剰なLLM
という特徴を持たせています。</p>
<p>また、複数のLLMモデルを用いたマルチエージェントモデルを用いることで、単一のLLMを使うよりも人間に近いコメントを出せるように工夫しました。</p>
<h3 id="コメント生成部分のハイパーパラメータのチューニングについて">コメント生成部分のハイパーパラメータのチューニングについて</h3>
<p>さらに、LLMのハイパーパラメータを調整して、発言にランダムさを加え、配信ごとに異なるリアクションを楽しめるようにしました。これにより、視聴者が配信に参加するたびに新しい体験ができるようになっています。
LLMのハイパーパラメータは以下のような値です。詳しくは<a href="https://platform.openai.com/docs/api-reference/completions/create">OpenAIのドキュメント</a>も参考にみてください!</p>
<ul>
<li>
<p>Temperature<br />
応答のランダム性やクリエイティビティを制御する。</p>
</li>
<li>
<p>Top P<br />
上位P%の確率のトークンを選択する。</p>
</li>
<li>
<p>Presence Penalty<br />
応答内で既に使用されたトークンの再利用を抑制する。</p>
</li>
<li>
<p>Frequency Penalty<br />
応答内で同じトークンが繰り返される頻度を抑える。</p>
</li>
<li>
<p>Max Tokens<br />
応答に含められるトークン(単語や記号など)の最大数を指定する。</p>
</li>
<li>
<p>Seed<br />
ランダム性の再現性を確保するためのシード値。実験のために主に利用する。</p>
</li>
</ul>
<p>Difyでは、LLMのプロンプティングやハイパーパラメータを変えて試行錯誤する実験が非常にやりやすかったです。ChatGPTのAPIを用いた場合と比較すると、Difyでは下記の画像のように、プロンプトやハイパーパラメータのチューニングの検証が非常にやりやすく、短時間で多くの検証を行うことができました。</p>
<p><img src="/blog/2025/01/17/waiwai-ai/images/dify-tuning.png" alt="Difyのチューニング" /></p>
<h2 id="生成した内容の出力部分-はるおつ">生成した内容の出力部分 @はるおつ</h2>
<p>こんにちは。waiwai-aiの生成した内容の出力部分を担当したはるおつです。</p>
<p>今回は、AIコメントで配信を盛り上げる「waiwai-ai」の開発において、出力部分をどのように実装し、Slack APIとの繋ぎ込み、そして今後の拡張についてどのように取り組んだかをご紹介します。</p>
<h3 id="実装の手順">実装の手順</h3>
<p>waiwai-aiの生成した内容の出力部分を実装するにあたり、以下の3つのステップに分けて開発を行いました。</p>
<ol>
<li>基本的な動作確認とデバッグを容易に行える環境を整えるため、コンソールへの出力を実装する。</li>
<li>Slack APIの仕様を把握し、実際のユーザー環境での動作を確認するために、特定のチャンネルに出力する。</li>
<li>ユーザーとの自然なインタラクションを実現するため、スレッドのリンクを指定して返信できる機能を実装する。</li>
</ol>
<p>このように、段階的にタスクを分解することで、開発の進捗を管理しつつ、それぞれのステップで直面する課題を明確にしながら作業を進めました。</p>
<p>それでは、実際の開発について述べていきたいと思います。</p>
<h3 id="コンソールへの出力">コンソールへの出力</h3>
<p>まず最初に、waiwai-aiの基本となる<strong>コンソールへの出力</strong>と、<strong>AIから出力されたコメントの受け渡し形式</strong>を検討しました。特に、チーム開発における連携のしやすさと、将来の拡張性を考慮した設計を心がけました。</p>
<h4 id="実装内容">実装内容</h4>
<p>以下のようなシンプルなクラスを作成し、<code>answers</code> リストに含まれるコメントをコンソールに出力するメソッドを実装しました。</p>
<div class="highlight"><pre class="highlight python"><code><span class="k">class</span> <span class="nc">Outputs</span><span class="p">:</span>
<span class="n">answers</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span>
<span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">answers</span><span class="p">):</span>
<span class="bp">self</span><span class="p">.</span><span class="n">answers</span> <span class="o">=</span> <span class="n">answers</span>
<span class="k">def</span> <span class="nf">outputConsole</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
<span class="k">for</span> <span class="n">answer</span> <span class="ow">in</span> <span class="bp">self</span><span class="p">.</span><span class="n">answers</span><span class="p">:</span>
<span class="k">print</span><span class="p">(</span><span class="n">answer</span><span class="p">)</span>
</code></pre></div>
<h4 id="データ形式の設計と選択">データ形式の設計と選択</h4>
<p>コメントの受け渡しには様々な方法がありますが、わいわいをAIで生成するチームと密に連携し、以下のような配列(リスト)形式での渡し方を採用しました。</p>
<div class="highlight"><pre class="highlight python"><code><span class="n">answers</span> <span class="o">=</span> <span class="p">[</span>
<span class="s">"ピザと寿司と肉が好きなところ、もう最高ですね! #食べ物仲間"</span><span class="p">,</span>
<span class="s">"自己紹介してくれて嬉しいです!音声合成技術、すごく興味深いですね😊"</span><span class="p">,</span>
<span class="s">"草"</span>
<span class="p">]</span>
</code></pre></div><p>この配列を用いて、各コメントを順にコンソールへ出力するようにしました。この形式を選んだ理由は主に以下の3点です。</p>
<ol>
<li>複数行コメントや順序付きの発言を扱いやすい</li>
<li>チーム間でのデータ受け渡しがシンプル</li>
<li>SlackやYouTubeなど、異なる出力先への拡張が容易</li>
</ol>
<h3 id="slackの特定のチャンネルにそのまま出力する">Slackの特定のチャンネルにそのまま出力する</h3>
<p>次に、<strong>Slackの特定のチャンネルに直接出力する機能</strong>を実装しました。これは、実際のユーザー環境でコメントがどのように表示されるかを確認するための重要なプロセスです。</p>
<h4 id="実装内容-1">実装内容</h4>
<p><code>Outputs</code> クラスに <code>outputSlack</code> メソッドを追加し、Slack APIを利用して指定したチャンネルにメッセージを投稿できるようにしました。</p>
<div class="highlight"><pre class="highlight python"><code><span class="k">def</span> <span class="nf">outputSlack</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
<span class="kn">import</span> <span class="nn">os</span>
<span class="kn">import</span> <span class="nn">requests</span>
<span class="kn">from</span> <span class="nn">dotenv</span> <span class="kn">import</span> <span class="n">load_dotenv</span>
<span class="n">load_dotenv</span><span class="p">()</span>
<span class="n">TOKEN</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">getenv</span><span class="p">(</span><span class="s">"SLACK_OAUTH_TOKEN_FOR_TEST"</span><span class="p">)</span>
<span class="n">CHANNEL</span> <span class="o">=</span> <span class="s">'HOGEHOGE'</span> <span class="c1"># チャンネルIDを指定
</span>
<span class="n">headers</span> <span class="o">=</span> <span class="p">{</span><span class="s">"Authorization"</span><span class="p">:</span> <span class="sa">f</span><span class="s">"Bearer </span><span class="si">{</span><span class="n">TOKEN</span><span class="si">}</span><span class="s">"</span><span class="p">}</span>
<span class="k">for</span> <span class="n">answer</span> <span class="ow">in</span> <span class="bp">self</span><span class="p">.</span><span class="n">answers</span><span class="p">:</span>
<span class="n">url</span> <span class="o">=</span> <span class="s">"https://slack.com/api/chat.postMessage"</span>
<span class="n">data</span> <span class="o">=</span> <span class="p">{</span>
<span class="s">'channel'</span><span class="p">:</span> <span class="n">CHANNEL</span><span class="p">,</span>
<span class="s">'text'</span><span class="p">:</span> <span class="n">answer</span>
<span class="p">}</span>
<span class="n">r</span> <span class="o">=</span> <span class="n">requests</span><span class="p">.</span><span class="n">post</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="n">headers</span><span class="o">=</span><span class="n">headers</span><span class="p">,</span> <span class="n">data</span><span class="o">=</span><span class="n">data</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="s">"chat.postMessage response:"</span><span class="p">,</span> <span class="n">r</span><span class="p">.</span><span class="n">json</span><span class="p">())</span>
</code></pre></div>
<h4 id="slack-apiの設定">Slack APIの設定</h4>
<p>Slack APIを利用するために、以下のOAuthスコープを設定しました。(2024/5/30時点の設定)</p>
<p><strong>Bot Token Scopes:</strong></p>
<ul>
<li><strong>channels</strong>
<ul>
<li>@waiwai-aiが追加されたパブリックチャンネル内のメッセージやその他のコンテンツを閲覧するための権限です。スレッドの存在確認やメッセージの取得のために設定しました。</li>
</ul>
</li>
<li><strong>chat</strong>
<ul>
<li>@waiwai-aiとしてチャンネルにメッセージを送信するための権限です。</li>
</ul>
</li>
<li><strong>chat.customize</strong>
<ul>
<li>@waiwai-aiとして、カスタマイズされたユーザー名やアバターでメッセージを送信するための権限です。waiwai-aiの見た目を変えることを想定して、設定しました。</li>
</ul>
</li>
</ul>
<p><strong>User Token Scopes:</strong></p>
<ul>
<li><strong>chat</strong>
<ul>
<li>ユーザーに代わってメッセージを送信するための権限です。現在はbotとして表示されますが、今後ユーザーの代わりになってユーザー権限でメッセージを送信する際に使用します。</li>
</ul>
</li>
</ul>
<h4 id="実際にslackに出力した様子">実際にSlackに出力した様子</h4>
<p>実際にSlackに出力した様子は以下の通りです。配列で区切ったテスト用に作成したコメントが、そのままSlackに出力されていることがわかります。
<img src="/blog/2025/01/17/waiwai-ai/images/output1.png" alt="実際にSlackに出力した様子" /></p>
<h3 id="slackの特定のメッセージに対してスレッドで出力する">Slackの特定のメッセージに対してスレッドで出力する</h3>
<p>Slackのスレッドをリンクで指定できるように機能を拡張しました。</p>
<p>これにより、ユーザーはスレッドのURLを直接指定するだけで、そのスレッドに対してAIコメントを投稿できるようになりました。</p>
<h4 id="実装の背景と工夫点">実装の背景と工夫点</h4>
<p>これまでの実装では、SlackのチャンネルIDとメッセージのタイムスタンプ(<code>thread_ts</code>)を個別に指定する必要がありました。しかし、ユーザーがこれらの情報を手動で取得して指定するのは手間がかかり、UXの観点から改善の余地がありました。</p>
<p>そこで着目したのが、SlackのスレッドURLに含まれる情報です。実は、SlackのスレッドURLの末尾には、そのスレッドのタイムスタンプが含まれており、この点を活用することでユーザーの利便性を向上させることができました。</p>
<h4 id="urlから情報を抽出する方法">URLから情報を抽出する方法</h4>
<p>SlackのスレッドURLは以下のような形式になっています。</p>
<div class="highlight"><pre class="highlight plaintext"><code>https://&lt;workspace&gt;.slack.com/archives/&lt;channel_id&gt;/p&lt;timestamp&gt;
</code></pre></div>
<p>ここで、</p>
<ul>
<li><code>&lt;workspace&gt;</code> はワークスペースの名前</li>
<li><code>&lt;channel_id&gt;</code> はチャンネルのID</li>
<li><code>&lt;timestamp&gt;</code> はスレッドのタイムスタンプ(小数点が省略された形式)</li>
</ul>
<p><strong>例:</strong></p>
<p>タイムスタンプが <code>1622547803.000200</code> の場合、URLの末尾は <code>p1622547803000200</code> となります。</p>
<p>このURLから以下の情報を抽出します。</p>
<ol>
<li><strong>チャンネルID</strong>: <code>&lt;channel_id&gt;</code></li>
<li><strong>タイムスタンプ</strong>: <code>&lt;timestamp&gt;</code> を小数点を挿入して元の形式に戻す</li>
</ol>
<h4 id="実装内容-2">実装内容</h4>
<h4 id="正規表現による情報抽出">正規表現による情報抽出</h4>
<p>URLからチャンネルIDとタイムスタンプを抽出するために、正規表現を使用しました。</p>
<div class="highlight"><pre class="highlight python"><code><span class="k">def</span> <span class="nf">extract_slack_info</span><span class="p">(</span><span class="n">url</span><span class="p">):</span>
<span class="kn">import</span> <span class="nn">re</span>
<span class="c1"># 正規表現パターンを定義
</span> <span class="n">pattern</span> <span class="o">=</span> <span class="sa">r</span><span class="s">'https://[^/]+/archives/([A-Z0-9]+)/p(\d{16})'</span>
<span class="n">match</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="n">match</span><span class="p">(</span><span class="n">pattern</span><span class="p">,</span> <span class="n">url</span><span class="p">)</span>
<span class="k">if</span> <span class="ow">not</span> <span class="n">match</span><span class="p">:</span>
<span class="k">raise</span> <span class="nb">ValueError</span><span class="p">(</span><span class="s">"URLの形式が正しくありません。"</span><span class="p">)</span>
<span class="n">channel</span> <span class="o">=</span> <span class="n">match</span><span class="p">.</span><span class="n">group</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
<span class="n">raw_ts</span> <span class="o">=</span> <span class="n">match</span><span class="p">.</span><span class="n">group</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span>
<span class="c1"># タイムスタンプの形式を修正(小数点を挿入)
</span> <span class="n">thread_ts</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">raw_ts</span><span class="p">[</span><span class="si">:</span><span class="mi">10</span><span class="p">]</span><span class="si">}</span><span class="s">.</span><span class="si">{</span><span class="n">raw_ts</span><span class="p">[</span><span class="mi">10</span><span class="si">:</span><span class="p">]</span><span class="si">}</span><span class="s">"</span>
<span class="k">return</span> <span class="n">channel</span><span class="p">,</span> <span class="n">thread_ts</span>
</code></pre></div>
<p><strong>工夫点:</strong></p>
<ul>
<li><strong>正規表現パターンの設計</strong>: ワークスペース名やドメイン部分を一般化し、どのSlackワークスペースでも対応できるようにしました。</li>
<li><strong>タイムスタンプの復元</strong>: URL中のタイムスタンプは小数点が除かれているため、適切な位置に小数点を挿入して元の形式に戻す必要があります。
<ul>
<li>具体的には、16桁の数字のうち、最初の10桁が秒、残りの6桁がマイクロ秒を表しているため、<code>raw_ts[:10] + '.' + raw_ts[10:]</code> の形で復元します。</li>
</ul>
</li>
</ul>
<h4 id="outputslack-メソッドの修正"><code>outputSlack</code> メソッドの修正</h4>
<p>抽出したチャンネルIDとタイムスタンプを用いて、指定したスレッドにメッセージを投稿します。</p>
<div class="highlight"><pre class="highlight python"><code><span class="k">def</span> <span class="nf">outputSlack</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">url</span><span class="p">:</span> <span class="nb">str</span><span class="p">):</span>
<span class="n">channel</span><span class="p">,</span> <span class="n">thread_ts</span> <span class="o">=</span> <span class="n">extract_slack_info</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
<span class="n">TOKEN</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">getenv</span><span class="p">(</span><span class="s">"SLACK_OAUTH_TOKEN"</span><span class="p">)</span>
<span class="n">headers</span> <span class="o">=</span> <span class="p">{</span><span class="s">"Authorization"</span><span class="p">:</span> <span class="sa">f</span><span class="s">"Bearer </span><span class="si">{</span><span class="n">TOKEN</span><span class="si">}</span><span class="s">"</span><span class="p">}</span>
<span class="k">for</span> <span class="n">answer</span> <span class="ow">in</span> <span class="bp">self</span><span class="p">.</span><span class="n">answers</span><span class="p">:</span>
<span class="n">data</span> <span class="o">=</span> <span class="p">{</span>
<span class="s">"channel"</span><span class="p">:</span> <span class="n">channel</span><span class="p">,</span>
<span class="s">"text"</span><span class="p">:</span> <span class="n">answer</span><span class="p">,</span>
<span class="s">"thread_ts"</span><span class="p">:</span> <span class="n">thread_ts</span>
<span class="p">}</span>
<span class="n">response</span> <span class="o">=</span> <span class="n">requests</span><span class="p">.</span><span class="n">post</span><span class="p">(</span><span class="s">"https://slack.com/api/chat.postMessage"</span><span class="p">,</span> <span class="n">headers</span><span class="o">=</span><span class="n">headers</span><span class="p">,</span> <span class="n">data</span><span class="o">=</span><span class="n">data</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="s">"Response:"</span><span class="p">,</span> <span class="n">response</span><span class="p">.</span><span class="n">json</span><span class="p">())</span>
</code></pre></div>
<h4 id="使用例">使用例</h4>