From e702a5cd8124e3e0e8b44a156a897429b51e1ade Mon Sep 17 00:00:00 2001 From: Risu <79110363+risu729@users.noreply.github.com> Date: Tue, 16 Jan 2024 12:44:26 +0900 Subject: [PATCH] feat: implement core structure (#8) * feat: implement core structure * chore(bun.lockb): update lockfile * feat: download and split a video file * feat: transcribe an interview using whisper * fix: add loggings * fix(src/transcribe.ts): skip splitting if unnecessary * feat: proofread the transcribed text * style(src/ai.ts): run biome * feat: upload result files to Google Drive * feat: reply file urls to command * fix(src/main.ts): remove activity since the bot never be online * style: run biome * fix(src/ai.ts): define the max file size for Whisper API as const * fix(src/ai.ts): fix error message * fix(src/commands.ts): fix type assumption * feat(src/transcribe.ts): remove temp direcotry after completion * fix(src/transcribe.ts): log file uploads * fix(src/commands.ts): improve bot response * style(src/commands.ts): run biome * style(src/gdrive.ts): use {} instead of undefined for spread * fix(src/gdrive.ts): upload text instead of stream if the file is converted * fix(src/transcribe.ts): remove duplicated logs * fix(src/gdrive.ts): upload files with filename * fix(src/gdrive.ts): specify mimeType if converting * fix(src/gdrive.ts): remove extension if converting * fix(src/commands.ts): inline fields of command response * fix: use UUID as filename of temp files to avoid a whisper's error * fix(src/transcribe.ts): use unique-string instead of UUID for filename * style: run biome * fix(src/gdrive.ts): fix filename to upload --- biome.json | 5 +- bun.lockb | Bin 205162 -> 235748 bytes cspell.config.cjs | 4 + knip.config.ts | 2 + package.json | 15 +++ src/.gitkeep | 0 src/ai.ts | 115 ++++++++++++++++++++ src/commands.ts | 269 ++++++++++++++++++++++++++++++++++++++++++++++ src/env.d.ts | 41 +++++++ src/ffmpeg.ts | 93 ++++++++++++++++ src/gdrive.ts | 146 +++++++++++++++++++++++++ src/main.ts | 97 +++++++++++++++++ src/transcribe.ts | 161 +++++++++++++++++++++++++++ 13 files changed, 947 insertions(+), 1 deletion(-) delete mode 100644 src/.gitkeep create mode 100644 src/ai.ts create mode 100644 src/commands.ts create mode 100644 src/env.d.ts create mode 100644 src/ffmpeg.ts create mode 100644 src/gdrive.ts create mode 100644 src/main.ts create mode 100644 src/transcribe.ts diff --git a/biome.json b/biome.json index 6fa66c8..94f5c60 100644 --- a/biome.json +++ b/biome.json @@ -13,7 +13,10 @@ }, "linter": { "rules": { - "all": true + "all": true, + "nursery": { + "noNodejsModules": "off" + } } }, "json": { diff --git a/bun.lockb b/bun.lockb index e629a9ef6a2a48225d2186a34013bdcd2d86173e..e73658abe374a38816fef5c225c28deb4128735f 100755 GIT binary patch delta 58196 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

pssOq=H;l6B7opdFxnUb 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 kJTlL_xbWWrbIa@UL#A&&mx_5ayA*aQP5=M^ delta 39926 zcmeIbd3=r6`uD&09U&Vr4?)ax2x3TL+J@FVPc^F`BqS0^3~7;|T2vLAF7woCRjZ{a zozZHQYUxN@R9mf~l(t%Ws(s#{`@T26=Q!s%zvuV-et&hpd~&Vpv#zz)wXQYZJ3IEb z+X|iES8!py+Sf1dOIY&uXBmIoJtt4Aa|>I~4JqRp_QQ+YzuI*wJWqiY?|fb<#G_Za zMg1%0)Guk-iu0qS$CLXV$^6LAkp+?Gy&g{iWJY4bsOa=@V>VI1hrR|WyP)2WT8pB` zBg-QbGcwXLq7$dg&f}D?>E@42N=O+;-ASPyPoW$PlM*tcN2er==?hT;{SCLl+`Jx7 zIQj=jHB^lJBFOA~jsyLXRnX_)NGar!Fpnn^8Hso5$YP}IXCVtCiy+mZ%*515i5VWx z3sjl|mz$lCl9HU6<#~__3Zdgr+T`&b&l7Z84orqu1u7u>aWtqNrYDce%1BJ~oW^1C z-7uEj2Bhlw1YQjubUQRTDS2cPJx|U|oRWylDCEhp3$&w@8cI*dN{Swrm>rD3xbey9 z*m>#^S9=JMs<13lk-9H!l(IdU6H`+YGO|7S;ETimNw?)tdO}8KVsvU|YV!ExRC@Re zygg(XDSt#pM8e3#Opm7`wJ9SJDbIJ62+RzvkyC)M%CF3ngiJYZp{rc-nDJ>D^x%Tq zPHJMtn8b!2Pv6pxpUIigV^Y#ac=AO!)!q${Y3}HR%&ZC2laV+vGkJ8jXFmDzw_6#f zU(1mSU}MKVC--~Cu^hx{*YU|CClm7|)F2@_MYGDoJQjaMXJD(f^9oLdL6r;gn8 z=+uOC@~1E_RPiySI&>JRfVM=cLtC+@id=sMkEa;&3G{*tPVV?oV>8p33Ry`RX%ojJ zHM~*Lsc2axCs6ZTS+BC&^YKZE8Od3hndvFXSw!SK_Uhq?v=sU2naG4yphqCZC%gH> zkaDOp4rpq1Lob>`BC48`(bhG17F|85hps8mtGZL+R#&b;st1OOG?J0nNzaGWqR7n5 zNR}g-66pzNay{Z2rZSaBMvoq!nwYANcosxE4yH#lD_Ga*(J5GDd3w}y8rbJXVuve(`&Fg- zPQ$@{?4S3qjTLg7ih~XP%D~8>b}=3g6E%0l#N?E$=InfRWV9ds^7`~1}VpmA=UGDkg8`F zQuS^@=Jzz`kRzex7C_2ymTQo}BF~NiN52wLirDa94aML-$G=V(dx()>+aTr}E%X~Vbsr=;tr^he4{KEsCAz^!^ z|GZX^p}C!Jn3HjNs8hj7WHIM1e~(l}A0o9F_qqILq!!^aq#T;#@{^IWzX!?bmfOzNV~`r_ z3P?Fn2&s0i4t5+lg;ZqT8%+OY@G^ude!|UI?CQC$9Ez+;erKfY<6K$Il|^0YLCT@a zgPaCWAZ5P?SsuC7kcU&Jsz>U_f}yPIo5S1f;?hyqeh8Ui9FtcO?00 zv_G;SvNH9k(fJvHuM5@7c{bCj_AY46+tJBmCT4JDYm8nPJu8)Rydow#cBitO;!mKf zI-1QGm6+wJgS`*EBvO2Ez6a0!;**_q@^z|XcN9tNa&qmv9SwYkfgDJg;>f*7`CW+( zO8yo{YN6dcL|6cNZGtZXKkh!K;hmI|0|{7Sij>_Tq#T+)H87}1g`8h+sDzGak9)YZdRN&a<$0YKQPyt>24Mob&kqIM{5=SLxyh^jO%OGlUv-|^&{xrO1 z=_;i9$u?k@8$H`z6*;-rNobayL&~q?u6);(FS)W61uK&O!9yNT6mlY)t{MvNSbNds zKm+X5f$W5gF-#s$>3NP_c1-+uR8L-XwfEax*|A3_W{ylqNf?zlG9`IpG6vhpkO2=V zBQ=-X&Ubnci&TSAuB?nygC&saS$?D<^El~}EBdLjL8k3uj1gBouqm*MXc(Qbc zKHoy8fj&r8bb|^sV!;BzC(t=Oe`$dSALLjMX(^KuJ#mYj2t^}RK6o)3ojiV2^u%;e zPQa-z%az;|(*mQ4mB`8X9w8|`Vk)Nv>wBSViXo>Sx(BL4_dAr4+MWeC!B@d?S28?CxRq$>Ejgip=-u8oE5_6+9!jYf_OyI)etaSMzvc_cVO1v&ep0 z<23L&Qmf)Er0kzTmO(B-mPSr>^GCb+XPR8?^WHyZE_nP}DQ}UEtseHx?flh|mCFy7 zoshb_K;T;Go?)Bn_W!(3vGbn=I!4qgvZ(%`M{4YB+0>WT;_dUjR=l!3Jz}djtnwH2 zht(Wb`TOma%2vt~SXZLK_SO}Kdm}#V|H;>2^$X? z7PwL;&e~=L#ULHw9aT&;7WX4=moS9f#}-Jeckdn z6?p>_YQ^~$qWQ7%1SZstv)<1Wh`P&fRS6A@z02>L6zcKZOvK)GoTf^P|$Gb9@R_s}$yOdE!>WRbx6 zE`F^k6~ z`bFeIqswYJMMpLFO(W&39458zO*A!+YlGwSpv0^C(3o=cQz_p_QZlErlXnJ9v&zm3t;vnZAGjFPJT#G1vp{Mr9V{Q%+~4o( zS;6Dsc{8_|J)~2)N;E@r0{kMHqR2qec)?1}un{*JxEsxFu3?<-Nwk{_BlNtqz!tjR zv~n==Okm$+G-u%13VhF_sdgq4Yxf(poAcC)sS+4F$nVRk;)KFk8?T_r#2zZ2ud2t> z39W$bPH!|-&8odC&NmNj2%0nLzeAIA&Zg3&S|DnO- z%?)8fd{}LdrvX{?fyvk%ts|OOi!AgpuTl(RB4ioc_2yhF4ytj1A+^9g?6 z>?kMdo$}2xUxMXzltm8i?-1ic<e;U5I=)Vv!_v2>>8Wi<7LgN(g9wqamw zvfnp_#jas<7T_T?6|m>ORlHFkYOLRy)hI9)@lm6|=COWXS)wT4X_)~y>1xil8^MOE zo^Wsy*1l+(+A=N^cSwB9UWg876)!< zOjtW>(1?s1)g!Lvl(PtiEsC2(?G91-u)QH3LXg61e?>97!ug~7&?r-kw zh6U`1?L!-6x614Zjiif%?9rY?sw;e6J3jl+RD*M~D9Gwm9z&KEABNJ`Rx~)DqxD3y zSBDkl4@719Lx*!>FjrHW`(7ZWZ02^C_&mhyZcU-kKBU^(lW8p}h1t1%e1Uee8&=ge zfv6~dXlfhRIP`f^t?f1NBPqqsS(McY4uoRfoBJi2<}Fv4xVX?IXsvZPgnmrQnZcFr zaz>0sxw#IzOU?K;L8@(bh{f??D2`hi{knGtSE%M(2AakjL$s}E?g+CdT|;w@R)4#| z`6>R;iQMGaVmUUpkz!oj0ltc+O6&kzjoJs!XZx+u?E_Kw`F(G&f~bd>sPT{vx0SQ* z=@2*{?Y9CQ0#Wt+*1-;evGx3+6>z`~tZxJ<%?ct>GtOGkF|e8XE<$Q*v7B(Yb|+_- zLgQjxj!{-enP~OmeErenqcd?9p{Z-m0{X_abe4U^E>0~h0d7=%(AXce3_HYyEt&3z%=gR4sW+Fh>3hKaV&)tttU zpfQ-*zp~@QdN@8icZ7auimP+44!D}VIfWiYYad9R(IO91vSZ+4PKz)S+OC{fY(!J< z9ql@r=DDMF>*b7zqb)*HD~|R78o_b2&@#Qz0@3%k2qVz|nicBjzJ|u^c6KT&v`=93 zOuw%~ABS|N@eVXiYC6d{op0+6}%@9HQVp&e2-HR^Cg8{5lxLaSCfNiDr(2vD%(GBzNX*Tx4$#NoQ-fk zT4PKc|M#FdUDTD-Gr+0L$!m+osJRhah^7t2iQkWIo-?>D2i|VE5>0c^j!Eb@X!Y#H zT$DX*fW|UxnA51^bAL27W1s!Ljc8g(xIie*p=q$4t)~9H!O-i`FYVsI=DB{~+mI^Y zoK*RTJ3U~#ZW$luD$d<%0h;D8HIJ~fSfu!PB_S>}D#7FFV^8R5r05z(S|gvJ$u%m# znW`gh#;RGIHF88CYQEq11f=3`-{P(BAla81j0{?;Tsqp#LGkTDlfO>n@{DpUoetiO zrpoBcusGj?Xw>dno^|t_=b@;?+hXj~Z@U#H4g$R1jixcR!y9@Ht+#!l?>stqBrvah zkD)oCRiM8_yZdHamB!q(*L+Xj)KuAbXwF{9t{K`Qg&x}HS1u_|RBr5Ad#{K`b9U~( z&@}$eEKf^yri^m|xq#+ORJqf1d|>Qjerv+`Kvb;X_tbc2AF$u-@SQ-5w`;l5AkONa z7Kk!_-y>O>S3TChz~=lYgbCltSOm+sFnWEoshAC)Vj@m7f7ia1d-cm^{n6yr2}dtnrgKl zdwk2$G~RZL*14>}*j0XC<%z-KjGHxhV&MELf9O`o)_T(MT_&X(xRG%D)tKZMYkJ3r zp*Z8u7+dQm1)|pYeP2K-0zM6JXr;+a7<=GHkaE3b;8&sP2yh-@uAnuvHQoBEP6>uy zlYFwPadvf&&x7L3qo`~rlFr)IJFuGF7}bgQW(USL@cXX9)w8)6L#6jQ^U*#XLMNlO zw%yvLl+!fs{)T2h+W4B>@6_l#aZN_kzU>6z8MHxY&QaTDs`E5fEO4bG9iC>_K;VXv za-wsEhvPM9nn6qqc9%?I&5ixOl@B;e#P-8?3QYy<$5~(L*&YuU z6xWF$kqOg|F^I!91MsWuXneS<7`a2A8Ex z6nr!01ZRqzKZqt@Sgt%4SaS)1J)hc>YJRhZx6m4)6%9Gg?E@+S<*sc71EnbY?qOmoLy-qB#?>#RBIUi5qH*INvNZb%hDc zj#%=#i=bWo=xp~BjmGUQ=b{l6Yw_N7TH0O*8VfA0#UE~Lw|?l4bTS7eT`RpJfkS;ba5G4 zCp5>M(`dK0lsapig-y6KdAwNb@eIV$(Pp9byQv*R)5hfF)m-QCbh)Wzp!K?`y@}TC zrdDMAZRMn%ChUw_Xfmj6vyKc2+}K;XjNE7dR2`d>Eg2w>Copth=`t!42PETxUXtFx z#C`PxtM(NM?ATW%M=TS?cBM5^)@^`ZlG58s;dLij3Vr~P9b;p^B&82>)H_P$4|d$P z6SgUY-HD-YK}i`h2W@$;WZ=fWnmKt%jRAW7C#jK1v8^1b+ERh!c$b${2-AW12|zDN z@tHt;7SIc6hwXO4Hk?GJ3@5vC3R15-Ne%t|K=xCC8k!FDx|7sc&jgD1GEE*8V6obB zkGXGGv<|6?9|xYm#s&9)c zpLhA&q!T9*GTa7K!3(b3j?_z1`8$E!-wpJV6#p_%xmR5IDpC_*AJ9uu`EN?$B`N(a zeOf^kybU~o+50Ot75y%dJP7oXlzvDGFG-a;EQOb(6}Y(HSyD%B&f~dV<_VO4yL66* z=(wAEyHwqu1J(H@P!~@D@n5;}v@5?x>Lr;EoRh*!QgitdP(7Dj`4dv1xTZO&ivIyr z@gG3?UqG+hrSfGY>9rL{%07?Fhq}C^_%K($UCNRCE}!4aScgFrgAiBT&5)E~Nmsv< zROGZ@NtQ>-w1R6dsboc0Rzk|I8dCMvP^P3jFCHj=pne&tEnND4$Uw^j6>*at&&~Zm zkt*tU%ik_VwQ_k$IeC|>ODc-(z3$dQ!X`Q)Wz$(dT&ZLiSN~747}f$`kkbjn(pS`E=AdUkSkU148=eh zGx?!8{SZLp}ZBY;Y!y~Qpr`WE-BB~ zxcpj|msE0{t4k`m-jy3%UQ*}uhpzr%FeEpLV8;J}RFRL|a{sN&`EL#XKeEgDf9&!9 zT}K#yRj4u5-vsG(yA<^)KO{fnMbky$Vuo5 z@idqJZzLTEJpVySdkp5f_0Dt4|0k))F2GJB6hLZ7^|wNL-AO8I9Y5svde=@;0er%8 zA4*8rL$=A4&$@<^GJM|E?!j#9OZ1k5nArMJkT(BlWsns==eK-6wAT?Nar9>hh9G9&>d`D*o7_G7wwT1nQtD%n5(r9rL{YU=#`e6Ha9r{{JB%p`xnOzvYusJII0D6-m z=ta$pBIr{tg_|?di<_9D=(T*EdO34N`dRdTCD6;8^(D}k{SSGCyfS(RGqAF^zX|g~ye6WP z@l}D?Eh4oFL>Kdth$IW5d{u~UCb=p^LJWWQ zc6Eqap%5oT^fR?p?^mf|ytjBF*d-aX>`%`VbS$ zg!&Lu3PXG%BGXiE08y(5#OwwT6U|W($3!%VftYMZj`3UNk6wuxy75nl{qX+wzn z&1n&5MYL-KG0iM$1hK3*#8nYFrcGmrjwK*AHinpCu86oMqF)n;S!R6`h)pFSLSrFj zn_jUH14=>c6!DPp#zBOYhDeNqm}|C)*e#-DJjBB$As!+r0%E_2M@-SC5D{e{CN_n5 z)a(^;Kt%Os5R1%&W)M@#LVO}3U@A9-FlI&zh&hoEXGAPF zF)bnD%R?+}39-_g7I9WYJ3qu~v&avztOCSU5o=AGRuCO4LTqdWvEEz}aZNqA)4L7KfXXmC#XRLPR$G{`Dlmy{VV?Gw7sTuqQ}QmDXFO)a zT`);iVfKsJ;xWbUhKZ;KGx2Vi=RIbhm;+*}w}aW{F&XV(rc{UdM9g-NsnQ;%Rt=ch z?O}F$%!gu*iD}XSW|zmz>;N;TCd?TzFL_MEjxh1HV3u}-dBtPC7IRiiyG}5#dCcNY zFw1JgTov<%$F%JX(=iHWV`rGX9`lo!YhwDb6IgE&pDr+)>cE6{h1pMhy21>o3$s(q zJH)3OOjtBbVmFv~iI14wVoG+0IYfNA!z9&%*)QfW@#z5*Q6FYv510>#kC+2us`rFB zN_=|4Olbh~iI|UwPcN8SF)*`x!F)n|#2gdTq&LiG#HTmRoQ5!G#2hC+ePH4n!7S|q z^9At{b5=~dzAz_!E|f_v#}q{Y2qX1nwWm~zKSRW~Z3%iO&F-uy~ln0Wjx?kC@$JN)Cj%Kzs(mBsGQEFXkfg83Ys23})gW zm>-Fcm;*3@c2~N1uLKrdVNo?ly^8vCpv_M#sTQakf9m4B8n}Xb4AoR0O!40c=3t7? zX$kX*nCk>{2u!>mX7&)6e-KPDXT>xb3Uh;C4ux6P3g(QMzX;|qn2xPsmJVZ8dCloz zT2&(2-3t+77TpW6sSU(c5uv8daEJkIAvO+&$Y-vI2)hfSUjl^BtWSX0Eh2OTL;=%l z1Vqx^5IaSL8}CSnh<2nBM?w@f+e91?QF0VSQIjwVVoH06{UVB+qKOc-IzUWJgeYnD ziZ~{s`e=yKX2NKQIUONB5mCle9s?2I31apbh;rtrh_fP^BteunGm;>db%r=2qN0gO zhUh34mnK70Hm5~gg9vOn+r_Ht4g7wVInot<=sDd-nvNxZKsO4k9!r6m=A4MI?hxI^ zLDV+O$3g5CaYIBM(_qc zh(^Yj22rawL~0sD6Z4XYV+-i3l4A(QOh$C$oGK#BLEcM07EoCPO3*g4i+{qMNxcB4RMa&?yi-%+pgK4u}ZP zhUjGmWRU zrcH$yY~B@dRz&nPh@mEX8pN{U5GO?3Yidu2=$HWU$aIJVb6mtV5iN5dMw+=f5SvCo zTojRLn&v_b7zwdD7h;S#Cn9VVM7J5N!DRkFl|R@&L~MlMj&fxN7Dn)Ndw zB1SXkrZE%K%ysDp(9O_U7)&rv&w`jT1|s|ch)gr^0f<^j5U+`tXneCFj)_Q}4Kdlg zBw|i7MEM6HvQ6@X5b#qNlWQWH%!Qa`X3T}ylnQZ1#B38Y4`RT0h^6x&9x|szgrz~Wdl+J_S@bZ(ZV^{S zJZ##`he%3?*f<~J5pzXE!~}?bk3c+X);|JqKt$*Qh()H?0*EOY5IaQ#jQ3H9TA2`u zkK+4MZ@{|BNh9Jl2<|Apg;>nVf=FEmvD~~QB7P!7`9%;bP4Xg$vmy?QSZ&HIhFCTU zV%lPewdP$B9VbIX2O!p)>;S|y5hp}EZfY-q*fa&=ktGlt&2bR}vLRY7g?P%$T?!F) zAH+owPn)KXLF^W>`Z0)S%sCNB_d|3u5L?W00}(M5;s%8Eyw`MI#=sna3AFoz%Zb-) z5HV#MCZWqQ+0NK4hp9CkW~Z2)USq9*IVL7?1M-@S2~*91zp*ahRh-=W&=Rb6`TBfcc2%JONW{F3e6bpHOo? zm}6qXH^O}8HG?+7%$W!CnwaBWlmAJW_=jOqpM?3sYhD&}R!sS)U``OQr(l-NhdC_f z6am`=)A13QX`5h96EHE?#6&+0^NrWs_cY9=1u!SXobj5d%`gKVg?VH%%=cdNxtOqp zFfE^fIp;O=o`Kmd=AxJjUeoMZn50E8tDl9r=r!lXL@b8swgu)#uUWAL=75+RVy-Zq zo`abZfZ6gK%+Fr)yO>%_V1_;qbJc4$KM!+EO!!uq--ypvm^n*fUK4Yj_-uoTe+(vd z8_Yk5kC?M!%D(_}gZR7vv&_I87V{VJ*$&fj8O*fp%y+MOcRTa_nuzEf5FsXe2gIi3 z5GO>0n%X-d2CRU1WG6&Eb6iB&N{E&(Lio(w7a?|wxG184X}Sv{X%)okT@c~soQR0k zq`K{fC~TJRhBzSNhKQo3(@PLj)|Lf7;OYpHf17BLSNq-mHNqkz> zw*AJo&409<^PjMNU2fO-L*5!8fd(~8$a>eZ_q~n1)`a}K-v7XR(rbk@+V$K~FX0S2 zvA8K8i&LYsEVJhg?@bsoI18QAMh3>A2Z6m0G(g-A=~sTOs#J^n)9D} zCxwPIqKGORXL}WD|Ie!C%1MsoBB#8qcV!*-W`(Mm7Ak9P?X>ICNpJoWM$T8MC&%ytH?*g11uvu0x zHU)zJGadiApgDES+fS9`5B@iF_3E2-?|bjDeH)RSmYPUqRrnJepZfmhu851?0%P(| zDXBL2ELf!JI?`&b58C|sm-7<+Id6FDJ#T$HOs}A>Lg6;Ur-Crh`{Y8JQ=8|cTTZ{0 z9pZAQTrL!DxXXRza{64G-gj5~_J48>Z+{l;m=e6cb`AALyDHb~8<+Eu?&)&hx||%o zOGdo(X-{SAGiG;-;Psu$6(rrkW;~wngP&zq&pNu~S=Ue>8}00J=Uh%75sh_wavn}e zeV$1lq|@sM*RCk(`&{m#%jxsp!H=O{a=GH_zYO&H(Ir_so-3pkw#zQ3PkR6C+9^+U z>u*W)p-Fv!N_pb+L8>=^qNdNRsz?MFrv$HGT}~ge&l0Ej|3*TE%Yv_gUe{fYMe6z1 zX87X-w{Rr-a+mwV<;tT6|M2C8%T<7z;oAKPr#31AHEzEk?9WnHLOY^dUS6b(*F9TQ#DOimg+ES0R^+B0b#Y3cFk#xN|b%75tEHUD6kAhWHnC z4Wl7~e~+P0>#Cl5peP9qjXt3(u0D7G=v4xafA;6o=hzHRx?Bu8dyxH#aLYDCo9A+6 z#A*E50zLeNtNkkL8a77L*y~l!<(iOQLRzm#my0F6)aCRWM>(iJTU$oD3bKOB#gkr6 zTCd=5B%6}XQMd(tY*>Yxf#65|tGZlsxXVDVYH(T^EkH#IC~CD`JN+eGX;s2YD?>>? zI4*)$9hYlG`a?h@b3An+`R8d34giIyo@>~KwEnzFulg?6mh>>2A?z-vji<89#kia{ zo+?0*YzW6c{g;dOhglVk#7W`&N+r)1Ow_vtQn~aSL}Rt=TV5OHyI&0raU_ z_2@2_>q&YeX?5ssB>(h>o1Q0KP9MY;*BfYg%K4m*FsiT*&{CAAom{Rj>8e1Uc6Pab zq~(mbE-rTuY0V)y-__;%lh((2z6RfbZ$S!31>=DZ#0jSIc&m6DeLP#gE7o~96bu8y zfzG{qK!2b=P|%Tg9O$go+4?0o0Zy9rDOTxj`Xi#QpgYj1*9-IpeL!E(58MO#g8^V5 z7z74`AwZv&Y6IE|re&&CuCk_x0;qs#bQL6xlm1Xzjvnuw}?=#;9?*L6}P2)yD zM_?S#EYz%<2j+trU?!La9ssk!gWw@B2WV_2nHXY|b3e(cAPI~BBf%(;2u1@h&}qpL z?a_&>6Ids%PFNk7Ize?{{seUR)x|y@5@=I=m7cu@Ue~hULt-yj zOW}{fbfCYfN(4zD8H@!wIa5F?NCOi<2FL_DK_`JR)RP2~!C0U_=hDWgPy6XJYIlQn zpa!T3YH8c8O(F`^0d+w%s0ZqU1|SAB1dTvr&;-PSI1mpifvVtZ8u}KT0pEe|!7`u| zdOFAjGr)adB1izEwCoeXXfPZM21CFgpk34tS^@1fx>M-LE(<;&j5?_gf#Tm=7ER7eDDZZ02YD8AOPlq@%WhrhLOG(3}P@RnYpdcs&3WI3)`d}k?6088Lz)3I@%mNR92f;%?w}ZK0 z9+(dnfJebXum~&$0kD+zJ&%#NA4~#6K^xE-v;gHmB&Yz6G8!L(o}d@#4f=q+U>^to z-57L3cnlb@9IOB-2hW1dAZIE+rh&;|7^n;?flqPh6VMmD z30?zlfLFk7@Dg|#tOfZ&0idf%MNkQ3Qb$vudsIX4IQSfF0ndTjfZJk@{kfW26bQxh z4vOrt%?*h}opXkMq ze_;0y(${49JBg~GI(QR2O6C-h4GMswpc(v1@FkcH9tIV`M^yGP_>A;1pqt<-uo|d= z+ffs}23Svh3BU&~$y43zih&PFe+&+R_rO7*OWF=l1-wqBuYw(5J=g#q2erssLHVDM zKLg!|eg(e))f)-EARTn_UD7XudqGYVKZ3n~p2{|YC&5#o2FOccg-t!V2CjqKBNpt{ zHf%S6%^=uNu=hb5Ir;+ml6K_;*Rv!BhS%*^krFu?r2{nmCU_NS+tZHl3V0dp0;7Rk zQa`nOXqu@mXnXE6z4UDd_AN+(&jUh$2D&uJ3qnCbkRSL!7|5pyRDeVY5DtohBA^f` z42pr`pcGJkNuU^30_8yj(5|6_q#P*grX{tLXy#M~RX{bMcxXLn(bfZXH7$dCo@RyC zg;wk{AQ6lN+Ny?t{-85x2kr)KK?~3vXaUB9I1mf87!{pHpfM2F4ERAy&5S_4)A1D1ltK=w0%!k-J&f$1O{Oa>Ey>dOQfKv7HsqrrHf zXeEJEFh<3tq=2y?8H@wsCV+IHiqzO7Fa^lT`@mFiKTy6Jk(bpdiQ(o&b-7 zCqb~xX3}zalfw73t5iVl1J8k~zz0f$EkMS5z*bNbsHe{Z4U^K_z%$@AumijTwu6_! z3t$%zFK#e+5$psxB6fq9Knzd?D*P&V1HA6)b&z|(`#_Dp3*^`Vuph|5H^E!r9q=|d z4Bi6=f%4U(0Y8CXK%$oo323K` z0z=?NAx8ooIXcR8r?PS z*EOUZ&^1KIjv6Zh)S%8$UFvk^>7dU8LVy~}htw$(27=D#$WS-GAkhNDVEXpTak}C7z_r1fnWgW5AFf|Kwr=Y^ai~^PoV2echC)V1zkY!Lf4VB?#k`GrrtEGMotUT z%|KHS58{BHikg7Npb=;YVn73+3!$Q}OQMEe!O>NJHCP2!f(76a@G#JxGY&ZxB!eWd z9&QYBu7Z$BLRXuQpBQ>+nqoHYOJHP9BRjrj=({Z2x`P!%c~3Z4ck`xMv&rr>;Z3p*TGw0KX?bc4Qc>Aw1fcl^B7Rsqu>Y# zI{7Z?55W82FnAB_&{lSc#6eqehF()d8~0~$pMp=o$KWI2488Lp)Bu~m;MCZ0&>a0w zq^98KAQgQKxIy|l_!V3QSHNX(8l2RWIRU-|Ux48hIEDNQoCn{4uR(SAZ;=|3b>JN7 z@4y)lfnEumCH+140bBqV!6on`*zDS9AC>(ta6f~bpZIYN{09C2{{X)O-BrCn6mU4=Bk^tOWD zUeH@cdh1AU73nRb5}-IZ5WI~~GsPaTYJ}jEiF?5M#I&AmmGbU4{byU=pCg${bnA4{x=NKFRSTD{T9Vr%0nw)GT}lnG|aB7^`7yZeDu>$sIAFTFcf23dd72re0Ik8T_8=qXRY{>GaFmGnSV? z+5zjI&!kY+%Fbuj89EaDVzNOs_r}gx*!M%~h^uFJ^}#Unv04d!m-Wia#h-ce%jMx% zG{T}Wvx^8QwD+~H^L3->z6IPI+K=>^7R#tT_aDaIMh3qVyHL#|o4(FTn{9cS2nvcPUr{r4rPVby_^sMY2YvEb z*SkiXv%GHD{$(i?@%me;{l$&Osm-)OKOs(`!(9R506^1?bXlUJ!SjRpk70c zveIVFYTWKy+U!@c&V(J|12T7w8rXgwTv|Wwal<7k!Jo{0ucNH?y$LcR!treBO0FN%(7AjXTh1O9r_!Zsn-1x9+`NgkAv&uA(bjnmPDNj&xV+Aur zmccK4?liCDf`T*O;KFQgQ?%jC?+9yO1+#e_-9A#moKy+Eb6W2M@<`2qRZlA4T6-|Q;_!s=P@IJdIbbfAS@5}UE zlY$~o6fu|RyP3MdYHXdaXkOSrw{BE4-)$i1$i|N|Q|482X5A|vem7!M=8lJORy!24 zZke02q0y(^*KWKXXXn`Sv0i1f>T#M5e)0LCi=hMB)OaKIR!2uvF-K%Ev5IqE9Ju&N z^o@k>BW_u^?wiObtg=>AHIu$kJARBO_+943vfk{UIIR3Q%Nyso6CBFO;J2GE{B`5H zm&=FDw7l&&;J@Yo=Zm~EDxL5|BH}L*rX=+K3Tu+ zMNKz%SF2RtOr@-Mlj-)ERmQ3rVzzE#Q;6p%=W3GMeBOpH8&#(Ha+q0nj7c&5Y2w~4#;kZ6x17B`!rIf&SevQy-=9Un({zqy#%^XaXvf)K zgx1RxE0)r?YT=J*olVVdog5v^d&*YN`^vq;zjFOzpKy-*lE(XtRWkoD%*tXmrID%h zj1^Jh1v;-{m*1L}zUs60KC-;~8kw%-+_VgDY{sMa{O5xqkG;pK?cjHu-$?OpO|NqI z=XMNZv?M0$u%z~Dzu%vt@InYJC#D5c;TrZ|+-DcwMOFT?C^I zAoz{y{XY7k+I?w722yLh)77I`@cfV)lmE(vsP-lDSl-uQO2BYo@U!1V-oYb{H-kjG zm-jPM+?_Ep_`U2uj2hJMM2kIl+pXB!&Dthr4|Q2i3Hy3aHx!Ybp^i%zTNf1VV~yq`jH&>-s9dl^2B&XH8t&@V_pWoWB%HvGC!|h zJ>m+s^hN!h-PA03&RSu$X>J-kPmuaFH%GQ2Q<|Gm&$9-Y_RF4UrZeNed!7Y3prz@) z72cVPW%%g+DwzkTcd&T2-<+4(EWfF+jcx?L^8NikLlO(0m{(nw5xad)@Jr!$W&Z!S^R;%^PYUcl4vR;I-Zv{t5-GbcBEzx{HNwX09u zDp8}ANyj2K4hyY?9n)*C3t3$~#q+otL7rNwKqj}utV*b%MR7DgE=CyYX@_FE`^G8G!^F2z2MhzPd?W1yEexjzOI{F zBTYO{b|e_)PUeSQc=2&3Q*S5Tzd+oyNyW5!qWaWXMaxnbPeE$_Bz0&zd!W_F z7IwKu32&$Du8^XE=0*P7MEgv{NgE#K~?3Tw)6e6q_yPEf3WaR|E znm*_28NYq^YoU_2EDv`zk@PL{+pf+b`gq9P2dYi1Ii6OvMbW3rT}@9cV*kKGcau_2 zHoI23{&Q@e_I`lJ`MTjXIcHXWH#N>o^UKWqA9|aU(y#S4*Q5t;S+Ts!RmWMS!LLik5%*HNxvv@i3ZZ+YuQS+f zKKjBFF?{?MEZpOvdp{?qe&MhY6&GzkPmcC6rt`pl<~fzU!)CAAnM$K6t9`>iExgn> z8M}Je?Z)Wg!7W=ZPhH0%_yx{b=%8SdZ8Ak4vdTmzXGW)u7|Z*uuWfqNf3DNl80gHy zfRStP>aEd_EY`zWr56fTDpO!)b!JxZZt~>-GhLPs4>ZeOB{Io_%zNmO*@K)d@nqMR zmUQ}aZWFh0wzau~9(#?Rt-wGJ9y5kDO}^S`#s<4ede+pl)h*ht&^x)OzU#1>96MeO zJbMP2?kal%i{g|`Y#NwXvu}rFw{k5ox9o{BKlh2gacOX{S+WM3Rg{e^I@l?jRr|T@ z%je?T+ZOg_QEjj}qT1tJi#Lvxnz>-s?%{6xgr&2aQ>^iz{<9ih)kdR;a_<^C*i?9( zvbk6k!?U6@;=e%_y64dBk(Y-F6wOc0){yH}LEpEGlD> z`C;L`@9((!ife&qlZTjeEMn(iQ3Q*Z){VXXY_l(`xa|}6W#lL#VmzX0Yo!+0)9Wl6x@Sn*W9vImwLb4 zf39sB7WQ0e>S1de?#xsZUNMwSQBrZT$B4n_nQuef;h+ z)GSchVOZ$&-u8Olr#|(KSHIm$VG23grt?0%^Sf1VMa_0=rN7gLnlI(q0xZ<;<3rA; z75J^(O4ovzts82J?4|Zsuu#nI9aiGLMdfGjwrjWF(Kzi#pJfiTPbEjAN$n?x znqew?84KODf8BG4sZvx&ux0`l`Gz^Qjymz?E1u?sAGCAqMO`$(JWJXCa5&dXF<9~)@{8p-&x{ne^Q=Q&6IlF4!(~p(>!S$5ZzD_k;_gOn4E7f;4h~0~(o@%>p zGM7?!tI^v(xo@&TluR=#-lWdTY36%CikHcm6vTij;uN;5s* zqD1#J^8oUHfAcrz-(v6H(b)Ju;?h%LKW~hdXj$c`1nj!CC>AXSO&^nc6-a>B@G0}$H*y9&hW#3rBqTy`&Ortf>ig!fLFlrjYm6Y=jnrp950WBODxNRpBZ z;3Eg+INQ+kbJo`Du=~5ix7>G~w8rL`FIB^7w$iaQqN?l?;t>g?QaZhT;M zvtF8Qx*nm*cd4=nRUZ6oe!gXm?tk6xwf%P4@!2L9i`a9nMQp!kK6w6gX+702mlVe87*QAb2v+oBXXs{aP5dCLX%AhPwUFcZoCY}+ z>Bk>?;;tV`>~-6*n-wq>R7Xz06#U$(V@4jcng`!&RmCqKW4xS(8d`0ankvW9XFg`` zk!)+sJjpJ`ypD{_3OIY^J-rTfPbjkE9UO4yj#GWt*tN@@J9V|KgWu@=+IL6fqO-s6 zBu5K!#>oqbJw9q0c`N7b)c)^xkWwqmm!H##+AGYjpR>8;S!srSK|rdkbbjyj zW0B+L<0-k?Al!8p{G-kE#|obyl0g&Hy7S_KJT!(S3|IH zXZiQ5Oo=b~^~q%{v|Ue}V>*6m<;IR)&r3Dg`)l{3dH9tnn;<*nRC0879gW@I z_Ko*{8g?sZ$r|(Lmw5UN7J8z1?b(j4BKya$y=C#1TkDzYjmCXjHN?7=^Z6Rn^#t?$ zCoIZQ_T?3c5gnF{ee;$@_*#>TMg9TPI9YgIn{GCouNWrU+oIdQAA_z=?C{1u+o{vbFMm#jS{uP%wvXY@ z$4^@Itcsn?L1dMO`Z%-nz`Pg!c(Z4(Ck?v7^-b2y_$Y%Yy z_9}mC$$J`>`s?9+`>9`gzZ=h8@A^ynj|t>!!Ph=GWKh+D^YYfpaXR&3CA0XH^|c9n zWi|GVNFHB5D>E}O!z7dmsj%z58`dJP*>l=zwyVWo*5)wt+f^$feDuWeBeRmz#z$j4 z$}GAoB+}IRmC5{U-H^(=@`Z)$w#>tgLkgM27p!W#-pC(P!ph$|D?2?gv;O$BQD#C4 z_UZSBlrh!MTdBJ)6%6TH*c7~OjoLLdGUSbrFl-W|M<-^P^!y>^cg0l-X_eQ6pSIeY z=WB%c%#hR8;9Y;!2YjWDEX}Ve= z<;=sUttO^<1gS6n;ui4xX{(w!m=jWB*RCERzS5>g%aFXL>`zvMU3*4{l=7NIm#l`n zJ{=RXAtc|3gv`X)#?fY5bVzv<7#9+2&i_Fi-W?Y*CDhctU=7?gbVA6UyoE<6WM)N= zN=!_TPMk0?A;mPvruLXAA*gn`pjmX;sf?=&%&d&W Zgw$PmXN8pTnlry!&3Bc1AmrT={|k|Ik4XRk diff --git a/cspell.config.cjs b/cspell.config.cjs index b0951c1..7675c5a 100644 --- a/cspell.config.cjs +++ b/cspell.config.cjs @@ -21,5 +21,9 @@ module.exports = { "knip", "commitlint", "automerge", + "openai", + "consola", + "gdrive", + "ffprobe", ], }; diff --git a/knip.config.ts b/knip.config.ts index 6647d2e..fb734a2 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -2,10 +2,12 @@ import type { KnipConfig } from "knip"; const config: KnipConfig = { ignoreDependencies: [ + "bun", // @commitlint/cli cannot be detected because its binary is named "commitlint" // ref: https://knip.dev/guides/handling-issues/#example "@commitlint/cli", ], + ignoreBinaries: ["screen"], }; // biome-ignore lint/style/noDefaultExport: diff --git a/package.json b/package.json index 1660226..12fe563 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,8 @@ "name": "interview-transcriber", "private": true, "scripts": { + "start": "bun src/main.ts", + "start:screen": "screen -DRS transcriber bun start", "commit": "git-cz", "check": "npm-run-all check:*", "check:biome": "biome check --apply-unsafe .", @@ -12,6 +14,17 @@ "ignore-sync": "ignore-sync .", "prepare": "husky install" }, + "dependencies": { + "@google/generative-ai": "0.1.3", + "@googleapis/drive": "8.5.0", + "consola": "3.2.3", + "csv-parse": "5.5.3", + "discord.js": "14.14.1", + "fluent-ffmpeg": "2.1.2", + "mime": "4.0.1", + "openai": "4.24.1", + "unique-string": "3.0.0" + }, "devDependencies": { "@biomejs/biome": "1.5.2", "@commitlint/cli": "18.4.4", @@ -21,6 +34,8 @@ "@cspell/cspell-types": "8.3.2", "@tsconfig/bun": "1.0.1", "@tsconfig/strictest": "2.0.2", + "@types/fluent-ffmpeg": "2.1.24", + "@types/node": "20.10.8", "bun-types": "1.0.22", "commitizen": "4.3.0", "cspell": "8.3.2", diff --git a/src/.gitkeep b/src/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/ai.ts b/src/ai.ts new file mode 100644 index 0000000..893c40d --- /dev/null +++ b/src/ai.ts @@ -0,0 +1,115 @@ +import { createReadStream } from "node:fs"; +import { GoogleGenerativeAI } from "@google/generative-ai"; +import { env } from "bun"; +import openAi from "openai"; +import { SupportedLanguages } from "./transcribe"; + +/** + * OpenAI API client. + */ +export const openaiClient = new openAi({ + apiKey: env.OPENAI_API_KEY, +}); + +/** + * Maximum file size for Whisper API. + * @see https://platform.openai.com/docs/api-reference/speech-to-text + */ +export const whisperMaxFileSize = 25 * 1000 * 1000; + +/** + * Gemini API client. + */ +export const geminiClient = new GoogleGenerativeAI(env.GEMINI_API_KEY); + +/** + * Transcribe an audio file. + * @param audioFilePath Path to the audio file + * @param language Language of the audio file + * @returns Transcribed text segments + */ +export const transcribeAudioFile = async ( + audioFilePath: string, + language: SupportedLanguages, +): Promise => { + const response = (await openaiClient.audio.transcriptions.create({ + file: createReadStream(audioFilePath), + model: "whisper-1", + language, + prompt: + language === "en" + ? "Hello. This is an interview, and you transcribe it." + : "こんにちは。これはインタビューの録音で、文字起こしをします。", + // biome-ignore lint/style/useNamingConvention: library's naming convention + response_format: "verbose_json", + })) as openAi.Audio.Transcriptions.Transcription & { + segments: { + text: string; + }[]; + }; // cast since the library doesn't support verbose_json + + return response.segments.map((segment) => segment.text); +}; + +/** + * Proofread a transcription. + * @param transcription Transcription to proofread + * @param language Language of the transcription + * @param model AI model to use + * @param prompt System prompt to use + * @returns Proofread transcription + */ +export const proofreadTranscription = async ( + transcription: string, + language: SupportedLanguages, + model: M, +): Promise<{ model: M; prompt: string; response: string }> => { + const systemPrompt = `You are a web media proofreader. +The text ${model === "gpt-4" ? "entered by the user" : "below"} is a transcription of the interview. +Follow the guide below and improve it. +- Remove redundant or repeating expressions. +- Remove fillers. +- Correct grammar errors. +- Replace unnatural or difficult wordings. +- Shorten sentences. +The output style should be the style of an interview, like \`interviewer: \` or \`interviewee\`. +${ + language === "en" + ? "The response must not include markdown syntax." + : "The response must be in Japanese without markdown syntax." +}`; + + let result = ""; + if (model === "gpt-4") { + const response = await openaiClient.chat.completions.create({ + messages: [ + { + role: "system", + content: systemPrompt, + }, + { + role: "user", + content: transcription, + }, + ], + model, + }); + result = response.choices[0]?.message.content ?? ""; + } else { + const response = await geminiClient + .getGenerativeModel({ + model, + }) + .generateContent(`${systemPrompt}\n\n---\n\n${transcription}`); + result = response.response.text(); + } + if (!result) { + throw new Error("The response is empty."); + } + + return { + model, + prompt: systemPrompt, + response: result, + }; +}; diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..9992361 --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,269 @@ +import { env } from "bun"; +import consola from "consola"; +import { + ApplicationCommandType, + type ChatInputCommandInteraction, + type Client, + DiscordAPIError, + EmbedBuilder, + type Interaction, + type MessageContextMenuCommandInteraction, + OAuth2Scopes, + RESTJSONErrorCodes, + type RESTPostAPIChatInputApplicationCommandsJSONBody, + type RESTPostAPIContextMenuApplicationCommandsJSONBody, + type RESTPutAPIApplicationGuildCommandsJSONBody, + Routes, + SlashCommandBuilder, + type UserContextMenuCommandInteraction, +} from "discord.js"; +import { extractFileId } from "./gdrive"; +import { transcribe } from "./transcribe"; + +type ExecutableCommand = + | { + type: ApplicationCommandType.ChatInput; + data: RESTPostAPIChatInputApplicationCommandsJSONBody; + execute: (interaction: ChatInputCommandInteraction) => Promise; + } + | { + type: ApplicationCommandType.Message; + data: RESTPostAPIContextMenuApplicationCommandsJSONBody; + execute: ( + interaction: MessageContextMenuCommandInteraction, + ) => Promise; + } + | { + type: ApplicationCommandType.User; + data: RESTPostAPIContextMenuApplicationCommandsJSONBody; + execute: ( + interaction: UserContextMenuCommandInteraction, + ) => Promise; + }; + +/** + * Application commands registered to the bot. + */ +const commands: ExecutableCommand[] = [ + { + type: ApplicationCommandType.ChatInput, + data: new SlashCommandBuilder() + .setName("transcribe") + .setDescription("Transcribe an interview from a Google Drive file.") + .setDescriptionLocalization( + "ja", + "Google ドライブのファイルからインタビューを書き起こします", + ) + .addStringOption((option) => + option + .setName("video_url") + .setDescription("The Google Drive URL of the video to transcribe.") + .setDescriptionLocalization( + "ja", + "書き起こす動画の Google ドライブ URL", + ) + .setRequired(true), + ) + .addStringOption((option) => + option + .setName("proofread_model") + .setDescription("The AI model to use for proofreading.") + .setDescriptionLocalization("ja", "校正に使用する AI モデル") + .setChoices( + { name: "GPT-4", value: "gpt-4" }, + { name: "Gemini Pro", value: "gemini-pro" }, + ), + ) + .toJSON(), + execute: async (interaction) => { + const videoFileId = extractFileId( + interaction.options.getString("video_url", true) ?? "", + ); + if (!videoFileId) { + await interaction.reply({ + content: "Invalid video URL.", + ephemeral: true, + }); + return; + } + + const language = interaction.guildLocale?.startsWith("en") + ? "en" + : interaction.guildLocale?.startsWith("ja") + ? "ja" + : undefined; + + const proofreadModel = interaction.options.getString("proofread_model") as + | "gpt-4" + | "gemini-pro" + | null; + + interaction.deferReply(); + try { + const { video, parent, audio, transcription, proofreadTranscription } = + await transcribe(videoFileId, language, proofreadModel ?? undefined); + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setTitle(video.name) + .setURL(video.webViewLink) + .setFields( + [ + ...(parent + ? [ + { + keyEn: "Folder", + keyJa: "フォルダー", + file: parent, + }, + ] + : []), + { + keyEn: "Audio", + keyJa: "音声", + file: audio, + }, + { + keyEn: "Transcription", + keyJa: "文字起こし", + file: transcription, + }, + { + keyEn: "Proofread", + keyJa: "校正", + file: proofreadTranscription, + }, + ].map(({ keyEn, keyJa, file: { name, webViewLink } }) => ({ + name: language === "en" ? keyEn : keyJa, + value: `[${name}](${webViewLink})`, + inline: true, + })), + ) + .setColor("Green") + .toJSON(), + ], + }); + } catch (error) { + const message = + error instanceof Error ? error.message : JSON.stringify(error); + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setTitle("Error") + .setDescription(message) + .setColor("Red") + .toJSON(), + ], + }); + console.error(error); + } + }, + }, +]; + +/** + * Register application commands of the bot to Discord. + * @param client client used to register commands + */ +export const registerCommands = async (client: Client) => { + consola.start("Registering application commands..."); + try { + const body: RESTPutAPIApplicationGuildCommandsJSONBody = commands.map( + (command) => command.data, + ); + await client.rest.put( + // register as guild commands to avoid accessing data from DMs or other guilds + Routes.applicationGuildCommands( + client.application.id, + env.DISCORD_GUILD_ID, + ), + { body }, + ); + + consola.success( + `Successfully registered application commands: ${commands + .map((command) => command.data.name) + .join(", ")}`, + ); + } catch (error) { + consola.error("Failed to register application commands."); + // + // cspell:ignore restjson + if ( + error instanceof DiscordAPIError && + error.code === RESTJSONErrorCodes.MissingAccess + ) { + consola.error( + `Bot may not be in the target guild ${env.DISCORD_GUILD_ID}.`, + ); + const application = await client.application.fetch(); + if (!application.botRequireCodeGrant) { + const authorizationUrl = new URL( + "https://discord.com/api/oauth2/authorize", + ); + authorizationUrl.searchParams.append("client_id", client.user.id); + authorizationUrl.searchParams.append( + "scope", + OAuth2Scopes.ApplicationsCommands, + ); + consola.info( + `Follow this link to add the bot to the guild: ${authorizationUrl}`, + ); + } + } + // do not use consola#error to throw Error since it cannot handle line numbers correctly + console.error(error); + process.exit(1); + } +}; + +/** + * Listener for application command interactions. + */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: if-else statements are necessary here +export const commandsListener = async (interaction: Interaction) => { + if (!interaction.isCommand()) { + return; + } + + // ignore commands from unauthorized guilds or DMs + if (interaction.guildId !== env.DISCORD_GUILD_ID) { + consola.warn( + `Command ${interaction.commandName} was triggered in ${ + interaction.inGuild() ? "an unauthorized guild" : "DM" + }.`, + ); + return; + } + + for (const command of commands) { + if (command.data.name !== interaction.commandName) { + continue; + } + + // do not use switch-case here because the types are not narrowed + if ( + interaction.isChatInputCommand() && + command.type === ApplicationCommandType.ChatInput + ) { + await command.execute(interaction); + return; + } + if ( + interaction.isMessageContextMenuCommand() && + command.type === ApplicationCommandType.Message + ) { + await command.execute(interaction); + return; + } + if ( + interaction.isUserContextMenuCommand() && + command.type === ApplicationCommandType.User + ) { + await command.execute(interaction); + return; + } + + consola.error(`Command ${command.data.name} not found.`); + } +}; diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..a48d301 --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,41 @@ +declare module "bun" { + interface Env { + /** + * Token of the Discord bot. + */ + // biome-ignore lint/style/useNamingConvention: should be SCREAMING_SNAKE_CASE + DISCORD_BOT_TOKEN: string; + + /** + * ID of the Discord guild where the bot is used. + */ + // biome-ignore lint/style/useNamingConvention: + DISCORD_GUILD_ID: string; + + /** + * Email of the Google Cloud service account. + * (`client_email` in the JSON file) + */ + // biome-ignore lint/style/useNamingConvention: + GOOGLE_SERVICE_ACCOUNT_EMAIL: string; + + /** + * Private key of the Google Cloud service account. + * (`private_key` in the JSON file) + */ + // biome-ignore lint/style/useNamingConvention: + GOOGLE_SERVICE_ACCOUNT_KEY: string; + + /** + * API key of the OpenAI API. + */ + // biome-ignore lint/style/useNamingConvention: + OPENAI_API_KEY: string; + + /** + * API key of the Gemini API. + */ + // biome-ignore lint/style/useNamingConvention: + GEMINI_API_KEY: string; + } +} diff --git a/src/ffmpeg.ts b/src/ffmpeg.ts new file mode 100644 index 0000000..96a1a07 --- /dev/null +++ b/src/ffmpeg.ts @@ -0,0 +1,93 @@ +import { dirname, extname, join } from "node:path"; +import { basename } from "node:path"; +import { promisify } from "node:util"; +import { file } from "bun"; +import { parse } from "csv-parse/sync"; +import Ffmpeg from "fluent-ffmpeg"; + +/** + * Extract audio from a video file. + * @param videoFilePath Path to the video file + * @returns Path to the extracted audio file + */ +export const extractAudio = async (videoFilePath: string) => { + const audioFilePath = join( + dirname(videoFilePath), + `${basename(videoFilePath, extname(videoFilePath))}.mp3`, + ); + + return new Promise((resolve, reject) => { + Ffmpeg(videoFilePath) + .noVideo() + .saveToFile(audioFilePath) + .on("end", () => { + resolve(audioFilePath); + }) + .on("error", reject); + }); +}; + +type AudioSegment = { + path: string; + startTime: number; + endTime: number; +}; + +/** + * Split an audio file into multiple files with a maximum size. + * @param sourcePath Path to the audio file + * @param maxFileSize Maximum size of each file + * @returns Array of audio segments, each of which has a path to the audio file, start time, and end time + */ +export const splitAudio = async ( + sourcePath: string, + maxFileSize: number, +): Promise => + promisify(Ffmpeg.ffprobe)(sourcePath).then( + ({ format: { duration, size } }) => { + if (!(duration && size)) { + throw new Error("Failed to get file metadata from ffprobe."); + } + + if (size <= maxFileSize) { + return [ + { + path: sourcePath, + startTime: 0, + endTime: duration, + }, + ]; + } + + const dir = dirname(sourcePath); + const name = basename(sourcePath, extname(sourcePath)); + const audioFilePath = join(dir, `${name}%03d${extname(sourcePath)}`); + const listFilePath = join(dir, `${name}.csv`); + + return new Promise((resolve, reject) => { + Ffmpeg(sourcePath) + .outputOptions([ + "-f segment", + `-segment_time ${Math.floor((duration * maxFileSize) / size)}`, + `-segment_list ${listFilePath}`, + ]) + .saveToFile(audioFilePath) + .on("end", () => { + resolve(listFilePath); + }) + .on("error", reject); + }).then(async (listFilePath) => { + const csv = await file(listFilePath).text(); + return (parse(csv) as string[][]).map((row) => { + if (!(row[0] && row[1] && row[2])) { + throw new Error("Failed to parse CSV file."); + } + return { + path: join(dirname(listFilePath), row[0]), + startTime: Number(row[1]), + endTime: Number(row[2]), + }; + }); + }); + }, + ); diff --git a/src/gdrive.ts b/src/gdrive.ts new file mode 100644 index 0000000..5e87fe0 --- /dev/null +++ b/src/gdrive.ts @@ -0,0 +1,146 @@ +import { createReadStream, createWriteStream } from "node:fs"; +import { basename, extname } from "node:path"; +import { auth, drive_v3 } from "@googleapis/drive"; +import { env } from "bun"; +import mime from "mime"; + +/** + * Google Drive API client with scopes of `drive.readonly` and `drive.file`. + */ +export const driveClient = new drive_v3.Drive({ + auth: new auth.GoogleAuth({ + credentials: { + // biome-ignore lint/style/useNamingConvention: library's naming convention + client_email: env.GOOGLE_SERVICE_ACCOUNT_EMAIL, + // replace \n with actual newlines + // biome-ignore lint/style/useNamingConvention: library's naming convention + private_key: env.GOOGLE_SERVICE_ACCOUNT_KEY.replace(/\\n/g, "\n"), + }, + // ref: https://developers.google.com/identity/protocols/oauth2/scopes#drive + scopes: [ + // required to download files + "https://www.googleapis.com/auth/drive.readonly", + // required to upload files + "https://www.googleapis.com/auth/drive.file", + ], + }), +}); + +/** + * Extract Google Drive file ID from a URL. + * @param url Google Drive URL + * @returns Google Drive file ID + */ +export const extractFileId = (url: string): string | undefined => { + // file ID is the path segment after d (files), e (forms), or folders + // ref: https://github.com/spamscanner/url-regex-safe/blob/6c1e2c3b5557709633a2cc971d599469ea395061/src/index.js#L80 + // ref: https://stackoverflow.com/questions/16840038/easiest-way-to-get-file-id-from-url-on-google-apps-script + const regex = + /^https?:\/\/(?:drive|docs)\.google\.com\/[^\s'"\)]+\/(?:d|e|folders)\/([-\w]{25,})(?:\/[^\s'"\)]*[^\s"\)'.?!])?$/g; + return regex.exec(url)?.[1]; +}; + +/** + * Google Drive file metadata only with required fields. + */ +type FileMetadata = { + [P in K]: NonNullable; +}; + +/** + * Get metadata of a file from Google Drive. + * @param fileId Google Drive file ID + * @param fields selector for the fields to get + * @see https://developers.google.com/drive/api/guides/fields-parameter + * @returns Google Drive file metadata + */ +export const getFileMetadata = async < + F extends string | (keyof drive_v3.Schema$File)[], +>( + fileId: string, + fields?: F, +) => + driveClient.files + .get({ + fileId: fileId, + ...(fields + ? { fields: Array.isArray(fields) ? fields.join(",") : fields } + : undefined), + }) + .then(({ data }) => { + if (Array.isArray(fields) && fields.some((field) => !data[field])) { + throw new Error("Failed to get file metadata."); + } + return data as F extends (keyof drive_v3.Schema$File)[] + ? FileMetadata + : drive_v3.Schema$File; + }); + +/** + * Download a file from Google Drive. + * @param fileId Google Drive file ID + * @param path path to save the file + * @returns path to the downloaded file + */ +export const downloadFile = async (fileId: string, path: string) => + driveClient.files + .get( + { + fileId: fileId, + alt: "media", + }, + { + responseType: "stream", + }, + ) + .then( + ({ data }) => + new Promise((resolve, reject) => { + data + .on("end", () => { + resolve(path); + }) + .on("error", reject) + .pipe(createWriteStream(path)); + }), + ); + +/** + * Upload a file to Google Drive. + * @param path path to the file + * @param fileBasename basename of the file to upload (without extension) + * @param parentFolderId Google Drive folder ID to upload the file to + * @param convertTo Google Docs MIME type to convert the file to + * @returns Google Drive file metadata of the uploaded file + */ +export const uploadFile = async ( + path: string, + fileBasename?: string, + parentFolderId?: string, + convertTo?: `application/vnd.google-apps.${ + | "document" + | "spreadsheet" + | "presentation"}`, +) => + driveClient.files + .create({ + fields: "name,webViewLink", + requestBody: { + // remove extension if converting + name: + (fileBasename ?? basename(path, extname(path))) + + (convertTo ? "" : extname(path)), + ...(parentFolderId ? { parents: [parentFolderId] } : {}), + ...(convertTo ? { mimeType: convertTo } : {}), + }, + media: { + ...(convertTo ? { mimeType: mime.getType(path) ?? "text/plain" } : {}), + body: createReadStream(path), + }, + }) + .then(({ data }) => { + if (!(data.name && data.webViewLink)) { + throw new Error("Failed to upload file."); + } + return data as FileMetadata<"name" | "webViewLink">; + }); diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..f949e67 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,97 @@ +import { promisify } from "node:util"; +import { env } from "bun"; +import { consola } from "consola"; +import { Client, Events } from "discord.js"; +import Ffmpeg from "fluent-ffmpeg"; +import { geminiClient, openaiClient } from "./ai"; +import { commandsListener, registerCommands } from "./commands"; +import { driveClient } from "./gdrive"; + +consola.start("interview-transcriber is starting..."); + +// check if all required environment variables are set +// need to sync with env.d.ts +const requiredEnvs = [ + "DISCORD_BOT_TOKEN", + "DISCORD_GUILD_ID", + "GOOGLE_SERVICE_ACCOUNT_EMAIL", + "GOOGLE_SERVICE_ACCOUNT_KEY", + "OPENAI_API_KEY", + "GEMINI_API_KEY", +]; +const missingEnv = requiredEnvs.filter((name) => !env[name]); +if (missingEnv.length) { + consola.error( + `Environment variables ${missingEnv.join( + ", ", + )} are not set. Follow the instructions in README.md and set them in .env.`, + ); + process.exit(1); +} + +// test if the client is working with valid credentials to fail fast + +consola.start("Checking ffmpeg installation..."); +await promisify(Ffmpeg.getAvailableFormats)(); +consola.ready("ffmpeg is installed!"); + +consola.start("Initializing OpenAI API client..."); +await openaiClient.models.list(); +consola.ready("OpenAI API client is now ready!"); + +consola.start("Initializing Gemini API client..."); +const result = await geminiClient + .getGenerativeModel({ + model: "gemini-pro", + }) + .generateContent("Ping! Say something to me!"); +consola.info(`Gemini: ${result.response.text()}`); +consola.ready("Gemini API client is now ready!"); + +consola.start("Initializing Google Drive API client..."); +consola.info(`Service account email: ${env.GOOGLE_SERVICE_ACCOUNT_EMAIL}`); +const files = await driveClient.files.list({ + fields: "files(owners)", +}); +// exit if the service account has access to no files +// exclude files owned by the service account itself +// only legacy files have multiple owners, so we do not support them +// ref: https://developers.google.com/drive/api/reference/rest/v3/files#File.FIELDS.owners +if ( + !files.data.files?.filter(({ owners }) => owners?.[0] && !owners?.[0]?.me) + .length +) { + consola.warn( + "No files are shared to the service account in Google Drive. Share some files to the service account.", + ); +} +consola.ready("Google Drive API client is now ready!"); + +consola.start("Starting Discord bot..."); +const discordClient = new Client({ intents: [] }); + +discordClient.once(Events.ClientReady, async (client) => { + consola.ready("Discord bot is now ready!"); + consola.info(`Logged in as ${client.user.tag}.`); + + const application = await client.application.fetch(); + const botSettingsUrl = `https://discord.com/developers/applications/${application.id}/bot`; + if (application.botPublic) { + consola.warn( + `Bot is public (can be added by anyone). Consider making it private from ${botSettingsUrl}.`, + ); + } + if (application.botRequireCodeGrant) { + consola.warn( + `Bot requires OAuth2 code grant. It is unnecessary for this bot. Consider disabling it from ${botSettingsUrl}.`, + ); + } + + await registerCommands(client); + + consola.ready("interview-transcriber is successfully started!"); +}); + +discordClient.on(Events.InteractionCreate, commandsListener); + +discordClient.login(env.DISCORD_BOT_TOKEN); diff --git a/src/transcribe.ts b/src/transcribe.ts new file mode 100644 index 0000000..c889d28 --- /dev/null +++ b/src/transcribe.ts @@ -0,0 +1,161 @@ +import { mkdtemp, rmdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, extname, join } from "node:path"; +import { write } from "bun"; +import consola from "consola"; +import uniqueString from "unique-string"; +import { + proofreadTranscription, + transcribeAudioFile, + whisperMaxFileSize, +} from "./ai"; +import { extractAudio, splitAudio } from "./ffmpeg"; +import { downloadFile, getFileMetadata, uploadFile } from "./gdrive"; + +/** + * Supported languages. + */ +export type SupportedLanguages = "en" | "ja"; + +/** + * Transcribe a video file. + * @param videoFileId Google Drive file ID of the video file. + * @param language Language of the video file. + * @param proofreadModel AI model to use for proofreading. + * @returns Google Drive file metadata of the uploaded files (audio, transcription, proofread transcription). + */ +export const transcribe = async ( + videoFileId: string, + language: SupportedLanguages = "en", + proofreadModel: Parameters[2] = "gemini-pro", +) => { + consola.info(`Transcribing ${videoFileId}...`); + const videoFile = await getFileMetadata(videoFileId, [ + "name", + "webViewLink", + "mimeType", + "parents", + ]); + if (!videoFile.mimeType.startsWith("video/")) { + throw new Error("Specified file is not a video."); + } + const videoBasename = basename(videoFile.name, extname(videoFile.name)); + consola.info(`File: ${videoFile.name} (${videoFile.webViewLink})`); + const parentFolderId = videoFile.parents[0]; + + const tempDir = await mkdtemp(join(tmpdir(), "interview-transcriber-")); + + try { + const videoFilePath = await downloadFile( + videoFileId, + // use random string to avoid non-ASCII characters in the file name which causes an error in whisper + join(tempDir, uniqueString() + extname(videoFile.name)), + ); + consola.info(`Downloaded to ${videoFilePath}`); + + const results: ReturnType[] = []; + if (parentFolderId) { + results.push( + getFileMetadata(parentFolderId, ["name", "webViewLink"]).then( + (data) => { + consola.info(`Parent folder: ${data.name} (${data.webViewLink})`); + return data; + }, + ), + ); + } + + const audioFilePath = await extractAudio(videoFilePath); + consola.info(`Extracted audio to ${audioFilePath}`); + results.push( + uploadFile(audioFilePath, videoBasename, parentFolderId).then((data) => { + consola.info(`Uploaded audio to ${data.webViewLink}`); + return data; + }), + ); + + const audioSegments = await splitAudio( + audioFilePath, + whisperMaxFileSize * 0.95, + ); + consola.info( + `Split audio into ${audioSegments.length} files (total ${ + audioSegments.at(-1)?.endTime + } seconds)`, + ); + + const segmenter = new Intl.Segmenter(language); + + const transcriptions = await Promise.all( + audioSegments.map(({ path }) => transcribeAudioFile(path, language)), + ); + const transcribedText = transcriptions.flat().join("\n"); + const transcriptionFilePath = join( + tempDir, + `${basename(videoFilePath, extname(videoFilePath))}_transcription.txt`, + ); + await write(transcriptionFilePath, transcribedText); + consola.info( + `Transcribed audio to ${transcriptionFilePath} (${ + [...segmenter.segment(transcribedText)].length + } characters)`, + ); + results.push( + uploadFile( + transcriptionFilePath, + videoBasename, + parentFolderId, + "application/vnd.google-apps.document", + ).then((data) => { + consola.info(`Uploaded transcription to ${data.webViewLink}`); + return data; + }), + ); + + const proofreadText = await proofreadTranscription( + transcribedText, + language, + proofreadModel, + ); + const proofreadFilePath = join( + tempDir, + `${basename(videoFilePath, extname(videoFilePath))}_proofread.txt`, + ); + await write( + proofreadFilePath, + `model: ${proofreadText.model}\nprompt:\n${proofreadText.prompt}\n\n---\n\n${proofreadText.response}`, + ); + consola.info( + `Proofread transcription to ${proofreadFilePath} (${ + [...segmenter.segment(proofreadText.response)].length + } characters)`, + ); + results.push( + uploadFile( + proofreadFilePath, + videoBasename, + parentFolderId, + "application/vnd.google-apps.document", + ).then((data) => { + consola.info(`Uploaded proofread transcription to ${data.webViewLink}`); + return data; + }), + ); + + const [parentFolder, audioFile, transcriptionFile, proofreadFile] = + await Promise.all(results); + if (!(audioFile && transcriptionFile && proofreadFile)) { + // parentFolder is undefined if the video file is not in a folder + throw new Error("Failed to upload files."); + } + return { + video: videoFile, + parent: parentFolder, + audio: audioFile, + transcription: transcriptionFile, + proofreadTranscription: proofreadFile, + }; + } finally { + await rmdir(tempDir, { recursive: true }); + } +};