From baab7224a91e116d55b7fce7a5340f0354eacb90 Mon Sep 17 00:00:00 2001 From: Junya Hayashi Date: Sat, 5 Dec 2020 14:14:15 +0900 Subject: [PATCH 001/606] Support remote files API --- examples/remotefiles/remotefiles.go | 75 ++++++++ examples/remotefiles/slack-go.png | Bin 0 -> 152289 bytes remotefiles.go | 277 ++++++++++++++++++++++++++++ 3 files changed, 352 insertions(+) create mode 100644 examples/remotefiles/remotefiles.go create mode 100644 examples/remotefiles/slack-go.png create mode 100644 remotefiles.go diff --git a/examples/remotefiles/remotefiles.go b/examples/remotefiles/remotefiles.go new file mode 100644 index 000000000..b3d013f91 --- /dev/null +++ b/examples/remotefiles/remotefiles.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + "os" + + "github.com/slack-go/slack" +) + +func main() { + api := slack.New("YOUR_TOKEN_HERE") + r, err := os.Open("slack-go.png") + if err != nil { + fmt.Printf("%s\n", err) + return + } + defer r.Close() + remotefile, err := api.AddRemoteFile(slack.RemoteFileParameters{ + ExternalID: "slack-go", + ExternalURL: "https://github.com/slack-go/slack", + Title: "slack-go", + Filetype: "go", + IndexableFileContents: "golang, slack", + // PreviewImage: "slack-go.png", + PreviewImageReader: r, + }) + if err != nil { + fmt.Printf("add remote file failed: %s\n", err) + return + } + fmt.Printf("remote file: %v\n", remotefile) + + _, err = api.ShareRemoteFile([]string{"CPB8DC1CM"}, remotefile.ExternalID, "") + if err != nil { + fmt.Printf("share remote file failed: %s\n", err) + return + } + fmt.Printf("share remote file %s successfully.\n", remotefile.Name) + + remotefiles, _, err := api.ListRemoteFiles(slack.ListRemoteFilesParameters{ + Channel: "YOUR_CHANNEL_HERE", + }) + if err != nil { + fmt.Printf("list remote files failed: %s\n", err) + return + } + fmt.Printf("remote files: %v\n", remotefiles) + + remotefile, err = api.UpdateRemoteFile(remotefile.ID, slack.RemoteFileParameters{ + ExternalID: "slack-go", + ExternalURL: "https://github.com/slack-go/slack", + Title: "slack-go", + Filetype: "go", + IndexableFileContents: "golang, slack, github", + }) + if err != nil { + fmt.Printf("update remote file failed: %s\n", err) + return + } + fmt.Printf("remote file: %v\n", remotefile) + + info, err := api.GetRemoteFileInfo(remotefile.ExternalID, "") + if err != nil { + fmt.Printf("get remote file info failed: %s\n", err) + return + } + fmt.Printf("remote file info: %v\n", info) + + err = api.RemoveRemoteFile(remotefile.ExternalID, "") + if err != nil { + fmt.Printf("remove remote file failed: %s\n", err) + return + } + fmt.Printf("remote file %s deleted successfully.\n", remotefile.Name) +} diff --git a/examples/remotefiles/slack-go.png b/examples/remotefiles/slack-go.png new file mode 100644 index 0000000000000000000000000000000000000000..51d93bc674fe41dd8b9c82ec171be892823cf97b GIT binary patch literal 152289 zcmeFZby$>J7dB3VC?FspHI#~ofRX}3DBVgoC?VZ3Fr+9U-BN>;G)Q-ebhqTtHFU!e z-@`fQec$uGzr*?K_s4f#-ykcQ9TOEop+`pBCn-HL}*{zSsR;L7@?p@21Ki2t1GEd3OXA`d`BY; zBRnE9A{G|MVgBGH`%?K8k>vg7k1z;%%9NX`#qLRn<Qi*yUfndg{dqzD&~9LH9SS-T1b5*mrs*?fWYH6$(nkE!*5Nwa_fP~F{CzOr)!&^r^l!UI+WUI$(_Q|T?&Lpf=#1Zc&T|}FUzSLm@1IsN zs4K3z~ak0YPU7%ndTpw zTgLj|Bb3riPFUn1TJWfw=Nj|4DgtRjizti1nNVB}ntB zRIraa**gX?55*vH>=Gst`vqUe#ZFO`OQ*XfVuM-Ff<2#dNPyz9Mq){x_##?5TZnWM zN!7>#BGb4onvQTz*3ac=t?o(jGBtaMe(or0F2fO18c#WKAvEyP<8JpNS&4T;?@g&Trj{ciVY)_jV;M)psu< zJMJ&2dtT{jh1mp`gWaNNf?+<|v}jhE9pA;`UBz*erM})f#}CJOEWYt$xP<=ooh2Ia z^!QB5_BVZ`xh#844F0!Y-g)fR5N*c1CRwVyb65Jol|QRj6{)al&xKN}cZ7PrDDOsW z22Y+;5Unq9T0pqy`>8twt?0YzCwD$=1eDlb%~@OxGJ@$Hh_;9eh^lY#WXg0dCNHrQ zI*43_Pkb&B%D?aICFs8M42=f7sOLIl-qwk(XArUToAKTNnUO>MyHfQiowRTkUe-RKv}*49dOaE$kozMX}js<+D9GIjJ(H28s0 z9wnlAW?nSWyTpd4(l>4Qp0-!t&y|I)o!}FzT0-kKwuR#zk8=iZ`DcukL})nRABL}3 z-;$;zz83o>^+NOorn3^k97(vOArWsw zA0&4l2xj{$%TEYT$neRp(;tS-ggLgW8YFg8559opX3uCv&-`0}%03yerO zAQOkFgo*hv|6`V@z$j2B?TFX9Bwp$xrTwhHtdU`?b*6R8_1o0c)RfeoNm6`7NuT&e z_>hjht5ZWRG92ku{gtJ%@e$>T<$4nE33FK3PrEo{e7vGoA_f>O??3#NuI* zrn%$PI#0~=z~(3VDkT#d7o*Fg1EbOi%Tel4AMOxtCT=5cq{Y@RMa%JW-g0xxNekp= z-o~Sep0eBuWDD$FUpd)iVTF+8)kM<(UC$h|-y=W9kVhflHpMpAwn=%1%)6O!@@4XB zi2=P9D{d=2y|LP_$!N%mWP-cI)P_gKROYM4%k1yG4Z6C&_GRMv*tCfiRmHms*9xdX zoPj}GOt2*d(jsxXGRQW!H3@V)Fx^(y=pIH5twkwHRDLfxL; zSAbgcP2&zhdTQq>%VzPMw! zP_dV(BdKk?FL`tAVT)Kx1s$6te*U>X-H8ee(-vKH9cD&r$4+-oYxewb2XT)`B5s%9 z63F0XoDLq5$8bm+J9KIrEfsIYp>mA%Y{a&;J3qPGqAX?~P6)2md(FqxpghBj&sxCb z!NKe}+C=0-WZLm*oV6sj*F&2^<7|`4Eks{_QX5efxIB~47FS^@$Swr+m_(bz3?w^v z8l+hXC3JjqnVQ@+)ukF+cTT?<7}G);NXF{1V>BFlxKzEgw};Tf^eA(@RWDkcZje1T zh$kbYio@KiQmv+{Qm^+&-D%4_v@o{;qzNmjHTbmsnT0ukIfJ<{A6KIcTGq5aI6^Qg zt36a1Q&#?Nv&(U4EGd7lV4@mUty@)J!&hGI;xp|VO*vzI&$?EVShd7D^7LCqg03~G z)z$=2b;jG=meJ1q?fP&xdjqZE@>Wg1iJZ-!n=O+xo08MEXIe)Uj{H4Ns$1vZWvX+b z&huMGd=1sr0?Sa<;k3ifvzfX$D4RRkrH;LiR&%*~w|g|Q$z$mfUf*|iIpO^U-4c5o zo6_U^)!QMu)|AiU>9$S=o{^Q=HN16I`#T?-Em^!-{5+_T3k3|L5@sP3G&;6Ht`@sR zDGM2G*qTQ9?u>Q{V3ULyC&c1nq}l{ynRDW2UdQFk_GEe3a-yKeqt)*464dtu$p*>h zeB03a-4A`AM1+v%ZduJ0X}hWLmSvgCSWUh*XAlB+jn9_%d{03QqSmVyTr=UqvNREl zh#RZ1#<2!hY1r(avb8kanbCwK*woC2TqfnzEIXTfI$k>JRahSnKVff9tnF|eM;j)P4#*3JFDCRk zjWq!YK3>O(UH#c(ub1NXb|@$IWGDv?DEDJ$Pv3rC?#+B8YOkQeu#^0KSvTvE9ty?m zSH1#!E!1BGg@mIK^le>A3xQbH;Ey~aB5Y4lXvz=L=>PhR-V2;Vd8s5KB?Y`I8Q2*aS=pOdI|#_8 z-USZavXRuVM?oQdc>P3`Qhcxj^gm{*tnQ#LC(CDGZOQu9(E6PbE5y>~x*rrUgb(;= zY2@&h7Gh~(WzPo@p#QA}AMpA5FdIGXZ%rJ`1?bh~Uek(L+ZoYvv2wCLqZh=brKJVi z85;8`ii-bnIq*$@-o(MdhL4TS+1Z)ZnS<5Z?mgRcUS3|dXY6e3>?}YF7JC;fhqn+G zD|?3DJNZ{XqDJ-xcBVECrq))p*ZsbIXYJ@9Ku>>tqklhsm(vJh`ky;l+5hpgfFHoc~0_YGVMzCOzL+7x1Bp)P7_3Fr*Chv0KAUT*Mj7yN%t{pXgyU#ennWG7;6 z30&zQ_#dtRaq*u|{_lmq-BaT~_vGc_{PQjUbmosM!EDz*{ZFd+UC+NA1r#la3ugQG zpb6s6ZKZkx<49#Hs-O(K16FqZKwSY|04u$I2cD?v6-Y3bISPs}ij?R}WeDoV^sQRF zCCbL#w)&_IQ(~4yy*DwP^=#2Pk~rZkitQBRip5HjM>^$C>7JxKj!2gq$oZ)F2accpQGpYN+idAZ#S}jU*I@#(mmM)&ePK%P6ULfv|cDTyo7zg9K5?D z*|ZIklR!bmxGVhEFG^ls*g`me*UvmA4kZ1gB8>8e9)9Z@4Rn6zW?8(1KabprN*PW+BP|-k4mn@4|H>m^= z=2|J}|0w0Z_5A;yQoQ{!z#e>;ezFkvK&lseB~khG#2;1-{(3}r zv&lBLu!Svp`C;)-$m-F0!UT!cG{@QBM~o9x(5@1yZQ};Nym30v@}WuLLk@I`#mhZT zRU9;9e=0JQ*1r_z%uo2KSV{HYBlBOq%~e#Db0p_Z8Wh(4KKmp+$cg1n2vi5*roq<`~R6$5s!r%83Met zL;hw?cCZ0$PT}a9H)|3FP3j(iAfm#oe*epDR#X?(pP8AyZqS{-{bNe2-0c$;uiek1b<&;$nGmXRV(P%mWAK~iUpI~YJ6b>+ zSnZlEA=LaC8vCSDIV{dwQ;H3`I;IopMNwrL`&R-dCS$CFX~+dS z9jE!9x0qbDY}?Fz@u=xpv^br?TMo2Du!0Vd-HXOSo>_?*`tuMK5Z)N$(a zHK-M6vP?q{N-fij*Zspux?=c1&U|-G`&y5=A~Lb?!z{*&c~#ar2$YX!U!rF?=BmQ> zo5#PL4RVt2PD!@O+=9Fqw7J2W6tjh=u)(j~_2wO=bA^)@Q)V(@dS%i^aeRkjlcojp z4`+8@Px=|{$FLMsRJaULDt;A49kX{M5O3a}I6pO74~*=|ZgxZL&j2@f9MfU6uCISN zm@c*8uF*fa(wh{yN5b4S*X&1KNH;qt*?fc9OJ<;9fZrXoVy{0Ss31?`a|#ozgP!Vu z2v9?XhuAfOJyPEjtnEp@H|NP3GjTCnqBzLXNb@z||Bwysm>08aqt2k}vf=-3Iq76N zo>Ml-^s2Er+Rx{&9ZeW6=DgtS9RIuKHGyJM#$y3^r*k!{>ZyFvVl}Ho8~)}+d`e(2 zV6n%=+ktFG+cR|@ZFL;nR_bh@tZE1NWv0+T#}wwOhBjYtncvU}@|T&znYnV*jyoJ@ zlK8Fb?@)k$o_@S7;UN0+8xJ=YxDScJs30IQ#=9$jbsrH&C19sqe|n_F>$LIZn67Oz zKLH+bep*;0u%%kz__Em-&v0Y)&8U@Rp$=&C5n?pox$e=hmqgx-{g}A@-5a#$pNfJS z`gq=zY;#yj_pG4CaNx)3Oy-#-q6|Hm;sKI?YKQ+D`R0V+19aF%^6D3@MuRJ|Wd4GS z@T{;0Tf^26y+-|Dm6rGsW-&5C0`5-;m-CbD;^h%FBN$(Jg{`2@$X|5~N6o;txi82IHnToSRlnh+3 z?qV;AJz_l@VHiXy^zxA$Y+m_OmH(-T<#}d|UCw01S8sG!R5?M}ZjaM+^4`2MIj>nM zAY7Q2gfPAQUk6&=%8%|IqlUhQhemN{sC-8ya8$3di+0#P{m9Y*o342PJ=%a7bWra= z-VPd)b2uet3XJ-)zMjcB6xq9I@==XB%~68Z9dB_@5|RYS*M48P=YZ3q6sTEkQFX92 zG**CX8*kQ?Z3TbzN~ae5rKK~U4+o-7R7-yYCg1vPB1Fy(OM#@ZFG7VbA1^VhmRs)n z9-3#^%>4i)A>TPUhI?idKc{Hn$$FD6NvP4_6ikRi&c-Gizi66UR-}Ka&7}1$s0?vN zaZ4c=t%8d~SEoeUs_N7jp}aqmy(P-1^Gl=F#(T2F@ZA1{-qOEYom*o_&!R;|9o?-=1nw9`+B!b8gXZgSB`MlUAfp)@4q z;rqsgg;1dwS3C-riFcL0h169k)wNX-{|#&|jp$MhKa z(9$hVKii$`yhVha=9q_XOq{|DdlPxz)KAMl0M~UnQY^h$ZKX6?@I9Q#{H!!h!mJvW zuLYNPDA;n_xy0{?W{%aXcW#|ykVuyu-<#1`E?0`f0H0^I-(+^I*LW|40^m*x2AI44 zvf5$ez~3^kVd^-}usy8F{%I8&=zZ{;#~EEw9Ap~DoUOlh@TkB%c2Tu24j*nSb#Ibo z%0_5%nV&5ojYWS9)pTCBvX@RArgmunEbEE1Odm?CIb4g>5a_Uo@h0PkVe-{;5IK)^#ZT=G2}F&jRhBuNo; z3N)|51j8s^2oGi|&=6TRp<$-K!n`8HssCg#S+Aoh_-lek%k^lLRj1~hEbnxuSzs{B zmbhVmtEwhO7IyGKuin~Msh=(iCBivQ=;9!fcl{@S-^8WiHfUggQPnO78NJkHc-9?P z?6k=Tn68S%Os&z=Q5ZMia!*G?umYakH!nE}fm9W~Vff93UkTeIl>5xHP(w}XS772r z-Z#WX57Gd> zTHX?L*mQ-Sr;AI6V#7XnZMi%y*^|W%AZ$D9?%xeqRi-y8t@KW<%S(OOI? zCxE_>o2+K-sw!EbC)(Lz2Kxu;!}n=F&)>TVQ`fICRX*{xH-M>IQ&mg13=Vv(2Qx1{ zPv)?BcSh9qmJ<@(2%txfi%q@9^1>NR#|?U&(U!Hqiw)mUJ^bve;2~K_U&eT;pjbme&>3HMuzY^{C_=Fy}asBPzj*1 z{wsWr5sYQ2?|lg(vE#Y+nIG|@fe?C=w4n}b>tiunp+b+QL5F>SW7Zig9Y^7tyPn>OL?ET0Xf5CA-@YlQ!tLAo3*EvYv+W#xlh!#fOsy z@z<_hvDT2OVWC?8IHspp$qtW#U3s}TX~lWkixDYPQ%-QYq(v?do5KPpez>Oz$KKu! z)2_19Pzh9;R&5kf*chn^eX3Re!#>#RgjKfyHF?me^EBTem4V;kqu)2l;2r-~s16p6^N zLJhspb7KXfVCOo)VIP?o)<{8jp^}Y-KvN=+VbD+IsF{pRPS#yu9_Tf?KA>Z&J$-TxhOZ z7^P)h)iM>T+{i$&U6t8naZ}30mKIodO28kQ8zN%$TAK1tcv9ZYQ5uR4RbOI9(6wbp zYG+!yx~|@BAnnon{+io*954xT<`@=|3fd1aVuV*4dXH8ol;6o@gixklNy>$6W0Bmd z(O{!;ckldmWNq%V3a-Xd^A%{oUefvzK4cY>%Y2#v2;d|gEH@{PJ8u&+esOUMj3#4N zl~t`Pp0dY7B_P10ylGzL3P+%Wgs87XCS+wSwQnq3NW208~S=-fqIwrLymulu*xs-ar(akq5=S5cKv7gpW zwfxnLN7lKP60GZYJEeYE^na}nqU7LvQxAlPsG(wkWHMKb*pI4Jot^3{rte5bv*-aT(sFp&Lh5M2Z%|D-upw1Ygt6w4jm6;+mZY;(N8ssA;q^KkjKCN+OMNxXk!A{ZIkcq^UX_% zu}Z6Pp*=iJvD$)HtLYWb%wxo8-IKMUj1mD*msguW8KSe^9ceKcKHU9#;B@YTH3yj} zTdIJ2MB+y0w0A@@ZsK><_X+N5v9$nUq?aG*i=FJ3sY8)2j7g7VhKdZ@YD;>=CLD{L zwnZD8wR|jZ0x}H%$V%iyLti*X0A9NkHBCG;yhBO1)G~1UKw^fm<;m>sQmmeIKGY1V zwmE)zuuvDlh44%x0b^TmgUpcMH{&1XYn4Sb`J88d!W|o`S#S0ukU`=nEk-H`9rhks zz~Q9)zo)g&akB<90Xs%Zu!J2sjdlTcf$Tsf=4u9(6w-8$q+ zQtA+RbG%Llh(nTB)hl6|?Lt@Q4A!+(9aQdf{GC5?L=o-_hj?~k^=_&$D2B#1r)rubBL+GGu3CtyYXCmaiwz`!9Y6lO-KQX_+*@V80t|;oID`sOg7TIqHM$ zKv?n%-Sy#enyPu_3Z=${Xf;*oP)1i5vh9k^MUreUiS!x!2H(h=LZ6=j*P%}xArN#n zVSpXn18O>>N;cES!g01Ors;X@&dL1ywE*Yw5!w~%v`H^eeN|C;TtFZg_DC*aob(u2 zpQT#fepu#_m2WY9Rt|rNN68&llqT`8(|J$ULGcU!Az~zYcVPovRx@2`TK@MagW5T| z$o;Am>^K1|91Xj_ym2PZki-NNkx={y*@l9CWF&sR!6JSt1DUX(N?@w{+=7@8zwd!D zi1-OuPEN>E^Ghbr1W4TdNk9ACr2TaqzcWlP;ootk$Jd~TGO<$0^%oWj$b*H@phMwe zz!|u>%H!ClZHic?TPM#%G#Oz8UCT&d*}3x`7QMz{JW-z0#gMcV028nXCEWRLL`VF; z==8_j+99T$os%F+x&v6MJ~{|6x|qFIO_02~yaf~*sO>nS0&A7vpG`sN-(z#}+swkO zSo;dbD;@8LGCRIEO<E)e69@rjc@BPXu%XIKW(8-D2glBZT#2<2N~qE_(*pAyuGLqrs3?LdJCS zrof?#^~Q9GRK&vHi&ndPFhvK~ zm^SsWioD}BXv|{AS415Lo=I&>JOMpoy>)t z;?3GjB%QIbgBjZq4rHAUHKRx%ae>g)VYosjmz-8RA~L$trfUNu{ssM=`!nMuD-5O( zJ%jD1_R!|DGQ~=pInmPrk3M3RR{R?>ym#LNvG@E2UJCCG4*1%t?^Xj7uD_=e?FLi2 zb|Uk#fSD)O(Q#t`O`}&prH8PZTFG6czl!7gJK)Z_ zPGWV+%>5ls-Uh(E==C@Jzwt=^IRLn4?J2(aI|uNOdj3(*U)th7*7MKg`fC8X`w#Z~ z1I>Tw2WkKDT>n2hcK?9J-Gzp(0Aj{PU>P6gur}C|F8}1O7g*jWcO^%?+OE@lZ5|gY z0H^0s14ssPff#1$2p6bX>`l_LTk3sSWxo`$(JGh}ySs}n!hF|X!n;SSy#4D-kwv5r zwm=W`s|Lr~WM-_k(>TC7;@-f#yg1h5y+m%?E$+PKbzAfYC`QBQy*-hP6^k8_5_|XI zZD&b|bRt+tw05z-idkv#G2Vf8`a?K36Q?R|(=1%g?O$WWYTE@&**mjjze%f(VpEeZ zHtwMqcC2T-I9*9*2a>sVM^n6T5!l6beU^D|X@8?+j&-M#nR%%PU>JedLKGifQ+h($ zl;*t&`+}orJkFo%5BU*LnqeW(VD}eqz3)v72XZ1|4Of@^?aYY^HNz*eIzST2u7yNR zvfeFLX71Iepf`kSe0q0(h94GMhLs^kd0}zS4a8lXPKF|wlpeL&|KR-&Fg`LS4W=#G z>JDSH3|sax9EOprtIo5=3rOb{=EC-Rw)!aR`qS2xJIHBzvCoo86UXuJmNO2KSAVGq z7{)u&ofDwwyVqU9U^s?N^WgMy+k9BkRbXIfBMUF%JB`Q5nw&3Y6_fLEe~O4=!K;j~R{F#3E9txL!5{c|zC?QP zz#1FsM6>nf@eqkXL9+cqE?#mZN9BS2ayMPYvl(1H7YRk;Smlt6zO?9$y5<(dU*1M;S0^*)H9F}kwR<;8a` zpF5_;Iec8r621KJ^odFowHnim^Pz@U-smrL>uNSF?I*Invf1!HS4Abry#?fUpYa#_ zB)wt<3bA}Fy4U40xpH?FmE!cLI>o<^)Xmz@7l#xw-2LS3eyJxj#7jxo<9WKO|MZwi z8&~ZV{|l?Ae1Vxty3@~;2h{0^h`BcV6FHzUMdg0T(kB>T`YWgOZ(eO=dTw;ia4p6M zqB_LibSC{?RkyA!sslwK>MX0M#`U4|-_b*>mq{aTyt!v-)m;Sv-flS~EU2n&SJAb#!klq^{W-16I zCf?amkva1)!-WKq^Xuw<;beNisk7*EF~LQrv*2K7hXF2Sp?IxbYEz%CkUW!ZYU}|V z>Lm}f9?KpRKH8jI`NgdxA}&Fb{_SA9;p%fe4IidfUL=-43^{{YLH*gfx|*E(Ux}5i zO*@Yo3e*c^HmeVH_-UuR?N@rKkKR>Rf0IC{=2Ha%)+9-M6(s_2#_HGCLaR@q*yn9JEz;n8mGI93Z~!t@SnA;Fa{<)Xy?~=+cbyusfSsQ%xuz(0+$slU(;31 z`64p(n?77a)&2xf7$Va_H(Wpzb}<*`-Knm%w6@-jnu8Le66l4eZ)e7Mwf#}C_=I_+ zULmox+gAO_G5=WXy5b=!<#Tpe%eHTk%xf=Rf3d}Xx}IBjP4a`C>jMO)FN{Xgu})wC zfO<1Qs}luXml+bx97Il@hyYv<^K`4E3B}xRNC&kLp2!abZk{_|m2;>j(B>(=LcSZ_ z-iNA77;BquWXi?Ldk7rmx*Y=?Lerr~18YS9bAr6ITfliNq9all=j=SnH5Kb6BRN9@ zKE+mgJ8pViPRg6FgqwHMDw4;Pm%=XLk$aGYV!M4g%wQzG(C+CS3$-#clLj`l*mm+= zdYiKY(`?^9Q|#avIY<;Tx=K&_O-L?x$Dz-x#v+fqA{FrO zZ93pb!N>(=K_ki-PJt*g?I&DU_m>riafmQAxVj%TFmmQLA2X{8TtFX73VN^P&0>sHThY zIa#nd!`&;=dC;h=q>}F^HRz`RQ;Bu8vF*GHD8g42TeW;UF+j)0kJ=9Y8g{r?=qR$m z7-S>$*^lc-^JhnO27zBZ%5~yy+IgV0%L|!r0+Ul`gt&<4zV~PelgHs$V8E~*f#RH> zLrnV9MEvmIx_eaO{0E}Fqn_Ly@bW8|e$)1)_sQ${XC8^exs((0eVOt}5%Qr9;Q)8b ziDkyJ2LlaM;P94O77sNPaG+Y!QPmu)^1A%(My;=UAIe01=fCHmn~o0BEW%<^E7WC} zh6)lY^UqAy_t?hMqk&!$iXW!Ol^Az@&-BAq@e3)S1g|nlkP;4R6(BmfV$M&--&PY$o}uWS3<3(IRq7#tKN5@Qwsb9;!ax4=*b966V}VEWwb*n( zj>*+4GtljDsXm1}zn;|)U!E)wFlp7lYYbVr(Az3;*NpS>OEMeWQxy4kafmv_VI5W* ztGC66_JGJ-N|a&t?euU&MpwE5++(z0A-7;51>y1wsQ5TNDe6K3#mU8xnobOrw~Cn> z{I>HSOSuGzn}QyY?{09|c@72IgLYBTvA!s2Ja2P%Sguqs&zpPulbdR6XNP)RKNzJ* zm)FXTI^Cw>z|thBpD)9-Eri-#h<@Ww=_Q*1Tv5*1 z=PsRr>}Ch|)$%gf*di1&q^n>HPS3tDeyOmSc`&xzx2-1o6dg3d>%Q8ru-6A~RhSbU zbmmtpSk~auMLK4DO`MHk)v$odn*n?r>>uH25^Qb-Aqm?>tVB5oy~WPxuF=Qwae<_4 zF_*2wp5BVjZuet95eY?1fTDAiZ$r%fh<{M~ZZBi#hyXF^Ld{0|+tbt4z6cu?qm z>~MAwTtGf8tfGAco+aO;PYiM9y1Dk&!g4=j2Yw z+X{TU&8k+Y(RG+uRq|fy6YkPQZ-6nt^i&Xhp1gBqcZF*3X0;G4w>4{o_ zA!XvOxf>2SfL5`2T;h|fZpfym@;U$`$@JHEZ{&4V2atV^u*>{*C=RxyVpmq z5N!Va(S%g8AsfKFA2U1J8fW3tY217$T~)tbVk}QqhR-9$F`Hs}c80J{*Tg#(s~>S2 z>pixxrUS4W9ZS#%>%fB2$Z%cr<(W%~%*1kG{pG|~Sh`%Ks;`j1@SWG1jH~a>qn`&4 z!vxRv-IcG>0bB!p%r z=zJrxZu$L#*#JRC6I!d4-eaTPh=+TCER`Ph2Z}munQwj021}(3nAE?2RWJ>QyYDn% z#~}sf4wh>bYJL4wLU#q@pW_`7!(KF;2pET&+#B_a?R#&-Kz4tQFj48pO3&GhluM!I zMCmPr{m(xKFzKS#lf2pqDmz-Bv$7sGTz2PxDdSQB-^M+s+&)v@?&5Ilw2XlQHPjt> z)NyjFugf=>@?!lqcbY~CGNYo%9|~|N5f$JK`Io@|}@OtypTHb!f?l^#Gqd|~5 ztQbB(2mMa0KxeC*Zd~oB648w1%?zpprt&Q+B;1_@9r?CQ2_B^QikZX`uuMYo24 zmKg;B*;-Tl?(G!S$w?@?(`USGm*W1Z#^> z)8(@=b5S3%c+UGgcEcuP)!oGImuur`H4azYFyD4DkycYVK{0wNj}u+ zTj(7$LMP>cj^4>uE_&CvpGWL4O?C0KCypO}cu~|_0Ue=@#j436_ntC&xcT~_n1UA< zczDz_-FL}jmnNBb8HYeHd9=?TnY)ccV4h-?nfn5EcSF)tkZ(TVj2gelo2LX4Kz0u)R>+$KR%u~cOqT2L zIe9!->{Um&Zma2-Kz>v{S1dJovu!cC3D6$U@+04>6ePv6$-|=3vs3blKe)JOZ8jGS zfrBYIG&xby)`0HARV}BkzMccrg&!W(f)l6&b$&qkyd5Z`56HDGa!MoK;X$UDs zT*^kE1yC5?=P*kL$jajbTuj)U_c=^gWIP0??3@f;}b@EU{HA+@65_9@UHhwfo<|n;XQ+wu}b9r$l zjr5xw*MX&jF9grs;41}2_bQJx0pe9Cuyep>tUzaQExf>P<5!W_im%;=0)hPxyVLP( z@TC-O(jD#JEUZzpxf#dh!NJmg{1fWskUQ3RfQ>b|bGu7GFq}!=XK2G%^Tu|QJ5a?WrF7Luenix~uu?3df0WGY-SWcHpD$8K_XJPURr zw}F|w>6}jHFSb>u0Ro(+VD0Z{pivyG+X)vK4S_8_UVex2d;j4Eg;89Gw*8py*1%@0 zek(p6kBS1Fbc=CzSmvTn{fi)C2Ev_w-t)}b7hFLTJ`JLoQLgUIXI}P~5;Z=rwep&> z9b5UZfat&)iY|2vvRaLfN68ffkk}?;ERAyfuyo%QNmi+3){k04k`bxfX+e&n6!2I` zPOx0UOoY=WKZI&z`La2NK1kMmLMx1^7_QeV?|2b%Ia{;859hkNkM9r52{h z2u;~kgd=X&pi$Su&i(0un{WTvgsN0*9l zWn^ku*QuNxBHqu4hk^ta{deoA*qCO-A==48nc&f!pv7+3NDyPzP)EaMOs|h>W@k7V zO#SnmertN_0iU(<2`Yhm=qouO@$o#fN4vr*>8bjxpqzP@5fYXRL_F6EhPb*Gh{-CD zNm)JuXgv&l0mCD`1zq<~HBNt?Yd3YUjCIFNn)Ipm zy*>AJ2eFZ0p=#-vr*u~wXmlDS#&}gh!ekG)W>Y+cV`>))ywV6usey<%l08GFOGQn` zNYLmM*kMx)WHH7sw`#oOO+m@rhhht%vt}VHW-HER6iv=|%^Fipic+tMeS49F`X}f6 z?l0M}w1G{11Z=9ZS!kh z8vND%^}-;*nq}E_dZ^h~I&hhw5@z81YyveDNEUr&McInsMEQnFP!lzQ1~N-APWkrf z_HrB?FGY97q242AJ6~yR?R%W3=g=ee`SJJKeNw$XIze3gKr~LHOw5U*S@a6CYB@f) z$8^zQ;1o#0;hfdo=XZ_bwD?7vRgIQ3TIRzbJmXciuSR@v^z$ikEToj6CfO%4)1A!z0w8YxxNz%D z;vaLHHC$*7PHiGEPZ#0T<$5tt?+)GH=dqpvyKI(?b)OwTtxk64j4!{xZ8n70yT2?; z15yo&O(!1bE6JYej)>8h?m`;o5OuiosQaVi`T~B(HfrZDJv;c2pEAgZNyg!`TM+ek zf7;2Ej^*u-4L&e+cb5idXZHv5NCyzLTqpLVY|})(0lSwXfUO%3ND=_Va>@}H%*ZvT zbQtbzC+z!GVEdJ9eSZETnTnPI#a4#mSsc&%@&zXMv!mN)I;aAt%dDTQze znoZ(&uIvB$F90rdBXP3^R$$tDL zmBPI5NrD|)=aS2H0LdPqEDr48c{WB|l)JyXC142fnVtN)Yv)_6WB;|ka??xV*=;?< z@H>FhS!(&t3^tDLZOo^K07Z!F>Wn~FwSkr#udYvruOs_CScEEbyf|PlZy%=C61)y=bdYM*{UL5;bi)fU9rf7OS#Pd2s|N{PX2` z2*`lu%OT5o-R2~HG0e{?>)S!P!kgo5IJ_2*dXCWKr_R%D9+)`9!CFaPddf9=VC`wK zMGt?m+cY6EELXRv4Xam2Uw~9QOVF1^k%-`k^>KEesA(EL@j0W=V9a|X1Az*Gd)iKya z*0-*oTwNXuF{!b>J(vfUI|5A!>Lc4w_^5|;zMdzy7>_oaL;O~Q)RGjg_w|Q zxOZDR`pIYcTWQp~8pVdUs?O7~z@V-E0mn^zJXQ>j!wA7_X9de+#^PyM`qgjSBG~7$ zd3Z23)yKr@O8~MWB)<0wUa03e=E!*Pjyn7+1F@S@7Air0q4}6q@!SuZ!}=wc?Lytd z&-b|afaIV`j`j%G;|zr^gwno~6fieeSe^X9E(vf~B?r2d*NVV04eowbr(7@(PHi`P z-$mME^sAq4)GBo>P+&GiiEb@QQr;8@>X*U`La`EKvNg+c*e^2L(lq7R^auh+*aLLE$!4|C726$jot29BX)2A?y6O zLcvkX1VhZ>C(0e1<9$<1Fc9{e%bSw)^m$`F3IPe2U7oM{j{=*G5*T<3xGt|6aUpjo zI3u&T<6`88+$JyMV;-(Cg?>Vkrf`tKjDFE-=bQ z-6BqQ78o@a{zKQkXc*gE{^uywD}V6^6g z2`1R#XOD;_O$*mLKY8L!p=QnY4pi^+@F z*ry~Hx^^;rA8gW!&k7-ik)u>Oc1l4t{!pIoU8x| z6kL%!el6^Aaj0hBNhzS#(3cc3s#5RfQo_1vaxsJBVCEhwc$Vb?=&7G6-%aU-X2JPGI9t+l>QLVGi?k8h&lINpa% z25P?r7bUA6dDyC7zOWT{`1$dhHgBR5X)rd^y1 z{L)6dy#6Zq9I{AY53jk%u zU9#zOTGF~yb@x6Z3oBuKJ{D*YG{T`Y<#i;-ktSkhO@>_N?=o($8zmt zTH99bzI=(&Wq!pByIx%=V&&ynTEF~RPXbTZ>HbPjbG2TMGLt}$J+J5hjzv8o5w&oztK-?L+?UHX6?;h2S?m zn)T6ndj~34L)%YTwBSPvt?1&eJ!*R7$s7w`bTbRd*$tmKXR~bAYE;puDuVALMETPa3tAnm5mrwk^qY^oRIA5$$|AIls+rb&j#!Wm)lF z7TN)MRrGnYNZk-?y^_t+;n`Eo7w|GB@6iYS1HuV2l3%KSl+hOY4B$X4+%6o+IUy#m zliyUV<(0;HPaPx*)Hq8WZHzl|ykmMzJ80=30=Rkf0;8cK5$M_^sa(nD*yubA9bWaV6RcN&L{50bAvnLn^orVywY9M18l1!1*enkcdyc zt}ywJjkL}&@N)T<3?)0vgad&rr9$s9P1-9ORlUzm}kEiTIP24Bq))GpCtGQo3Q^-T$5aeF8 z`c{ErCND*Tr6mvTcsi^0ucR9WDAS#hxDkvi4`Gk;T=wlyR(_lJ zc1Xt#8BZ7deQzi5kDymdI=QZ$JJYM&7Qs}=O*ScW3}ukDimEi}Awjn(d?8_ypgnJi z=0SCGRa(417MZRmwBg)-t9Cd?{nykOUoQnRAIiejcLfiQmFX*AdRRi}wky|g<)R6D zK5^Fkmf@>i3P*VlCbqkfllytv)SC?oc-P{Jo`Q7f`_c7>-~5)e7JWZf7gKoOho>*G z=v)=$xh~jmE|I3K?LiQB1;=1$%a-U;O`WGIvJ8|f;Cgxf3>`G*A(-#w=do3~pv7NN z7wOR!M8&Hpc(%&0e=%@mMq6Y)Rs0DY`z|E{2%t-JRZun z?;lU1&5~5IE0vV&OBf{CWs0&7%5JhVV;hvBvS%m8zAxE#L(0Byld%lh4aPQx!T6q@ z=YH<%x}W>Lu0MbO{GPvfc^x?CIFIAAzCZ7`^v^tMX`Lb?UewWUqDL}$mUUx;O;B7k zepWON10vOl?kK(a$Wm{X0pgPo}fP`!;2>=u3u+AmcQT+!t|`#$^DO zpicqJLNkCAc*;^RRsoHzDLi(^;(;Dy6{Xa2lEm@5ir5-@%fgG2b-mDP!FZoIs4 zPUrLXA22w6>bE)6periJD%Yh}m;JQw+@{i2<_zW_7&Q%C1@85&%#cR(igvk07ti6q ztXDUt3KuTzJeL#2Z`dpKbN8im7D3hfs73r&1~_FB7>LHXf?!Em&D5Z4%)Y+}zRspi z3&4P&0gGr3=SIDCQi`^HG{1K5$c;F~HWRUA8btZ{Y*CN5ThG0kpP4P+iN8%xbI?~o zan-W#VWKNmkI-QQJeRTVvps%qMeyRm-uCZBZM>0iowT(ev1x`CPx|KIYDWV`9tP`h z=@z7i4nR4x>)}<1cn8Sf%t_2M&`Nw6i)xW+r@H z{r1YuU6KvfGis{bb5Y)A?NLSYvqqrY-Oa!-`m;8Ck6!N3bgdIWfBwuA^0 zG#Vy3qUVT+b>JJG>gQ9CK20Eo!P;$487iCTsVcj(Nx8OTX!H5SZ2D8) zX7k$x!n{|`u|t2%MDc2ycf~wN_WUXijLt>}^E)~ngk-gG+UgnEzPS6w`1M5A%qvLJ zM(sjRpZO*xEVE-$F;l+heeZ8Zp1yWvBtpP9iDYR1z&S)H?ZyqRgJvGN&R_BPIBqr) zJdaPZ%t{CWxG$^F%Oz^-&@(!kWI`1U{`nBwWa@rk=pjE6e=IL-(am-H^VO(=c#(u` zuzt0|)EJ=0O`n=P62z@+`~rB}E1FuwJ4jm47LNDty@ zaY?}Udh=IC=b!VEx7sZ9pZ3wq$=ph}s<%sK?36jXP2R7EB*XZwd!ekPqe2|zkQjYD zd%;j&c4*q*z)zMl!x+s(pcEG=!C6qM<#?*Vo9 z;Oj!)Cr14OLkIgaxIv;hX{wYQK%%qb-JlsMW-CALG9Rt$iLs&rbw{2)oE81vLXs^p zcBr1A20v0@f}xMI`|*spYHE>y+o;>8`pD#Tth^Mdh%c@snuYtWN?4`-KVs6N_R`hM{|RDJ2i%LXr^c-Ih@ zKkS zXtciaDGYz4R#ba4_Av2ARYsRa@{pyt)phKnC8nnAK#D#F7p_$s_{cit zg5+|N=XxVXXmmjRPVWyr2IES*t+OkMWY4RaF5^-Q1d-S^;Vg`lDTjN1*UU$T@+FBk z0p3SRjm57ue#Swrf!FR!DUNQ+X?Ae?>v~~CY#e2{zeW69>Xs8Y3S`9(oNawPrX*v4 zeW|uu;3nqkDbrX&hbq zyYm*lui_}efXfe%4oGJbtX@hJ3wLFKF19lkasKTw0f$aZS|hpSv#)t-MEcmfdfvt( zt&%*|flDViS?H#$^!2)(?s8$YW@r#;v_s#xnkA?SMh}dyej^2#0hswjA&U&sP9LM)mTjyE2g#$A{n5*8_`?%ilC+5-F>iLU({>gx2-X1U8c5>i zt6Bz}EM&H@Sm%BWR)WoNQ zpF>$I%AVrYk>}7ENr^gvDe*HK;2fwsNDr?A^qx_^(!_W^r0-^u6)jESP2EOZrgm4} z!tisc1pNBJS+xWj{b1AgMU>$>yRWO`D5 zdet?CH_yf-wh@qQ9v3^~eB9<%ykk3>IelZUg#~|6FP)Fkh!+r(;_B-%;qi%_O2Vmy zbpGstCJ^1nXatvf>j$2O;E&6=l97?@w zkHeqCj8$W0{l6X^M17U@&@eWUn%J4#ACPC3^GftU74ko{o=#IsZ?yDa7BP7p<3Som z3d0-?d5vF4+xDsEPqi`#UB-+Uiec9gYH6NV%bgT+s=b;Fl3zohE)$eot3!v{1 zw8ZTi$C=05#4Uhw$Kr5rbD@%4zjBPS-`PRIjWZ2c(xt z9HjlB3<|*e28PaE7qg9uFXonP-1~k`n7M=3B)jIGwJQl~C|aF@yw9GLf)uh#3_Vx2 z1;mURrCF(iE(FA2X~A4WvE3G}yE`Y^@1sK#0KPa@&;uH-y6ms=>_!KKNsuhqs1t2~ z^|u$1@FoTSOH=bWHnT?(f^RH{c(>rQzrvhR1h zFR4eq3@aMSNMHW)JAGb%4Q=<-{vdcmMew)DKS!V54&S)6?H4G3YxbA>7)tIBjgaw@ zyBT;0$sd6T>OV{VNi(On4WK*aB=poRcek>4v7;Qa!(f~972Dd8)Sz-R4e8nP^rzAWZjJ71t3!4@ zXk58JSo&5)ndT7C+33dR9~}^5#~o0UG(OhhiqdmCEI291p5A!6YW9Sr_ue=CWZ6`^ zkxZ~6bTMN@mNsgMaY+Y)xdu6>XW|y>*&wiXZunr3P7Wuv_I0FJBk9;!nz7J3TNe*B zV|HsB3@GT=mWpj%cT8e%_d33c)iH3jn-2rx5!MpPckntxdcO{x)6;kAy@@RXtr%A3 zZm%k`r0+VXOYPlBH1D@-4YlYui@{c`s-t}Ib4Ou~5hZ0)+j}-%mLM;rxzIJ`W85&a z)G}gT~#f|Q2R%q+N75cS(t{9*D>5(}1rHfzU^zGQVbWyU5 zuja~2fqM^ve-4VV@9lmQvH;(487C?C@rKJ)38mc(@g~j|55kA3%ZwZXr3#hRD!=M7 zqwl!0KEG%KIrepik6d0r=r@0)7PF5zfMPALK~Mw$DLyWil{jDM<9q0-B78ll!m1}m z*)r7n5G%uau_#*==iJv{Ywy5L^F)p>uxO4C>8!CCf~DM?sT z&A_ph^-xyGat=4B00__Dl(GV)Gf?@gK9GDPaEt91+?N)g`6KQ3r3@a)$&oyUw#pf& z#=-u)EKU7=2}S8<72|6o7rq$C>1q~ZfD|Q_+oWFEeMyWZyqY-W)zd|&>-2iB9C_zy zw>K@uR`3yJs?Okapp>magJ%U#Q$AoX8ZJWQS`7SECdI1riHJ$JPiV@4rY}H27O!wb zM=F8n!PIB<(JnT1QT9;|pI+Ejo==nOK{Nm;}7$KMF~e5aXV6k*K@0Ud1V z+?~A3+BS8t#zohMzxN}~xxXmX($8YO7>(nc>xefV|Mpx;Zt!Qt`N9yMvf>7D6i#wf zK55%)(EMOJ(XE%V)tlHy!)duz+t?~K?Nm76A>{OZFD2W!_BtiUKX@3N5GLOAt-HQ)+p-D@R*K1Si3`vJWVT^8X zYLcLE4e1vxs#5GxMab0V5B#kLH(9Q1Xg129157r5c|JB0;J9Q=(rT4hwx&-CRgQ!` zED{^Nobb>oZEPS*TYVWrA=TFa|7;EsXxvL1^T=Ks;lU#CU3F8Yl?~{Pa|?$$grEc< zxr^IuGTRQOJ9uwnQD2N8=hiB1%xK~H+SVkh5I2Tm*TuuUPIGt#`?an9K$$UxdNsM5 zum!2Qn*Pdox-~}TwwyW?BC;yk%#WbuqNt$6+frb7ZF#kNC)TOKq!H2zRt{qg3NMzR z_mD1MwF$nvsoDc&il`O)oS0PAQzsxE27|s->WsR3cQJ%+GGL8IeN^3L-zeH$V!}() zNX^kJs8#OL#3xy+4+r~3<<=4VW%=M4=yacijp;M#Cc?2Ur#n}wT_XS@U^odQ;Y}?- zZ%FfL_J1d=t6PP5KZ)Rh^wAxsskQABU*&><8Md_4DoK+rUgKLj@811Ya)>7H#E0cy z1r9L}^hqo{u(4<$_1M9j%4Wy&=*TogX@DzIj5e8H)}9I^>TVkHyZ^Za%b8s|nBM&I zIr^ks^**%Cw*qr^^mK6}Q>_CP|4t*oi)4|Uhp!?ZH@G#rHE2RC3c+d391Rk*w#)Fm zN)zyf&871t*#qSr}ts-Ch;J-&E3=x~{h|DBd??#_GWqN7ns@1LTL`PwXuh`8%9Z;%jfA{( zxdH|bOpPs2U0gOUA95JVdYaZuh4aR}lRFC4A?X>tEE8t|(;Ss5O3%UmUf^0zDrb zXgd&$h0HdJrM!BopGiRrJrkIm-UdLstlJvzATG7BG^bF|A5CrZn<&52zt}BcSlP|S zklF_H?&7E*d&~smh+gJZwUAQGCSRl{;RH{=+xyGhRol=;DD7@xbNZnNahMg*-n39@C(BH~1gh1T!5$!L$ zIOH%@KUSd7brG%Xt65+^i5!>Z2Jx}~35uiTe?stFAR$l5$#_RrfHZ8M;tCKZn*+qj zKH0Y`xV4VkJ?oXhv&)$G*>{ibeRK?fG;#OWUQ+ygg`t68hgS<=NW$ksbvhojG9o%6 zLOG8ipJVx=*3vn&bRU)uZH1 zH`?-I`CpGe1f7w38uI|5uf_syRN@B8!YO>hQYl}xety1c-dhm=iLNg}Mmd1aOX_(t zz89e0?dCP~-u53esj;nam1r!U{8F@^!5%boC!bMZ7r88Mf`LytXro%!LG*)X`}uRV5I$Hmbaq7S~C&nx-hs~M4V+nguyq}3_W+9#!# z7|%9=TIL;@JFYz*=f|5<^BqG8q#e7*!zT4ls0Ox2oL?IDxT?~->jkE))x`_R^2?x7 zY2B*{$FiBVM&pI?lc?kA^QE3^ItLgsV>v1r8$^jfdzaNLDA*NG?blt%8x}^d;zRF0 zK}76w9=MK9sp}tk+nUu{^4J6fvKBW(f;21cOV~}Ir>CDvOq%PS{4~m_F4cw7qV7mE z|4|#dKaGOkH=vCX40;s{oqjGiZXjBH$+pIl&Kmwq>dt$gN`H07CT@2`jt6ac_3wA0 zsSAXJY zXR$hjCms-k%2i{~ZtH_jeGZqiza5wiY&v`FL^Q2u-id4dA&VF~yH8s+3&&~*(*}vP z6oyuN@31;_Qqs#_aY~FI%C@){#9J-QZmr9jXq2v3pv|tHt{lcuDP?CglAQpN!Sv^a zux+SS zW{vL7-g$T3V&4RLLAIu=Z?VHoLd_s z#goQ%*a|GDBWY@BeegsbcvwF2G<`K%KO(#|rpnyQbGyRtMX@=xl}Up> z^^V;_-9ni8<`yk;B^sCeJhPx+@|y@EI3Wdhv2n_ATc6h5oUpwN3}f2A$0f9rn0b0k zS%hA{Ck#8WiW;gPg+Ba!&Vi4vrxXaEf2R_o^@|}g%fC1bJc;XTR2M}*PM>B?piK!` zFfgf}$0$O8cBEav+lw)^#8^$SpeOzUEUv?EDB)8PS!5yL7-cUO&VHzVlx2C>I_urI zF&gTZ2IS-<06X=Aid!^grj}{QFCf%rW_Ku8&N>DAfrX}jp6nNRQN?=ti9fwTxzE~n zaPi1hTD}?@dh~p3ZFSlhJOA{|C=Q-znPCI>B{_}&{GrT_KDEVDB9P)^`XRGgq1Y3I zAj{IowwvNvLAY79wPPm&T}L3v7#A5y;*TO;avme#ZU+#5G*tWs)t|5lhd0A zD`t834gBpiqRq|H^^(oQZcnGub`4Nx^PATG7$BO;4_#;JdkkDe5oxT5egkKTxw^Ig zL@d(RmbJOe^Xs#N<;YT6)+Dw~=^6)QVulG;mI46Z&V~(ULg(yPWjT&hk2q+53AHl! zoEsL3xf#9cqigGLtMi%qo~-cXhh@`tgPmM^#67(_odG@>_8;i0YlEFK4~l4$Fw?n; zWwPZ^_Coa_SmA84&*-{#`3)Vwq)capRAMe=$;lCTw*XX(+I(m9nl`!zU|MFum3r~n zxersTBR=aR-;esN5RA%~Jglq4q?Ss=&w&#}JNUzPz_uZsHt; zQGsfAe5G4FCwt!OGe1+Ht3~Afw0Df4T=L((FvweA= zJBiddpA;MFpK9iQ*mnk`1PutROZFd{tgGy3A~SVuwhdP-3gY_2wubW~=aKAptY7yw zyxza;>=a48kPsG;i{g)d?vRPpr4IC$E%IR>o^Z3VNa`j{N>_Dg6Dy)?EAoC7qP~_k z1)FS@$%Hn-`K_HZ*dHB*QQ^$ngRm-NH|5cXp*0I8Jz|O5YGM>|j4Wp$Wuq*vh^cy+ z*Lws!4SuDveONRf31;U}kdtQdz7BH#Mx4w-_PwP%i$NkWaRvVG4$iKR5*x4CiK4mE zES;=RX`THss&P+Ov_*lnv*OHIaiaj`>iNa5E}=_{FS-DZ#<}LRoaJpp%_U+fY1$9? zr(ap69nSV`d@Rp3&X=yFJ})Ymf#tH>xEw=o)^k})r ze57rS6Bh-jRcO5l{O2T1IMbQm*EW8c`;@4Q9KizbXZ8qCzN9CAzhs}N7Yj`>{r#hu z+g_I3LccogM-C|+x-?dzZM6?z9gapmco))o>B8`q+B*vNj*hSRLeQzeT&`U(jFU0f zd@*-Pg%DuC1Z`3%yDsj4VH@Jw0n2Pw?^BgM&1t!99xp31IrlZ|e6IAUn%D_8P~~$3`2l>=Lpj6ta%HHWvXmXH(W=zbrCk1S)g@7J@@SyhUjuzZpVeC0XdatDy~ZI^ zOjm{B=LA{%xr&EOl1m+DZq{u4RzD09uJ`f$r9D_XlF1y?RyrM!NP4pB^Gu|(@#xN0 zUxanrKyZK~cv#7xDC7evssv^@PyL&~sa{v>>>q@yW=(KQB`m}J#G7o>ldjq23~8LF zy;Hqbu@2zH#~;-dp*>_m#n05HbyYM@|1MQ^Z4mFanJt1bq~D$>0nlM)bz_61@=dUa z_Rc3OUD1tHbb2#8Tm$jg7@X`e)HBQQwq=TjiMn!*`1dk0&c$Zx`FqaaB+G?=Lmy7b)BDO{k*3Ie< zrV=FWb-h+6Ex(1{{BV&J^Xa^Ma0y|V+)NQRy**h44_p0JuP5t1Z!FTQ?dhm-f81>4 zsVc9j|LvkJ2Opzb>8%-7$=0zt4|>({rJYiHMJD*SFq@H;>FXT!M{f~#qAk!S!n^x* z^`0Y&ln)gza60eXjjFM3{Hk?|Hk#b*jw{;#+_><`1F2iB5ju^7|KSUJbws}Gxa7SB z>9zvdHEU5tkfaRQZfSJ7MU^LMPJDC9cj}$LfT-ak{~NU?leKOQG+|!rHEmrR=cyd-VF(oug?-tnPZgAaTKJ59LA z#2*FB!@4uK)*2V<`u7nqnEa3yh0BX@4chc0rru^4u z=amDWL4pBT2Qfh==O+7KgZq$qDZ*S?$$x-#+c{4=U37nfYdJczoiX}8j2o_a_5Qv; zJ#9F;hcQ2*lc?Po3Dj|@-h3GWBG0o_9LF;cb4^gjO+qa_KL#H$1H8{8x{UJ)(Bg}_ zg^(gh+fpPs{sDlIigdG?@z+rvEp}iXJtVXOgaf8+Ltq&j-{%e09$hqG;y%u$xNN+t zF`p-98K4xn=}#?pxhw1Z_yXS$)z3HyZdCSp_xB5Osjs;4=O>;Sf_A1U#m_O@PI@pI znX}$~TsUM=H8EjgUq3@~%va#=TOn$+cM6wml+wj^;ZfkQ>OdG%_Lu{?we+L(k8fLf z$qn7whmqX&k~Y6%wa}ug`|5nN3+7_8>TO~+!+RzSSSG8ufRZY#=a8>jyiXx*o`F&N zmwc`mkF`q0Hi~|pW&*Dx;y`Ha)*AxPE?hLsjjp^D+Viz;K<8KPjO&M#U z?5B?zT@z)zh#lq37w5-HQ0==(mT$+7qm`vpIxZ{DXWIm=rpyPfFpgFEh7}kJ>1sl~ zqb7XSC3v+-{X^r+ZK7b9!#J-AIGNu}R^J*obcAIY9kawd-Lq9{7XrCrA0+C;Q}ju+ zBaZ4+%I?sLOt^?as?96vAHwqZAPrp{{Qe3}gdkTtw|C_=IT7qF=`JV*W*%P$y&H;J z@Q&~U&yEsW+d6GxR@bqCfIvN=u-l|WLG*f=y+ttkFt^6C5Bq}2Rd!$ji^xiZ)5Uv4 zjPzyu+!6K5q1|0~nG4NFW-Lv7Y6aRP+MD_)GSPZK$^D4K9RQIFn+pjTl()ltvym>7nsw!a zzkB<7-^YcCwJ2br3G7*B5!E!*y4lo1q92#LO+4nWIy`f+>xY}UTI+oDrJ7;Sc{ky6kprEqqx_Ta%KB#dJ3o!18-eM%z$-^eH9<*<;JBz;!BhErZ8h z`|3J~L$@DW7`Kbpx+>rNiTw7FJ<~^k5q@kjIW{#+hGW326OMI9eSI<^*r~Q=pjH?6 z`Ng$fE>@tt)|7a)&I6@hlK?U6 zOSwkg1BzP{W!&6$mz^g^8j2%|c@T`wN&IZ&9qqwRnx-Xz0l;y}be$P`5aLC87=s%n zi9OnFKEQiI8u!AO@o0b*X^Z*-t!@<^Uvg3!Mf0>h+dJIB%mFV$S;{&0GeMbsj2kNO z>oO!-kLlDMrLBIgWpytjw@i?u?;Kx=%E18|xK)Zps}WP-vnK1llhsb#%tW#mS#h^Q z{AQB7*6c_5^R)fldq-6?*m>rA7Y$P0a?+ksya%asjd@F%=B1dY(AMD?@+m&a_K`d* zxqRk9wYA(K#xVzM1JxBYr~+p71JJ7%?W{+d-pTe9fI-LRK3Pjrhzk%(+FIifH@Qh; z5qVKTr}dgejFJ&;fwg0cNjZfsr9kt+epn`DDs&dfp^iekY0y1bKisL^`2>A{xs(I0 zhtB4}M$##m8;tFyhl1(hUmdj_Xy}Iw>_yI`X;e9k-%UgdL`Dt2)2bN#WQprPSQ{%- z7%ff^IG|sR96rsfRc!AjxtmivYSVjYQEJ~}=~b^L7AlskXeYy}BOnTfIz$W4+p2vM zju!sf*Xt|Z)mnZV<{=-+>-=4akf9lJuhf~q5?|%`VH0B!td(Jq6j{G*Q1&`zRU-bX zg`4Bf<@ttr^0v52sSm+>tjsGAVp6C^W#j$^;leg_74zG|(v&1R1~)}TvA#3GFvHmNu)N#K=wfbl-J+NpUw*MM4zJTi*pc)_!(a9I za$KR5SRP0HLUidGlKqPo*UP+7p|>pCZN8BB_&(bvZXl*TGOF&&6mNx6)=+ba>Sg=7 zCGn$)RbJQwIV;TFBBZ+A-e~qdVS7=4L2l>OJ!08M4IwHsNNSX4+YX{)*l^HhP0kLhMc;45sTN~%er|x3A zli(BlXfn?hvBF_HTmXOr-z*%j47&cUpIyPtYX08vsSs-0o6!ZXPVggIiOy^!f+N>} z`3RGrRkE@Aar}v4uKamB^9#C0={h+C!qe8T)Mo)=88b>&gD~-O8-HXdDmBE1^kdhq zYBJ^(d@V~2MgK0W5S)(N19=)fAkWr4`h*f*D^esnja^&+rkrN{>OG?upW{zG@igWBS%m%O`L-t6E~^<8pl|Op8u$>|;05o9p}pS|xwdc~ zpF&Xl9P{P(?XGdpNw#=^wQ0rkly(0b10rh6Gw3V&vOv}4^^{682 zP;LDS*Z6-!uv3!@2@>(pDf=Qx>w}v=yTw?QX@8vYGPfi*FicJw-qao{@R6->GQhTA ztcyYh$Z2>(T39td<`5~5D8zdmb?*hq&J_DXEd8OE&_onR8|Q|jnar89pb6(f-LQ0| z>Ji5V(~;3KjOA`W{xWUKUSA)oun~qt>4|_`Ib{Uc<=P=Z{6lHYr+V0iQ{G;qZOx0m zMmtekqH6T0rRd=vxSGJ??MT>fd*pOr8x!ai{={F7#rq|bZ&QmMF3Y~b2eneO7i6&9 zKu>ojKXZV3Z)_JGii`_ zS+_;?JZ&3%J#x?`Ujx^f_^pLr1Bw8@JF*|YTzeo5%sk8ybWJuF(i=YBJ}2a`zUtTP zTG3ko5OdW z5ByabxBJxhxr6H#FGHWd5J-4yE|$M=r<2q7w<@#by;-=4($yivz%hgepyMk;Kjg4Y zbnGhMT*2UqV&@lZj;$>)4k#7Lp-0#Amf-vI?38W0=cY7~0ZE5z88nk0&p0eDnB{7V zC})8WM#L}F9+e8)0i*(cNbxuu?zH<63l)ZOWxY~e)KoiVg|wNN@Relph?Farv&7w+ z-m~az3PwA@5o$>^7t8V@C~bH>yK!DmnDQ{3O=9s=~Avwq*Dpj)ujUMdJvVMrJ@xa zZz*-=t5wEPPfW5yKY?A4_>A(tCEO+9LwCd7D|*G_vOxqdG;|kR*H*ncZYE-RAuA!6 z%|+vwbjp{}dZ7DKj~;LM7u$E5clKUa^^%V|0Iwm91QR`ma(#Mt%F^+#5XW`DkG+?K zC5p#aWDSfe-!;N;go>EeUYwQs@ru{aq}bzaM(9y|$A%+bU8YEUF8>p?uj@)^?0B~4 z@lKtlKZNB9j3`)T!bB0a)5m`NhkW83{o86P&<+ztPraV$AS(Upn%Wbj;OM&8Yk;4v zhTJME3=G8Q)Yb2N-*zVNFIxQKk9pLhJS!^*aTKNb1z#D)XO2hfQt@3(_Sj23o9R_m z)Ep$zeZ1KQTK0KKkIyZqVcom-{-Qgdas88C)W%J0Osa`(z>i7zq>V2#U4tTNP_NXb zT$+OYoNTo@Pz7`=5Q5w^a-8}397%@`==cOkxa6+JPrJ}a6r+Y$Oy^1F-WkI1-R?M6 z!yAu;*Yci}M7)sNJEW`3 z3$y9_v^tHig@;`INP3@PZ?hpYAMTg<@J~E^RPXZ#XjL#}*1@P4nW0 zsNO*)y!p30e9hC0|B@G6Q0H)Rt18Llk*5KGNnrOcd43^)4na@}~>bs+BQpQuwvOfzUtY?@I z@7C5z@a-qenX?~Ew;2MeK0(&y5}W$iK5sxfhI zx!S^xA7=5}~9(XlT{*#36RCZ+2JoZagV$L*L? zK?c5Z^L61N*}$E^QVD~EYkv$XLRuRJW35wuf2?)0qfXw{U>QsQA1;6h4)v;+sVkq| zf@#+9jTZUh#wKg2D_a6}wYuN|t{fZ~F>i9;Vxl_i&8ZO-q#CVjJO<(LoF+Twi&Pj< za_HMlIGfqN$C%VT8`u;o&yRbC+g9f`Yp0=y2O4EP-iRVBpKj*2mt`V(3$EOQkH#lh zE^fyoW#Q)T<|}s5P>IIntbY2TIn2f1KmN>w!F37k7s#QEEVc8}KR6etF_EWDCJ1;y zyICtylh2d^W2u1i1bPi=As;*VB7j}t0I*J9XhKV%@vC3q8C+U0N{$4J!RDPAPIX-i zJc<~4h?|QnbK#w8^qgotmJ+kQ1O?jXmVT43r+mgdQW3^TAIr!b`Fh9r$H30v7&qFN z((rg=AT8w8&ayO$e}7v_w%x93>hgAkWlw?y^`pKx`D-pzLiRb#e#eP|vg459nwI61 zc2W)Or`VTPVZdf129GMB0!e6jAF*Glz7^(KjrG-tM*)Fe?=Tk5f=DvCeVgMZXYfj@ z170I`)mxTex<9|I91!Tx%|>ka(w0RsULUV=$dXf7JrP*>hdj+A`>P+lniOti`8c%J z3;JvElvxvRIfVK=Rf{k9?08$6BCG<6?qIh1u+6$@c z5I`NWpEF(x+4Z^D03VIDCF=Fb z5epFIahq35hM3Va~#hz0gC4cnpPnN`deZ;5?}D^)Z0pX$*D(?&k;lV!4x{O1LWb}^k{!G8+lnNlM;#hMR!|0CfGB06=0q@ z)hA1=r$MZ4=Cpk-e~U|4?!L3A0^B4|HoLpt-Cu_;W(DQDUu6%RYxKg0VhHN>z{Q&) z%59|7Z(8oOw#eU^%}`&|)Ng#TCT+snksd8sW*N6$@L74BPa8)tPGV4~(kX5rm5KuJ zcE8&5?q?j`Mb3agpT7wRi=@DRsogB-U%gLzN!*_8$mMD(OWjJ404fSvTqldM6e^Zm zY3H8*$&?JpBWkoDKA95bmN6gHKZY{V1Jf`OGxgNu=mw#wDKe?o0 z-PFQP%rCyv>G#&Z6tq{E-zH}_TAphYj#6G3k^YW)_#5Yb2G}xy=q7?(HNoP>7cd<; z!1Wj_E1?>FFM2J{oeDJJBuZ(~-rrU_Z&auAfLUtpx#ZE#kWmFg_9l({>O_q;rUC`B z)iv>ajHwtIQ08RcocLaN=>FX|(*Z@E(HWnAU?3VB(MJL<4-jmr~ESAa`f^`zOgOJ?(Wa zEJ8xk`sspYaqX5`%~7S{tT-JwCmq+ct`I=;$)lg}%VXN2HQ8(pq8rH$<6~mdjN&M4`fA(VxFgmT0pqO z`i&10Oy_g3Er4p5#y9c+ZdMkvFh$adHNJ;w#}+KtoRG>nc$71-w0i=u)$jH(V@`tQ zsoRGa1uhsO4F^s_oZaC_dvWa{d4JA`SYw~tzhwcq@15AnH_T3n1x3Tb*}afj#R&){ zK=8Tk)+AnHo&`u=wo#9qhG`6T549j8UV)YAF83r$Ldv~T-D9$PdV}^5TL4qaU@BsX zqQtPmo^^9OUw*_`#>;%gb#`ASGMCco_3Zw|YwdLl(yv#g@k;E{JTEGAUF#=A)5>Fp zG-brpCv`vMi3|hX&DgGLC(h2es;N-PrUD2X42SvYgH&W0;vL>BEz_v$iwDN-_#_q! zLNsPr<|l>pIBSIl@R0@za#d$VSXVE^E;E4Kyr=}=+XY}IM~>#9uQO(3g?k^Krp*2z z&Cg9G7CZ7>jpar5?erzK?D!ss&&<5oYJGt(B-7*p?6XET@5p~vRo+T}G{!?^vfm@42S^gkC3rxG{Pf8i$7J z+mf>~3z>Sf`co8u0!@Zb14%1g$xT&NRg!NpmzhF6mOoMqif+e?Kdn@qRSHRiLEQBC{rqqGJ$g(fda0q+TcQ9`rK6LG4;;xyx97dg@4>z- zJ#O;JHOTyICz|`3Mf2pB@9PRlEK%;mi1wulc7ho&&#axwg@coG=pmr+5CtPyf$%w@$QkZobIwo+1@(o}OL*DSG|M|oJ;gM9Ya!p6Pu&4Vwt)owXL{Y@Kmn%>H1Jn3F z)01`zi_ax5#m9Qs}z`T8O4p{U4p4%Tu<-a}9BdrrD!{|i+KkVwiuek|G9o>2y zE%m41?|-XCaODD0M_)R7n*WqI{o5J_V9n)JwWR-vlm5dtg}(sgkW}XvO#g1pIbhAF zXPKJ+oE!SLH*rbjL^3JE*+ua0*1QMSJjv1G`Maa>5;z)KHImo=Zq5H4pZ_~P|M`5} z`v3I!gagEGn1G&74+Z|#ba?YU%Y8hi0g<5y({L&g`p-}PUsm9j0@pG{n7PrFw*P&X z|HU%hJZ~=D!mTx5@HfT?u%IW_k-3xY?*!Z~pIF({`WJsE;MQ?s9ce5=|3nb}Z-(Uj ziIsh3d{6mbp7UR9(_8Zs>lh&XcOUKT`x7gBS0MUNOx^$VE&s5SkAzRGqe8sdzuC<{ zo(gb#z?!Jo&cCyu%qJE!wOi<4p7LKU8&~Ry1?^Oy|2ykQ16aq7XA9T=Zp|}b&H4FE z=fAU#0f2Qhzw1KtcWZKhHSf2yB>(md0Txs^N|O5T)|}`eKlaFF``3^AFE8)^j?e#3 zj}K7a3j@m?z3?F|^XS(&XRc1x>0Y>Um+LGY+eQ($TzBlUcTtT!L&M7Hg|G%6kzz%j z&Suir#mIy&H^tOqB9&f-hHtMVTjQCI|MG^;alEZQb0RRnldcr<1ptv^?xU4_Xzi=N zAQ!q|Hn#lZOd~2xlGo=|=>5ORS{)4YL*$D<%UG=vJznB9%Ri`m|CezHZ=w2qdBYu` zNVfp6iN;ESU32YWL&6Y^fWVsN#(t%b-E^aiu1JH-`<)N0%6n%xO*HBM>IyUgMs|7= zsFVO%Q~m5p7jdW*l1NHp=8wGLZX0)+RP`F+DQD7q!)mWwa^;02{#KNeS5`YB`tY9>regX z|K(HKn@^9?xdW+wQi-(2=XbF)ui7>Qw-@?SOF$Fe8^f{7rd3vm+C-O!hq5gK%Srxn zjbB;*%BJ`+epGL2dD;5nmLm;4xAWbV83TNL!kN&C-&-0kr#ZZf{=yKyo!~lgqE+^j zK-L^PGZT{tf9%PM>N0xx&;sFUsX@2v5kM0*u_4nc`JcDq)alEyerH1i&zu$pP5h=j z+m^Ah&~vfp(D4JIwdL$@;YZGXeIFOl)n%rCeQ5ltO4&D-FctTiIrz0(G`f}mEaB7> zvD0lC=XzI75t>Bi0iX*`M<)Ls{?9M>6a|=zpMt))@*%2!y);3>f!}L)HG)IN^GD6@ zj~7-VAfpjx;j7&jOm4O``xH%#7Gd*^M0_>ZW;0%IYL(iwiSJvEeKmrUOZLLv>?0M3 z{Cs){gPCP6+@hoi=~{#^bRn2PE(=WnKYz(clvfBcNWe8h06x zfU4$$)QYONl>wiwtVMEeV84yOUQO)^|2=8qdQ766@5uJb)VEwV!`v9Y$$CxWy|s%j z1ZEM#b`L$vO3YWGQ_r2A}n+&Xn52;=0KgGP^W!*#ji zY=Ihs3z^rVa)TDz-&|%u|{G1 zAAuIa%SyB1SRg`A%r7;M1UW5kb>b;R1G>0UvlS*bS+_yhWE**Dpr?F6hsuqvHv9)w zz~%4R{yJs&`mWxm|6$C}1Zt7UIV)L}TM_uLFb&{>rIQN0q=mQ=wmD6{b+S zYoq(8*DjNpSYAX7>YwBbLD4!7<9eqytt-D0u{UOZat}gP<^w0_F>`xMn!Pyo$@&pp z1PixD9lbZ_luFcrld)>Nt6Ni;ZSV?KVbai51wc@kyd}p41y>3P`PxO%*#`@Z7F{l_ zFCsYyABxG6X-o!ZDB=5aFW2FBUGt#ZR2>ex4n0T5Nl(Wv3^N%^>7Q?PTQou1)vfb% zSHul#*#$0}bw8`KKkhy~89q#@I6HDVIT+*5JinGH8$R^$dflZRbra2#@TOmuNUQLd zooQcXoNipg;Ozg?f9-J_SYdmN`J?&Dc@YXQNzZpJfcpGcwUwF_cj#H02{3DSeH#j> zdj6#;&M5>a;{tPolaOR!qijo((bP;lgH8{-dTnwbb457zGuIl=OfkAa*$u+6CPFw!U202eIGWv47|O}7yC^-e+)Q19{WsC+U)@80KnQ~TP?y6=F$@q#Vkp#T)88!*^f5zCR%(wt&X<<=pXTcpG-cl#aq!Ddf6>&-e$uvdf4gzM z+!9*|cgZN7YK>!h&}9vvZ?QX)@xXK5^)hA3SIV?r`Dkie>Y>oYZuI-kxS`Y0=-ZV~ z>Vv~qF3~+>yiWxNQ+aVQT0HTmzSDxf@N+7*duNYRv?GNoXs^A}+yAxg{QD`NHKGHC zyYbdOx7b*XjaFj_D(0hR@Ei|h^qUODvTg$+&BW`W1XYkco+}VaIt345uROpu!aCXq}h~n z4K}s=bG?-UuNyQ8@wgw_XK);3AezT|p;Hb)Hm^x2!d5da)nGI-Rj66_67rB@Ww35& z9$SbEgsS$PoLgmk_i~Jtsrrl#5EsKsN0n6c zR2F#AYomHvHwy4XS^~(Eevr9ptIZ+7qv$|PqPYX^m1E%Xr;K-j#(?0IBOny-d5+Jj zyMU_5^(g}>^>%n2=TZIgMwL&S`YFPC?Ki@CgGPbt>IoA-vJm0fk3KL`B~E&;WuZSnY$-#3slzY z56^QL`%>VC=9icKG8tb{HVc=1qv;``7h!4z(kKjS zc@1Zl-r!`@2I$yn<8>g!c&YETu5e7hO2o7sh54|X?c7t6hjm}htDQaqvguw)W%|BL zCs15wV92LH%P+=OKk$$4jOx>nh2C=hV~)+Q3#26B>ZKKKM@x>YfxC+&i|*^8uGYT* z^Tt`!9bvDF@2+M!5&O3tS~z5yYOen}_60z# z;>orOxLw!`*@z{gic^YV`eYf|USW1Ce{I%5=P`~;E_7+IIE{8U)9bYpXiV{o3+S+(7fwaH z_}WFoy*&QZ`n1smW*f)+tnj$DB$+o$$9DG?h7yrnZ=x;nNc6}4{!P&TLog41f0FX+ z?nqE+ZeOiT{rPN*SGcuCqqy$DZG;M=@{n<;b=*AIeem$;lH~pz*~4p$Ubp$C9aY&< z4M&~OX6t%tT5|r|`9CKsVvorR;6B@Z*LmlMfV5A&nfQ6M;0qI(-^fSb%j{-Uo`;89 zyT@KXw&K~&Xoo9zPJa@woo$>IE5cWLZGl{bz^b;Nbpu-)J34PQfyBzoV+7C$7 zZBFa?R{!5@2Xtf6*xTxlWCHwjOCI&7Att0UL6ZFA_Y)Bzo-;SydX1f$S+W7NXB-@x-fBHaC1dt?O1=X zgLrQHUTVZiElN+mvgtRiHyb0itA}6FYCb@oHHdh$VR@W}=OaLvG_vE(-BdNJl1eGi zE)oTt<>Bo7^tmkseWreHgL5M+&9+xZr><~XZ;|^{GMd5*>lnk${!h>GaOYa=T2!eS zP?X=NhW9sNq`rWaS>OE)P5pO-}{bf*iaM;H@9%McEbads1t4Gdeq$Fh|AcegME>SH` zB!Z2!4isrr?w<@i-~lVXZh5`|m$$Mlq-1;G)_57*U2hK&Y*9k0Un@GZjt8eTy%D_F zCmhtTyKQ3HrwgD9i!qt!sT(|~@?2Fq(LZH6fMzU%SuuG_vDK+8a}8ny9J6-Er2Esu z_2Nv>YD{JZP=sOu@*akJontA6`-I3$P7JD$c6~pxP*@p4xE$TeajX_O8->SiwQlQr zXubb>RzvBMbn< zi`oPskBTPX&bYu7SjE(KhGG@g%vJh858;TIByth`Y-8^xbUh#{~jR(xe zIUmY|tFX7TH6r>xWobjxtXdj}=7~$#Q-6A?`j5zB&Fm??b=CgTKxk7Ig-tEH4Q&F; z1GZbbh+Venz5uG3#+GL(tE-5V%Y)Nc;S31dyO#;Dd1FrfYrQ36DqHKwMM1aTC7ni- zVSk^Bf-8|mU#>L8>C&UfPyHC~GqrgC-*Bx2=^kO{b^a9!lz1N&|?wn6Nq!E(Z?1!&+6W)@- z)N8oeILT!K4dMhtXd9?8)4|-GZUud0JFh$MKCC6Vf)qym{;w1V5vo5qWv!`&GA6+_?yw z8K`VBy^p(y0OM;b#dnQOV}3mVJO?-%?bZ&_kHKCvaKH!r4CoNtI1TYjE~IZf`r&v3 z=}ZkK)1o9CaKg}Xt|tW4E3j}5w)OxwhT48T&)wJ8K+$CgW^oe{Z~=-{OwRZ6_&xSM zdl?BBtU60-Pjd;QL+5MzDD;{r%K$yTH4bW?cn7w__^Tjc)&)?p0>I=WEb8W?GC<|Q_5B}NRn`@B%$^RCn4%6F4d~}y#R9c5^TNtUG;^- zT-q}zxTNK1Axt+2(wqxr+_vxEE#8>fbJ;J`%q~`AG9JY4{gz=VCm{tC()w^==pyp=t?#GR3RI+0 zeJ(T_Er61UBF_i_-ze2sYX~adsk5IFAyoVr+I*scG9OfDQ+Tq`OvZu-Q z1W(3+gmSmTifXPyv@~?TxGDNnBrckoU@00~hFAUIk~F zq~6&CLu*K|h_b^?I7X0#ViWy>m^6KvqgEaM$P|`WnD=L3;lPhwsCY}a9mVTub52{d zW(r*Q7nwO#StEVAvvjW+m5Nn78nxjXmJL;;e49RkrMHcKki9{&4G-+BncoQV@FYR{&ATDx=T+a{Y`UtQY|7z9}u~-k)Lba42cmV+3MD@w9(0farS7Z8hZ_T;?C5h?H&B+5sTVTGv;DsbcZu z`PvTiwhOm&7xhEQc;&l)VF(jSz|Lb29BpR?o()plCtz5au|~7kV#SVi$;_-<_!&T! zbd)j`KMx!9Cbne5$rPlzXrVJ$L7s`L4s0vOusFK}01f|Fji8T*ixIQB8g?XlbbcD! z2V1I@O6i*v;=F7}#rPGNdb{S5E5n5CPCiiOp1w=K)A1gO@{*Fg@I92ZxzbN)c2hB3 ztdzNHzw)RRRDYNi=?c%?+>^RAWnMI}UIE_A#FN+C5!KzdH;ZZ9$?ewj{EHQ`wNU}D zjzNxR{}jAZ;!r65CEg(9=&0pEF@eHX7y&0d@t;~qVqt_pSCf`g?awS8;8>M>ATQlm zdO~DloGRkefbDL*)TE&;25@*wnBKxK07h^WfHsURrf?n@gfWM_*`P8%sT#Zf{vw9|F*lu1bV{F z@kf%1KYwWyA!)C5K~MfxoYEK0o$qMv!=RlRuT!d{TJP8WUa!dljrsEEPe81wyMtS! zc{;}Tx5p-abq5Q_KrheDdWi-~#1cnIR1@PQe-{_cssr?omR>XQ7DYu;ff?SHfF=6k z7@1q%KDnRWg~^J0CMj;QB7EFPa`U`^O->$95f7{}}U=>4BIJF^SV59QkvQ`pYej zRH&4Mn2aae%}ND;E&<-J87e?0Z)-4#9$-gTwfzJ%&gX1PA3J5FS0nqs`eK;o8Sl>n zwMrKiY?i@`sH&vAwr>U94j;{L`o5xIE?CUwyZYw1%+tdMUtoU+oHqxbd6#VG_(#<{ zZO>uh3#$Rdqu@jw-XKYP)4-uTW_3D2*$zd!_+II; z##1h|ytz&|xICO?Dm1;b*petnnGt(R$l$%>J#gb=daq}Dk0B}5mMMWa1r1xePCB?h zO^SUmK_WmSO2Xt;GYg9{^nSQqLFZihRN#FDt>rM7bw2XuVbx>SnzD%oN)oB-2D&=u zvWI(s^joldD(fm+K>npUY~qjhmnm!6C=@GI>74bw*?TwJB}NO(xC95GpMA$JdouY- zfMQnxL4^Taooq90 zl~0GR{73zHee+<9qh&Gb1)p0B{A&?Igl)mC9S1^qG0)29DP$o_)FB1gOhx7eDu6P|F{uTM^vH!+7~0;Uf85{FQ84Xv^n#ZBkiI? zAB%1!1yWJjszAc5#e!2e=Q9uY&T#c%Na(78>&Tm*gF@d0xQWUkTsudc+nOWD3|ySF z%C`{DWb}E94lpY6!>mb#rR#&yAR{3wv&7R}v@csMdarPNk7My!wHTGBbHo$x=~{CV zXc8k|cGEfSB{js>Y>Bh&yvys6^Z1SZ-<-#P zcl#b6Io6121HA?;`z{o;6KE4zM~4a4M(l1O#TbO!;c`$krP(=S;qr!4Y41-W4|SHC zV6C>A>oDi(PM1Z*0O2#`Z?8DFs@7Pcll8 zaMlFvx(&Dq2TuhLUGUTENkKMn% zbu`|Cf!d;$MA8_3xM(_n&6yg6VVjN5{5EjVohJWf_fn6=cG~w-hImayUCZU*TAqI_ z*I00Y3F3OJmTe@^{{d<)&I0j2LJAT=0 z3+XwuPm}r^LFIoetC&0yu!S1|;zS(IJBstwU*ua>KBVc*vdG~)TYR3}s~_fF2G1Eu zcvdvbAaV{&b)dryDvWV&lXhw`&;JpG(kb;g?sH=S_(f3w6QTzg-#i*$nv(A#UE5mY zP;_)`vSo({I+#aeYz^MFNpKHv(R<*_GQ8pLSB>Dbq@raGZma(_zW&$KjTioa zU5H~Zw^QQ(_&WciPeC040gx~o87XH|u00!r2R;B)UlGL@B+1c5j z+P`gBLA=^&wzShRGjV~o++i5;a15UHUY&i12Ax{4B$UTSO zfBXo=1qR&49^^cp&wl-TnZ*2nw?BDi`roOge=>d9L7K1Ql9a}zpNNdsUL8mQ$O&Da{SDnD<|hV5cYmu@BMT5c=;mr| z<(VujER_Bz8ZZE_$qRB1p#h)T^Sc#r_A>!yvr5gL=kWj~-!5&R-3Sk#SyKf-`{9m@ zSVv!?I5+(STrcJ?C6-#|cGNz#(l$KTqPDMlT;M~&*5kiU=p^dNfyMQ%?RKcuptY9N zQr%vM69GTR3NRNr&A$C2T-3t=zljsN-0yk2yU!VFuBJPw42G|wF&HQfK$_S!)N#)68XXzsnaEZrvAVC7DHZSs? zsCE$)EPJ!1r8Vl5^|6N`FL~`tBA)NiBP~CboJSUPYm*4fX}VLPvu3=Jyt=-Ff=BM) z?;Ci->z!FFh?K{Q+0T$)Fh!^#Q3Ap-5gxt|48Xt92V2`-_c!O8@kU{>!06*OAs5lK zi0i)K$yJoXEK}V@;*w+NHIy|HBza~BfiAGNdcAbIWJ^@rvNVG3qi>wS=_M@{`iER9 zSrLD{_+DN7>N8~6?!5c;%4pTB$4R(HCa>V;g41v$AlNdXdVG5{z6q$C%}-;K@kAd} zfg*AWxJO2-=VO+@6mxoX;n>)VS6ept+eDHRqJ4!97X!OIuuM##M53clotV*5%Xo)! zeZEcAeC<#!pYGoD;G9B8DcDS zU}2UM39*BOM_)V_xnyrv27GH z<+eN} z)&pI0Z`{y^@x2=oJz92jf`eZAxL&ePHvqr7{$^jx#vr@VKFJh!=z5BPPGrC^k6mga z>UW)vIuKQw%T|I$!wBf!F7}v80d{eJU#rLIJj-jmV|21`%IZF!Y=3t=_GeV;ccP}^ zVe_ReLww?1alZ|#^U?$Z~|APYyrztqV{_L+ z8>E@094BE_bv}RPD>;xq-Qi)P<9GS4L8qw2>ceT@P0FW%)E`$O3IhqI;3@Y9(d+Zk ztj)II+bP<$|JDLPHGX*Y^VW8L8BDM85*#dz--qnta?~~DPmWv ztRO70L~&cgI~>x9-Z`fFJDzrXSaTf^JA{`(iF{o90*SP#S%u(5#Wbm*Y+1|h%1b=h zRP@|JpKY5rP4fYBL05Us@!O9#!}}BY%l+fSLbN&3vC$x(+nyivsXLQY3P`0&y3`Tx zQ2@zp`tc`IgQ*mv);Pm*r2nNbe6g}hDr(5jNx^Bcv##)+=T2|DF;07~!X#)hvDW%E z5a=uW1$CFiDSeFXQ%M?W^P1ym?=yc#friY9%tGZwCKgpy;ird7))ay+7pb2>x5c~% zv~L`ZaBHm?;v2|k7qAY)zGYffnWwW^FKHgBU^J-IV6E{9N z3Un@1_nrQ}MbPN&Sc63X>p8sia2yi4`Mh=w-(a_EX1-i<^F0grIJ>qU-tBQ<8aX$f zsS;(Sh6XR1POHZgd3d8d?X=R0P(Ner_Ex=aWImjSIGiSf)zE@TY6oMZvJd$2z8kxC z$oT;F$RgEA3r?3hV}OQMBu_2WQHfS|A}X{(Toull6o-|GTTY75YZ2OceH`0)BlF4C z#u<0-Mv~&-%jwq6bdFS1Wz+S-1I@ped?1lqMzdD<%z3Pcq$LS;_O}K|5ZKZ=blTkp zLJ`7UEG#m0RO=j12W$K8u1^o%BH-k;tDpdiBv*ixo>H}R%##m3o-WnB-xIkke)~kU zQa!;quN+@SKN)}-yrkko=M#%3p^(|V!^AO(G_P$O7Uh+Ga5?Ek@Y=Z!CFhss;#>PE z;&R*Ob<}-^`(70!=?v+`9ybrI(YSwHU%_qSH^2t9NISL6o#}zHNBK~{;wLEt9>#%x z$w?E>UZ!312)m(97Qx-_`z?XFAd@SO_>gvP8zYLo5i5~sc{x5;{ksJh2D5?*ls>Y4 zU?~H8zNSt}?^VA3_(KVBVfa^saiOd)XN4yLAkwD*qp;J0UMnM4;1FAzT-~e1uZ`{l zDiUbCbSser)f@X+{zwOqYd;JSkNJq$5a0UQ2034tDIYTES1V){fzF z=UpYiix{n~;a_h3ccIj?816!xhS@^b6Krb5-z=L>RTGI=!Dkg%ppDOVquaTn4)@lQ zOn|mU_$l#V=y|;YwI&isbsp0(>BIkx5Ej!E0IuD4sN6g4wpKJ5fKrgl-;Y2p;6-j) z=58zDSfo3qS@L$^bDnEhM{5x-BIW1V8ET@Z8wGyBxw^Y`HT_==gOAzckNP~# zr$j7oB`G|zeNtw9&l(=?P8AYvgLhRSO{HhL(}ilocfvHrUv8F?c#jljef3+K53|BW z&c1p6q1a+o*GKb{W=Nm*b#9Ha&9JU|CQK#Av`}YY)DXySyYMG6o47+!LfD0h8PvGg z=+>Xi?_{!?DRJ>*;^$Vc(0$iw4=psffNr0|XUwqocTR=nxusJYkbK1yOf`b;Jq<2L z2{KApCz?wE{Ee`$4zJDJks}-lXU_9HRpfjE@1h#O*bRGP4u*X2Htk5*^Q&%ANMix8 z)scY8ZC^Qr+zRZ=A+Vzy>3O6UfAl-iRR8nAjksu5dF*7jW=p_?cFTG}qS0j0XXVEX zcIHytelfw}(Kp*C&78t6{t&+8aVe~|NI=0Fceps-NIZ^mK9HW>^|R|jcnAI~mRY&G z5hS-f!QydN>8lakf8cONbr4)_AKC5-Rv$Gq5Di#*1}3taDmm{KKS^89l}p<9vK(FO z>Mji`KK*ohPX(Q!zPV3FYT5G{X<$q&!G)(a7y8v^UOfb4s*5%@mOc3_7K!>?@xya` zRLh5noE_*z?liAyk4U z*Jg%vwA0^=<5U3XkDCtIvB!1f(~|Z~NK_Fvz}hhHjBGYyt54b~p;3zB(fKvF_@cGL7U$7@nzZPw)`{(;L}br^@+^Ib z)g7FgBq99#7cAcH>ok%Gx;sr?s^?FUX2J_*C-3`#-lsyil9><5c~L*S%^(6_x7l@^ z3h0H}uPokFQsG!nA1-zxX-PD-40DM-S9@1~_u=ezMa#dm{Iv1N**6%v9WEl^=^H1l z_Z&r1WXcH+oi6?{L6vn7S9iilsgjh1F62kQ_jG+v6n-ZIT~|KiCGLD5BJ=I7B5N;f zb%Ae;LK2({dhij*!G72p7KOjN+c|M_-o#+>r|`YvSHg?u~Nr-sp8T)aQ3 zYpVpuBKZk0#iw@kQ$zP;U(I<8_~3`TQZn_m6Pta+!Ge>dJ2BGSbnh;Tn`w zmv--*YV8M=(h9Dgh+4F>V`iE*EzCVJO!j>*Gb(;uIvmk$_$$Jp(xm0IMlK|ImbLI* z-2S45_1(K+$xBM%!NTnyw?tai))|^yc3WgEkji118RLu5C7+2NqK~_Z!VRL?q}2Jkz)j#(8lZ1<{DClm)3SiBf;-~ ztaVLJFhp4^Vj&NUiY{cS&j=i{uXt9-%HgwisLx{Jmpz~eE!SmhCAO}y=@Pr~-?l>Gb~UmQqh=CLa==Jin2Evu2SCzx`&L5M>p? zP6va=iL|ZFV3W6ji(am*1&%xqB4i~`fu<3xK{WnjT~9@)!Btr~7re0{ZLz7}iig?E zYW{LEPtVuOhw9Rs4nw3njD(;LH$d;LJnA#@B1K=&H@*?bYo)E!w=7L*$1>A1fxBXC zpaDD+^U)KQ`#!hyhNtvHqA5hwDBcaF)b4>Bb~|Iywlr@GYAskd^6J3RZ{G-HR@9ET31MP%59L ze$L#k9I7swFAKO~&;xX7roEEaV6kUEF)w!92Di|NsD3eBF3e9IE^Qx+&4|LvAHb*R z>#WZSzr!EGC?;IEp3%$XKNWg}N1#h;(*cbFerq_##4>+&r^bi4;ius4%93IS`YWUrkT~lfZaS@w1K&>X^&sm!PI4~ zWV%pFL3eZ9$JkPJ|9h6~mI$#Epgock zCUo+NQsR}9>rI!HcQd1;@KETqNBl)H#_zD!08ARo$p$HG%By%e&K^H(^j6$& zaz=;5yF_7mc;|CQE$(0U6}OSlu$4-|7Y(IEXFAPP2RYI@1jAS2PuPg*Mp8~YQhkQp z4p2nZd4<@GzaQ1ML$Vu>EOTx_-`G31vBjnPqf(|5>Nu~pPrAvT3Z7zTb%m1gJ@eV? z9l0jpnOU4J@u3bohfXSAE_x|s4`S2Z1DYpqlDCip2dVgt`1eb^z$`6!a#oY6Qvu0k zBA(VG`olK~jYgFt0aYv_LCA(ICYj824h={A)u~t7O+g8{_tP_%wiKoLUw@-00 z2ji1)cBa+*iC%mlw$ml%)_kd3iMkDXjy9#i{9_x>#R(th=mg_M%Fp*RAMW|@jA@o< z>%+t-IVFHA=E@CvGOkUj9x%UGxW%k>ePN>WeR(Wf%1Ah^%S8c4c%kD`3r%!0AwwZB z%whM8;4^H)NccvigKU2`tI@KD=Pha%xAztLV6C4Zj@^|MXtlMFSj@Wz7~)>@7Xl7^ASHTZ)Rf2VWeh^b-yqG)!f;r2!;S zwfh^<+-a-@O4A~EW8~bAeikN6yA?k_)4_sT?b7-SVKsCi%jtrA&ol2=_#2jA3Jc7F zu|rSs=wN;u#WJOQ}pEO`$ZvCedmPvH72 zb5|$1G`mi#UG8YIpg)X4xx;0b1tl$m$$ikD!=mJ3v&|SvSo$(u+~FY@ciOnu*cDK zUTwqqRKwO6(Zy%Sxry9g)GUV$zJ+-s9!$JXI-v!>fjMov663Nt*fUrz<^ie5q1{v$ z{xS+-gZMWo9OCrJl17okq%{MW%~1Ts?{+E1xH2(|h3o{YgWE8KO4e!=w>Xc2IvA*% zw@oHOnhzWA{`&FOz2npMHsEploTZ=MK`Id#SU96F)SW z84TTerc(0KGr@X3=9SJ1!kq=!cKV%H!us_(!dTEB`C$ad7wto{kpW?Z`XWc^oiyji7q)Wk1aS&Uh7jd z-GY;&@NvDVy2h1ez&eU4rMxk5N}0F=GP~C?b2V+V)@QKEFA6}1?*q9W?Hf5GAfjZV z3%xksPUvxSEv1#c<~$y-^+)?QRJa?2k1id6PUaCp#7dYq-+qp>^#I#WJ-4CXy&$mF z1RXUgczxgU3KyEw^6F5pvVVYPBn7vXv1)-c34X(1;)pnJf?AplXj_UpXG1B3r2!o% zo*|J0_Swyjmle*C$QZlk99t`UNp0}m+mUA@M|Iyk5U$EWU)2tYys0| z+xX4$R(2&AFRiJLUMOfpUUy%e-P!42uHeRHdT;Sc{TDOPmG>vHKo9vdzc2 z`Y_eENhs+?6UE?<#>Q?#oX5wgc-uAkB;=F$YcP_$?d|4($03fxJ6xA80=1FI;{(g3 zrfW)6%Dx)sUK$r-ijJ*sKVE^=*ICf@(_|Niy*&sIZgOa| zoU4gZIdEM`7naMLdDhYLDzE|s8e3+mWk-3VGZs_z`PTO6_Za~JV7bP&!xE;_rmrQO zw_1YuLh(4#Gy=&I<^>MN3#<}^z)|#mp(Te25P=a{{9d1#{W6`lu!QG;8hKb)$ zNRExRNW2EMcYG61b3C7n+1X3aB<0<=T)B@~J|~ZF6*J(Wn-9WOh+&-GVY;?=20eTY zOa&7j{~{BdqePOcVNP;xPfgefsZ?HxWU-wQ7EJ1nBEAniM*vENuy(h|?=YRUGlU&> z0d|w_kI5f9&tFq(bE-$V9>!bc@QKVJ-KXOe$lC01G$_HS1Sr^jSKkeX(V+*M$|hMt zDOVL^HLGo>8aw2KuEM18PSNsO+45R>O>drFVQAvV!wxw${L#8+nPg&5J_H;iOpC2( zZG6L~xU$u5$d%^SqZ6iT#6|rYg|QCtkmo$9!>UY}Z_`fOQqkFQwsd_#=oXs9b^a@V zXV{+L6|!qk>k4Wm^V8Q%u$2e7J6xK2MBIIrZ~9!{%j`*H=}r0TT@ZM+#W!rN(FIoh zqv-hp1x;nAswMvPN74pC*AInKWD~|2knp>BttVzU=`vXsFMg%00YIj6WBxzboe#ae3qg*##mhHz)}irxO+!)sTzYIQYNh3BKiIlIqMNYhT~Dq` zlFK{gUan8AY4Nr|EofP?u#7ySy4}VIde!@VvzK(6HRUo>3WL}#kvsSqS3j_CrBMGe z;$2egz{77pG<@AeKXzZD?R4MVvMKCUiL@0t!)Augr=MlA+8#JztEmT;`l=){+?nWc zl8uqd|4zWBEt~9YVE3^~4p=wo3kB`$Nyn)Cc+oW($pr$+#z^EzjrG{$(~CX=2Q@qg z5a!*g7OLI4bps;ywDE9KODiYbZoHv-ZvWe`l+em8$rZ9^ltPLI$}~L+E33SzYa81HXB1zl>yYe5aA8_a z_TcTrcIRvR-7s2poR~tzUe2v7thxfyyv#PE&V^_@^^QT_oqqn+nv4geCVE|mBouj1 z!?&%doXO7|8NT?Vg`@dQeR(ajHyA7E>YYAMYgfyMfk=s;R}G!Y*VHarG({sErjTO9&5n%F^z{{vsQfydnq3kEKoZYavIgs{;lhDJI(jPo!+7- z)axvsosw4+%kTBc#uyTgYsE2jiUVudrF#O8PXmMk6w9o4cxR<{lH1$NqW61;FN&M^v zfcF&JlU>P#oKrY^L)oEbG;1J>mxsrPh5>gY`fJAlaa}U8?qk`{`q#JD6-18fXyvLt z&mC_Z2NUfj3j=T6Jbv%S&>evjb(AhN>JvPLa~E5x$Z#>e?CZ7W*iWQ-Nk=WUXwzh{ zOcE|A2-Xml-Mz~0%>})PQmYjWi1GOC*SC&yQ0Ek9Tcf=bqMeBm>>?@j$0^R7E#w%4 z^|%0{R42$!=%7bRt4h=v&7f5c)cWWWL_@u(;Behi*(>-RPNy}H2gj+t%_|3qGzuWd&M_PQSf9{V@LMu3jpkmM{K%V>1p zY6r-D8am^EFC^b3t8X%XDHa5i;SKrF{JPewHVwrdueYjgWwRyE2WKrNIja6q;r}T6 zB55mJ^d5h{%t3j7^-$RGRdm_KkFoYk!!_9rqC=SP;yG{U_qOM>_nPe4m|jA8!^yiO zzW#-J$w2?J(t_pyO~55AGJ^F2@7&}2_Px!#kFVcPrWBQnWgnuq zNcwRX>%HtujuxtjDdIS@R_$KXn2U6ITezrgzyC?yCYwnoo)gK~F@B-TDKih&+O5HK z$xL7EQqB2j=A@34(etzWhKs|d-_B@txpR$U_5q6*_eMny80&Of zv*escxr?FYfWRlD3{@shNrU#IR8;9=gx%Z-9ubyPp>#~)3kqf9P_#MmgX4~E zi&xH}TAZz%S4alx?ljK2bh63j9b*h`5N^V(P}Hf#Z1HI!$hB6Pe#SYZg?RwYT}NLe z4AlPGFmVp{$*g-IPFQ!$0yFiyx31%R;RI_*9V`1LpmFENBV=iiYFq3df~J>@(FuP?#be&B2(ksa_npFoZO3VzPxzvz8IcZ6q1>PU%> zb?Yp1KO8#nd|v3_?K9Bpwi<0d=zo0R4?LE>bN{>&)36cN3|a^Dg00+7zIr-hY_1t4 zGbBM-d0V!h`Uf*%t@uUhqH9IQCpUBo(y_o=^&w>IH|Jyv={G6aE}n+rz4z@BAwKut zG)}k{dac#b21!1DM~ZUfZIX&R%m_P1J&>RCpbx0q;R?hcz5JluQ7 zNSCLfcA9UO9&TTzXrHjQz)46U#L^Om|8U(t5kA_p?mU>x9*p6J>t7$*&@Gxa(}
    hqmOJ2c(qQaz7VB=^_)JGF;V4OTT7Hy*Z#$8_ofOZCs`6W#hRn%qFrw4{E$NW&dcu_N%h&2yMCI2hwnQ zJzDsDe3S`a00!9RHcT;y*^F-tqm-{jdj-M&Njy}DN}n<5WQB}&5ueiSa5}y%o5$cH zuytK(PfO0zxmczBF5z**G~^sebC|z*xXsuNqil35{~1b0m+XOfIafPTz4^pxxvpMe zZj|8mcYH*NFr1DW=I4hwKouZ2X;la@zcke5<4sjr9%t^DFI1gvdMAdKk&;A&ULxzE zzjfG6$wz#Ne2-eoQOo8dyn>$rzu0<7w+%%$D`cs;`9X4+XaN_sc{xAycOL zTzF7#&E#8=(xo2Su2TD>(QN#hy`8HGsTXuY#FK0H-^NaPnrDnm3HtqsGLA`ZW6Y`+ zN!nDBL7{^?i<5J+<69ISgplF`%^IFki-dnapR1b#{ZHSM=zN~i9;XP!*Ka7@Xz(_M zXUMc+Sa|OGj^rQXBb<`?jZzd&BIRjb;!+Tt>$jpg?W3}J$X9Vbx!w%XcME=B;Szq8 z$??NuVfJ{iA-;hgAwq2=yz>o*`)Ykk{7(?_N0duQ3!aF5W*DANT)60Qb|~gOX`bxL z3zz5Z{UI?x64JW@cHi*A%hpXV#+6%$#T!dGmt zMoGIVPLiekCFLQZoNif9h1i}2K3_KIM2QA+xD^XLb;WPb_5OU5GhXD&x=7}8yMnN|0ud z&L{kcsI-5_`Pkw(xS)f`0{wIW8aQ+ZV%q!bnwqw znRVV#ymd)j@sSV@dNX9yu3&aP^C5hEQM-VI7w#|$_!}+Ufbk8C_E$yau%n^r(6lnM z`3nkbvUbl9JhRN}VjI5LLsE9UQ-RdDeXjQr)_rRnu62q8h!}3u&U7bTyn6O+FSk}u zCD{h}#zQe{BhXmt)((iRu$6h-rYn-%K#8~pt@b?8@>*;T*|!VJ=ily5K|xRUootqLiGb=3&RJ#Fb7AG4+ zo77d@wr^w#pw#XjfUeQ)>f=js3bt0+p=swrd6 zImS2M;U>W_Oo_q`M(x+ivexGT^DI54ruXKBzcS^>x{WTW%Vu4?@+g~J*5k|6LvBd< zxjZf?;aX{a#eP%1H1YZsrD=EZr*63`Jn_~Ck;&*}*6a6r{Rer!^D7mBP{+CAR0 zxLiw5;CDF-RCuv;x{zt)aju@?N+t2|G+E7Otlh!d2P#nxt9hU}Oa?TyR2l|e=3gv2 zt9yAOPWabx;lfB#1$yLxd1_O-ip|hWUv-^|Th*NIvKKo)n?h3scpj{yi!848+`|)N7^}d_tgtW6s z1_qw2S9e%yauxW#>Kj|tzZ3PaBOwir5p+JD{S^K-t1`7%n}>eK@!2Q`Vm_q5i6;na zv**w}r-gkoLzmJ_b1InTCi@$o0j7RnB6hPTUyR(Wy z`4A4how0q)Q#cRsH0(bA<&!>g>5KTXtdH$G=~%dM?;e!dcI=I6ky@Fs&@sM)_{fk; zz3+qWp)?I^;puiUd-X&^RWV9-idJBc9nr5yAU+extCKAqZ-z@Pe8c3B>4zDUDniI( zqPqiC6RMBWsGj4^7Q&=BYWE+CfI|l}zc28%(xU01mWUMABe$DrgjA%{S5|rw?!}4BYk#$fdt#^snNw|^2%9+v+X>p9odJ2Oox8xPKL+C z2Aw+3=k731p|lsCo%06)7yAd?rVcJpPKgBn32kSi-A*D-i`;jb*2IMVt&->FE0^MgSt zJ&%6+@=csR*slnajirQb_si)G5T>8KNNqC&QgNL;HiCqkOxV`dPWu;fe&5A-*d3%usPYF+xr4s9uIOM+%!?BIgSX zxP~Fud)IpMuJ?ewGI8V?eTF~)XN7p29|g@6cKPtOg1-67h-n$FNo5hl zmoMJ)v3xFKHP|V3g>3_8l?o2z{ z+`t#RjWpUq4z^=j*@(JU7`UY(7qKEjgcl*(ABGY!WQ0U4K0}^cmaiFzNwHU$MeG zSM<9bHpX|P?Dwzs%=0x!$Pyz{(RR-4fzU0^Vrshff-Os!YWq9^sKK?&1qh%vDE)k2 z_3N>}g>dNVo2=B%7FtGdG<6=MHrbfYLV2Kn|4XY6IxB5R#pHq=)M5G{^tt!~tJH`# zT29Axt`IkRU5~2IJbU#L5cGacUopZ9@X2qX3LZAdZ+Sxqldkq=Kb_p=HXEXn-^A6gC^wRk!BrPl@mJJ>7C` z7@^|C5MH5&R15SCry#Ai6tEA0v5-sUTkkZ5(=G&cM$Rn~)OZE2fT<$wqKFSLfPROf zmCh(E4HFtJ6?&~-{%K{Em1yD4d-odxPLP~(>ig*8$VY)ETIS5Faf^Rw*%`Urnw>QT2CsoY>HTyG?tol#t=5c+M- zle%Fbd=u45qqb6J+rgV0j!X@XgFl>e9x1_ytudP%hZ+VzEDc_KrfDfDQsU@Ta|& z`I}T;DoQ3L#aIy@)i;D|i1&F5RTvMx__D=i;gD0b@1Mzi)`=}Uq94|pagr3Se0zCV zNkVYdiom|-|6PNSaRB3?1=>zapu!4bxE_}E?%6MCN@t{1!|1)nGWN;Ls9fN(`zpmd zeF0nfIjy4vKuNzae$+pL_s@Ey%4!d$<%K6r?EM!0lHgeBZT$}snRwU_jq)$Su5OcN z4c(|A!%2bg;e^y+UHV$$OV%e-l6mHriX?ph!G1g6s9#m>>u34IBOdH~>+!D)g@V&B zk=|~}i)x>B6BpUEUPSMZw6Ar`lsB&jygZiYYY-^()lk(X@yjIT5L#b6@8486E`oWq=O47K6CN=57A%)sp8Vk}6Q3BsFnvq@biZdq+9TbMsyo*xQ`FlLjlKX!AHL4kmC^ zEsd{s7@55Wn4<+C^iT(NTBu6n<4$d4qcQ1J;in zDpf|S_~t^`qIZ_~RI;@YEF;lJeyW{uW5u)Nk?=UE%aB5xF7fB|T+znA!c4RAb#=G9`QFc-j z=phq#V!p{i0SBOyrNIE5*eC3tH?JqADSv#KE0<;8puzUAbwUB#v08m|&_xAv<55iR z?swvoG?T^|m4hNxI#WebtaoM%BPRjpFZe{%aVIU%Y~OOeIuCNTWqt-%xsKcL@5L0zkZL zQf$U3$qwgGBbMG?6_{TvRKw_hxi9K4PS2IQZa zKxHnzXusdQ#??GP5cj|>`{izI8slMNHa(L=H6DYOU>NpebX}3DkQ@xZvc;51W|WuU z!~mWYz}8IV5a`!@9n>(0`O5Hz0t#f|a2H-^O-%GOW4Y+9Kc(lSk8=sAC7kDsQwLwVv_@mFd zC9(Ge2E_|$+gRzKg08zm`{^4W2GGZPcDqWk`K(-imBBe%m=Vh*>;#N69IMIu|6fzH`E zEdd;fqEnnccD8Qs*53Ju?rz4q3V%p1=D=F*Vp#=aGeXtma>gWwL07+HK8yxilg9Vc zNahF(e`&68BcT9AHYKQCa}CUk&LEER5C zt}0(;H>4zVLaOsknQT z`o9*|cnlZlK5FTo4i#p7QD=*MVynhp*I7wb$LqsXY207Ap%@9?&qQX(Bgj9V#SNhi!eP@N(??DGmz0JVgH5`)yvN|%2(WArto=MFRZSp}1lI z&#JZTu)jRjifYG<)W(IJ7X7{|$BbB1f|b3pHn z#2O~%y!meO;OLRop2)BNl{Zq$9+oQ2#qqDI4gjkAmO0AS!@uq~+$wn@qtngiUgfy# zc+LZGEb(%E#pwcFznq(u28NlZ5+LG&=JD4-k!=Oz+CcRH_m5E*)NmLtFM0}wzFw<;QjtOt_R1Vr@^M^m98UJ~3_cH|?Q@ z?c4oYD?;^ZIJ;!!I%R~Fg5ID@z5yG(2e=Cv!W$D2VH~>~_}%mE_&%IuZIuSy#Ug_j1Dn517VcxiP)$m5dCx$rVCHG5Kol@Jm?ejsg{v!$=%@TLz|)N=xdPnZ;2+A}n4WX!w$FX{ zi;Kk1{+yk^#$5l*-?Fky6&d zS$m1?;;^^iU+kTPYVF{2ig=vDDdW_@AF473KE<@gjsa* zebGT-y5KbS5T&>=yQ5(a5nB=lc|Oz(%$5S2#h2U7ftw73S)1(Fbqa1v0)V6}lyGbV z)v9*PPSrGm2FumEp_eV@@Cm3_jDLUhZalz;L{>h+aLsnHR$yA1m&JlRLoJd1k8Gy= zOgK{?uVW$$EoFc(Z?rMkv?lUpgj3g|=Z#SbCC|QT{Cd~otRlCytJfB9_GJYSoZ?LB&F3c!PQ}ZTxQ31Ln}T3 zr~0lzWq8$*Fdme2X7>eVtzc?Z!Tz5l+4I?p>^E-8S-sG!$?4@8Cyk4_J@1fTbw7rT zO$O}8S3dR&q(g%CBb>tww}^i;+x{paBV|;{+N{_fnhdFCDPeyU3Ymbo>VKuJ%MQom zeC&{Dj$(ccij3?R_@3Tf%ASg`umWea>gl=r%6%Wn9&;Mq+7R7g@AdW(?88Gefn zdbW7A+%v*2{a|7*)n4lz-?j`+Lmp?6vHE32SkHf6I=JoQj|MmHtLUsms>9V?Je|>G zIn(uNsnj$0#_W)6cd=*gj8&B7%$jrHV2Hl1+=%p5h?C6HNg{_Er}Jf89i>t|@7y}x zg+Ja%*<^w0zE^XsBu?^CQ}s<_Q5#8cqq%b>nGz>IqXg>fmEbE=c~~;)i6b`Pn$(3C zA8!<+*FSfV2EUVU&AgEOpkywxVS!#? z>f#e!7|7$I_MPDOMV_LW5{^`^;j>2YKE-P^Z@kr zoRem7p*5v`o(!+W9qKyvm;90-MJxU(;;c>T*V{~SRj574XB@KnANwI%YD`8*Vyhkg zSqUbu+iDT~_p_N=8{ynv3#_Ttx|0t@vZF5FuQ59{^3_^0g{fjqbxSX7)wx|+X)?w{ zV(-ny#KX5N^iB`#&oMMuf7AMsSI%3KC;inIu{gEycVFjt67#7P4v}rSpA=T)@}V+T zS5HShWIYL;Iy8|RF$fO3o%uKK6|NjVi#HoiRYjl|^OSUQo8C*xIS#%1wwC^xR|cnu zUB(Byj1I(1B*CRb5C)^V+HVd2bn zH1e5eZLZrSl&5}Ok_7r~-Tlxmd|kC;?FPOti|vT6@Xzq>8rnC0D!p?Yj_)LpA4_v+ zR*x`MGfqMAMvB90ki+vKgmEAb=bD-F)F>L1y3^>d$wYUcvCBnbZAT$*`AJHMS!^ zgPT2F>H!Vz4oo2-;^u3L%igZv?n&kS03b#F9sY~0g_v@+40UZ>U|5X*YCXNPzK=Ncku_uz4z`{u_;FTXTwrZjyF zxq7420yc|!ZIhw3Uo}51l6|WO@8dW-_PM!`!OYvB^`#9mH0Qal-T5vXYRP;Mx^r=< zVGmNXXz$tliKkoq&Q(69Bn#%vfCI z1SqTHbPhletA>0=a2Cp7`@$68Mr*;{BgSM94?j*Fevm8--8vK$fqd{(SZd*ypxGbL)Xd!h5K z2P1q}Bepv&Sca_QMnE!@?Pl@fSARjOCQJ z$a9W6xsT!J<0V6$GgbZLdMs%l16&N9X_$MF!6OQzm^90oE!qC`$A5O_K#?s^n^gr| zPVQ4#5~G2bR$s5$xS&&Q5yS3?vl#k#eCALYkA!F{&(#}vLNpdxt#aP418PTK2E9hx zV~#IDoPg`87D?uS;$$mShy=e%LZnf|kNCmY&$acZoV(U4=JM<}m)03n%CU;I8){~u zRX^FWjUG$<1u`z*UW@kBrF#=y_lno!r9=GKAoP0Hwpc==oB|SqTzdh#eWXx(k@$9h zHtY_(YwE>RV_*l$%;>A!w^zOD&BhHogiL-t?*igA*8Gb_xM>n;>)XT#P?{Xfo7 zjt)jHyHVS&W3xH8AO3QpY;e6Go;jwsc#qc6BSU5JdlyV&K8C^thlMK-2B?em zSuBC}CP7@uc3@cxwDLk>4is?y_W@UV`q_=q~klKB?z=Df4@Ddm2=jIkbUzkkln;0A{v)p-48yt^JOrWF=qMf1W39qF^G`JZJWy-C$ z#HM0gYX_gDpA(@dfUTUkPreDbK4jm8t^#>hS~^zpWZ#HMuesJwy%#PC*=t$@1ljQ7 zJr|QCPU-?RY22k9&sGeEZ?YvrvHg@11;T2-WqpG7Ob-OnDAZfM{&0UUKo7kR!n4oD zT-)WU15p(tksNP7END{(zl#fW{A&_npz{k~QQdDPSo00=Tz9bO$O1FVCck}yuqQ=p z_buMbqp1Z)grIqf5;ta{{!5Ndm{t|@d(&mbE zYH%e+a^<;~-cm{GODZzYbK4iH(kIO*p-wICmabQeGx;3m2alD6Y!-8R^7dSDWpy1) zq(ihUkO;f>4C4|B2Up9ZCMHDvX9U~J?zff);atmMw%MRHcjJ54<;umo#tMC32Do;i zAP&gh1OZIp&PymqNroU9QI;gkWfg%T>f1`7Zihi1&Ku}VwMQV0%*3%fj{@o{ysTWS z1;L-%?D0(NWcSg2IhR%vRRIoa>ZD${IW%}=>i1sRR8Yy97Z~SpVBx61aoSq5mxqWK z<_jZ?5EAmoZU!#EHY4fTTK0WA)t5;A1nBXfhW#mod#v*{Yi=UrV|%tz z)vq_E6%MEZO^Cs@b$qwMd-n~}b{4Mv7_dN3d@p-Ax;RtKABFh9_Sfuvwf@de?mC4X z5<;hwnbR-XCQf_xkzNbZ%>s{@Smx^pH}w`VhvF&byPey4{uB~^x*f!&$ zg$A`u%S)~Kq-GpXmhEj2DmFv6ZDF8{yNkim1{UYI?R&|RZ#|!8?w*rE?igEIvA>`F z_^%lqP+C{WOCzD}!?HgnS|1TT-FA0_rp1(Y49o`%h4cq7_RSLhevrKU4+jUrJ_TR< zhcGw8kIJM3%W6UOzzKn0C>s`%T3N?Owto*#s)f-v&(UrE3s9j$_b&@(z8zftRAaN} z$KsqiJ@F~=_yL4d3|_CgP%s^3vqK5$$n>^)kk&T8kyQes(Q6$R(zQ3`nAP?RH?_#p z9WHP6Ennz=sJj<%_MJ`2Nqy{?49m5*fVN)mpz`|k9d#!qES*tQ%;x#N!S#30>cvv! z$Bh|g7SmNJ(9TKCMPfI;?9U&0`)kIe7K>UZ%d%#y2#KmC%fRWKvF(y6P5=V$epn6NGQS~-_kEiuQ7iuIC?)cr zm5-!!$UNA!AT#|WbwJiVcL2yS* zEU|zKlCTX>w2DT_IG{4QJr{BACok``Aauj z=QryI+1K!@cemDIF%3+S1#T_%GFeAbE9s22CdW0w(y1@9q&^q` zUM?IQBk%IR!ZdUiK8mHEP<8Xq{8C8ftMh@N>PWM_2mvX(&}Wt0$hBpz_Vh-1$%y0c z^n-p0DU@Ta5S+uX*U5zL+at%f56|q|hGF624hPFg!f(}1a!e8I`a9YppB#D^zN2O4iFyi}EXB75*`Hlc};Ztm*B8f}u^ALD; zQcrIk5k~Fw)R{$$o;K?Tj43?n*FwV!%5t7+u%aigd6^{puPmj+5Lu&Ba#T~Fnv?r0 zfzG0;5yTwn-#aHMJ8yYmXAWmisIjK1oNgy-Q)v+6;=6h2-|BRfRsd46My2$p^4NI@bJ&4}B)dn5fYM0{GaJwKa?K zAu_ut0Z51K(gf82ac|GmfHTo?O5N6T_ZRAt0Zw-$aX|>)1uN6StFqgU#fhe?s&(~XwhRlki8QLS46ay4m7@6t;@@O`BFWw1 zghk|FTe`{mD?@ooD`x6a0wkj=u6=|0ualZQ+E+A6AoNbOZW$oY5%+M%K04U#c>EUA z0KS`znaw}6p_P*`j(b(CX@>Jy|0$-us^u*xi|O*MO^BBn2X7!48EsvMuO122EWL75 z)bvX})0Sf&A&+E=oS+h}%NNudz`@Ln@?NRf$6B|0UWj?H?3 zd%*4NtBi`9!4w(5dgy+)`jq!Wsr6~#onkS54>|w+`MAn7V-oGX2tx;#^olJO18TCH zQ=FOoa8rovZ`V_e1}BD)f(oAN6<@x~tuE%@gKp`@KuXm5v0>!PbNH+NiXOP8LbWo* zQ97L}i&@rbsTMf22^s>Q-k}qq?h-&IcUNb7xK8v(14kiqhMfaotkEMLIa20WpXbZD zt0eiIxNoa80WS<`^J1OS#qPK&13*Q>#@E5n1sHRc7|6o*vJ4v4iu9^q!%aPbh%EwD z4J0&{b7p{Wd=j#qWWKELa@;hSuX8^Sb_zZ{IFV+`i+z5QI{ZAAsRi60{&~BK35A!j zc_*El>-~d*L>+MO0_jIc&~RDI;tjYhDhVW_81RWPLm{yzuiEerzlNDtbT1yyu2jQN z?N@S_6@c($6c=(2bB!GreeF#*7n{Y-xN8TobBBAJtfp-Nw>_{xPWt2Pr0$4TJM{zV z0_?N;rw|M3Tg3C@oV^7*do9BD_slc_Dc4x@uR*uHh;rK{+u?09Kr zB{4ij0TIiUdh-Ip?OK|Y`R)&_ zXe%r(P(hVM&gV8C*}ah0@u4nv1Ay!Ax-s-uV9AqF>3I9Q-)9$zf-?zgv(0^wpdWU5 zSVWlCObU7*jq5`GTl5}M|JNSw8Mk8j5E=*_2fPTlG+f*ZudrRnUqR1y{K~>#H8y0k zFFVkEZ-slT)g~)Q4F&)+ZnGb5n<9)}3ib;dz(u(e<>tQ-1t@>eY@|P=? z99%*%{pb9`Mc=+7Vs3o3uMMrf&Ra9V@TRJi>4s^pzp(CvKmv|^nIq)xKcGw5;Tm%$ zAZYJWfxU4-XrdSfQJc+=yK$1`SRu>y^zu+04bdvo#kpT)=6C*l9jD#SuMNGQr3Fw# z2mx`nyf!$p@c}vqyih!*OJ>`s;RcN=Wi^v)7P+ru>80AYg-N;kylT>OF3qQX1tAgO z+2C?pI8Tg+l{YVBec~cLX2;&T)eivveLcrsZbn4;jXeSg zX>+(lQmS&>FZd)3R^OmP@7PV4Lri~rzK_AOyFZ`OS7VHybMoQ^MO{<0Q)xZmp6da# z#OY7x>-Ge>jL05aOh_KbaOz*PP1g6+1RY}>Mlcn(;xqyvfCNz>aIg3AyN`Mj9#DBz z>&QqpUV24&MC=z1XUU%L;V}{;G-Ktt_^w}Y^!Zvp-=ViCaH_#)n=`>M0^uierxidP z?uorg#uC`|8<%?VVmNDhb~CdFf1A@EBohFl$^~^ab~GKAva0!q2fUvt&ECqn_S05o zW0}1?J5g=E6EYmuFTxO`0YANe#O_}(T28Bqz3r3(xQLQssm&NCh9mo#zU>hc*ZF)3792V+&+guH528%~o~8(pY4Z6_q8A~zdj%zQ=qV$Pgn{gG_gg@~lkKD0~N7CKcDu5Zg53Tl0L9rZ=5; z^aytynS&4Ri&_cNL&IqvYKp>!kw-YMgLck@&IO2MIt!IWcu?h7{4|9{8`>r6gQ9vU z1-f4DdFG%2K-;@2A3ijCFr9cGyZ7GxOqy=<8dW^VAQ;(WssUC-UkX-+8V!|FB zfCB>DOTM}G>I)O!T<$ru^gR5PlDWnaBiwIJPj;x{D>4MQP;wHE540rWz+cybKd@PS zW!X6T%yoIh7M6nd6RDd^d-csqoPs(Hc(F456lH9eeu4dd2_jHq^8l_d+eqZL{*OWBB!QuUx`wWkEA%W(73d?2fPE+&{7PL!0bj6zL_dnPThfhYD#;tUtw?hqIP2~6xW4p=2<|3p-msw z1}gYTD>P5ACZ?;AxwFY^hI;!I_{(CPjV z=VbIK6?(=7Rt0AfJFB_W=cu!_tmaT0x*tfXMt%9c^GdGi8P86t@A>3sp#~-2F^-wL z`zu|)!FUv!K5A0c6@5Gn6R+Bj;W6q3Ps&uDBmxT0Y2P@PJ9yIWmn7s1kFYZ)#{=7* zd?mRLZ%wmV<4_!oE1H);FJ!b3>?v8A$_t9(8`pz{P>clY_4MEm@dg-{-!o~(5pE)! zA)?v4Z>za`j}lD1x?2OX22BDV8<6*czxEeW=(o3L;c}rt74x01%}% zSM3Yn&d730IwZg9__Dwg+j`z_s(Mtt-!c+98#`#Hn1zt&Qu!r0NIq#8KEejhwUCL+ zNn$!}(??zJG|ma;+8hLg_es>F$Zq-H$x2jL0@cpdmrpwb-$xJ5uiH*;2l!5Q?eM{ ztJS>1NI;X8#T&80PR>c@eH9ok97y7%_DJ8D+>X&9+8*b$Kyb<{&C{anSCtJ zK;#ecr!~3n$LsHn-haS!`SRjpFs%}@9+taUuOestzfc;-i!#2!4j$xBedr^1VOzxrC8qlAqKi%D>>;NhPO31VQt z!+URFKJyyW8bqexD@H<_d;ii~+H&kdUzUAs15H-+$ef7ZAd`PI!ohr`s>yTuA^|LP zoHe?N>D1SIJuD-K>9UbM2SeY$?HQhTu3cU#Ln6T(*Zt^zY;NTv4o_N_SN*00xE*rj zjTi8uE8p4ngzPAg{V2#mk<1RAmbw(*_J7|VC`=se9nYykq8$F~rW(IJD=um{%~!nS zVbWR;rf1P^M~R~lA+4D<>F~HLj8B{X(O~;!kA$ao0M~Ow?tEY*$KCpa8jx>jChPj} zg6~eAYw~)iaGbQ8kxM{q<$HoT6Z)pdGdjwyV1xy>UOK|X=U^>a&QKtqY&v2?=C6iG z4E}};w)@&7Sq%R?0{D}g$Y4nqdF9Avf#Q{`HBdpbuH4Y;r_31cf79ey3R-M#S~i!Opv@2Iv;#r@=yw?1FI(P_cKZe@CFIii@XFhaW2gB-J@< z9-mY>tc1Qn6_w!A7g-lTZTHL)yIWCx`|<{$sq7B#0#CpCJqwJs)6+6^>^Xd?48Si= z-++8~cex&?Q`y+!OINrLZhOI4+4+#>VXu7cA?F2wbuWi2Z9E^zAdkJO2 z|J6_bZaLsVkWoSq7%7dQx3_=45_N@neOA&k0Z=vLuh8DT#euh@10j*Qm-1Yv`r4Tu zy`BI2y?^z+;F1vVEsz|>tT%tZ5*3IUr9=v;waYT8BYOT}@q0mm94ph`w+g&7ss{rw zFQf(XbP<0Kq74~%o20Ma%Ff#N@5`e|qUJ+x1+CoZ-o= z;JIU|{T~KK)P)sFdFScHw_`@6+%4gYqHqD9B+-C*m5Go7|KCUO z|K;YNR)fTEPexFfL6Y?Ub_@UW!~ef`fE_vL@$M`V2An1)KWrx!0l?f}aQ1Oc>LFbQ zdo|0RN(iVp@<^mEdY9DHWq&Qr|7CRlp3F}sI5Qk*_^|-yV^R5h=?_4uSFb$ZS3Hso z8q9Jet~+Uv92>apZ*CzBP!F33{9*WMO?-UFzG z@IU#G+07P28MI;9p2U1+nfda~?#Vq{!89p~PZ_KX+khaxK+~$&a``MjO{~9R;tm0A zE#zu#h9zG*e$4iASIw`gzsS6SW9d&cbSU)5Z~R+nQatnD0bYs@ z07YzT25{hhpfV-@^K|pR_Xm1`;FSJzai#Pi(3b%2|0BzEqcx7V#8Sz6DHgDJ5F{~o zJJ{q7G^|W?g|7GP2MzP@zgH<)DvFo;A{WNJI+FKEmH2UHG()-0&gjrhhyA@qqbP^v z)B@fiFi1WdN&T@UNU|`sGp2IH(@&N{HCF{J(wi|X)$%9Pq2Afq^0!I-dx!|iK3VEQ z>SXslQ;v-jno605>Z?4rW77_;vt|XrhPOm?e53_M>N&I%jJKt+9-mIlTNl7;7K^Ex z*Fvz&`Z9z6y1$kOSOO9FOAsFDiJRmomoT;W4er&S>T^5cPJmsZIt*z4E9{0oZ0D+O z4JZb_wv((jVkp!OZl#*n`xQj>WPOmygYo~#_;|m5+CZsQ1+|J4R~ihi3K#>eX7e+U zCEHiTXvqjj8DLGhO(Zj{z+;$f_YZXtr+@~m@bc3A_3gahf7N0??F&VbgB$+20mPmf z;H&5g1qglW^@F`QrVG3(gusx8TX{m7D^y6kQIp3HeO{sNi9@ z7eMf(uY#Q6UA*P>v(uTwnV!N)R9GxRQxuv;3 zrQp2q-O=&I`=P9TnOA)0@`qawZxXVJml^?6GpyWw!E-Edf1SZ|e0Qdj_K8t}CdncF zlciT5dnlxBnO@g3k2q0l5^5b=+zYFIATt9YD`_SA!K&d4M;xz%wq zS(BWX>Y8r=@yu6qbg!qKg2xrxQxpv_U0&m|#)+QwJg0xC)*J5ugJiZ??>u3g;#J)I z_~7PvQ1*0;ouLcdr8dK0=J%fMCp5X(a$zOo%Kfk3K(7P) znmlfus=glbTmXB5{Be~1ex?>_68rN}PF;OOznd7Gm9+*@zC_7GDBz`05eval2qf8h zl&L$pUw*t#(P;7Xwfgked6CQutnXF+4p4r3FJ}RTZ6z8+=buO9Mhl-?YRQ-CnYf`D zN+-CSi0VS&Hit*LCh%d}a2LDADRdif50Ah$Z!v zvAPxqN}hkYPZD1KFz1|2ix;Xux~%%x{h)$G;t&Odoer^?a-%N<%>*NBeZIC052Up&R>>kaist-UoP3@tNbGtZ5 z3&Cx8EiNOuEl6a~%}C(O_~w>b*z-h&Gz#5t-9Kj2eacO(0Tsw%zdL0a6W=3vLa^`o z@=sOoD2-aB|Ij7L5SaORXnXh@phez2C|cJ z%Xk@Yk8>S!1=^g&n&~XKjJyJBx@6BEDkzhN`To*!FFS03E~ciyrOEhjT3#KIO~PKX ztEO5%I}5;;11kvSpl^L>^r(ZZ9Pj+%vY5%`&#v~qj4XR`kACXMPYV@~_PpMo4iV^%%*&|3^!3Ne3dtc8LLC()}hg)s7XgZ+^&pZhZNr zRI_iH8F(CB<+Zi*()TyizpO{V@Wo%KTFyI{PKU)kOIThsAw(#JW7A)0ZF}MtcZY66 zs8M8(%qCf7dzp;iK|tJ0*ffo~#qZR|3=_D;&!~! zE>Z@7WU%@UENLLh+>>jZ;^!`xQ*aooIO1Sj4Sui7X?6vru6vb(6=ORbvml$tw-r40h3S#PeWpjYnQ*4U?F9&4z z<_#VVE6Jh1^+1U<-*4{(C-RwR?~Anv%r!(>(v0MdG;3?_GcB?T)`yLQo$LUdUIfWN4;r9kgD% zeu=2DEE^>_2JddTtJr>xQUw&=HQa;%!Mu+zcb2DBXK=dXIKLNa*T^m^8kb<%p5q&e zx2(_+!@{0Nz6{$RmVW&dmDc8UK1B{+?QW4-pZ55waf}hyc}@M&1zoC`;GHEsBk3RyLP|01*gdaViMEZ|j4GRHmVNvToo z5Nt)flYy%18h>%LWq!fHWM0b~FJE72f0^pkMuyur0 zY=2n0*;d}{Q*}ESK#NnJOLHolDWt?v0K(( zhOiK~ue%+a8~dwKzXFdAzuH-5Ds9S4z>mg@gEf)v7JV+%GA%s7UKYtgB9pH&U(DQF zoN7&G)F^jJ`%CR1U?@33mhO2BWMToeP&lwVuY#@fIsT^ zW#(5_7hsQuxGQ*tifkG7Zj8@FW^;vg8kH`=o?Tn90D*vjX4yD2)_Jzhs3X8RH+hll z@`5JzA2*vH;Jhs`UG#)BZew{+KFyTKSv&3*Ir2SR7Yj4Lu@P>QGigU4Rcqw38XLft z8|i1@n*QuTr=5uP+%{WLo?r$TsSaZsyDM+Rqq7Iy3DFnG1|0Vn(n)tG$2$c5`Qjce z=c{Va>}C)!;|BEF*Zh1xC9+%{bzl6ZID|=^4-sAk-|@pFnI>1N-|IiSb8_3!=SYsz zCa0D1g!R5hIGt<1fp{!|C-;h{=J1R_RHKQ=H{r6EmFCL|ZE}>L^_RP~4FMlA&R0`uFsUn+kvqd8!e}QU?EZ@m>6n7i8(<-OcT%s(B zQBo92*tgdss$t}5sw1YfwCMrDd!jh!9)2v@m^t&2&wj0T&TP`C#WLxpSy|7}D&SS# z{(Cc9r2Am0@u{a&0D&|^hv0iDI-))4z@?}UU5*;ek$X}%1^EJLm??rdqc4W>5I?*` z8_>nBD9i7bZ(6E<9ZW2i`=pN6^Q~+KIhfRa#gpNSLnD0isV5XTWLT!`<3E!=fdIMC zJD)c9#)B-XTM9Ao&YF{>P-Mf(trM%hkKP^KrTSoz1SI!%s>yw?ip~ zNGyMEvH(G0QV$kox@-~I-*2u#$8nO*SHw>hS zGtx6ivLBj-p$5Kk5PqhLyFwI%irds6R)$up*cH;r%r6VnRZ(P6KMfc}Ut0_Ps`awW z#5VTFT%EmcSs2j- zvA38HyBwM;GC!!lI@p|jj(%5C^jT)r??v33PqFV$GM`^%eZrePjdS5v;=uLpp|9*{ zxZu0`y>qTK?R9gyhi-7kN210i<+^2$XgnM1Q>ZKGdg zv7U&gsXs9#_Bq>YpO;!U-UZrQgSsnil`{KN`69PXG;c95jnCtQyom{B#dgf_n9mjP zxj)1NT_4FaUs^Y&1<(XFM3Y7Uc_pGkBkjExR}ZmQg;9ZQ{utChS?#@%yF4teXUmRB3#qOMe`g^HP)_I+EsB*(?kITcjunxn2wtF zuH6mIpq=gnGq*hyH7c^&{Qt$?TgTM-ZEK^2Lh(Y2JH?#>#oe{IYq3(?Vc~AYT^CT? z-Q8V^yIXn+z4zR1G&k)DLNUKeu3FJ-;wkL`$e_9--}Pi(e!Q`pl}@qOdt<|} zH0voAN-vJ*%#(H#9-dXkpvSqcC&F>LtLX^F(JiVsDZyjDfCZ~xVM1IiZ7w59jNa>3 z81qSdY&;(08`^+Gd~pHwA?~Vn`RyyOdgR!CCb^dpF$mY#9`MW_owZo`Ql@wUj5RA9 z3BytiCbnyAcSf>GY8Wa@TURxpin25|?o{R6GY1d+iqGQG^Z{=^)=FLppp1!Rp4T*L#Q^VUXL_5fS+76K*dz-5*Lfdv@F(mN<`!DtzpZ z@cE%NTU-nbG`h#vuK5}v0s7AEDBsdl(DgG|HLG5w(bnLaDI$UMLlSk}}RGtl<>1sig_vFKqDJc_Ygs)9TgABZRvEp*yAM(G$*?B!k zW14Ie;Em}(8CjU6qHof2ToomE;QW3UMfWL?aLEnehwJ`^ux^=PL0batxQff3bc;|D ziwO$H($H)w|)*k8`yyJhuCg+`H)C}AV)RJ^*ixcDxbvaJi831UwuGxsGC)? zzK0>ze+%;eE!~x{fTAtZv{GQr_X})J7P-UE5wY;9UBU58tlwsEN zE9RM|P_n}3_4WEOpoQWBleX{q{=o(evr1FG{+Xbn^*+^R%F*OI)muX^ZEt)ItBc0E zqo2$-kc`_1s*UMdSEML`_qDjDPmas1x<&8EC_@M2XOaL|w^ZBg>`u*y!(3e2Lc?=h z8gbe1rAzpz!Zl27j!)Sn-$Kapk&@V`KfLY$$A2ACYchhhU7ATC(7e>Y9fVzLp$GS^ zAw9(RcnC@NbsxA4_jq9ka{@@vYH;90t7l->$~4!kZNoA3p563Ldv_WU3DnjsHOFQ3 zH^=C`lW*w&I{BDu<&zZjc{5wb@H%YTuImTn0nX=$IL|3Y+oOTLQMch}->bfb+Gv*~ z&T`uVbS-9&o^&5ruzA%viEMQM2*tTjz;J6oOsIf}_qx(;ER|D0k>00B3Q6PP-M#oW z+=OWF6ST6N&@rMHXkD&bfQVkan>j7GL^>&=XB9;Br~NOuK83!?Dg1PbqWSwozQHi} zN#A*S!$NopllxaB_5dNBFMdCz+_xNH*hH1thtCc2 zgQ9wFkb@(sZ6HH^X8Db^pa0o2`ND{(H*}utQ8t}3l*fV<0mtYY66M=?s3&@=(S~rI zjLUtdjq#;$SA61;O{T@u#PXAx0}^7!4;8$`lqd}^ojL=zA{nDPQ0pyK1D934{ww5H zJm9tJXCGF8CAC?z0glIMPmYN7wB-P%WZ<*jKYByd{MJ z7?yjhZ4g;~!q3KvY#qk!B=Z3=W;fV6CZ6V2~ z8?odz5WdJdtDInfM*T9_!7KoUH>`ZLr(G(bjsp)7?S1HKSzg&bB7`>1NFwnr7ff?r zci-x1DKcf8fq=5HpW7-lTjsV$VTo?N{I@G+?RYZnX|6ky$35y z6USs3vQcZhNqQtc57wlb$uy%oL!W_s31_z39(tHoEl~p)v)i#k4Nm%cE}M$S!g2oj z!Vo|&lAfLM@O8?Zu)O|t4}Ub8(xdUM8R^SW46+80VN5|v)4(*Kcq)IPIgCdk87R|~W&Tw1v&YeY*c!#ejhrGI5 z$R6-x$mhJRyBT1UZQ^u?H|ty@wBk5ruI*O2o|E$sT`EK7pv9Ix! zFOtN^2^O=g)2*j8{QyprZ;v^IIKR*$4O*@qB$ zM~7f~H(i{9)82W}LWO(DEihPL2)m>^6mb4P!6y1rP>K*tb$}EqREcpFrMuDJF%q&D z&Oh~IYeDx9XLpt}zfRe0mzIOa){@;uH;SD(JMZ@x;#?_-b8(q_tDYnQbkY1NX z`>(je!8W)Bb+%7xq~iGHn#{=?xQ-V?#&89xjov!2#v%G9*v`qBqRu{)ODSn(C|;kmEfRHCOU%K=?~4URbHUT{=KQxx#Rb`0Y;$2?cF@(VvE|K7?~vje^~nYK?b7u9ZDRe)v~ijrqvkl8o{8 z+Z*Rp_iL{12|q8CWX>O2-x^1m?% z|18@2luLTn8H5-Y6-;zu9VhZ)b3f5?VqG@y`ZBUT9vwTksX(+*Ao(5Bo+xLaFADiB zzYjPR-*vN*6!{>f+I2^x3#Av4`HfvSWRg-9W99kBi%2y8wD2wKI~B|P`KqZIw`U2 zLaiB8ZKCk6T!ztV_y=V(x%5v`P6zkVkyz5n`rx!YpXFTiT`3*`)dcf-t%{?$QY8gM zLUG92yxgR<%rUIAYm4)NWm`c`ErGPhK`JKU{N)4+8Aajz0_X|ZU^f$(ghu)X{I-Aj z4Sgf@;h&rib51n|XU|yQYv-Y#!94YvCb_MAEr`Z(q0_f{t2xDXabNEf=z=yrE+tcr7Z1P3=G9(kQiC&D%(9)gH(j+Sj8mju<^LLY0IvZp7wYl?Dc20&kTNqH zPebbjxJ`ao7ix~s`Z)WGY%)$20vr@t2aGNS=%el-4oJkK!co-__t0gA)RIH)np0Zz$#a0BYm z2mc`B_(ZCEaqGr&N-a^#F0q(NZ*rKe-lgLmPI=mD+TB|^tb%%a zN*6vpoGXzZ&Yk}z@UMU*%ecUO(^FY3K?!!(U_3V8!4zu)qORGoQH?R_e4GHSCxt@wG z3F#LVn63~X5fxh)#XTv7cuh)7+r+LU7&v3`-Xv#hscE4psJIa_Il%6G&;u0)N+V$ab<4=)f7W)KaAEWl3)8~3qBAS>o- zc!Wu+&CMxC=Z>0ps+d*zC8vMKuG)HaIh%bcCtd9evDh0bb5h|M{lKuc`(nD?jF!{y z_VE&%2B*5MXEbIr)GdMzcNf@VpT9RYAI%hzm8iGBuM=@e8jQkYi!5(@1@rDuT^Nf= zZ)+-x++VmiI663L0&*J+S&yStEpGgMhj@R%pzZQ1r2Lc9V%ZrSGzENOB>y3`>s{V1 zqwZ66Nb02hzTRLqrG==AMUWJQNN_OHg;sm;n!iYjeo}R=S`ZUQ4>wU| zE#Q26CsmGcxAQfs<51M$ZL7=xjUVPhQwZ`4K*?%2sk*@k)R~7)wLj&ZtVGJd6MpcZ zml@1I^U1P2*b=+wA%2!HoOB+S9F3-6QE5GiLPQPE(eu0){~ki$n4SA1wCOv3XicW5 z98tdhRB=kx6ZF<@{}7~=n%U+a=~H=MH%pgipC`>&_UYvU3^%O+vRt`szxp-HlN0-? zXf;p?6yLzW`gQz0{h{Eu$?T@lG}dLX>4RxPjoL?>{T@pQRi0*EAvjokl||SMvR39J z-;8Q!&aFIdu{=(ZB(~{`f83~1=YpDdG;ZN)2y1#WwwNF2dEfw4EvZBEtJe> z5malwIOW+OlJ@TY`k0I?2-RW#d3}pQ#^mS5>h|iuF1J=We}z>}1-_XE&epQy0R{HR zYh~w##q|L)gy>iQ^CQ?tp@0?`{4|+dbfi|e{6oY&h4uNsa~UKJ`ZY%@XREmyC;G(Tcg?O|;T&M>WPWeNHu0yRn&?;zIwRomKe ztYC&h$d|w#{J9Erc>7BfwU1b|q_M-vZ0?canYO_)=AetAYRiL@SulTv?)lxBh;p9p za((vO;@#XJcMO_SD-EXEV%5urko!6`HZZ!b$eC`P`Rp(5WvRU3gq8C6*KavHO=_N` z-p3l#(J3!E>9&NXF4_-Obks|$SE)s*eP70GFb@)OgBjChBBWXC_Xb z0i$$Ayz)BrMQj$5Sww8Leb{1S)lD7`VckX} ziDgsGpS9;rN1xD0jNYd4xMZxbTSNALs0N6lUGi3)%GbO8M2r}z?s+X;<}hJ_zuffN zz%#BtA`jrbw8qwmzzc4KSX}JDrN`pvQU`RCMbrz@pziWQ^Glq&rZQ;2VqR#@nM+H} zFr~H-u47V$x2%HqFHb|r%*9)*e)GmpKqy)rKoGRydqP-gH|nEzMCtcPO&gESG4Fd% zmK3~&vcJ~o@ONBZJ%`4Lj>$DQ-bIZi)TFXG?B521Wfr=OU$>K`gSHTqTGSXgh`%uQ zxBh&tUTu+zp0~e-7)r#X*4QUGzmx1Zf(uFAwLib}D7fA|mxLx4{GT5MDEQ7?RlKgH zd$lgJaZ)E!VPhWLNE%O{2ooC!SJLV?whr`NG1n&O-U0zQs!3%oC)cy&Oj=ADWQnXh3+1kcDNwEB8 z)#P1*;u^m;F;UYRUoCKjgo&Z+!8Zism7Dv0OKK9!Z53}94j}2avi3SQBM-lFqKxh~ zse0j#I@Mj`?|vx}m!YES{`h@@`gGR0){=@#t5^?a6~@1&VA&5TEad04ZzFT|qK-gC zRsGeMas27YQpyn7a&EtK?P@c|%A>_jJ;ngb~2`Nz_ z6}xnrg$X6q@6DET1>)@M_$k9z=Xc}&_3f7JxusDkN8YdZ+bcM`Uxf3&Jkc(&L<=b2 zNttK{FU$o?;90pk?Pg3cUBP(;Rl`1L`aIrvB2GW@rj)hjej4@8u;X7(+7KDupV+@7 zDUuuQYY$%@h={t&RtgmVjU^D-_Cn3$a=OyQnmEsIx-+q}{<>ZH_kQ^9AyVygRW$bV z%x@REE54+48hxy&e?NCYXiR@Wq4dOHxx$`u;p7uYHCi4^2HksW<7YcvtyxsW_P&uC4#fOacQNsW~UHItxVqv6gq#Xk) zyu=8e<*Jl$Y;`=+KuZQQ!QkQBq+i^QZYX_kE`)_(O+q^4J-y-!xy$&q1K|!I&U&Jx zXH3Yl!U3M)&YIe;8>HP%1SaSJ^>!VC`BGOti^(7UUwE7ujNn&mxKR69lOYQpkjPE= zt|ZhHm}zr4>bbexDuQv`KeJky>SrWM&Zv3>Tj%)UYVxpD`LE z-L9M;Gvs!^HX5Aw-<~VeW@R7YP8VMjrska)5ShtvKW*_&I>8Dl?`q@yux_fO7Z`kU z8@`mj!@C55>h>CGM?;MIwB&b6S`XHfI(#sQ!jV_FUw*t{9pIPWnOsG=xoMpcfvp2m z#UWOmqW24FLf`LUzP%lNS!JCNeL@!K=^A7OTKFO+U8nruA5wz7Oc1~9j`U92*FnB0 z22Q{Qwt#DXRDLCadjD^qg$8&G(M@0C$S5X_Xi4aj&WCkdqpR)rt}pdQ67db65K&tY zQFoCW?Ka9~OGb|)p>_f4_#BP=e*DZ97VVTxGJ&yHgS=+Cx9-L`}ZdXlE4vRi08V z_5HQg;m)3gqDYf$Y?qVX)_9YDGS9Q znajd?29*xke~KL0?3zmj^d+zi^M2W_XWvR)QiK|H7vp&*J+zI%ITEraKu2J;Uiu0% zy)ur1W&?aKtN3OQ3!g-##)+JU+{26dhlgIg*?N>LVidUng)`|Q+DSKpI{R{~LO?dE zmyThuKZ?ThE&HC*tA&SQd%brUL}~`nWgRyTuRRcp&R)sz@b8bNq=jH_51fb#wjlmE ztE8Lh8a#1KVRg|nT)v2?JyoMLU>>gK9j@-#9&b5`?03qa(3>%Wo!rob50ucdYHEM+ zDU|<4w>O&WLl`63;Qhg(W%-9u!8q=|NjMj^>(isJ?l=C&>k99PSzlMmX-FO{FsWy^ zkCWP>5()FSsb=AAp9}Ux=brxs7xKRT?KHIwdtk6`h;Ow>dAm-_Hr959O~1aUQJ%rXT6m>0&3j)c}( zU`uZyu9G(28K1uqrgkc3619+sz7?VVi4ZsgAlYtv2i+(FHp;``Q~6l?U;e1<34_7x z6bOY=hYp2jf)6B*2xDb|r|1`&CWLCO5AB=D_=IcfT2vZ0Fy=45%?9j)OQ# zvoK8p^KCyqmsX;=X7Sc^vxkqYKLhoP+B41`zZJhKfk@CX@Ni7)nLu9b0TYCNzs@wX zy>AG+4<8k(u=y?OnDw&>^qHK0ZRmCpuR*3#yNlovQRznE^jqmZpeGMjU%tM;)Y>%I zS8h!LQCW80Ds>=LG5b0PX7VidtF}{?DKN!9c|B@k0n<%VtkjAoF2Qie?iyHu65sk6 z&9HmCpDxrDi9Pj^ew;~`Fx*@s9rrnOS^FlGs2%?)aCJ6-Ohy0bk-0)}73uQ0Z3-ns z*n9|Y8mGlphy*8{pB>A+$u-ieyks1Pe~9t`I;&9(mKBNmEpX2Cmd}GcZhQ8na4O@5 z3XD!r2DtVdliRLbADMR!N5r0Un}29XA*!7RxpI48NkKkF{>PR1a{7)sWZM2H73M9E zcAYs5&#YaQ6K@!=q&6^eRjFj|-6TVvomOO;%10)vC0&x`daJPEPK5N=J!W~SR^PdV z#g-<#Z+srFvcqigkZj6y>1Z26`@nOxMFcL7cH>!>7@ zQ_E;ncB_lY6Ht#26pQiMSQY0{)*MymqQmrb>hO)#G8tS2KgzhU_vKNZQl2y>@owZU zum)(yzv*u>ue}LvhvsFC z(RQ=ZK*{b2j|-1m6lNB-+0`O9L$J{|%uiyo>8|~_NE}V)qXp)vHtl(8 zCOKiM^~M)VLO3>yIgIP34->qXvX+*>G*7`MvyF%#}X`9FV$-9|G4yg2PCSi z^TNQ>I-CqlD9og`^Wcx4zirbe9ZS_njXaI%Z54 zp8Yqt34LZwh^u0l&{cbzd^XN%tdLb9%#@2w3F5vfJ4N_{U9&pa<7u#4_Aqn$SCk}u z=adcHLq_BzrUO=}32ax(Z>#q@U65FY!&be|y8Zn6aeFgX%MEnXSoQEOkyY*rb>e(Q^@J`T-gqT$Krcu3rgOLsT`a#tui0fpdjpL%(LIz-2$$b)3X(ROA_{f z!mmCuz#SY2mnazOOoO9%-nh~Yd}aOI+B8+DsB@;O6$m%{FrE2KzUqDx`Sj}gI$)sX zB%K7z?Tnn0V?nnNPI_(JwH<}vrSK4~1~zAr6pth#Gf(Wo2A_jQLN=!gT$s2vd|n&6 zst8aXeo>iGk)!87fcC2~LlxKwm^W~9#U!FW-NmFc?nHTA!|%S+bD7CuTK~z~gO-ss z=q`#0t*(eDPsHxLM4$hiE*1FKu&ZK?W_F8bY^;$T8t1wv1guoxe5~Qo?$^HX*;U8m z`EEVWZhAU9U4fq7`+4!#QbstwfY8^86nrs0&TDM@uDj!G3-?&@%(Cv+r=ch0@7Ycb zOK9vO&qM5RBcEy}xw(TC-gY;AL~g#L#06~=nWSQFIQ{ymM=4M5Dz6dLR!)tG z>Om>EV1a2{59SP>GX3?li{&g|h_UD=BC1OE*LS=1%QCbJIMFALYPmo#05twc$=NGL59h*l_^^szu7F__ycz7(l#MCjuDi-j zvYnvlbRy%{ZNb&_FmLD-a%8r7h{+ z4r6M!-R4noTd00?J=NngIm=4h&GLVH0WcgQ@iaRh#Z>VPhH!nG?54^qW26+Kj_Q65 z(GH|4*VnFNIp-NX&2Uwl!N93*{8=Z1-Skvn^~}P(#S?YX9S02Bkg$`YG7^t^PhHFW zLT0QC67==z62l(&u36)zYAF;ckBn_N3g#Q3x$@-x2e0D4q8xBuDr!YKnltAvIY$jJtfDeve5M>0u%sk_~l!I;` zn4z_Zj<+iNlawH`@0SUp)!pKfa#;+&ES-`w5CnW}z3W11hq zs?1_u)K?-|0Hb*gAhW`S?b>Y92;}qZAAMh97=yZ{c}oQgU;n%Kc)jB~ALwlRA*GqqcyWbW+UmEx=6;e`Qr-##?KEj+ zu9z^Y!M5E9))M0EzJ~%G4{`}`(_oefBo9v!=5l=S1zA^Qo~1MswS6MvL)|D){xDae ze`*sIsUYlpG)GY}TX*<4KZcr2)eRQMK{Y)vxBtyjq}S%A(k^hYSmIJEa$MEqz|tP3 zP+NpRo$se zK%leOZTwJN?o(cpF)a%-QHv6Zs6?lGi-D((t7@uCFCx5mn`LOahmuEa#2n~^QqrW^PXF0T>!Mx{V5tyw(uWa@YA zyaZK(uY?_7;AcPRH2i3_sxsMh4@|9$g}gkVTElf41QSsX5Iu;Pd7jAUdOh7UTJR53 zv_8leL7N1P&u*rFH(is&{)9Y7nDsgBQ`Kw|=YSu|D6um~;Ka;l0c_@|nL3@WJ&t?G}f1|G0A|xUAk!LQLw%CCw@s0 zPIWSb>#VoR9G=%djR)E)9$aZ)yPXY80Jvj~UfW@xFl-HhmPH6GIqP|tY7e?2toy)d z-k|qc>uKaVFOffN_(ktL!`U-s*x&GQT3$d^=s^9V?UaxaX#ZF~vKpMJ7w#)qHRT-{ zOXC=Mc|PUfW{h&oFOp`>_=sBgqQnC6ZK~OaiuG@~BMZZvE3v|!XPyJQtlk#uO5}0dVny2|)-?|$6&o+b;oPBWMdE@J4#got`?BCdIe*8F z{`%9%Sl~x4jis}M@qx_8395@-Z2jV*x}J2Y`nIP#oh5dvUC8M=(07(|x~|AvV<&YN zxAie8W3tq>cI4~fgf}I&UZZ=kjFMM4QnEQ8z!MY~i!1RzLvTPuPC*nJRvm;1E&XUAsDuvyYy0pK45; zbFvmec77MmzvW#1y!B^j70~ariHI^vKmdHhVXzpr$VGzQx*S{*r_q_RE!W%&Y=4$r zD1cvac@gy$ak*u#{4lg0|9Ssz?E~6B z**|B78P(YaS!F^MU>Rex+-^Dj`AMeFU3Awv=6Z4q37_X{?bWZfG5re-V`v3&ln#^J z`>1<~@x)H5Nm-{@&#h3ZozW5GU8+ix5jC^be5+CtYMwJ>If18vDv;j4rwd*o{MLq1 zQU=LpT=@K6sOq`)0vKXU>^oa*ei&Iu0V%62!}rPjM){?{2=&AH3ML>qQIZ^CTrw=y zNykf)8;DzSRDMivwOmW1p3=(Y3B6ozlf-OfgLxYwi3sv4)2efn+q0M}O^FY*gC+Tb zNuax6MHyY za>^627Nva;)ancd6MKJ71t)y?S{W_-zmQZ+hY1t8mE} zeRDpW{yshkXfW|WcLQ@+p$~$JU#yD#I0>TPcVkh}Y?yawF$CliumE?NW>-M=4t*?) zDB1}Z-*OEf@KaOS(s(xe@;EN*;^Z{D%vY1dt*d$vY4QF&G(3sVtH1n;4uYL}cP81R z9#u^C*FW;{ArtKM$A^M~K@)uS&%dTxUqMBE@7XR;-{O^{)ll-sX{D)Tg|JRlX7Q^w*mEL{kf4((ZY&8k9 zTx!*1FI^|Gm@kXFoDk5z*d3#5y4*<&yWEMZhgJ1?Q zne=qNV=$(F;lUs0^>tZuRewvs8=bY8XH84dMTv%v-y*~Cj^Ap(diBp1lyeM5>C?vf;b_WKnfrt>8C|qMN4U31ItHVM>kJD5 zNOaer01>dB?0>VKa!kG$z#H>8%idpkssS^gQ-5o5+3!RLUhYe^d$^J%e(oeAMd8_k zfd|>r{mn2IBnf_p;mwx?Q?_NEpx4ARkb7ONdz&0D)iDzqk@4+~ff=|Q_O8a{JA0A> zBZ#1&iHMNbNdJ4&|Lrsid1{LpDTCh(r6B0fjV1;>cTE+fiW#wM`8l!+TI5tG{k>66auC6jf8kpaD*vOxAA-KcO1qrW>7w>OviCy3McQP)8@qD< z0+2W)+V7skT2!>DvE<61Dog*OthqJ0X8M7D9Y^TBXcFkR-0@(YNAJFkvRG;0KV9Av zj4Cr}D3%ARGE~x7R=uCoeottCPEY@Ptp0e}r0)W=+If|`;8DQ&i6#k)nxwDx(NHW* z`d}(gqFc^pgSMSu#?*NgMq=+>3H-wk_69r-O z`*D6rdbA0&s*dF;>*rp}v1A(tN8_z8YY!KT`ShrYuJuCafn(@#Y>?Bk^N!u6=Exi zsEjt_`*3y7n$e8A;uS~bp=i%gSYCFUHG17Nc2x}XWGvXG_w0v~xIcOoCDKwj5uf*lJL$ycK9_*; z!Wbs$ z)7<(!*|fQ`U4XRVa+%O>xyUw31K0VV@BAz17Xx2oBoMvcMXDpHG{;6+X5FnsHj};2 zMV}tus#o|Lj_7J^X}4&6s#$d;+H(?`XqK(;XRYf?+pnb>S6GhEP$x`4VAsl>^+hNfN|nRHk4jwO&gqyt7J0 zO7^iOEkaC3QnRZtGVmmI6@fc&{6*NVe{m3eU$XxkKattGke;_^kq!O>#9a-@1|GLC zB}TCGse~n#?RItrfbURMDl)QuOF{v)xWzz#6XI(F=V5sL3t&k9WHrTUSnvIOCxQ3L zoWQB;CIrsidBt^r@Zw^Dx&0_z6cIF2p{@>uveI*X0^El~(e>W(nC4yq^CqOnmYm3r zBb!&nGh|bF`{sGHI*sBZ$#E|pe-@fh)lWk+l1U&(aT@N4LKFRIV4RunoEXZFjB<*; ziQ<1>m#n-=R44pf^e@`*c^i5N>#R_1Tga5$N8eol^uniW@u6b99AgoSX2Xt!s^*|< z%^PA>b&&Nq6$j3YZMxihIF`PsH+7UQ3_>}i;$&3nBopc?JmV9->aI3$79a03 zaK1g@6re~~1-n2-(Gq1|Y_KN77_-_e`S2&XZ_`0|*WS>%_?;th7@$N?&KjI?mREpc z|CW7uUSpUVy#!*gW`9eer9>`9X!YqpHgx; z{)*Odon`y5+$|99VbG{jtf4qnAWytq8FN%2oDoPqW$;_X)la5ATsce}Gtg%h7nW(F*Fwng%0k!=*k)w~eAJ{@70) zY@w)ltkE>OKP)h(*4nLY&R{o1f1*t&RHV^(M)#ImSRc!!GOilE2u?6S@*t5n)^qG3 zp|VgIZPRfl%>wdcQ9Ht@9MVT$2i5Z4B+s2@soc+QVkm+v7Zu2Z`0F_6mK^r#fC0)(hcl zU=#3hmA1PArp+}sS@Y!+0Ic5!;XM&YieJ&%V}}?G9@Wys<;kW@eONX`0TJ+mfSY+N zFx3am%+8<+kUj|yzkT^xqi&PJ?kRrzb^r3pOt}|Y&RW{a1=8C3H{fZe`$vk7<|~qT zMu6Fpn;g2aHvPscIDozxfj*Q;VvKW+s)v+KJ~lb*+#gk3uDX!opElN{pW_VYb@vqs6||uBq24)A|9*F$*b`e7bppR=Jj&&eI;z&4lia-&D(c&`CIyzUy*!Rp zyaZsIcZ$a4n>8tv@aaXQ8{yB+c9&_w2 z$YL3eaaMT{(XM}Im2FxX*lzVHK)<>uK{}ECpn-0S{c^F!w4!|XEa}l{!)IY@1rC+K zQSaf#ecUA?7|`4RP~49$ig~1Eb=ae?$_mxLKYvJwrI1P7D3^qS(pGqii{gjh3%mKc zk=1oEwUFdl69C7X{TZL_+s3PPmTr!vyGJZui1q-P(nB}4Z6>QrMh&IZZPV||TRSG1 zCVQ8=W5h+Q;|Ikub4*-*%WogObKUad-OqYkh5lm5rWysSVR%PLlq9T%@NMF`YE1KK zm2-V0?k;wB5|W-II6-ucPDg5{d|`+3>9z2NE%Z#F}^?&g~NZXWY9Qdlr{E6|O5aC?isnFgNNgo+A^Nc)q? z*hzyJbF4W)R{t3E4}GG|`aQ3Sm?H5QCQu2n>`Nau+g#RSWtiG#o^Y<~jEl5APW{uY zX=!Rq-{imL!8}o<8&#V(z3 zh;w9xZVUUQ19{>H8mK5hISOKgLtG6kE4d+l?_sCZ~5mvs#qdf2wk$?m@Rl%b#!5Yb`hyG>6EYY5oR36qQN1CUXpGN{em5P_ zrNQg}O^O$xi6#!UFt({=udXZKL9w%Jh{nNUyg?=)4@M=HQ|JW)9DZL2183fXkX^kB z!|@2YO_cn;Q`+?uA%lLewy2l4ba{hgo1G2^9iHy{XHp?A5COBP+9sQ|Gare(hvHut zUj!cZ_!cAbY}!=w<LGQ zUw2gT+NE&P2Gk}+MH}TYkK+{sK-E^|Wbj@_yWTQ}rD1h5ZOqT$_5A>xiqi6GgOcUn z!0}YppW`S!p3JYSezGakUAf7l`$^EDOV!E%2t#e)JU*K_wWk|Iy7n#I znuoGh!#V2BpH5z_`jE^Uy1G^LGG5%jxiX(fqk)ad^t#uiUDu~DoVNP0@5)V@qfuq} z?h@oeuYD85`zW~s^qB)w8gO~3H{+#%;|Tdgp_<$5y3BEsaDJil0=4$J3sS(n!R)f; z;aYvE+;tCHUuZ@T!)AycO<{HC2yXRmme)_=rEpwDwh21(8942zNVtHk9F4vAI5mB@ za^Px2#%w7n4m$joXJqUEh!DPkog`))j%Jjs#CAh+h- znN>)*=lmsid=HSHnNn4W_Q%5#nB&Sip2W6xQ5KWo_%E4O8jato9DiRmp7JmMnjc!P zb4N|#a!~U#8ct*&Q>K!auH^IR`7cAE{Zgmk_2^|Xh(`yETa8z0h=0pOdb>D&H}y-= zaDF=NkIT;#?x85&`f;j-tLx#+C)gyU-nL2K%jU;v>t*>To5kT4n%gxSQE6c5biGTg z072as*EJ4~RTU+GR*cvVW;m-$#CTN3s4W;~eVIDz&OxN$900jpi05zq26B6#hltK9 zzXUqz{ILtPGDNzaEe8Jg;~47o$&eF1_g?Xk()(xkJD@;|tQ>X=>)v*m{bC3G8Ok5n z6Gyi#DNTU3U%M=N{^6vA-t&Q%0YC((*6VHV4YFW%K0~j`o={ zB|mi}=US_2E$R41rN}3DS?AviAO1uZ^yf>ZqB%VL^uwd8t?3_8gewYaEMo8Zwzb8LSui*&rE#`kt zWBYaKw-6E`Ika>s7=ef2Tu|@shgkWyM5CntpN;tF50GU@%^srW;siT=oJeM;==uc> z3HWon>kI3&Gl2&5P*`C0-dgN8l0m+`4zxeROC!<%e2>B zdc(E7A_=&1<0Fd)QyJ2J|Gf9Sxx;=pk5Fawm|s(U47e#8&*$ir+!iwn50XQEp#4k$ z-|$prO#s&C1A2edrS3LG-6~mK@bO=9`FqJ2pI|7D`$f~KY?Grx@FW$qlWORD7OO+! zp;R`D-X3Mv4ZEGecTIJpR-uuSp&E02ox8 z6o|N8ssp7XWy`!CNa<6L%T_T^`y9OD~ok0 zKoUAES_52(Y)sPEe?co}#8{G(KEZX%a~hSfrN^O8?%t$B`OMXIAAv4uoaxOX!m~>0 zCxAYt`J1zaCcqah>*P^<83zQhWXu+u$GrJB3{a}Yx*m9^Ir6i{$&Qx}mWayI>)5|z zK~5K&EjvdfY7L zSK;MDwOB5aM#&)xFxIX1OiY;m4GfmS=Yl$W0y#lv(e%3YvH)E;>H5o=e+$>4#qMxI zu3?~io=21YAEIc{v7^<+b`goHI<{`*CpV|v#rBB+8@aPZKWvUw4-3EJdIMmtq5ZY0 z`rQc+=qzg*m*4cHH6#c9aIT0f%IO!Im?q zkQL8{R$gb>+dY`f^_TURT&d|jg!<*6S%D`K(@f9$Q+}5mr<;z46ZdM+kj1zbIgrG0 z@Y8_3*iWJ>ZxsHHNmm?ZtDf`+5OeinWp`}$r>*pe=nVkgiOzC#gaI+2GV?7QJs4}g zjwOnPB?TO#Jbe?Y0s@ATIYB6X`>12@?hlGB$}dqm+ddj5*L-yQb`p?jsd#aRgf$pn zG)<~!0V(_ghK{xBdh-{!W8#-isFWlk|K>f&)d?2E^0uO^0@}@Txn8&4dYMwY!8!qm z*bN+bj2D6DfE)(KQ7fZftHun~Rom{<{Mz!?az~ja8ISYtc%y+Ru30*dJD`KM4|_z@ z6nw2;aw0W5uyoiU9)x+HCpWKJuJe0&#fkC6Q-~wg49!Iq;Zt+41ZXUzJYj9gMMN(L}CvdgjPXvZpkke>jTg)nr z@Xg&vqy9zzbIw8+kZ;%(~Zx{#o9sXazgBMR(;cw@bI5>UmDCtW5rRSb5AjWRbrs z{Vf^d%-M*p_}0YFJkA%X3XDaAYyzJ}@aSJ`K$(zzUR*MVNU`N7Ij0Q+y3|M3GAuu^emE>WPg?EC+u3`0SSPr;f%0?7C?bO5BhYhraw8 z>0#2VnE!{p?+R;b`}W-yupnSX1pyTi=}MJQZBzjPr56*jkLg z{pX(+6$O4-7-AS!AxIk4;gPgWaFevCdYWnN(<9$uieIukNkNg4b8Y^)zsDH^V2`ox zwwx$!aUgpTt!0>|>O|40bskUZFwT3plqf}fc;hdc?2Oou=0A4=Z36&E>&OrIlmgk? z@d7l4nJ0Kv?nBAr$#bTrg_aK*pi5W(0G{jRPU{EikR4{ontx{MXc)5{^}qIH_*`%MHV?Bei{s;NYUT}2sJusQgC_DczMRyZe)#MS8 zYfllY>5AZZRT#7jIQ$9&FO~^c?y=Y3YX&k1%L%n(Ey=oXu){!)XcnfX4Uoh7Z@j7t2X) zBcNJDJN0J%y;v^C@&0Ki_tJd+4I==)tMMM70%#lA9@qN!6@?C;ygVJx+nI6bU!Q^l zhm9Wti+;aueEZ*@f}=U_fX86?(ZZ$wwCJoqi%v@Y{x5@rKTEhQ4^R(s-pLUE8JGR9 zRfe7gp6~dqrnCS4c>i@U{|eBq<&%_-{HH~K1r~jFKG*XX3ZLjO}j|6bJpQ$qjWyU@2Z5l`SG zHP+}?3UHQ)-L640HU2|+`U%Q@9vvTV*~_Hp8Xe83Ke*3gl)3@p?Bi$Kr2)>19y9`A z2I&C!m9Q(8BLVFO4i%l^qSo)>1p!9dp9vDcbz7h4QTXrO$sk|j{Sg%|ezog-&`T*j zaF)=7pJ2R?EK<0>;ziQ;RELxdx~{H3s4AR4IU2qvlp6^y@ zLxWXq5~d8Xy^?SNEL0%5@*2WAK`7fgLp6J@@kV6^pi41tVL)o}rEr1xZYQL$l2rQ* zHXp~NgY_~)Xk~T!qR`SMPj1~ z`#sVN{v>F{{C1ifEC zc*n`-uu^B{061M$*tc<^5CCydRQkgOfu)l*B4OXS&~|)^F3bMJ9$0zP@4ao2do5@G z-oZ0|9TeqR9j)3BOk?2hax4=rDDiQPTo0pm7e$ecWT=pX2Q+;8zh zcYs~)`$6FKdzuJ%O}Br&C9QRi-E_2#K8vYJwBgvc|k?t=g< zbd~{Egix_hD23J?On>9jBV3o_U}BGlWe&nGR5oWwUnXG+DWW( zcErejf$x;m(_j9f>}iLWt6n^;;EtE5eu*1Nh|$|#88caF)_qrCeE4szcolgHnBui1 z^zA2o;iyqUd~b^t^vjFsPmMsPC-n(OtMV_{B4`+bc<>819%WXOgi}X??N4-Lb~7PQ zNOzfwF6__31)fXvsl0|i#ljZ^2lnc>&@)kO5)_Nc-Ah|<8iuUwNN1%#x5ONawUKiA zd2@5xkiUF&+NaRIpp?xqGqc%9jTHdDi9twz^6RNMubZx0FNDp-f3|ZhjBzW=SIe>~ zU1f47_g*0v{4lhW^JSS@tpD_ABXMz7YUh_KsBRHt~XT=iAq~CCbyk>Kiaa( zOI|iR|DL=wr6jnLFrZi4X3ct%nkmHOT}b8VRSnkc^_SMjwN>vT@UGtM(}}f7$%&T? z&Ri1zLwnomxgBf|+~b7bLyi8V5KxmH5C%02?(bi#q(1xOo8}nxq9sosxmNI@{8hLu z65tr=xuS$*>Xa?;NoaD+^e(}=c_rudSw}Fg;x{E6CB0Hd)@u6Vw=VkMR97P#nX)x z$(O*CAB`WYa7@MV9HD%1P>YtZSpVcU>~dC8xJ@;2aw}WqZ(aVf=${Gp$Fk?)-TfkK zXi+qDdtz8O05bkXA5=X8w&Lzm*E(bv*SM?8qh=V0>q~ul_}6~Os7RmlYD4B%(dl8WjRG)MBtK14f1Th#baYH9uOdpjrs;qJOf`g|HE|Vf&<8rPC_#BCy-xhlTI!!^XlTN5x9v^%J{6ut<&^GxQnQcLK;KGre91&kV6E zbA#E_Oa_`j!j_YdZPKfXT_NLDtHZ^nwxWLS*wvmZNXgrkn}r%SQ~XX&xAoo^B)qpjru!G`e1=$)O7P) zpbrDFh9gJkmLea2o84Bfk{}mV`c^oy>FT8IHy6>!fVY2GOPYkmT{xAS|2-r9gl9KlreiXJJ8N2gg>x11=dr@Q} zs=XehZwjRDko{^H0@Zwf>)ru_g}1y_5%Lzq;Cmv|r*6RS(0&{e zfI%cbe5*1Jr8ko6&_8wuvps(E)9~~_e$5)GkIuw8HWfGKVOpK`R`um?FoTnq`%Bz- zo1E?DpNnQ^i>~Acfh}`QY_>9*b+}mW{M(^>57uAA8@Y1?R4P&VxGb^{#KJRzVxRO0 zCF{K~j1x)W6yj+(5bNumj?E_?V^`q`rlURtx~H26HgfSSmc?s~c+}r)VOBlCNu}*z zxylEWJe0{02C1+#IWNNk6x)XnRn1P?$B78g2w<4ZxC~ znAr^9pl_`(*t1Br!U|6$RLC_mco?h^iUY2-Hc8gdQ1P6zqpM6pcXBWQi zOoaJsHPl`JYJ0?p$78*aYKGYpo|?tVm9KBSbYhj6roNA+L?pRgp=6pUw$n)5Y|jsT zDt9f`n~N#DtEDa%7!n=heDuTW;v@3&d|&$-?FX7utnXL(y6 z5b(wJ_nLv^n6aDO`U=a>J!Scgz6ia|${iEjyK)9Ud9gh!oCh1vD@{M_w+;BR4V$=v zcl#8jFY}yupHw7vhA?@hQhoBA1m-)ZMXr6c-c|mD>$PUVFCb6fgxC0Gu&b=w-TyX= z@x9{I`$w#NTu~I?6tZbV4%8_b5Ov~#Uk`kVf$dBgn0cp z(JV&tw(30;hpZXU%dub3b_!1ElHXb8sJ3|#n~87h6TjCth`sR-(|%*bU2>;R@pZ&F zoGW{!g)%U_P3w|3vlCWWznW_t-UeE>)Fmhr8o%5N&v^W;)r$HjFkLe`=t^#Ze!bd3 zo>k}3vT`4S1MXGZUVu-KH)8L4qm${9sy)W3)nGkSX?Oh5!M~k(JiY%Aip|%01TK~s zZd;+sqGg<|Sy%ImIkZJmYL?HM^=R!a>8x9>me;Ayoy-AE%Hq<~(xqSegKwOMFQ&oNv+L z$qteCTpWo5N$LR@euV3KY6O0X{fnHv$r8Luw}}F7TP{lLiep7Wx+`?c%Wm-{oc6e@ zqAp}C27>r73(9?eIP7+t@2%~Tbgm9Xkg-_Tm{X*_I`pkAYPCZ0!sbw3SVag!y|+QZ z?A&cFsm9wTKdLRhlN^bx2m9~|q*lhPbQi+nJt<%%Bc*_5BN-0qRB{~{h4QXye?J;~ zFPtk<-_?=EU?U=>>wVe`Auk+ZiISUu#K_WOV}eR&QapR)Qk0WwU zJ()rZ%!J%P$VFLr0Hijoy@NV9#3iCStuDdZ9AX4jPf z>H7QcNl7niUKmSsXd?GYpa7d=7`E~Gtb~8Q4;6429QFnY4x)|y!eljnV zlo8!zQwjh6aCM3wAUl}J=yvMJkkaAsm*Znul@*QIyuZ)s@752wJRW1q*R5V^WfVY; zQtdTi)(l`F7qA6SJuw76aBw9u-L?d?Vja+y_ChqSG1h>3Hs5~gpa7?D(%ba!B-fUs zVA!XnhRfV3k;n;xs?=a%w82Nl0J3E&3ZiC*9&0d_JL1N z7p^r0c&#Z|e=5h1ZEt9EuhBmh0ae#fg>H_Z++c)(o@{BVr;!M`|2*Llp3n}M@|N8v1Lkr z0ppgm3!d?6TUL@i7rB_e(09IrEBL5679hDeLjQ?#cWb|W%tuL!5O<7th?&PStSJ36J)1nG<#nJ3#N{r&BuH!a&QDnyfcTG66@)>rOzZUexV**f@r zD6)!R^-J}f7@PWd#@cq$TK|{UpP<_B6DCE@MlRVVAREZYlNS3XFeU%#2J=WsY4W_H zY6|3n<+mBY97*p{GlKe#&m#`oV5%d5sK)z<%roCaCjQ9dsN8r`zaN4ck}jWh#g+)c zwnxD>pA*YEnKU-;NJW*B4T|)tdo3i-FjM9U&(hVQHBnYb4~iez^GPm#!b6-7qr8F` z8$<6QZg+#2{5dophI9IL5Ew%U5a|dnGv4tS|GN^e=niJ|*BxNbgEz z+o%Ie)X6O5mRI0=& z*EZdYFn*lNI1cGSnop4O!Nw_QcV;$*qjZ#d_xm~;*P55J&i;sL@SHY(h{a zsW*GQCu53d^`oroiy&;Bp9r0H-Lvll&iHhQ-K^nhXYh{d2A~6oV!p8a{GGa^drtf| z$#5);J3b-U49R!%+cEzi#7K-CFqYEcRro5n#SnmBqW%qJ4D}CnIOae4eN>)7+SKsk z@5UY@%u%e@oOI@P@1iMQf=MjM2B9~L>cKiAN_cJGOD-8m z)AP-2Z(9S27)0o!c>2>mK>`wzzG2@AFO)X-e($zE4YiC%I#X9R!JJBpkdj zka-Y1(hyx_hiT~`w8dZvA#>X%B0aNE=~?wHyS$Ipo$;dj%`piIF!zhJn3(roBe zD6>)l<2+v%@U33P@#Lzax;+kJpI-mkJ}}$XO)Z&vP4r5qzvE$uvfJpb5`)(VqX!%slq6vPrapmJ)s)U0A4f``S?!J~)1-(+# z;FI~2?>Kp{iB8YpxTw^-IUA$U^em~SjdAxd#>)e-;V)f0wKp1VLrRmyOi!fI-5c2P zfOh@UqL-;h4#~XHLv<`WduAob4<8cVM8nX|Bq>fSF{heYpxTC0c7Vcx8G0nFP1=qp zu&0%KlSS!CQyn^%R3Ff^#jED~)xZTOYVL6j6$6sAeIUQC2bQq=;_7%J79i)T?as~0 z_^)mlG`m?;b`+(_-hHo}hEc5C0(a~jqt{-GC%I+s{n3i6P$dwiUf*+cA_e@sC@LWD z91PW2hl9(qtc}{djwSw%+E_em)u$Yae9Gn@_BTq)wX1Ow2V$#vi+~uEK-DMHcKTtP zk+kQ#vtc}u!&jCmv5+@y-vVmhX@Qi?lxa@B2A1El=e1#UiASXu4S8a2e{Thql9pf3 zDD=eTQpR=c#5|bs2IDn^42Wio9$ML36Hy@kh@kQ6dOExOb&~e^^t`Vd_sLBk3frPn zk>Y6I94&v~z5Wcp;|$Od)KsT52b{Ia^RCi=?8ot>HdrLikK}cYU#xwh)5!4YLgSp2 zzj$kr?#^~$%0=X2_0oQy*LEVmG1Rrp;isJOm3z+*Hqq?CK7H`(f)+n7OR^#)_@)UK}wJyItpR7`O#{Ow@k=Bh;W|y~9!XWSE&LY}wBY`{w zGf@6GyFI~sou{m#@24*!K7lt*S-s0*9f9XY7S#+Gg+eVg+*8=Mn>w(bkHl2)WKCQRa`%cou1FvE!BN^nOJp702xHjR~e#Npk7&dG(QM*6{$;K5gi_f zEH?-W7eeu)LxT!iy&;G}B114Ft`;9?wY@UZE2Uopr&o#gCh@j}oetP&G_V*lKzVv4 zge1Ece6-RdT>TlvlPJ;9`@Nx>se0OMpaZG#(XJIx{Rsj~T8QM> ziBys+m-H=bT}TyOAC^qQ_+^+?vXs_XH7JJCYtoF>30lf!bJ(|ocQgV*dO%zU@r3^w zu0xAInY!Jq_Sh)fI9xtL{dr#V_E37Avir5pH<4!$v#z)jVSQdjZ$BlI?Tm+vsn=pt ze({h0RPWbrN` zvVcL=kA^MY!TeY+9mbveq5jqsNl$Wrr+lJ|&v9zn5?kFG5bU9A!P>o34I|cf+5`HK% zF_4q2vl)3tM0u-eVY$%pGc=1XoG$RyHcYdH4!}QC7hPTiNCM*rtVacE6TP%10_uuY zXI_{bE(-~+H>Tc8H(dl&VoBU;JCCp>R(zBDfHZ)PXqMX<%S*E=^Zc?=@1Bk|uGT$A zWGd5xYh9|`-lKMTA_kIVwG5beh2iZRP3*pI^sLux93aMNhG7}MSEh%u+b5;;-uBLP zxqt%MpK&(0BQJ+N^PDQk3QvY?{HF3t8-#Q#7#pg^wftaA{>FLd%UXR-7!*E`*puFu z2JLw-BM7rp|0oB~H&)aERCB|B(N8gZ7`kvP3@4>#ot^X2WiwLXb^K#3j0uIgfKS~< zW41rJ<~qlJU#HEq^4|Y?`8=^>R<)ak2ITSnkKk6t^?}WfdiP2LcJB02Z6|+Ol=dDy z=6{%!%ODG=`99iLbFJS-1Buw0yQI=X&xI61fPuKiu|Ty6K^DVZ89oG?iPsuGLUP=F z&m$^UJLz4SQ5^aUw=tJK=;EVx*!{*a+l4(bprHf~~rvK=!;Ghx6Z*)`JCrX0eYCu`}~l4sVUx zJ3#c5VHJfy&b?~8#;GZ>=T_?zbbw#=)?MYP#82Bx5ubkXs(2TweqG9KxE$~T^|1p? zPGUo{FIcJ%tS4F`SNE!Yb&G{Sxye?TBOd5Syvq%W_|5eSa_{C73iD~{7PVWHJGp13 z=X#fK=uY`3T#up-wa+aO*|3y6KY_>}n`F+H^oo%S-$HpTY(OzqBI&=e*S$#c6_A!| z`Yw(KVgZhMuTnJxzq#_oFt{zZmJpzoiF1iB5=g6Vbsl`P){ycmfZ{R(x5nYro4EfCJ|FD+dZ!2u=;IUJAsDZ zN2=*7|LDk61ltA#tX!aeX`xlV=t~g=xkvHCE1@^HC>M{en2%95g;MHM*kyQ0k2PjG zG|!A`pHOD82OIDHq1!vS6mCrbb}E(dIgVDqQA3J@f>Z`KLszEIacOvNmeP2p9+UK0uBlEZIVS4*kaP( zk-iF8iL|7>b~A` zZzAZp_Wi{)u$|pCup~M?6|4BRb@9|ZZ-c-&F?=Cz^%SwGe6KBUvSK<0wl^%A2npF= zImX4jqpYi}J~A@qK`z(G;p?u_2!&WS;<_|Jbsbp#a+kTu_2Mvvple+$CS0dcOMugs zJN4Dv<4v0N*Gz?_re`u+LFJ9UWXg*4m(a7M74>)ra+yZDa3R&$P&p%- zj_fsrc)gaZfotrCJin85M&FcLVhPsc`HOeO1kCZua(ewUEALY&vpJSuw)0rr?IXXK zZP*6^bvD)-mbGJ4AjL11^VmzAYk$85cPm#pePTGmP;Ef8<;Lv^qNm;-`c(V%!>9R;%v=sx$GWsm55Jk0S)^_X{e{pMif; zx&slFLCqo8uCGvYnCQ*xRD!4<3|nM@fQ>UVtl{7yl}4v#+_%gT6#NZrq5lg8GL@Cm zLb7-D81oYj8W-c}G#tcuc`!2jNkjy@$LQ(|-LqA>mu0M&oFp*LT9q%B@OL(;s>jb@ zzLXsJdk1@k{3D~r?|PJe7(~&@f{Hv69~0la{mB%YpW0qBWH`pLtE>pBQq(gIHX+Ni z4>BMcP4kV^BRx|R$mOqpDff|urQ*0iJ#pz=i{r->Mv`E*Bo&M`ZevvsPk%8YT0A)& zp-*$neyXtVb$iiah|MO)Rj#h%pa7n2vX$hTt-ej7pEZruoR>;i9@ojt$nS}2=r3Yd znS}mYU-m=W4ZdniCr}`rqBeU$0e5jJ+Pb_APosizLZy`VA#4!2hs+=yrO6IgA*ev0 z2V}K*iT=p8Uex19ZyB4_e5)W0@pxCK2s>-}LtPnze4L+it4OLyATx#qvwrFN z^aq+tSdJj8w!7J4<8(ND3T0|bJhIUVq>_dnp1?hAFI}gpw`F**FGa*W1Ie^ELZe`K z8&Csk>SJ|F;8=iGjZ{vy#=&gC$)Ew-;*AC%amPT_qj~qm(tv9Ju5;VyP!HA!JfG6A zq`5Rwi2@sK%WajYA3`+M?6~@eCs6Nr@sT?$NSCTbK5mde{5(~wdOV(OHaCa=XGVj$F#{Fym-hXoBf&uWPpn$Lw{44+e+VD!-4Mp=tZ)M zHhTeN>5acXT~8~}Us>{eclsF_$B5d!nbz=khgICfPLdv0H~{)f;n}=IcZ`)ma>2+{ zZSNLfNFl|>!E?f6qZ!aq3R^5#Y&=~k976nrO{vgaElP+h7NzWm1mLWCtFU|eVKhHX zNU#vJyLp4s2x4{}MYVTZ$aH;hF}c??>4WGl)c+~#WN`T?1Yr4q;34S|KP@hQNjKQo z0!CqMBz324iH-~n@lgnO=K4fHYD_D*E|5=qRI)$3{6fIcD37 znJFk^{+@^P@SCDa*_(Ig>a0KWe~Hh7Gk$kT&tF7n_6|aDcH+T|&10EAn-dkdG0E_u zV?iQhUWGx|MXYp+tFr*n*{M7lziyCtrs(k-#VOckLbA9S2p_95sc`32J6B~s9`NE> z8779<80JC>Q(6Wk9zC+tf{*SNH|y~n(X=vuH&STUb;eN55sY9)`4(TaDpFDz`#!kM zU+LTN^SrWQbz{KFWYp%QX-K-M%`7C15E z0nxom%@I2AIkWo7?E8hz6{r)hU# z!hq4bk5?}?Sq#n%y`tCiNgJ3uu32LJqf=f9%V;%?ITxsa9&~JM>kL)HT8L$p;lTOqaJgePP zYk+DUv$-*mo|#szV_KrVn1R`=w#X$1e=%%t3_x#H%d6N9Y=I`L$bIk}W*6zTtNPh) zgax}|LYsvt~VE}G?)8pyro3p?h3VwaX35Q z-ZjUn*=GCb9JYs(4fogIR|i>9f6okHx-k3fAjuWMT@R;l+6Wf?)iZ3r+LLseWeElv zt=#8lG@nYCkIsAo3&CD&x2F#!*B-x=`i^(502&34D;tqnh8iyiBX7Xfywg+x~1@PtLw{0FVJ8LS@XHQRc7();o$w1jlSwI_g5f(Tz-Z} z3T3L{aOJ4)6X~X%ENDuO&o=i+uT?tx?Bkqs;O1Oc`w!4%BU{v?iU)|P&EH>*o->Lw zw@A2TOZj`K^a>G%AQm;`j)62AB2<>%fS$Vr@{{5!y=@0$gs5jF6{b3V>8EGVIRzCS zpMM}dXWDn{(%7aRG($4T!{nr}`~z1^vLgr6au>=EjNV(~KI`t(OK3kXG(bFM0ZLIW z10jci!YA-=qHUd(Pa~AS&+5wynln~vAG601nBi_YJs6{h#)6$Y;TG-`pWl$zKI&nws8J)c-( z^Z?71KK|nSj+e*4F0}w9XfW=`FnV**RGQ;yH8f7 ztxUMKtjx+d^nZTp*}iV^`5UO1>^}kK=xFRXc#+4Uid=(9W|t5%E67n#+b$He;#Gf^ zC8Y*#N%8G{JyOHe3$sbsg-6buKU&2#klC8nf zx%e>O$CFy1Xe6T4ZLDH&H?nQ2HQ27vcj>zN(y(C)0Sj`$7rER-MxQNr;q2*n9dk~6 zl5a9~SaF+SiSh_0s+eZ7Rb&ZMshMm%O$WHaXo{4bfxt+$!_{2)7F=#L;v|hWbr7uZ zvU|{&lM4e-r-sx-{FQ@0fjxU9VLu|`*S8y~w|84H7>`VI+iWJfeAfNiD7X6f(b#rO zsluneyzxqsDPXOV9rL>K+x}2+rdMa>Ud?D=-owy+w36G-(Q;~~d#y_mJL$$LKJ?wY ze^ag`=>2i*p%zVwOS_2F=00q|ej64^C_S@LiKKOH@T^AYrMkpj#yW(4kZi05GVR75 ztJkas_y<~PeFdi3DHRSXdebbH#-#v|ZkbnLcDxPH zw>Qlt_dum$7p{RT zJU5SKeJ$DJ#3bK7BLvy>HM?|DP9KvF-PhFRks#f2xA&x#@vtSzeV_H=@LxRhnYTD0 z9pbT3Vspq)-zpTlHclDXrc0?=`%YEkI?=SrY|>rpnQlXm-2`Qe@Fqx&fQqLE*J2^$ zZg7bqTH2=Jv0<`7=M)50`KknFo6duR_2KD8=W{UEN?fy87vAtz&i;=X2cW z1Kv-j_XUIR=+Y)lxvk2WpL_S!9-l8#%^}?!4RBNaE4`v!pFE*{oljv5qK)Esz|939 zB98?VAs|WRUI0T~#`$q?1na+K6vr)dkN(Dnl7=l%LZUVeRp2qouW)&dO2;VXLXOXC zA#VT7Q3wwwmTZbI9Iqz=_9o7io&%?y7}1K8CqI7bnrK;Jub(v-1i~kS*I~#+jD0wl zcN>3g8(Aka)nzEJ%i78pR5E2De|uU#6xDl(Tn25}EqGG=dswx}N5!}iv-4YT2?u}W z(`0cNrEuJ}l+QOqjrk;aa937jU)3)aXYi}tHR{qp^=Y@wxMHf82GuY9d3&>8&F7jC z9O#?~2_)`uJV-=x^gJPxRD#x2mZipvmuT-PU#xot{KnsTeEOam&ij5L;Q zMy*59h9V^wGm>1L2@?%ld@pxH8Qxj!W=)2vR!15trCw2J47}y%&qj$aT4vApBKe96 zgSD)xJ+gk!NMk<)YqBdGf!K>4CiVDUr)=paq{jRHW8gihf2|Ex*T=sld&M^j{B^OBvf<2k%pwbDDn{Q`i^EI-Obm z%5$1!dmC;$d=HI*nOxTgMoIpp5nU9dpeXxWR<11?iLJifjFV_XY+1Wq3lpXWdq0UR z%plFKM64olThycg3#A!?9p;|F95p)mOr28*wAbE5!t&8Am4H$oeniPY-pvzvWmJY; zAQC!a`hVsc++@mz#G8{2W)uEcqWmbX&XAnESLxtJeR`f$H=rz(B37 zcD%)dBWj{De)|Wu5CP%jX81jnxyEt%gp~uy=~AjhT7p90k36Tqx!mf&A|_HzXW~m4 zHObUQ#61o&v5&p9l;fHCaBv5p;|I%AFf*(Hc`iE0HRy2WU%}R>CU50_bk8Q1_S#3L zeN;<&g961*IL9Lu1blm)Y{Sy6#n^mv-m(2|%)wsTS)UNzL2SeI}169SFb+j6angS;VMcK%AH zy$S_r`t!0 zit4rvks#p-Vp}M!X(; zeE-g=2Zs-HTt!d(jN#`PJ$3Gs#MyHvlkhM&Qp7K@YXM5#9#w|^BQPleJke8i(9rpe$r0kC zV^|?9#Huf)h5oKK_4|B7U&&>fQm{+tml7Ka1csh(3fQRD8q@+gwMv7!KdGf!48xIw z4F3(^!yIJ+BAM01ju;=JUSHWhD>*mQASndMvAn;T;@LKlKCX}v54T?2c{p*1;YSqU z>c`(WBci`63l>qRC6r`~!m+g4dE@Uv+7QU4n!Nisk zimhm0Ll#WU_zi}4uf?e5nGS!cdrbuAtMW+IO~3v)Z*?=@$r+y+(QOg5T5>P!psjHq z4syH6bGe}94xdNQo?zft*zhmX>aB9MBQ`IW=LS__o9#&>?~YGY1tj`41Lofo>FvrU zdEeM!w&5rE7v@V%(G4nJ?%1Sy)aB=@=Zt7DZBA?l`2_s70f>a-FM60n*`U7G`JFQv zBrc835<_8iNabE{H+#7)QmKHo3xgBq`FI84MK@Lme1=!wMzUF>A^fMysMC1{4e9k>hL z-IaD9#s&@-Sw5!VzSMdrtlZ{n=t>rBHZ(<@Bl&W1_uz~V2{;K z9QWT0o)=70@C&0cT}u*?jaO=HSX#dfb1i>; zYoN1y_aJ@oe&@FGBaChEQV`81tzzuac?I~dhy02JJ!ru+d87|p2Ayzs-}CB|J$KqZ z3_ap;;5=+%!qBLbJF|4=qLV*yeS`F#)7{}rqE*8}LE)S?S?Adj63(O3xIONfyH#xd zB}+y1GdxcbqrU%JFBk@Sl}ccUq;FUW#rK^1{mZ}B9<?i)}LCtKlr8a-T((0XLOJI9DweuJG)w&Fo>O#_oel5tAIfNFM+au$FX?UjY z#gVSTA>M~tG5zlr9#sVV<>qmLs(dk94N6({8*B^@bZa8g4AN~`PxQS*xQo{S)?j$k z##qx?1*0ZmR}FOiNO6d#&sVNfW;JdOC&v%6VV%hoY9WZIdwYz=_?c>(0KRV6J9=(? z6pUdu2|SxooNn`{{kAFHOZRBF(~HB~wK%l0srK7Y5h+d>Fhz}h<3}aMPAhQ18Rv!B z&hf&8a;x=AE?A#g%ji4k!oApkKjn3w-KzP%1uk0Qu^F^Ai@DnRcxeO`YxX99i%(`$ znv$1NJV?p*2nf`eqh=vRd(XLM)GFPX+_=~yW}f87zeJmOH_E}TvKP3-Isifqut4E@ zrX7Kd(IyQj#?|#Qfs?wpPg!c|k-zDuqY!FEdOOl=uSI=4(2S{Hqy0KgLtZSVhHz2NU5g z+<^-)%@SNF!exqs?wisBda?Mk3RTIR;^zvRXRFo%n?^=>Yd7l#__t2*Lt+TxOgg;3|Z^aHW+f>OzmLqqsCRj;%f^(s9scoO}( z2n*nZ%<+3aZHQ?7f_5X`r=2<#w%3tMo(2n#&BH}2>mOTpJ1o4`UtiU`I8Q*`^X5Ob zxOBQNVBFd864?J1@Z&y8^X(rL@60N(*vSuO*qSzXv?`B^O)3RtN(_9?unA{ViHR9# z&88Oa=k&m`c0ajt=lZy!dPLV*w8HsaovxmOt9ZYzG=9iCO4&iBXbit;6}C7EjAV!C~x9+Ufm=N1}Kn6Sny&tDS(1?LC)x zvceT=`uOdQ?@`hL6YgqnSFPq0Fkxs>zK3msc6Q;8N{_b1E0JfVZ6_J<#ylw->PDq% zE~j(Lt2TZ=ys!c;ELSZ!PzO-Gp&<;Sr29=cp3hh>Jae#iH2tNjb!RaNoOOoT;E=c> zki)rWAF06a)X9Fb?1Z6%&EKVh;&PsxtZc&C-3KMw* zzl0vqqc$;0RAxEdQ=oM{M?8P8wdnzg08QTHiMlGAIS4X|zimKxr5Z>kYSQeui^k{0tE`}W*Z%r*~k zPYPDAf$$sSU|;B#$JjE@er6SzU~16u;%E~M53`fdaOhYNS)O8?!hl*TzGI?7@@|_I zg@@w`MO5LYw8@IYm`a`tC)ib9Z!KrtlOG40n!4;n#DiRDZMG>xzF+fdc)!)2-J4R@ zf;+kGR2C&{z4k<3ZzT|R_VH(N7)6(sJIZEqI1ku)CdPXEoj;wZLQxHzCoOnv9%i;NT~^XKwiF2g+E^36ZbWj)KijFMr(s_=@M_GCE;7Mo7q*y zZH-0ilqVpwXbTZAA@81=MM3bRwM+We9<4G{d9)FJ`0ttAtC{(pyF5wtsz)GIws-tK zwELG(hB^q`g{S>JDgdS3jin%ok`T0&bF1Bl&v#Mr8;+4&E{;o-F0r-~{wjHi!~1gJ z>{M!lY}f9A*bYo_;+>@p(x?H0byhrq&~=`Sv?GF(@VqKsqJ<6hDlPLg^}WkXx+=xJ zz^t}pGIvA$N8*Y*#MK-|MEF&=%!=%vbnWh=aGIwEto{D^-2OwOFT&uqM{DO?A96QP z9Y`g=_`leD&!{HXt!-36upl5RARwS3AYDN~x{5RrkluT*q4y%9fYQ6vh;->Sv=AZE zdoKY(M{1~n5JJx5-rxIf-QRMJ^W*$EW2}FKk>oC!&+PNM_*Q6m3VWHZZN|(+LLlXq zy(wFMzOPudO22^6$Qg_Q(8N)NntPLXvSBv7tmh6A^6Ys16HXF(c!iQ(*2CVeO7IiB zlh(_jam^yYGx|)}F2481+z`;3!bReC&s-k*c<$O&&xuqxPh<3$79xL;SyGW(36yy(( zZF=^XrQUp^)i&+5@5-HCGRtE7khDpGv4`3q>M*_PDVR=?(~~@E&}YJ&d>XE4;5E7t zVRC5iwy)rvi+v)ZAEIHDze|-0wJMo<< z+V(uTPgcNYd-v(Yn_eC#_pQy47gL$V{Q{fuy>&(f9|ca)SFbjho_`kzK%wbMZp}TA ze9>AzuxtzIPG?&2q9@|)zeD1Afz~GgZ&}&bT|R(B%F|^WAxj;L78K53etcX4Xp_FaeQUJmuGl2UtzQkU2w zISBg~N#8?|y8g#?-i9hEpLtiB%BP7N(drs!^TOa(Xy)BIyU1%0*!w=hm^h=CJEx}N zU6(vZMDANQU(oD(ZJ&*oUImp$hm4M|lsK?iNq5W3tl)LC?<}8o*_#I2*%dpy(|LLkn1W7I zK=_;X8QL8!8!^$xZa^7ad6cOXj>yD|o}EbW>Ju^m$$HT~A@1Uk_Jsu9<%~zxqz8$R ztUfW9&1cETiYh-q*$#^cXMd6xxS&yF6yAM94+vLx+rcB=n?sLkH@eb2T(53N+Rn@> z)&$?9lm3x?Ln*$-9u&6mgf6&^<$3jD*QTa4@w(%Kaur5<$FsG(H3PB!CpgzS^;N=4 ztUfmafIb5QFNGAnyR1JD`ySJyome}#pX-)4==j>&XXx`p?n-2{zO(Mem@=Kg)*YX$ zQI={qIJ4NUCoB{gOl%PGiBmwF83@04b!ans>Y{C$ObRm_w{HGi=;p~PU+0AbW}(wH zo6=#K=r`qZ-ljPKo~wK+0(7*GxP7B}-alpi#qhT9Rx8>xMUhtda|h7)ENq*BT>RFc z>Ya?Zk`W;j2N*ORb&`#K_w{wA?}#LFG~-Uc^_!kF`2=oe0WU|zII%)>&y=6A_G-CR zs`zT}xHmc2-#gk6Xi=hxTRj@^HmpNs@-HC}=&{%K5rZ1AM_JM@tG-1MT`77rBL?CT z$LlH5m`&Ge@;^W)dN9!K^=uc)3#{Q9P%uYZmh8u(_M4N5wIx2x+r0)}D zzcDpzcU$*Yv)dHoKb^zngqOME%gL|EF%47)Q1m^$SXB0RRCp z?c2@OG~6pV^Mgpx10ovvD{xvMKJjS@(37^?w7BDC0vbo8W(usnC2e+*6#zBC(4#Ny zjjDx+hiJn~MXQU_40(#n#~fOZsCEX8)LcVoZ1F=j~L49>&(ta^#{n2TpAI{eupjzo&7eqC!@3!olomxmtFKWokRT{(W_7-!oDo?u3&je;& zJQ_WygQlM9ZchkUDbLALWB}dBz}Hz-Qor~>b+t~4RSlWz#d&Dw{Tpb0Nfg zwLvwAvYdcHsi|Bx*A{hEUV$z?p>mjz0bQ3xd{aNVr%BT9KnP;Vmv%OAf*$cQncla= z`mFD#>tB1Q6-i2IenSfZsZ&g^7go0Q34oAdDx6ex==+D;zR~i32}mlQAMiHP5H;Cbq&?;GlmKI7w*$#pAS3#JPsrR!X$tn)g#ig^WpAbf8@tOyGd zG}S{c3m=ZCE(0V;XmLp0p+92{h-*PZj=LwHgxV}KCw8a zG-%q95coczMNb~Ekyu=BnfMM0B9-Co80o)7zPG$$7Z31a5sDVp88JcQ3YWRCXWDe)GjO zq#WsKwQVXoy~@Y4a#qqKV6h=Y_h1@xz+gZ}u_RxrqDSIG2h6Vy&t`zeONz+``M!zt zt*C$CzXIc~AOZ?av|QVPlP6(O8!2qqehK^+ICoBOr*Z#cgkaUq%3f!@vS8y`8{O<} zMF%h`Db;CTP1BaUcG7IzLiqsdiwFyAcL;;GmPLQ^7QM+4^OHd+M|+bc4of~Te=T4K z9Mv2|EPX+A$_hI4*2bF0c-nK3RQRf)Bp^doFfbu8Sv(t3aknP9{TeB6BD?kw)^6Nl z{KwGliC@LJFIePFhX$@<>Yh;LtW%WcF*a2<0JN#Mom_s>c7hc!ZR;Px4$9z8T!)Nv zBgIy3iHNGEh*?H97DFjKwx-zLhus{!3mqyMBv|6f=?BM282Z;K`gDJ%trt5=c5c;E(CUyKQd7(bV*-**o=r}T;Bg*C*^A8PmLT;`;HXi=BaHkwbBRJ3D zCf3M=Td0o!y4xm!bOXIuF2A zPYU0Ti1c)M=nf|Wdyam@$d!b`YoSnOCt9VS{RsCzPxKLW*LVgJl2}btPUpx`3cY5% zN}j6=?z`kEYIi>B=>X>^l)+Z|ytn6cftY$^kp^ci!VrbGcsQr5*f%6(tatKsb=Pyb zka9a~WT1ZHp=mQlm&zucD;lCrnACi*<5nK@sM*&67~NH}3Hn@3t4-(?Lbf%{P4fl* z$u|cC(b;G{fARJlsi}#}^$T%CB;E(UF9bkCNTObk_li{h1p@EdNzDKnvF`l5TTiUYUY((JQQmfu7d)nSG9^!gv2%fA zR%bQD_cu>CwhZOA$LlT>#dIA|W_qh%)VQ@)PS=7|g5E!XQhdY!?I-*p?{jf47>s3UG+ki%vjw zzx!3sas}BLgdgC@e?)T%(X(^~zpR*VDasJo^$y$Mdn?|&@xf!JVf&zGe&cL~t{8Q# z800Oi4K(lY-72btUY4-?ehIT)Sn3DP%kWj{ikI`bI~TZ3-U)UYe#=7TTUqz?ZM*K` zrs))-t2@p=;AIj!7a_Rs2j;Qa#6y^GDs>Jp2jT5vQtLgrR|=!@6uTh+8boC$y1Z|S zK?PS;;AX`}g0)>LalO^ble8>BM*{?p0Z{oN$O(UF9;T>$NRr$=s>9U0HI+p%?Lk&s z_+bSC0bJkY@~ltehw7{Z(|+X*fvJHs1Er0Kg`rhp^3I(YCcjmnpQ^L@BIA;~k37la zgYj=&MUzvWCF9o6#_hUQ2Z<lR0xpB!uv-1f2@GmK~YS69M3#5@f* zF|dZ=bP8CWh6l;h0CX$$84hwn&Tvo#K;=- zQYdlT8dXIhDF;%b5qmOl>7ihEPB%}(t5shN7|GYUZsyW);Ip|~#ksah5)j~Wdc*=v zvnqifCYN6xnDP67?y*BQ>2r2ec*dYjYF4#|36GHAf{>}{I+x{`S-;b}bX!v?1}+gv z$c&&!k7>5Ts-%>h;R6(k^h8%T-_aOViETgIoa4(cp@n zP410valb0jbLUy33jtbp}4z6WIfnc z%ySI%uFuysQQGFm>a0f$n*uy1d?=T1w%W!bLIier^jt{M@!< zJcs1LTt{?c#jAm&9LuyFL5Ey9=cC%)4Bb0dF07%a2?9!plO(hZA-#!IoZiPdu)|_p z_?=+;(rl=jjT-ws^OuzIhqUKDHJF8QYBjaGV>;99Kix|;yq_iK};isQYmoZlrun6ADR_SA4*Ew@K*W8MUzI3V;w=Zt4@%MURo%^HQQ5#zE#M-x$+ToA2~?7igaI?{=Z z3V-BuGRdij2D8ERZ_7vwedt~*u0A(los+2!G6Q&SYW@8+c| ziRN%Nq7YE|onEmc_k`*V-ZKj=p|Maa_;s#YJc)I@BHqv2y$4;=b06go_7Vq zr9toPTHIh_Y14_0PT5P3j2w`IF5K@PTj55H^HyEOD#s_AjTas0n!PVl@@6#xMLy4h z53WYIy*-gmGFZ2SWG2jd?sMTQi*Fmkny9b+q-~j~yrpO%GFK3up_JSdcOqu(SntPr zG>%%*D1{7BQ)RzWaz`-?&*_!jeFE9e6p;~?r`ukssiA>!xxWH%Mwc#cQC#DAWAyLD zc2VEz+z{24&`612$R$*qlDM{wS}FH?63P*5xfY8b+UvW2>Wj&@4BV5xER2e>wr}in zLB(J{^rS-)q^fNp)xm+XytheLfKzj(-fiiVPW3%>>q+#eTdgVQ17Sr%&t@}NyE%GB zvt-yP-1am1c2u>vDj@^a{TkO##G>`V1UI*zq;j-*&t8ZV! zgWEG2r0%a$#l>p7vBew1g-OH|OoZ663FI?69K3T~J>QWcITJ>*jd~|G&i&-t+1bh% zlCDsu$kM{l&w}&KYDTiqiN^@RK(ktb33IuZ^=5#`M5E?5C~5fvI+fy-&cbWc$1#>H z0T_PN)~>WDK125oBHOOY`)u3!-#MD8F}O#+T?;2WRK>GqbS!P6%!qzXlApN{)B$ug z%^oma=0(;Ggf8abz0lV6d7R8gw}`U*H?WcLH7t3F+AD{KvM=%BLzh73-(?CP#M5h5 zIie+B5TWL-Y>hgiCNO6huwBBSX$y;2MzQ3v7OtpWa=fsRn7r57Z@!{y48?jGv&o^VlNUn;3L1R{W%XsfQ9lpcYk559x5CStv zzBjbJ@Np{I@PSRj_UL?E+x$fnCaJ8ju+OqCIezkZh zKly|0j~}$eC6t5wB`oz|hw*h9KDif$w*(66(dWun`@J(Sga9mNK+zs%mVDSZ=u6LC zz{ziVuygC7f6VD=60zT7q&n>nq5V&<3B!nKeIn$^W6hlcKm!?KDBGy$RsrRDAmO7> z*mkw;!p7Pd*W9D0tL9~bMz-VPeT*k*X+kb)Tu!8uEL4CZ z-k_u;hIw(JTZS=!lc(`BvNLp1Pl)DhtULd(xcCd1r1`#$=-7!O2+178wJKz_V)7Fe*iqP1)Y)xIwpC6H&!ms6|UVk%Wvv zJ2#EkOP}0#u|%0gf}Jb9CxUl(d_d#oaZVR~2;P(R9yqNOX%FQ^5j!Pd1|)$;$;8_> zpYgG$m5wR&NK|SzVAZC1vthfDNtK6I#0yJCk3%A6Pa1CBUguF=UYIu84-?H3BbdhU zCDI&LO8e1SJlyioZ2V^QaE_u#^CIaIIgPOBSI~}7IfLp&dNn2+F0OM(rJ#&4j{8~S zDWfj(x>G2SApP?m@BRt&&XM3gGPPrI200!M6Le{GZUIKa-dgwf%+SVP8Ds5j_s%B* z)6j_zt{6Hmw|@=rqPHkbKV?bJB!RfxM zRRnD7$uA+rBz4`r$as4quG|K00L_Y?u4tiAb5Nl9n ziyXvS)ih^9-+I|SxtCh_4XTbpC-4~dZOj|xqQ`umK);B+n-*mi;CTiQCT-Df6w+=Q z^f}BR=1S6fEg&{2)^z-ZWTu@nX;&|V(rABD3za4Z_RDoKvAV`8MoQ27;LGlmeN=T? zmdm4BWLFA*)D8h<`QDT}iMRILTvn8LXMFE8oq(uh&@;>SQ)q4B@Qe*?N<`qa;Zwo4 z+jhsUKFU(C!-w@a0BPFsF)FF z*+wvmZZL;8zgKO2Ntu&DW^H{)PHYXw_@@cNQPJM_%i1ySPk$~Oy8+I(^-bz|o zQiJRV5>B1+Q6G+gWMc6#hl#s6RghQO_I60a^ulk& zbmV_-j=;IzDz%{u``B9;BYQ=>wD91zlD$>U_+rEnsD? z3ES}}Iaf4l*+MHy@&m4|81!_vPt2%&s*JbwBP9uS>K=GY!G1%#OeLg#GH0gGE+f(^ zVqIIm1HiMdF83-pqOUM`g?2yQzK>PZF@-?~?Qg(WAMaQl8Atf%6{np}J+K3L>1J)g zxU}bbS;8RSlJzAWBs8gtDh$NkQN65r=j%Piw*2jeI+wQsd?|-~>Kpo{8dqH~MIO^# z35DOivnQ&j_@Gh5AFt$F^2T8mX6sx+&YI%mt~1MjS5Ft&-pocS`@}GT`(%dUbitiu zRheC!6IMq1oDA zQ8YJq$$!`~=V14Z#FQH6`wqS^Ztq5Gy^i_4OnoOh!Dh_<(e)I+LtK&pTho7rZai#gSOPWoGNBk&WjOOv6p|4{bK z!MJyT44=;pms9{8(_l{FG2T#c{^WP8XSJWCIbS`3z3={{oBC_spr_NoS1;t=WBSt@ zfKO(B1xRz$pnM}Fzs4}X?eP4C1*$u96oQmAzir|7_uozkibu6?K4j>FX@b`X3nS{&jCo zB-eIAQ$L3P%_T;E{VCRmeXn``SFHc1J? z=lvOv{pODDjzNN8;8XD(?7!Yse&N_pb_*Gzb;Nao2EOwJ&!^(Q(GqXIWn8~ASDu$G zME*DWZz&m2$f8*}Mt>s*6RLo|LB~H{{WC@5xj@kyfH|_Px*7i0*ZHrF`T#Vc?HREv zqJOjPXiH$Rz3{}he`9Kllr970&ZTo;k#O^iP!Y3?Wx z5G5oT#b3$d&QTol;{OrC#B(t60vz_*Mw1jWgXDbF528xSzghU&KhpC5R$B1weU&b& zijy_=sS_1X0|`{}U*84zgOLHR0=uRDBy7O-*3s@v)VmdKPE~WL&X7=X{^0kvoN5lv zzWlvjlj>6kSR&vBbTdQe%&I^(=3cS@YNA|ntVEwf{$cy4`R)ky~l`9I6* zb&8JaHzE8VOLd?+j~DKqum^mv&?+}E!7II_4+LOVEb5iqkuPuZaL?aqb0;w>(!Rlu z7pMJR#&Em2%3(&h)I;W2=z&gE{>||{xj#$$Qq9v6V$m_JEYEl|(it>9dxS#EzPaE* zC&BgiB-D`;reXq|p%h$F8`Ttrb|m5P?CR_~kL-SJEBjpz@7xX4BlY}la}&ZqIB!6B zc~tm4c})hl07$Cp=JsU@uKtWE3rR&Od8$tz0fMhGW67#bl?t=YP~)$gC#GT-G5gd5 zr_5gx;eX>)0vFTzCIkq|HnPTAX$RmDKa}~liRg+2zXRFLF9vcSt~0AxENmZcWFa|7 zUQLQJG@G4Ii=Y!E7Q}3fG2Itk4bg`Qy^Bl08eaB&Uz*=_aae%4_Hz|%ms2o$UzyACg7-NX|LDRlzZ#zb3(mhs8q2N2UT}q0(p4BWNM6hiz zIjN7cn;}r0&xCBI)U5N@-E4#n2Hmn23X=4BA9SyTGx3jib!jR1XLI4E!Wn=8XI_=j zpy8^o6BX#lj96A>IVF~lYnK?01`4g$Qzk>vp}%gWwNCu z1DtLN+ZCbI-wedBf_ldE^9c?WP_7ozDPz)+gz;y~$WwUnM=d*h*{`8Pk_NlF9CRHg zn7bQ9mK*Bp7K6Y+t5H-3PUF{lsO@JGX!N|>bEA07H+B=vm)q+#HKc1Z-22Mn3Qeng zHr9XaT}Vn^|KuMKipt0o7*Xi&fCQgJnrPMmww7^{EgXrP(2<1YSdM))_g zy;S`3$u!|7Krp|sy%d=s@kvl4^9@kgo2Z#MR#(7Bxvr@o3*q6v$5li{ND*=e#u9CW4O@~_okilFe<(;DXqSp`|S<$pEoi2j_cQJ z{^b+r2z*KbmlS$1dwpjDfL~7fW3YTp{Z0-6L-)9SJgIJtHzk)!*=MyWh}pbEr=v82cpxxb>ImVP?I=ok>bdb04vtcTey+A1Ob0c@~}6&DQAqV||iOzv2yZ zJ(Y#ZrkivOXxKDrU-wkJ711a{K7euSv4m-q#`&HETuemBkRf122B1egbmyZ!Q*HeD z+zn4(5{YJPab8{kZd0;gm4s|-BMVN{V545vGXn?$@O3*@|}^FwnM0Z^Su zzwGf*%kIpCt`o%c%UW?wuxr({;PwrSO&>v0t05JSifp~0!yVG7uk#?1Qmv;}bMM&p zy5bGt4UPR=bR&bd;)*9t`#FupREZ3YMS*&+u@6D%0lz-Tzxyr|bVMrbbH-4H61wA* zo^gX)iIHFR45VkB3vL3wO3T?OC?G~0<+}{>$%2onRNGmjTpoTK_-d@54Dqdg^=UT* zEI8Fgflb`t+WzGuZ_p6%NgkiSq3ujTF)>#yf;C9%h|&nGy!j$Sc%YK&e; z*xS^Ukc!_QB@~pKe@ird_j*wj!s^iG^Ro}aqen?VX|B5pme@gu#ILxAZ3STaa#e&M zt7)uKOT;Qw&vI)w@!5_=XMIgfde1T5X$k9%d%p(vsuQibKSwcQME@tXYXrLa^BJ9# zp?bdGb}D>V`cA~BC+fleCs3IADgAiI*_w;%^x^3RjNOVT2+0=0a7XE} z2r^X(ZXQWxRF*+$-&cUD@a(o1aetYrjIt^-E{BOtOJ?;S^DK{|hJqt2=32TThRN0j zF*&w^9SKSi`#}s$ItKUJH42xKXiB0I`0CY}gz}%$NJIgpdZ@U=_$(c_*k?&yeKl6o zTVGN5QyHBo|Msm!9a>i!=iye0W=e$6k1Ukg!K%aw#?BmQPSvey?=1<9y(cjt${~<+ z`SPERd@j(8WJ&HD6Du{PeMaOKZMc;Xr)eY^iJj==sc-VZhUnVC!i7>#lG}}AXJ22% zMTQVwb>CbZPfudYH+esCe{C$tRQ+UWHEBdhZyV$rY>3kqb{$z5-GKIwcB42(T}^ha ztl#vB?Y%mgW`d-)We9RKuo9 zKE(6r-}9`6IP9mo`l5qEjVs+*=XOVnVA*gNMl*Ja+RQ}^tK4v$Es4D_3PE)4{&|QFGuQ@-P$sYODGSH`E&48`qD&pY)&s~ z9&j2;*Orh_G!8|9Q{}%CU}R_ z?sKC^4Ev*5XYp-V&qxCVlXpa-`}Q<5DI?9|(Y-a_K{n^s%Q9ZrFSts_RvqipCHb=w zAm84jTQjv$w?KW}e0i|2ztj2c;e(K}zxky^DFEq|Nxt5?^ofwYO?|$BM_h{i#ae;U z#Sm6j526f@p1VVfU3@d`q2>?28J!Q8(+71pPvn4CcoGwt+OX!YYV+z|q&7~QPgJ=z z*Z~9$V<1IrfG*y5#g``UMuBb@`-+Y{ z>?`(Md?XfE8FMAIN5?P{COPR1?wyxsg0T%AtaHW}k+_x%$@%+lU&srvb`{d6e7`#T zLL}J!kBa`%Qo_@W3!)|Y0okriOC#?#>`0I3GrS~BJXuI`8>gD&axX(?I^Uj;svqUp z5Tz~$gs*2kB6T^dM}75r)^ejd5>_F#s#}Y8unhT|~ez?>BE6 z1eYZuWN=9;iDe-)*sZjrjxP@vBxgkRD^)a{k6IH>K2d-p_xkDd)|@-7hid(M=UB%6 z=<;5ZO?mqAqUbAT(PWQBZ~mO_oV?Zx0Hy)=6ZC7ZosYqf_NT~5tZpOiKY=ZKxFVJw zq+gpJ65eWc%Ih=T%|a7=5PTm*+kEow=zVuM<<7DByo&Xs0|g0ee29+uq-`tjEWRsm zulWE8HFXyRF7qNGtAX)J)L|N?%!Q9lC;v0z`|lIT|C`_eEKXqdm2B5UNi`I|`PzW$vYLIx&Qg@n6cU-0El z0`h*Od)>_sL-0F*{UEGNxj&5#@rm}GTJ~mBFA9Cfdgg$K-N;#EtG*x5sb? zxrW^ypRI9x&7=k1X$pOQAo;NN+U+5mnOrXC6(&>MrjlVmV49<>I^M;zl|YKsjd32N zcz@0q{*iyMb8*#npfyKnB)^I_=FcWumNGD&)U)TrvQAI%*bUj~?eYhen{IF z-SSHlHVLQwHP^2`oLK#8{<6E$o_jwST+!eBST%^fm*81~(Y58U`XZW)SBfl2xY0!{n#TC!f za}HD_EG-2EN7g2C7^4+1%ZT{svY+Uwzb<`gm| zbNz45#Qd|vsG-Hk@ophUNx*CKwaFz;iEgERYWZ%8MO;7Jp&f$3@RtazWfm5$qz{DeLI4Ta&CnRoy=!Lffz7Q1_ zv6-SMwt0@{n~8a?Tr=)!{dF>%3mZ3PsYUz7DzA7>oSiYgN?dK~`mt<`;QZmz6G++0 zA^H-onjxF1vS5KM=$n5({c=QeAQc+B|M=p)$AjOrB%XG|ogIw+ zi+oA!q2`j@QT{2 zoiy#CRGaUkUR@r8?C1$!ikvXYPPLW3^oH!^9mpvIsX*f}!h=5nAeWTtnKy0@!P2JR zWyGI`pd*jR$g^t+>}!isX-8u4^ggoRU#mO7xa_S8{Zam0aTb01J_)rrfmNCP;m8E9 zZVfC{g?3!*dA-%n_Z)Kn6aDRgLsgWiC%Aht5&7f$>el0LCY{0a>mFNm4oR>o+W;k2 z3dVg*R5HP%iwQhTTdwzxRLLY>O9!B{uAUr=plTU3)7Vzm_+$U9v?g?koVn&==$;~~ zFT&C3BG4~;tKF;p2#VrO`jmce(OR{t*Uv!wg$$H{KMy!-=Bp+8Do)^7l^Qdr!Qt=e zn_6@#g=*z3D%w8oH`%XEGTq;P>wGs$AKrDCYSx)@XwV-IgpyLEWXmlD4+#G3TE4?~4`%hW__ps=v9aH(4Iar^$Ve?;&3%{v3p_@R!EcvkFkJ%c)OvEhfE}UrsNm6bm*bD|)cX=wU@B*YRg(@*DwPAP~wuw+m`G zKSKS-L!ZBJof_C>?Oo7}(tT}Utys{xygx>OfQ#|{=cXNGFaOsbetj8j z2COC5bUuyYp9}iS-~XQr`bUEPZ|(O#y6T@t>>nNUt2+H%_5Y)!|FL4f#{9pV*niCY z|DXM!2Iex)&g;ofoW2z4Hz-DmuRjo4j*s&wyiN9pT3Sptu_zU;NwMwlOiBk`%*5Pv zv#%QaXccH0Z{9&JkL9h)W!@ulE}k~Kc`)mP>IN{u7R22;W0|v`W3H%Vnh>!WHZ`h! z)l$Af-6w{q<#-O%`a4vNP7cFi-5uX=xWOv^_ za?U$ju}v{xjf3F?GC%4ttuIH=$)yXs-48fBy`0brbOuF>5Ga&1NjG7)v9I1o1nj#N z03b=ZTXui_1Oyfl#a`bz-dG*w{oD(w@PfO;+|4uByLK9y zlIRg0m=Fdn7ei~2r3-92dq|ptj)*rQ@O-s`*aP3%khICNz<=f1V(&X1JZhIZ*mfNC zTqyS+${);OWnR)uKY`TG{#$kp% z3cbaOsF69dxb6NU)7gI5Hr9WaNhTr@M#UMLFEjuA{nD(Du;pad`ZPYB$HgBnw(tbI zGtFn3+|3ksh3-E;)-Qi`7#Kwni`qFvG~2`C>~%d}6i>b{CX3s55PH1cv?<^)S*uT6 zlYGy`vgl)q8=f{N8@F@A#jPYdpS5c_!i!?3j6K7%*nlL{y1;m?Hp=8d*m=(A8f}YS;)mAcepPDQ+RXen@rKcx^1aEfRZJ%DZUWO z^gXz$YrQs<@c4IY_rLCQ(>DeHA8mDPr=LN~db%Vx%?tM|kW0vLrp7$vY7d$X zUe?keN3E5%3u_caEAt^hP2oZzpAXuu4nPP_Ub*Gy)s@J_0ai+R&>F^9mB445tn;&o zzv2Iy?k|^7QNN=^$S^FS&&h;JW*70;>~h6n_CDzOfw+V)dxU&j-4UcBHK^EmdRUTS zlL2k@lhiv<+)kVstgB#V0BrRQ&h3A=&hOk?Jua1Q`3+5ednaDcu^Cg7Vn5Yn8sdvt zPU7`J-^O6U0gZR7Hb1kgf9*I+VDHQfxqUAW?633O<(d|G0%NXX!rHK9hlF?JdlHqU zgY0wX*aziaJX6Cba_QE{0z)F%u^EdyaI!dJ(!LJ&Gm65EBV+)NhQUMiS%4=X7k<$^ zp~fD;g2eAg=5o5H&u4zO=uKhf(3I+tTJJ%SHv0b2nElnT1iw!PL0hG| zdG0-T!#AfaPfsg*QuuWNAReaHk5ma=2^?XhmhS5XdD~;z`yw9O@c_7TBGNx~t&aQD zCsR|Zp@dxrtt`X9#+u>z_GYN*bF62HPxIUNyiN-*wDTp4ag|mv@tn#n4emaQk;3b; zlK?35ZRPLIxRb-#YDC-Uzo6d`(wh)+cSi3c|L;hHvZv1qG)qgCDjAbFW3_ee46Iw= zVX_wlYkA#N0E0QGi4BG!iUx}%13*N)6$EE}ER(kLI3{tuYPb`y72_9;b6 zi&vL%SHWR_-H)~O{waXWm+QY16%GwZ*_@`CoUo%d-ijF)b1b?Y;56D;%L02vQWd_L zO}Yx^LPLl&(QKM(!jYnKT^d{r(`hOjb%639sUY4a;jVwOUJVRHxtMp%1u7raMF4!g z36B7PY|9KkZkxrDZcBOF5ql>;m%6n!x@=&Ozgb_qMEA`?PLe^Qa#ni>0I*TnV=I!X z?@>HGSe~`n06GvNKRb0PNptI~OYW-MX(3r!%R8~0Vwm3PE`@bTQ5(`uFBKb(F1=iv z;mC@a5ipoKb|&q671d$e9DaoKL)gA(f^7KhEk+IBLpP9H93H$m8=4E}n`W78^s2mi z`|*nmhFie+eSg}Z@R~1|{fj78?dWBxYI-4OwVqk=lG`NglhB4HuS!lrO>O<7z2)xZ zz>ACk`KAw&lFrRKQK6*qo@%s7iwt8Y7o_Hc_bUy$JO!M%1tDx^m^EXQ2kg6Nts&)! z?K$cb5w@Bs>A88VRp*>DQXKHS#{3;$`0Yn}eIw`I$!|@0!~-0m?S2PIVBg2j$b|EQ z@j{Iik}y_a{yI3Eflt8V8lPtmWs)I_Af&OAWPE?mKe)fu@-LtIz2|q72pCjn z4IcE19mh3kgX_AeHm!CY?F(ECmU~3@jlziZq-QBbekgx^MX~m9;eNquvBlmE(fl?C z>SW#XhUf3(m#NEk6+!FlPS z+0N(Cxt9*aqD3;&kc;)*`#g=e#NBGZ8>7V>S&z%qzN)cFfOrWR1RBSG0zrcn)0W0r zXA|17hY4*FbgexZNk@Dua+80V4S&jgWy$;7;>P${Y?60xDD5WDz5&)lF4l@sZ8|Da z9o#~gr@X@bV1%%lXq$jroZIu`RC#~I065;se3o4pYH8H6v6yI4p3D=4ckO%KH3uF(NYd((gr0amUBUZ-^{aWeP*=l)`0b)Z%t3veGj!u4sh%G>fZRXK=Zkha5i zB>%3|$D1DtSD)>q%`ny)`oupz5?r?UwisgoH3vYI{U?R=g?8WFFx&j3e72+3dBu}c zx8_wz?cUd!rX=^7Ektz!*%m^IxuY9PbNJ5gFNbRGB>ik(Gj5KgW=y82{)i!2ic391 z&Zf5`)V9fQ<7vC%?{$qhF8|Q@68ma-Ku*pzpf^T^*P`Q7!Fif)HH&-6&u_mQ$>}M0 zkfhqB_PnkZqc51$aezSPRBu9q{l+FO5tf)k?NZIH>Wo8CTd@t?=3><0O#eRpYlE(w zn{HwOB_wV&og)iB4g z`V?%|idglKe#^@ZX~aR!X@yc^hk26zN`5Y^DWHC^GPCgxSh&wT`zfm2WRjJFXQh&N zE+z(Gmf^3Bjyd;XQZWfW^f}f~g#Khm0#RGQX;|Lk&o0?sjVfl&dd?@dnTjBB_MwNh z3;P#pa8@Qh4b%K4nN(^sR8)s_@EoHXCr!i&KJ@)^ zWthZ!sW%`{R3f(`zIJ=zg&W%p_Pfa3?ofAWE%(;&ULDom^Snq3oHfo@7c&8kTcI#fgY0r|)Mp{qMrZYDH_eaXjDll!4t_R@(F|ff+U@fqCqq?UmASEc#VIjr z-s2@R(z67m=A}9i8}lXohouzpZ$eh~2h3Al$J#woAsyMBalPEqxRDoo2>>Kr{B3MJ zr$OWW^;k7GW9h!D>g#d&Ukr z5Sfeq+gI%lt$TcR5|2aLDh6UzEQaAeVo zB2Bt<=^z25#y|iYEd&A)384jrPy>WsBH?ZCx#Qe($Kam#?~U=sF#ogn*lVph*Eh@e z&EM)Be9q~7^#PhejEeP=&>k}pCbgUO3_?72cN@1hLwQCd^vr^@(y(UWPR z9RwY<&d~>Cw6{?>NOBx0O1vvY^;hkW;9Y-7nsMCm9G4{2W&Xo zB#7qgG4yD%sB%w9u6pGi(W>^j&oVRUYJ*{WMKPQlp15#t9#aeF3o z^VWG#SEa0$XM6jb+XQda%0gdWlB0mL<)Hm;lhMbe*&O5;^3|a*rLtN9+tFF{8F3S= zY%Fi)7Xj6=Qy(0edo0{Mc8+m+ZK!+1U=%+pOFx~K60^$7FUV$5p6=1{`sG<&{{}gsRAFjqTKZsJ5&)!RaiaD}6}q0A zsYP;|(b20@$r|O3W*uhe)61b9$z`8oe(BvrG)~5i*kB8NkPr1-Ds_4nGg~C!E$h~T zu<*SsaPC#NYfI=YRX5$n3iM&Qr=jSe`rzh-q<7F0`}}>axwCAKPqKTWk6Mbm8$X9*V9O>&?@w=lwyv=8LP@trZ8;^)->O%( zG9a}X8-A6C!Tk(bzlqY$cRz>0AC73<<=(^2GU`JA}k+XqH{>RG?YfN0xxqY69s zsm8>mle9-RUAG@#4uj#XyAAv1a4V$iNXuA1k;t;1)&UOsWB`U=4tamgexx$UH=+7` zi^Pkw>vJQpix`!D1H9u-IqkWwrtUF$7lIt7b7+}}LJd@z;D_+@p&c(Y4pRVqyGQE= zYy-xzV-3z0ElF8Q9*x)WZyJ}$%y$z~nhzTTor-zzBHnO~524-k#EtK7ew8r+fZ9I#1E7%9K&Q&D5P@f#3j zzx0wan6w{z0Xh)FeUo@G7y5Klq*=Hk>_w~KsI%6x+H#^XTvl;@01eZ6lBz_|^WYM) zT=*{BltZV5Gv_ObY|ADBM+Yl=MSK-Lb|v%riFniH=L-bSR1 zl%uD)mC_Dz?7>}c(ajYi6H7`WRJWE!L|8Su`Z+)lF8SQ&q-EqWU*Fgwa%)maPOz47 z6=NXq_`+I`VkDNlROJW~ae!9v)f`BQQ_o`l1_(t5SLloh%kGAN%&)bRblLsoz<oe+-LJf=?-;MxD4lxf>qfBkLPAyqF_^GGe%99Y>C`GxfN!TH=?=Rnw%oB4BR0O z>5=1yhA)kz@vix6fF=8^;WhrTT%`H{l%HKtPIPDoV{bn(p+LK-cleD9)mo&S2@JQM z+gko4-nsK|%Y{(5QahORzkDVyt*@oRl9qI9vA$nrHbEs5I!3n@3t3LL+V0pfgG&3% z57?&<1cf85YC7uUSd<|c%ezwhx~jxAGqZE}(5H;V=Fy&B{$F+jzwTVbBnmYNLpEzx z%p(!Q16}PkdL^2irH+Y}Ud}CEYp5KZdP_cEE`}N4$DCA31J8(vm?Y_(#!qwT46v=@ zBw^nee^JR7;UE! z??k$uGv}fEAq>n*ec5Nyw<+lsrpy96oW>x)86SOBa#+GaPwj8Ze z*T@5sf8F)DO${h+e@Qd0oWq~QQR;vcCb6fpn&`3J^>wAESkHZ*oOQHAHu0n2aP&8i z=E4*r@*kj&rl<^`qRvnTA}O6!Wg<`4*dBb=5{q3fR)ufW3QXkf^-{??TijNubF#Kq zk5HA=PXXRK?!S8LUY&QE^ae(Oip(5^J^v6Zy;vh&Bw$qN%yKJ$geyBr^drMS(!Q+9 zQes&a!?03;h{{erBE5a>;mR>zezo9x^8Hgv4x8~hoCAk zq+TD8Z8>RRt48~+6QC{xw`fTJcM;q;;t%iVBotM zGaH{By`SWaG*ge|5*Zh!%d3~T1rn9J&ijIf=xsdgC~eo8f$ z&^)sr@(@Q=I_Jl6{!3d#uj$e&?8EiT7nbpAeE*Q^Zf>Dhv!r2|WHx#9dot8SbX6Hc z)W{9ePK5sIz3Az|>XBYxxKrVsxz@()Q}i_<2dJg8=C22aF>WWXmK@?(Bp2VMQ!4bW zp0ivSZP*=~JimT*g)YW3;+^dTr$>)S7TsRKwBzD*rSOaXsy8E6j`-T$f2ECw=fu}P z+%fEbGWB)D2&N`jZW6)^|DB2oYKfg4-tTYdw{FKS9A;H|J>(G@!>gjja)OsyT5>3A zYo;dw>W2w8h&~%6S}}Z;-e2YFT4q(1I-_)hNMk!ZTF=w{uCt>`WtM*NscWYe6 z(&8Fsbl+(rL}za?(+5ko$_W!uyPMyhO`aBkVF4u>0qpuK9J?aGJWaEpMEPqhouR!6 z$wkIRcw^e(;VQiAE5n`_2kMDq6aK^C3zx{dH_dffqpm znFe&BS$qND(jYtOK<4>{UZxo+V6$W2ccf(+#vn>=-;*7;&Ijei2odSg3+R}$4sWdQ zl-JidH`5#U(yUVOouq`;+m$hJi0(Y%;zYIMmJ5$+;1isF_9SWRY)__6TCeogUVOT; z3B-GzDoPB4nN+;^);4w;zf9hj)+zHt@v8<7^$#)t%202%pF-$3H8;pOiRUt61TMOOtRyt@godn)|4pOd58?49z98U95S6H%Y5Ptgg5!V2~ zO8>ZRX0qB|DP1x|^dy&8wLrdC3v42K^iz5*8?}<>-iHPUQ2zL73`5F6cEd0T*v9Q2^;Cs zR;oJ=W89qhB*Qf@p0Q3{sezJSJ)YXOj;hdndZOz)GaIES&8oW6o~&dAe1~flLK@&H zpq*mXvM%O*J_UKMZDqZpPjAS|(^$NXs z1E5}?ku=udJu|)Bzi%Z~j^S_hO%5fTmZ_nblU6obEvqWt$9cP&Bw)C$Cj3=PBLJ`J zyKZtiC38n=v~s3?D%otSiv@PBn?D;8WZvL3b}2~&b_J%SYJqYP2F|KSatNc1J0Cw= zKPSDqd_vf^H+NrvRPTtw0deC3mD8ErY++(QTm}X=Pr@`6aSF&JiGFsyV z_^F*Z}7RxQj8`95zxFHj^!{B9zlC}|29IF4#PEgZ+ULZOK$G@0T0l_tY3~ zjRuiBx7M4^IjyOtXbK&}rVPIhAa>=&<(46F|<*M-E6|LF-D;(^eKz7+-$v?pNoKj%aKD}qpmGM@s|`g)glr(YfC zU>1o3t3FWCg`SFP6293wzsp3p$VKf0X`XDGzjt;C$fG4>-P0RCCu4dH&>&bS+oX~N z@p29lBMq)wcggUw-@6jqaiQf^jo4r;WRT(mT%EHE5e;8NowP%~swsMTe{$D-P+~J* z=R={G_&?z;YO31N8cl70keR^y?2;bEU1MX3FG`|MzJB$%kW(;Hhs5XVc?UMdwi@!? zI=mX0RB*Z>j27Af6qYVJk9g6t>K{5f@X!0`Af)UGK6K#1@3R^7WO+`EIkpB#2h44W zw@3P0{jC(gT)SL|t~O%ft#cGV3`I9OkR+^b%UmNgk(bTzTdR^mw#~Rt^|?hM1l)Sg z$P_P{EBSq^OO~*>Z65nt6uoL2*nZRY!Q>ZJ_#qC<)#+;O^9jkxTpflAa~m6-3FJb0 zv^18!#$9M4{|$4|2rNgFCl(So&uFri=x56WH>=vwPSVRtJAeo>A$V{1W?d+Dt}oHz zN4fjGW~5egs^4mQ2_J8_wPnBh!`5xea=3ll->xo)((mO|GZ4 zx{sXN3k#fh^*+BFd1K_mVX!$Yw;6$l%Pfw~^=I(R;3j&3k|HJIHiPY-i{zghSaT;s z&e6s7lv@@l^eOySYxn8#9MK^9M*LfEe^y=qsk$KVdFbijUq^vq__#+0Ha|Z;$6O-; z+{Sw|o0>8OWJQ7#t-?)Ds8S=oC@JJcv8HPZn>*!M!o_nvyecEtQ>#qBHp%X~H~LmT zG|ayq)fduMbf?VF-D+{zu&|TTQuX!jO8Lfu%^;FACzVhrjg`NAJMTk9qNIgFRW~|y zA1H*TXJ6r!I-gR8<{K%n%{m9l3xr)SPWMBG>z^&uL^Ec$Hw8L38id?cGUwc6GYADJtA;95_ zH;$LEyrgK)hr3jn>!?bXSl@eTjWc!2H29iwIS4yoFSC-ZQ+br?W!$mmY_7ZKAJ5la zDQWulwkKMQ-f*7*?Wd414cW>*yyOv^Zj-m;2p~hqZc*FJa(CT~nn_GF`nuu0G%V~f zjgx0mayemZ$$mt^H^0U1oh%?bXj{F2Fpep4)D}IsPNDT!0+7iVLS)!fhxTr7&y|eT zxRQgFXs!F=q*_E;v~;gD5ZE4-6!$8ci3yGIq;IcG=I>qfOl5FFqe} znk7F3h9bs3Sz2ikAs(n1a-kGx3kZYAtpA;mKp-H;M$@}-SAbmIk@?QP zzf*^!0bKu&if7Io7@z;~s-MgAb9w%=*YeK}?w_aUr*Zvx82%j_0%V#0Ti^3LWMXA6 zF1y85>+k%t`n!1bvP2VlneCTfCmtBQIWUX-FRGFT=S|;tf}$MyYJcN|C-CAg=6Jv% za7YgSf3c7sixMUP_;XP~jlX-;1zUhQbmepZ$)C^p>pA*!HGZzo&+~KO!T4!k{}25J aEo7a|CT#i%6YoEOpUw^a>y_8;hy4%sNO*q$ literal 0 HcmV?d00001 diff --git a/remotefiles.go b/remotefiles.go new file mode 100644 index 000000000..61ea48050 --- /dev/null +++ b/remotefiles.go @@ -0,0 +1,277 @@ +package slack + +import ( + "context" + "io" + "net/url" + "strconv" + "strings" +) + +const ( + DEFAULT_REMOTE_FILES_CHANNEL = "" + DEFAULT_REMOTE_FILES_TS_FROM = 0 + DEFAULT_REMOTE_FILES_TS_TO = -1 + DEFAULT_REMOTE_FILES_COUNT = 100 +) + +// RemoteFile contains all the information for a remote file +type RemoteFile struct { + ID string `json:"id"` + Created JSONTime `json:"created"` + Timestamp JSONTime `json:"timestamp"` + Name string `json:"name"` + Title string `json:"title"` + Mimetype string `json:"mimetype"` + Filetype string `json:"filetype"` + PrettyType string `json:"pretty_type"` + User string `json:"user"` + Editable bool `json:"editable"` + Size int `json:"size"` + Mode string `json:"mode"` + IsExternal bool `json:"is_external"` + ExternalType string `json:"external_type"` + IsPublic bool `json:"is_public"` + PublicURLShared bool `json:"public_url_shared"` + DisplayAsBot bool `json:"display_as_bot"` + Username string `json:"username"` + URLPrivate string `json:"url_private"` + Permalink string `json:"permalink"` + CommentsCount int `json:"comments_count"` + IsStarred bool `json:"is_starred"` + Shares Share `json:"shares"` + Channels []string `json:"channels"` + Groups []string `json:"groups"` + IMs []string `json:"ims"` + ExternalID string `json:"external_id"` + ExternalURL string `json:"external_url"` + HasRichPreview bool `json:"has_rich_preview"` +} + +type RemoteFileParameters struct { + ExternalID string // required + ExternalURL string // required + Title string // required + Filetype string + IndexableFileContents string + PreviewImage string + PreviewImageReader io.Reader +} + +type ListRemoteFilesParameters struct { + Channel string + Cursor string + Limit int + TimestampFrom JSONTime + TimestampTo JSONTime +} + +type remoteFileResponseFull struct { + RemoteFile `json:"file"` + Paging `json:"paging"` + Files []RemoteFile `json:"files"` + Metadata ResponseMetadata `json:"response_metadata"` + SlackResponse +} + +func (api *Client) remoteFileRequest(ctx context.Context, path string, values url.Values) (*remoteFileResponseFull, error) { + response := &remoteFileResponseFull{} + err := api.postMethod(ctx, path, values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +// AddRemoteFile adds a remote file +func (api *Client) AddRemoteFile(params RemoteFileParameters) (remotefile *RemoteFile, err error) { + return api.AddRemoteFileContext(context.Background(), params) +} + +// AddRemoteFileContext adds a remote file and setting a custom context +func (api *Client) AddRemoteFileContext(ctx context.Context, params RemoteFileParameters) (remotefile *RemoteFile, err error) { + // Test if user token is valid. This helps because client.Do doesn't like this for some reason. XXX: More + // investigation needed, but for now this will do. + _, err = api.AuthTest() + if err != nil { + return nil, err + } + response := &remoteFileResponseFull{} + values := url.Values{ + "token": {api.token}, + "external_id": {params.ExternalID}, + "external_url": {params.ExternalURL}, + "title": {params.Title}, + } + if params.Filetype != "" { + values.Add("filetype", params.Filetype) + } + if params.IndexableFileContents != "" { + values.Add("indexable_file_contents", params.IndexableFileContents) + } + if params.PreviewImage != "" { + err = postLocalWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.add", params.PreviewImage, "preview_image", values, response, api) + } else if params.PreviewImageReader != nil { + err = postWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.add", "preview.png", "preview_image", values, params.PreviewImageReader, response, api) + } else { + response, err = api.remoteFileRequest(ctx, "files.remote.add", values) + } + + if err != nil { + return nil, err + } + + return &response.RemoteFile, response.Err() +} + +// ListRemoteFiles retrieves all remote files according to the parameters given. Uses cursor based pagination. +func (api *Client) ListRemoteFiles(params ListRemoteFilesParameters) ([]RemoteFile, *ListRemoteFilesParameters, error) { + return api.ListRemoteFilesContext(context.Background(), params) +} + +// ListRemoteFilesContext retrieves all remote files according to the parameters given with a custom context. Uses cursor based pagination. +func (api *Client) ListRemoteFilesContext(ctx context.Context, params ListRemoteFilesParameters) ([]RemoteFile, *ListRemoteFilesParameters, error) { + values := url.Values{ + "token": {api.token}, + } + if params.Channel != DEFAULT_REMOTE_FILES_CHANNEL { + values.Add("channel", params.Channel) + } + if params.TimestampFrom != DEFAULT_REMOTE_FILES_TS_FROM { + values.Add("ts_from", strconv.FormatInt(int64(params.TimestampFrom), 10)) + } + if params.TimestampTo != DEFAULT_REMOTE_FILES_TS_TO { + values.Add("ts_to", strconv.FormatInt(int64(params.TimestampTo), 10)) + } + if params.Limit != DEFAULT_REMOTE_FILES_COUNT { + values.Add("limit", strconv.Itoa(params.Limit)) + } + if params.Cursor != "" { + values.Add("cursor", params.Cursor) + } + + response, err := api.remoteFileRequest(ctx, "files.remote.list", values) + if err != nil { + return nil, nil, err + } + + params.Cursor = response.Metadata.Cursor + + return response.Files, ¶ms, nil +} + +// GetRemoteFileInfo retrieves the complete remote file information. +func (api *Client) GetRemoteFileInfo(externalID, fileID string) (remotefile *RemoteFile, err error) { + return api.GetRemoteFileInfoContext(context.Background(), externalID, fileID) +} + +// GetRemoteFileInfoContext retrieves the complete remote file information given with a custom context. +func (api *Client) GetRemoteFileInfoContext(ctx context.Context, externalID, fileID string) (remotefile *RemoteFile, err error) { + values := url.Values{ + "token": {api.token}, + } + if fileID != "" { + values.Add("file", fileID) + } + if externalID != "" { + values.Add("external_id", externalID) + } + response, err := api.remoteFileRequest(ctx, "files.remote.info", values) + if err != nil { + return nil, err + } + return &response.RemoteFile, err +} + +// ShareRemoteFile shares a remote file to channels +func (api *Client) ShareRemoteFile(channels []string, externalID, fileID string) (file *RemoteFile, err error) { + return api.ShareRemoteFileContext(context.Background(), channels, externalID, fileID) +} + +// ShareRemoteFileContext shares a remote file to channels with a custom context +func (api *Client) ShareRemoteFileContext(ctx context.Context, channels []string, externalID, fileID string) (file *RemoteFile, err error) { + values := url.Values{ + "token": {api.token}, + "channels": {strings.Join(channels, ",")}, + } + if fileID != "" { + values.Add("file", fileID) + } + if externalID != "" { + values.Add("external_id", externalID) + } + response, err := api.remoteFileRequest(ctx, "files.remote.share", values) + if err != nil { + return nil, err + } + return &response.RemoteFile, err +} + +// UpdateRemoteFile updates a remote file +func (api *Client) UpdateRemoteFile(fileID string, params RemoteFileParameters) (remotefile *RemoteFile, err error) { + return api.UpdateRemoteFileContext(context.Background(), fileID, params) +} + +// UpdateRemoteFileContext updates a remote file with a custom context +func (api *Client) UpdateRemoteFileContext(ctx context.Context, fileID string, params RemoteFileParameters) (remotefile *RemoteFile, err error) { + // Test if user token is valid. This helps because client.Do doesn't like this for some reason. XXX: More + // investigation needed, but for now this will do. + _, err = api.AuthTest() + if err != nil { + return nil, err + } + response := &remoteFileResponseFull{} + values := url.Values{ + "token": {api.token}, + } + if fileID != "" { + values.Add("file", fileID) + } + if params.ExternalID != "" { + values.Add("external_id", params.ExternalID) + } + if params.ExternalURL != "" { + values.Add("external_url", params.ExternalURL) + } + if params.Title != "" { + values.Add("title", params.Title) + } + if params.Filetype != "" { + values.Add("filetype", params.Filetype) + } + if params.IndexableFileContents != "" { + values.Add("indexable_file_contents", params.IndexableFileContents) + } + if params.PreviewImageReader != nil { + err = postWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.add", "preview.png", "preview_image", values, params.PreviewImageReader, response, api) + } else { + response, err = api.remoteFileRequest(ctx, "files.remote.add", values) + } + + if err != nil { + return nil, err + } + + return &response.RemoteFile, response.Err() +} + +// RemoveRemoteFile removes a remote file +func (api *Client) RemoveRemoteFile(externalID, fileID string) (err error) { + return api.RemoveRemoteFileContext(context.Background(), externalID, fileID) +} + +// RemoveRemoteFileContext removes a remote file with a custom context +func (api *Client) RemoveRemoteFileContext(ctx context.Context, externalID, fileID string) (err error) { + values := url.Values{ + "token": {api.token}, + } + if fileID != "" { + values.Add("file", fileID) + } + if externalID != "" { + values.Add("external_id", externalID) + } + _, err = api.remoteFileRequest(ctx, "files.remote.remove", values) + return err +} From 46dc7e1f311b35ea6e6a01c88ad34d6907e39800 Mon Sep 17 00:00:00 2001 From: Junya Hayashi Date: Fri, 1 Jan 2021 00:40:27 +0900 Subject: [PATCH 002/606] remotefiles: remove redundant field from remoteFileResponseFull --- remotefiles.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/remotefiles.go b/remotefiles.go index 61ea48050..06e33315d 100644 --- a/remotefiles.go +++ b/remotefiles.go @@ -69,8 +69,7 @@ type ListRemoteFilesParameters struct { type remoteFileResponseFull struct { RemoteFile `json:"file"` Paging `json:"paging"` - Files []RemoteFile `json:"files"` - Metadata ResponseMetadata `json:"response_metadata"` + Files []RemoteFile `json:"files"` SlackResponse } @@ -156,7 +155,7 @@ func (api *Client) ListRemoteFilesContext(ctx context.Context, params ListRemote return nil, nil, err } - params.Cursor = response.Metadata.Cursor + params.Cursor = response.SlackResponse.ResponseMetadata.Cursor return response.Files, ¶ms, nil } From 3eb3ab316abff0e539fe941ee849a8bdcb576032 Mon Sep 17 00:00:00 2001 From: Junya Hayashi Date: Fri, 1 Jan 2021 20:56:25 +0900 Subject: [PATCH 003/606] remotefiles: add tests --- remotefiles.go | 36 +++++--- remotefiles_test.go | 194 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+), 13 deletions(-) create mode 100644 remotefiles_test.go diff --git a/remotefiles.go b/remotefiles.go index 06e33315d..5ff6f46cf 100644 --- a/remotefiles.go +++ b/remotefiles.go @@ -2,6 +2,7 @@ package slack import ( "context" + "fmt" "io" "net/url" "strconv" @@ -90,11 +91,8 @@ func (api *Client) AddRemoteFile(params RemoteFileParameters) (remotefile *Remot // AddRemoteFileContext adds a remote file and setting a custom context func (api *Client) AddRemoteFileContext(ctx context.Context, params RemoteFileParameters) (remotefile *RemoteFile, err error) { - // Test if user token is valid. This helps because client.Do doesn't like this for some reason. XXX: More - // investigation needed, but for now this will do. - _, err = api.AuthTest() - if err != nil { - return nil, err + if params.ExternalID == "" || params.ExternalURL == "" || params.Title == "" { + return nil, ErrParametersMissing } response := &remoteFileResponseFull{} values := url.Values{ @@ -167,6 +165,12 @@ func (api *Client) GetRemoteFileInfo(externalID, fileID string) (remotefile *Rem // GetRemoteFileInfoContext retrieves the complete remote file information given with a custom context. func (api *Client) GetRemoteFileInfoContext(ctx context.Context, externalID, fileID string) (remotefile *RemoteFile, err error) { + if fileID == "" && externalID == "" { + return nil, fmt.Errorf("either externalID or fileID is required") + } + if fileID != "" && externalID != "" { + return nil, fmt.Errorf("don't provide both externalID and fileID") + } values := url.Values{ "token": {api.token}, } @@ -190,6 +194,12 @@ func (api *Client) ShareRemoteFile(channels []string, externalID, fileID string) // ShareRemoteFileContext shares a remote file to channels with a custom context func (api *Client) ShareRemoteFileContext(ctx context.Context, channels []string, externalID, fileID string) (file *RemoteFile, err error) { + if channels == nil || len(channels) == 0 { + return nil, ErrParametersMissing + } + if fileID == "" && externalID == "" { + return nil, fmt.Errorf("either externalID or fileID is required") + } values := url.Values{ "token": {api.token}, "channels": {strings.Join(channels, ",")}, @@ -214,12 +224,6 @@ func (api *Client) UpdateRemoteFile(fileID string, params RemoteFileParameters) // UpdateRemoteFileContext updates a remote file with a custom context func (api *Client) UpdateRemoteFileContext(ctx context.Context, fileID string, params RemoteFileParameters) (remotefile *RemoteFile, err error) { - // Test if user token is valid. This helps because client.Do doesn't like this for some reason. XXX: More - // investigation needed, but for now this will do. - _, err = api.AuthTest() - if err != nil { - return nil, err - } response := &remoteFileResponseFull{} values := url.Values{ "token": {api.token}, @@ -243,9 +247,9 @@ func (api *Client) UpdateRemoteFileContext(ctx context.Context, fileID string, p values.Add("indexable_file_contents", params.IndexableFileContents) } if params.PreviewImageReader != nil { - err = postWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.add", "preview.png", "preview_image", values, params.PreviewImageReader, response, api) + err = postWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.update", "preview.png", "preview_image", values, params.PreviewImageReader, response, api) } else { - response, err = api.remoteFileRequest(ctx, "files.remote.add", values) + response, err = api.remoteFileRequest(ctx, "files.remote.update", values) } if err != nil { @@ -262,6 +266,12 @@ func (api *Client) RemoveRemoteFile(externalID, fileID string) (err error) { // RemoveRemoteFileContext removes a remote file with a custom context func (api *Client) RemoveRemoteFileContext(ctx context.Context, externalID, fileID string) (err error) { + if fileID == "" && externalID == "" { + return fmt.Errorf("either externalID or fileID is required") + } + if fileID != "" && externalID != "" { + return fmt.Errorf("don't provide both externalID and fileID") + } values := url.Values{ "token": {api.token}, } diff --git a/remotefiles_test.go b/remotefiles_test.go new file mode 100644 index 000000000..68e12a38d --- /dev/null +++ b/remotefiles_test.go @@ -0,0 +1,194 @@ +package slack + +import ( + "encoding/json" + "net/http" + "strings" + "testing" +) + +func addRemoteFileHandler(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(remoteFileResponseFull{ + SlackResponse: SlackResponse{Ok: true}}) + rw.Write(response) +} + +func TestAddRemoteFile(t *testing.T) { + http.HandleFunc("/files.remote.add", addRemoteFileHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + params := RemoteFileParameters{ + ExternalID: "externalID", + ExternalURL: "http://example.com/", + Title: "example", + } + if _, err := api.AddRemoteFile(params); err != nil { + t.Errorf("Unexpected error: %s", err) + } +} + +func TestAddRemoteFileWithoutTitle(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + params := RemoteFileParameters{ + ExternalID: "externalID", + ExternalURL: "http://example.com/", + } + if _, err := api.AddRemoteFile(params); err != ErrParametersMissing { + t.Errorf("Expected ErrParametersMissing. got %s", err) + } +} + +func listRemoteFileHandler(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(remoteFileResponseFull{ + SlackResponse: SlackResponse{Ok: true}}) + rw.Write(response) +} + +func TestListRemoteFile(t *testing.T) { + http.HandleFunc("/files.remote.list", listRemoteFileHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + params := ListRemoteFilesParameters{} + if _, _, err := api.ListRemoteFiles(params); err != nil { + t.Errorf("Unexpected error: %s", err) + } +} + +func getRemoteFileInfoHandler(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(remoteFileResponseFull{ + SlackResponse: SlackResponse{Ok: true}}) + rw.Write(response) +} + +func TestGetRemoteFileInfo(t *testing.T) { + http.HandleFunc("/files.remote.info", getRemoteFileInfoHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + if _, err := api.GetRemoteFileInfo("ExternalID", ""); err != nil { + t.Errorf("Unexpected error: %s", err) + } +} + +func TestGetRemoteFileInfoWithoutID(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + _, err := api.GetRemoteFileInfo("", "") + if err == nil { + t.Fatal("Expected error when both externalID and fileID is not provided, instead got nil") + } + if !strings.Contains(err.Error(), "either externalID or fileID is required") { + t.Errorf("Error message should mention a required field") + } +} + +func TestGetRemoteFileInfoWithFileIDAndExternalID(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + _, err := api.GetRemoteFileInfo("ExternalID", "FileID") + if err == nil { + t.Fatal("Expected error when both externalID and fileID are both provided, instead got nil") + } + if !strings.Contains(err.Error(), "don't provide both externalID and fileID") { + t.Errorf("Error message should mention don't providing both externalID and fileID") + } +} + +func shareRemoteFileHandler(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(remoteFileResponseFull{ + SlackResponse: SlackResponse{Ok: true}}) + rw.Write(response) +} + +func TestShareRemoteFile(t *testing.T) { + http.HandleFunc("/files.remote.share", shareRemoteFileHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + if _, err := api.ShareRemoteFile([]string{"channel"}, "ExternalID", ""); err != nil { + t.Errorf("Unexpected error: %s", err) + } +} + +func TestShareRemoteFileWithoutChannels(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + if _, err := api.ShareRemoteFile([]string{}, "ExternalID", ""); err != ErrParametersMissing { + t.Errorf("Expected ErrParametersMissing. got %s", err) + } +} + +func TestShareRemoteFileWithoutID(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + _, err := api.ShareRemoteFile([]string{"channel"}, "", "") + if err == nil { + t.Fatal("Expected error when both externalID and fileID is not provided, instead got nil") + } + if !strings.Contains(err.Error(), "either externalID or fileID is required") { + t.Errorf("Error message should mention a required field") + } +} + +func updateRemoteFileHandler(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(remoteFileResponseFull{ + SlackResponse: SlackResponse{Ok: true}}) + rw.Write(response) +} + +func TestUpdateRemoteFile(t *testing.T) { + http.HandleFunc("/files.remote.update", updateRemoteFileHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + params := RemoteFileParameters{ + ExternalURL: "http://example.com/", + Title: "example", + } + if _, err := api.UpdateRemoteFile("fileID", params); err != nil { + t.Errorf("Unexpected error: %s", err) + } +} + +func removeRemoteFileHandler(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(remoteFileResponseFull{ + SlackResponse: SlackResponse{Ok: true}}) + rw.Write(response) +} + +func TestRemoveRemoteFile(t *testing.T) { + http.HandleFunc("/files.remote.remove", removeRemoteFileHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + if err := api.RemoveRemoteFile("ExternalID", ""); err != nil { + t.Errorf("Unexpected error: %s", err) + } +} + +func TestRemoveRemoteFileWithoutID(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + err := api.RemoveRemoteFile("", "") + if err == nil { + t.Fatal("Expected error when both externalID and fileID is not provided, instead got nil") + } + if !strings.Contains(err.Error(), "either externalID or fileID is required") { + t.Errorf("Error message should mention a required field") + } +} + +func TestRemoveRemoteFileWithFileIDAndExternalID(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + err := api.RemoveRemoteFile("ExternalID", "FileID") + if err == nil { + t.Fatal("Expected error when both externalID and fileID are both provided, instead got nil") + } + if !strings.Contains(err.Error(), "don't provide both externalID and fileID") { + t.Errorf("Error message should mention don't providing both externalID and fileID") + } +} From 54bd12bc33f979b483eebeededbebeb567fc712c Mon Sep 17 00:00:00 2001 From: xnok Date: Sat, 23 Jan 2021 00:03:22 -0500 Subject: [PATCH 004/606] feat(slacktest): add handler for reaction.Add --- slacktest/data.go | 6 ++++++ slacktest/handlers.go | 5 +++++ slacktest/server.go | 1 + 3 files changed, 12 insertions(+) diff --git a/slacktest/data.go b/slacktest/data.go index 37d413a0d..e33b901dc 100644 --- a/slacktest/data.go +++ b/slacktest/data.go @@ -35,6 +35,12 @@ var okWebResponse = slack.SlackResponse{ Ok: true, } +var defaultOkJSON = fmt.Sprintf(` + { + "ok": true + } + `) + var defaultChannelsListJSON = fmt.Sprintf(` { "ok": true, diff --git a/slacktest/handlers.go b/slacktest/handlers.go index 85eec5804..6e7f1dd1d 100644 --- a/slacktest/handlers.go +++ b/slacktest/handlers.go @@ -117,6 +117,11 @@ func listGroupsHandler(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(defaultGroupsListJSON)) } +// handle reaction.Add +func reactionAddHandler(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(defaultOkJSON)) +} + // handle chat.postMessage func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) { serverAddr := r.Context().Value(ServerBotHubNameContextKey).(string) diff --git a/slacktest/server.go b/slacktest/server.go index aae24995d..6e7395f5a 100644 --- a/slacktest/server.go +++ b/slacktest/server.go @@ -62,6 +62,7 @@ func NewTestServer(custom ...binder) *Server { s.Handle("/users.lookupByEmail", usersInfoHandler) s.Handle("/bots.info", botsInfoHandler) s.Handle("/auth.test", authTestHandler) + s.Handle("/reactions.add", reactionAddHandler) httpserver := httptest.NewUnstartedServer(s.mux) addr := httpserver.Listener.Addr().String() From 3178aab754525464ca70dc253c3e13f108139096 Mon Sep 17 00:00:00 2001 From: xnok Date: Sun, 21 Feb 2021 00:22:03 -0500 Subject: [PATCH 005/606] feat: basic stucture --- .../sockermode_handler/socketmode_handler.go | 138 ++++++++++++++++++ socketmode/socketmode_eventhandler.go | 115 +++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 examples/sockermode_handler/socketmode_handler.go create mode 100644 socketmode/socketmode_eventhandler.go diff --git a/examples/sockermode_handler/socketmode_handler.go b/examples/sockermode_handler/socketmode_handler.go new file mode 100644 index 000000000..d1857a77d --- /dev/null +++ b/examples/sockermode_handler/socketmode_handler.go @@ -0,0 +1,138 @@ +package main + +import ( + "fmt" + "log" + "os" + "strings" + + "github.com/slack-go/slack/socketmode" + + "github.com/slack-go/slack" +) + +func main() { + appToken := os.Getenv("SLACK_APP_TOKEN") + if appToken == "" { + + } + + if !strings.HasPrefix(appToken, "xapp-") { + fmt.Fprintf(os.Stderr, "SLACK_APP_TOKEN must have the prefix \"xapp-\".") + } + + botToken := os.Getenv("SLACK_BOT_TOKEN") + if botToken == "" { + fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN must be set.\n") + os.Exit(1) + } + + if !strings.HasPrefix(botToken, "xoxb-") { + fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN must have the prefix \"xoxb-\".") + } + + api := slack.New( + botToken, + slack.OptionDebug(true), + slack.OptionLog(log.New(os.Stdout, "api: ", log.Lshortfile|log.LstdFlags)), + slack.OptionAppLevelToken(appToken), + ) + + client := socketmode.New( + api, + socketmode.OptionDebug(true), + socketmode.OptionLog(log.New(os.Stdout, "socketmode: ", log.Lshortfile|log.LstdFlags)), + ) + + socketmodeHandler := socketmode.NewsSocketmodeHandler(client) + + socketmodeHandler.Handle(socketmode.EventTypeConnecting, middlewareConnecting) + socketmodeHandler.Handle(socketmode.EventTypeConnectionError, middlewareConnectionError) + socketmodeHandler.Handle(socketmode.EventTypeConnected, middlewareConnected) + + // socketmodeHandler.Handle(socketmode.EventTypeEventsAPI, middlewareEventsAPI) + // socketmodeHandler.Handle(socketmode.EventTypeSlashCommand, middlewareSlashCommand) + + // socketmodeHandler.HandleDefault(middlewareDefault) + + socketmodeHandler.RunEventLoop() +} + +func middlewareConnecting(evt socketmode.Event) { + fmt.Println("Connecting to Slack with Socket Mode...") +} + +func middlewareConnectionError(evt socketmode.Event) { + fmt.Println("Connection failed. Retrying later...") +} + +func middlewareConnected(evt socketmode.Event) { + fmt.Println("Connected to Slack with Socket Mode.") +} + +func middlewareEventsAPI(evt socketmode.Event) { + // eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + // if !ok { + // fmt.Printf("Ignored %+v\n", evt) + + // continue + // } + + // fmt.Printf("Event received: %+v\n", eventsAPIEvent) + + // client.Ack(*evt.Request) + + // switch eventsAPIEvent.Type { + // case slackevents.CallbackEvent: + // innerEvent := eventsAPIEvent.InnerEvent + // switch ev := innerEvent.Data.(type) { + // case *slackevents.AppMentionEvent: + // _, _, err := api.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) + // if err != nil { + // fmt.Printf("failed posting message: %v", err) + // } + // case *slackevents.MemberJoinedChannelEvent: + // fmt.Printf("user %q joined to channel %q", ev.User, ev.Channel) + // } + // default: + // client.Debugf("unsupported Events API event received") + // } +} + +func middlewareSlashCommand(evt socketmode.Event) { + // cmd, ok := evt.Data.(slack.SlashCommand) + // if !ok { + // fmt.Printf("Ignored %+v\n", evt) + + // continue + // } + + // client.Debugf("Slash command received: %+v", cmd) + + // payload := map[string]interface{}{ + // "blocks": []slack.Block{ + // slack.NewSectionBlock( + // &slack.TextBlockObject{ + // Type: slack.MarkdownType, + // Text: "foo", + // }, + // nil, + // slack.NewAccessory( + // slack.NewButtonBlockElement( + // "", + // "somevalue", + // &slack.TextBlockObject{ + // Type: slack.PlainTextType, + // Text: "bar", + // }, + // ), + // ), + // ), + // }} + + // client.Ack(*evt.Request, payload) +} + +func middlewareDefault(evt socketmode.Event) { + // fmt.Fprintf(os.Stderr, "Unexpected event type received: %s\n", evt.Type) +} diff --git a/socketmode/socketmode_eventhandler.go b/socketmode/socketmode_eventhandler.go new file mode 100644 index 000000000..aca9e52ab --- /dev/null +++ b/socketmode/socketmode_eventhandler.go @@ -0,0 +1,115 @@ +package socketmode + +import ( + "log" + + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" +) + +type SocketmodeHandler struct { + Socket *Client + + EventMap map[EventType][]SocketmodeHandlerFunc +} + +type SocketmodeHandlerFunc func(Event) + +func NewsSocketmodeHandler(socket *Client) *SocketmodeHandler { + eventMap := make(map[EventType][]SocketmodeHandlerFunc) + + return &SocketmodeHandler{ + Socket: socket, + EventMap: eventMap, + } +} + +func (r *SocketmodeHandler) Handle(et EventType, f func(Event)) { + r.EventMap[et] = append(r.EventMap[et], f) +} + +// RunSlackEventLoop receives the event via the socket +// It receives events from Slack and each is handled as needed +func (r *SocketmodeHandler) RunEventLoop() { + + go r.runEventLoop() + + r.Socket.Run() +} + +func (r *SocketmodeHandler) runEventLoop() { + for evt := range r.Socket.Events { + if handlers, ok := r.EventMap[evt.Type]; ok { + // If we registered an event + for _, f := range handlers { + go f(evt) + } + } else { + // We need to explicitely subscribe to event in the Application Dashboard + // So every event sould be handle otherwise this is an error + log.Printf("Unexpected event type received: %v\n", evt.Type) + } + + } +} + +func (r *SocketmodeHandler) EventTypeEventsAPIHandler(evt *Event) { + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + log.Println("Unable to handle recieved EventsAPIEvent") + return + } + + // We need to confirm that we recived that event + r.Socket.Ack(*evt.Request) + + switch eventsAPIEvent.Type { + case slackevents.CallbackEvent: + innerEvent := eventsAPIEvent.InnerEvent + switch ev := innerEvent.Data.(type) { + case *slackevents.AppMentionEvent: + + case *slackevents.MessageEvent: + + case *slackevents.MemberJoinedChannelEvent: + + default: + log.Printf("unsupported CallbackEvent event received: %T", ev) + } + default: + log.Printf("unsupported eventsAPIEvent event received: %s", eventsAPIEvent.Type) + } +} + +func (r *SocketmodeHandler) EventTypeInteractiveHandler(evt *Event) { + callback, ok := evt.Data.(slack.InteractionCallback) + if !ok { + return + } + + var payload interface{} + + switch callback.Type { + case slack.InteractionTypeBlockActions: + // See https://api.slack.com/apis/connections/socket-implement#button + case slack.InteractionTypeShortcut: + // See https://api.slack.com/interactivity/shortcuts + case slack.InteractionTypeViewSubmission: + // See https://api.slack.com/apis/connections/socket-implement#modal + case slack.InteractionTypeDialogSubmission: + + default: + log.Printf("unsupported callbackEvent event received: %s", callback.Type) + } + + r.Socket.Ack(*evt.Request, payload) +} + +func (r *SocketmodeHandler) EventTypeSlashCommandHandler(evt *Event) { + cmd, ok := evt.Data.(slack.SlashCommand) + if !ok { + return + } + + log.Printf("%s", cmd) +} From 65335cfa6b73e83bb98f7b351ab1a743f7e5b31d Mon Sep 17 00:00:00 2001 From: xnok Date: Sun, 21 Feb 2021 17:24:38 -0500 Subject: [PATCH 006/606] feat: socketmode Event Handling --- .../sockermode_handler/socketmode_handler.go | 166 +++++++++++------- socketmode/client.go | 5 + socketmode/socketmode_eventhandler.go | 80 +-------- 3 files changed, 114 insertions(+), 137 deletions(-) diff --git a/examples/sockermode_handler/socketmode_handler.go b/examples/sockermode_handler/socketmode_handler.go index d1857a77d..46b295608 100644 --- a/examples/sockermode_handler/socketmode_handler.go +++ b/examples/sockermode_handler/socketmode_handler.go @@ -6,6 +6,7 @@ import ( "os" "strings" + "github.com/slack-go/slack/slackevents" "github.com/slack-go/slack/socketmode" "github.com/slack-go/slack" @@ -50,89 +51,124 @@ func main() { socketmodeHandler.Handle(socketmode.EventTypeConnectionError, middlewareConnectionError) socketmodeHandler.Handle(socketmode.EventTypeConnected, middlewareConnected) - // socketmodeHandler.Handle(socketmode.EventTypeEventsAPI, middlewareEventsAPI) - // socketmodeHandler.Handle(socketmode.EventTypeSlashCommand, middlewareSlashCommand) + // Handle the EventsAPI + socketmodeHandler.Handle(socketmode.EventTypeEventsAPI, middlewareEventsAPI) + + // Handle a specific event from EventsAPI + // socketmodeHandler.HandleEventsAPI(socketmode.EventTypeAppMention, middlewareAppMentionEvent) + + socketmodeHandler.Handle(socketmode.EventTypeInteractive, middlewareInteractive) + + // Handle the EventsAPI + socketmodeHandler.Handle(socketmode.EventTypeSlashCommand, middlewareSlashCommand) // socketmodeHandler.HandleDefault(middlewareDefault) socketmodeHandler.RunEventLoop() } -func middlewareConnecting(evt socketmode.Event) { +func middlewareConnecting(evt *socketmode.Event, client *socketmode.Client) { fmt.Println("Connecting to Slack with Socket Mode...") } -func middlewareConnectionError(evt socketmode.Event) { +func middlewareConnectionError(evt *socketmode.Event, client *socketmode.Client) { fmt.Println("Connection failed. Retrying later...") } -func middlewareConnected(evt socketmode.Event) { +func middlewareConnected(evt *socketmode.Event, client *socketmode.Client) { fmt.Println("Connected to Slack with Socket Mode.") } -func middlewareEventsAPI(evt socketmode.Event) { - // eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) - // if !ok { - // fmt.Printf("Ignored %+v\n", evt) - - // continue - // } - - // fmt.Printf("Event received: %+v\n", eventsAPIEvent) - - // client.Ack(*evt.Request) - - // switch eventsAPIEvent.Type { - // case slackevents.CallbackEvent: - // innerEvent := eventsAPIEvent.InnerEvent - // switch ev := innerEvent.Data.(type) { - // case *slackevents.AppMentionEvent: - // _, _, err := api.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) - // if err != nil { - // fmt.Printf("failed posting message: %v", err) - // } - // case *slackevents.MemberJoinedChannelEvent: - // fmt.Printf("user %q joined to channel %q", ev.User, ev.Channel) - // } - // default: - // client.Debugf("unsupported Events API event received") - // } +func middlewareEventsAPI(evt *socketmode.Event, client *socketmode.Client) { + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + return + } + + fmt.Printf("Event received: %+v\n", eventsAPIEvent) + + client.Ack(*evt.Request) + + switch eventsAPIEvent.Type { + case slackevents.CallbackEvent: + innerEvent := eventsAPIEvent.InnerEvent + switch ev := innerEvent.Data.(type) { + case *slackevents.AppMentionEvent: + fmt.Printf("We have been mentionned in %v", ev.Channel) + _, _, err := client.GetApiClient().PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) + if err != nil { + fmt.Printf("failed posting message: %v", err) + } + case *slackevents.MemberJoinedChannelEvent: + fmt.Printf("user %q joined to channel %q", ev.User, ev.Channel) + } + default: + client.Debugf("unsupported Events API event received") + } } -func middlewareSlashCommand(evt socketmode.Event) { - // cmd, ok := evt.Data.(slack.SlashCommand) - // if !ok { - // fmt.Printf("Ignored %+v\n", evt) - - // continue - // } - - // client.Debugf("Slash command received: %+v", cmd) - - // payload := map[string]interface{}{ - // "blocks": []slack.Block{ - // slack.NewSectionBlock( - // &slack.TextBlockObject{ - // Type: slack.MarkdownType, - // Text: "foo", - // }, - // nil, - // slack.NewAccessory( - // slack.NewButtonBlockElement( - // "", - // "somevalue", - // &slack.TextBlockObject{ - // Type: slack.PlainTextType, - // Text: "bar", - // }, - // ), - // ), - // ), - // }} - - // client.Ack(*evt.Request, payload) +func middlewareInteractive(evt *socketmode.Event, client *socketmode.Client) { + callback, ok := evt.Data.(slack.InteractionCallback) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + + return + } + + fmt.Printf("Interaction received: %+v\n", callback) + + var payload interface{} + + switch callback.Type { + case slack.InteractionTypeBlockActions: + // See https://api.slack.com/apis/connections/socket-implement#button + + client.Debugf("button clicked!") + case slack.InteractionTypeShortcut: + case slack.InteractionTypeViewSubmission: + // See https://api.slack.com/apis/connections/socket-implement#modal + case slack.InteractionTypeDialogSubmission: + default: + + } + + client.Ack(*evt.Request, payload) +} + +func middlewareSlashCommand(evt *socketmode.Event, client *socketmode.Client) { + cmd, ok := evt.Data.(slack.SlashCommand) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + return + } + + client.Debugf("Slash command received: %+v", cmd) + + payload := map[string]interface{}{ + "blocks": []slack.Block{ + slack.NewSectionBlock( + &slack.TextBlockObject{ + Type: slack.MarkdownType, + Text: "foo", + }, + nil, + slack.NewAccessory( + slack.NewButtonBlockElement( + "", + "somevalue", + &slack.TextBlockObject{ + Type: slack.PlainTextType, + Text: "bar", + }, + ), + ), + ), + }} + + client.Ack(*evt.Request, payload) } -func middlewareDefault(evt socketmode.Event) { +func middlewareDefault(evt *socketmode.Event, client *socketmode.Client) { // fmt.Fprintf(os.Stderr, "Unexpected event type received: %s\n", evt.Type) } diff --git a/socketmode/client.go b/socketmode/client.go index cce8a703b..fc15d9df2 100644 --- a/socketmode/client.go +++ b/socketmode/client.go @@ -61,3 +61,8 @@ type Client struct { debug bool log ilogger } + +// return a pointer to the slack API client +func (c Client) GetApiClient() *slack.Client { + return &c.apiClient +} diff --git a/socketmode/socketmode_eventhandler.go b/socketmode/socketmode_eventhandler.go index aca9e52ab..12821f936 100644 --- a/socketmode/socketmode_eventhandler.go +++ b/socketmode/socketmode_eventhandler.go @@ -2,29 +2,26 @@ package socketmode import ( "log" - - "github.com/slack-go/slack" - "github.com/slack-go/slack/slackevents" ) type SocketmodeHandler struct { - Socket *Client + Client *Client EventMap map[EventType][]SocketmodeHandlerFunc } -type SocketmodeHandlerFunc func(Event) +type SocketmodeHandlerFunc func(*Event, *Client) -func NewsSocketmodeHandler(socket *Client) *SocketmodeHandler { +func NewsSocketmodeHandler(client *Client) *SocketmodeHandler { eventMap := make(map[EventType][]SocketmodeHandlerFunc) return &SocketmodeHandler{ - Socket: socket, + Client: client, EventMap: eventMap, } } -func (r *SocketmodeHandler) Handle(et EventType, f func(Event)) { +func (r *SocketmodeHandler) Handle(et EventType, f SocketmodeHandlerFunc) { r.EventMap[et] = append(r.EventMap[et], f) } @@ -34,15 +31,15 @@ func (r *SocketmodeHandler) RunEventLoop() { go r.runEventLoop() - r.Socket.Run() + r.Client.Run() } func (r *SocketmodeHandler) runEventLoop() { - for evt := range r.Socket.Events { + for evt := range r.Client.Events { if handlers, ok := r.EventMap[evt.Type]; ok { // If we registered an event for _, f := range handlers { - go f(evt) + go f(&evt, r.Client) } } else { // We need to explicitely subscribe to event in the Application Dashboard @@ -52,64 +49,3 @@ func (r *SocketmodeHandler) runEventLoop() { } } - -func (r *SocketmodeHandler) EventTypeEventsAPIHandler(evt *Event) { - eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) - if !ok { - log.Println("Unable to handle recieved EventsAPIEvent") - return - } - - // We need to confirm that we recived that event - r.Socket.Ack(*evt.Request) - - switch eventsAPIEvent.Type { - case slackevents.CallbackEvent: - innerEvent := eventsAPIEvent.InnerEvent - switch ev := innerEvent.Data.(type) { - case *slackevents.AppMentionEvent: - - case *slackevents.MessageEvent: - - case *slackevents.MemberJoinedChannelEvent: - - default: - log.Printf("unsupported CallbackEvent event received: %T", ev) - } - default: - log.Printf("unsupported eventsAPIEvent event received: %s", eventsAPIEvent.Type) - } -} - -func (r *SocketmodeHandler) EventTypeInteractiveHandler(evt *Event) { - callback, ok := evt.Data.(slack.InteractionCallback) - if !ok { - return - } - - var payload interface{} - - switch callback.Type { - case slack.InteractionTypeBlockActions: - // See https://api.slack.com/apis/connections/socket-implement#button - case slack.InteractionTypeShortcut: - // See https://api.slack.com/interactivity/shortcuts - case slack.InteractionTypeViewSubmission: - // See https://api.slack.com/apis/connections/socket-implement#modal - case slack.InteractionTypeDialogSubmission: - - default: - log.Printf("unsupported callbackEvent event received: %s", callback.Type) - } - - r.Socket.Ack(*evt.Request, payload) -} - -func (r *SocketmodeHandler) EventTypeSlashCommandHandler(evt *Event) { - cmd, ok := evt.Data.(slack.SlashCommand) - if !ok { - return - } - - log.Printf("%s", cmd) -} From 1dbd284b566b731a64f4f897f5a7c65023c0bb9f Mon Sep 17 00:00:00 2001 From: xnok Date: Sun, 21 Feb 2021 20:25:46 -0500 Subject: [PATCH 007/606] feat: handle all type of EventTypeInteractive --- .../sockermode_handler/socketmode_handler.go | 12 +++-- socketmode/socketmode_eventhandler.go | 44 +++++++++++++++++-- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/examples/sockermode_handler/socketmode_handler.go b/examples/sockermode_handler/socketmode_handler.go index 46b295608..95c3dba87 100644 --- a/examples/sockermode_handler/socketmode_handler.go +++ b/examples/sockermode_handler/socketmode_handler.go @@ -57,9 +57,13 @@ func main() { // Handle a specific event from EventsAPI // socketmodeHandler.HandleEventsAPI(socketmode.EventTypeAppMention, middlewareAppMentionEvent) + // Handle the Interactive Events socketmodeHandler.Handle(socketmode.EventTypeInteractive, middlewareInteractive) - // Handle the EventsAPI + // Handle a specific Interaction + socketmodeHandler.HandleInteraction(slack.InteractionTypeBlockActions, middlewareInteractionTypeBlockActions) + + // Handle the SlashCommand socketmodeHandler.Handle(socketmode.EventTypeSlashCommand, middlewareSlashCommand) // socketmodeHandler.HandleDefault(middlewareDefault) @@ -123,7 +127,6 @@ func middlewareInteractive(evt *socketmode.Event, client *socketmode.Client) { switch callback.Type { case slack.InteractionTypeBlockActions: // See https://api.slack.com/apis/connections/socket-implement#button - client.Debugf("button clicked!") case slack.InteractionTypeShortcut: case slack.InteractionTypeViewSubmission: @@ -136,6 +139,10 @@ func middlewareInteractive(evt *socketmode.Event, client *socketmode.Client) { client.Ack(*evt.Request, payload) } +func middlewareInteractionTypeBlockActions(evt *socketmode.Event, client *socketmode.Client) { + client.Debugf("button clicked!") +} + func middlewareSlashCommand(evt *socketmode.Event, client *socketmode.Client) { cmd, ok := evt.Data.(slack.SlashCommand) if !ok { @@ -165,7 +172,6 @@ func middlewareSlashCommand(evt *socketmode.Event, client *socketmode.Client) { ), ), }} - client.Ack(*evt.Request, payload) } diff --git a/socketmode/socketmode_eventhandler.go b/socketmode/socketmode_eventhandler.go index 12821f936..45b75445d 100644 --- a/socketmode/socketmode_eventhandler.go +++ b/socketmode/socketmode_eventhandler.go @@ -1,23 +1,29 @@ package socketmode import ( + "fmt" "log" + + "github.com/slack-go/slack" ) type SocketmodeHandler struct { Client *Client - EventMap map[EventType][]SocketmodeHandlerFunc + EventMap map[EventType][]SocketmodeHandlerFunc + InteractionEventMap map[slack.InteractionType][]SocketmodeHandlerFunc } type SocketmodeHandlerFunc func(*Event, *Client) func NewsSocketmodeHandler(client *Client) *SocketmodeHandler { eventMap := make(map[EventType][]SocketmodeHandlerFunc) + interactioneventMap := make(map[slack.InteractionType][]SocketmodeHandlerFunc) return &SocketmodeHandler{ - Client: client, - EventMap: eventMap, + Client: client, + EventMap: eventMap, + InteractionEventMap: interactioneventMap, } } @@ -25,6 +31,10 @@ func (r *SocketmodeHandler) Handle(et EventType, f SocketmodeHandlerFunc) { r.EventMap[et] = append(r.EventMap[et], f) } +func (r *SocketmodeHandler) HandleInteraction(et slack.InteractionType, f SocketmodeHandlerFunc) { + r.InteractionEventMap[et] = append(r.InteractionEventMap[et], f) +} + // RunSlackEventLoop receives the event via the socket // It receives events from Slack and each is handled as needed func (r *SocketmodeHandler) RunEventLoop() { @@ -36,6 +46,14 @@ func (r *SocketmodeHandler) RunEventLoop() { func (r *SocketmodeHandler) runEventLoop() { for evt := range r.Client.Events { + + // Some eventType can be further decomposed + switch evt.Type { + case EventTypeInteractive: + go r.Interaction(&evt) + // case EventTypeEventsAPI: + } + if handlers, ok := r.EventMap[evt.Type]; ok { // If we registered an event for _, f := range handlers { @@ -49,3 +67,23 @@ func (r *SocketmodeHandler) runEventLoop() { } } + +func (r *SocketmodeHandler) Interaction(evt *Event) { + interaction, ok := evt.Data.(slack.InteractionCallback) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + return + } + + if handlers, ok := r.InteractionEventMap[interaction.Type]; ok { + // If we registered an event + for _, f := range handlers { + go f(evt, r.Client) + } + } else { + // We need to explicitely subscribe to event in the Application Dashboard + // So every event sould be handle otherwise this is an error + log.Printf("Unexpected event type received: %v\n", evt.Type) + } + +} From 6d8be03f54f4cd408735d1f39c935d938815f87c Mon Sep 17 00:00:00 2001 From: xnok Date: Tue, 23 Feb 2021 00:10:05 -0500 Subject: [PATCH 008/606] feat: handler EventAPI inner events --- .../sockermode_handler/socketmode_handler.go | 39 ++++++++++++++++--- slackevents/inner_events.go | 34 ++++++++-------- slackevents/parsers.go | 2 +- socketmode/socketmode_eventhandler.go | 33 +++++++++++++++- socketmode/socketmode_test.go | 4 +- 5 files changed, 86 insertions(+), 26 deletions(-) diff --git a/examples/sockermode_handler/socketmode_handler.go b/examples/sockermode_handler/socketmode_handler.go index 95c3dba87..de085a4aa 100644 --- a/examples/sockermode_handler/socketmode_handler.go +++ b/examples/sockermode_handler/socketmode_handler.go @@ -51,19 +51,21 @@ func main() { socketmodeHandler.Handle(socketmode.EventTypeConnectionError, middlewareConnectionError) socketmodeHandler.Handle(socketmode.EventTypeConnected, middlewareConnected) - // Handle the EventsAPI - socketmodeHandler.Handle(socketmode.EventTypeEventsAPI, middlewareEventsAPI) + //\\ EventTypeEventsAPI //\\ + // Handle all EventsAPI + // socketmodeHandler.Handle(socketmode.EventTypeEventsAPI, middlewareEventsAPI) // Handle a specific event from EventsAPI - // socketmodeHandler.HandleEventsAPI(socketmode.EventTypeAppMention, middlewareAppMentionEvent) + socketmodeHandler.HandleEventsAPI(slackevents.AppMention, middlewareAppMentionEvent) - // Handle the Interactive Events + //\\ EventTypeInteractive //\\ + // Handle all Interactive Events socketmodeHandler.Handle(socketmode.EventTypeInteractive, middlewareInteractive) // Handle a specific Interaction socketmodeHandler.HandleInteraction(slack.InteractionTypeBlockActions, middlewareInteractionTypeBlockActions) - // Handle the SlashCommand + // Handle all SlashCommand socketmodeHandler.Handle(socketmode.EventTypeSlashCommand, middlewareSlashCommand) // socketmodeHandler.HandleDefault(middlewareDefault) @@ -84,6 +86,7 @@ func middlewareConnected(evt *socketmode.Event, client *socketmode.Client) { } func middlewareEventsAPI(evt *socketmode.Event, client *socketmode.Client) { + fmt.Println("middlewareEventsAPI") eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) if !ok { fmt.Printf("Ignored %+v\n", evt) @@ -112,11 +115,35 @@ func middlewareEventsAPI(evt *socketmode.Event, client *socketmode.Client) { } } +func middlewareAppMentionEvent(evt *socketmode.Event, client *socketmode.Client) { + fmt.Printf("middlewareAppMentionEvent: %+v\n", evt) + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + return + } + + fmt.Printf("middlewareAppMentionEvent: %+v\n", eventsAPIEvent.InnerEvent.Data) + + client.Ack(*evt.Request) + + ev, ok := eventsAPIEvent.InnerEvent.Data.(*slackevents.AppMentionEvent) + if !ok { + fmt.Printf("Ignored %+v\n", ev) + return + } + + fmt.Printf("We have been mentionned in %v\n", ev.Channel) + _, _, err := client.GetApiClient().PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) + if err != nil { + fmt.Printf("failed posting message: %v", err) + } +} + func middlewareInteractive(evt *socketmode.Event, client *socketmode.Client) { callback, ok := evt.Data.(slack.InteractionCallback) if !ok { fmt.Printf("Ignored %+v\n", evt) - return } diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 641d1691e..675bbfba7 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -303,43 +303,45 @@ func (e MessageEvent) IsEdited() bool { e.Message.Edited != nil } +type EventAPIType string + const ( // AppMention is an Events API subscribable event - AppMention = "app_mention" + AppMention = EventAPIType("app_mention") // AppHomeOpened Your Slack app home was opened - AppHomeOpened = "app_home_opened" + AppHomeOpened = EventAPIType("app_home_opened") // AppUninstalled Your Slack app was uninstalled. - AppUninstalled = "app_uninstalled" + AppUninstalled = EventAPIType("app_uninstalled") // GridMigrationFinished An enterprise grid migration has finished on this workspace. - GridMigrationFinished = "grid_migration_finished" + GridMigrationFinished = EventAPIType("grid_migration_finished") // GridMigrationStarted An enterprise grid migration has started on this workspace. - GridMigrationStarted = "grid_migration_started" + GridMigrationStarted = EventAPIType("grid_migration_started") // LinkShared A message was posted containing one or more links relevant to your application - LinkShared = "link_shared" + LinkShared = EventAPIType("link_shared") // Message A message was posted to a channel, private channel (group), im, or mim - Message = "message" + Message = EventAPIType("message") // Member Joined Channel - MemberJoinedChannel = "member_joined_channel" + MemberJoinedChannel = EventAPIType("member_joined_channel") // Member Left Channel - MemberLeftChannel = "member_left_channel" + MemberLeftChannel = EventAPIType("member_left_channel") // PinAdded An item was pinned to a channel - PinAdded = "pin_added" + PinAdded = EventAPIType("pin_added") // PinRemoved An item was unpinned from a channel - PinRemoved = "pin_removed" + PinRemoved = EventAPIType("pin_removed") // ReactionAdded An reaction was added to a message - ReactionAdded = "reaction_added" + ReactionAdded = EventAPIType("reaction_added") // ReactionRemoved An reaction was removed from a message - ReactionRemoved = "reaction_removed" + ReactionRemoved = EventAPIType("reaction_removed") // TokensRevoked APP's API tokes are revoked - TokensRevoked = "tokens_revoked" + TokensRevoked = EventAPIType("tokens_revoked") // EmojiChanged A custom emoji has been added or changed - EmojiChanged = "emoji_changed" + EmojiChanged = EventAPIType("emoji_changed") ) // EventsAPIInnerEventMapping maps INNER Event API events to their corresponding struct // implementations. The structs should be instances of the unmarshalling // target for the matching event type. -var EventsAPIInnerEventMapping = map[string]interface{}{ +var EventsAPIInnerEventMapping = map[EventAPIType]interface{}{ AppMention: AppMentionEvent{}, AppHomeOpened: AppHomeOpenedEvent{}, AppUninstalled: AppUninstalledEvent{}, diff --git a/slackevents/parsers.go b/slackevents/parsers.go index 07f296aeb..fab7185a8 100644 --- a/slackevents/parsers.go +++ b/slackevents/parsers.go @@ -19,7 +19,7 @@ func eventsMap(t string) (interface{}, bool) { // Must parse EventsAPI FIRST as both RTM and EventsAPI // have a type: "Message" event. // TODO: Handle these cases more explicitly. - v, exists := EventsAPIInnerEventMapping[t] + v, exists := EventsAPIInnerEventMapping[EventAPIType(t)] if exists { return v, exists } diff --git a/socketmode/socketmode_eventhandler.go b/socketmode/socketmode_eventhandler.go index 45b75445d..a34fbe0f9 100644 --- a/socketmode/socketmode_eventhandler.go +++ b/socketmode/socketmode_eventhandler.go @@ -5,6 +5,7 @@ import ( "log" "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" ) type SocketmodeHandler struct { @@ -12,6 +13,7 @@ type SocketmodeHandler struct { EventMap map[EventType][]SocketmodeHandlerFunc InteractionEventMap map[slack.InteractionType][]SocketmodeHandlerFunc + EventApiMap map[slackevents.EventAPIType][]SocketmodeHandlerFunc } type SocketmodeHandlerFunc func(*Event, *Client) @@ -19,10 +21,12 @@ type SocketmodeHandlerFunc func(*Event, *Client) func NewsSocketmodeHandler(client *Client) *SocketmodeHandler { eventMap := make(map[EventType][]SocketmodeHandlerFunc) interactioneventMap := make(map[slack.InteractionType][]SocketmodeHandlerFunc) + eventApiMap := make(map[slackevents.EventAPIType][]SocketmodeHandlerFunc) return &SocketmodeHandler{ Client: client, EventMap: eventMap, + EventApiMap: eventApiMap, InteractionEventMap: interactioneventMap, } } @@ -35,6 +39,10 @@ func (r *SocketmodeHandler) HandleInteraction(et slack.InteractionType, f Socket r.InteractionEventMap[et] = append(r.InteractionEventMap[et], f) } +func (r *SocketmodeHandler) HandleEventsAPI(et slackevents.EventAPIType, f SocketmodeHandlerFunc) { + r.EventApiMap[et] = append(r.EventApiMap[et], f) +} + // RunSlackEventLoop receives the event via the socket // It receives events from Slack and each is handled as needed func (r *SocketmodeHandler) RunEventLoop() { @@ -51,7 +59,8 @@ func (r *SocketmodeHandler) runEventLoop() { switch evt.Type { case EventTypeInteractive: go r.Interaction(&evt) - // case EventTypeEventsAPI: + case EventTypeEventsAPI: + go r.EventAPI(&evt) } if handlers, ok := r.EventMap[evt.Type]; ok { @@ -87,3 +96,25 @@ func (r *SocketmodeHandler) Interaction(evt *Event) { } } + +func (r *SocketmodeHandler) EventAPI(evt *Event) { + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + return + } + + innerEventType := slackevents.EventAPIType(eventsAPIEvent.InnerEvent.Type) + + if handlers, ok := r.EventApiMap[innerEventType]; ok { + // If we registered an event + for _, f := range handlers { + go f(evt, r.Client) + } + } else { + // We need to explicitely subscribe to event in the Application Dashboard + // So every event sould be handle otherwise this is an error + log.Printf("Unexpected event type received: %v\n", evt.Type) + } + +} diff --git a/socketmode/socketmode_test.go b/socketmode/socketmode_test.go index b9ce77690..065db1eb5 100644 --- a/socketmode/socketmode_test.go +++ b/socketmode/socketmode_test.go @@ -229,9 +229,9 @@ func TestEventParsing(t *testing.T) { EventContext: "1-app_mention-redacted-redacted", }, InnerEvent: slackevents.EventsAPIInnerEvent{ - Type: slackevents.AppMention, + Type: string(slackevents.AppMention), Data: &slackevents.AppMentionEvent{ - Type: slackevents.AppMention, + Type: string(slackevents.AppMention), User: "redacted", Text: "<@U01JKSB8T7Y> test", TimeStamp: "1610927831.000200", From 03e47e9d37153318b3b9c65e22392b302cc81aa4 Mon Sep 17 00:00:00 2001 From: xnok Date: Tue, 23 Feb 2021 23:40:40 -0500 Subject: [PATCH 009/606] test: write unit tests --- .../sockermode_handler/socketmode_handler.go | 2 +- socketmode/socketmode_eventhandler.go | 98 ++++-- socketmode/socketmode_eventhandler_test.go | 300 ++++++++++++++++++ 3 files changed, 370 insertions(+), 30 deletions(-) create mode 100644 socketmode/socketmode_eventhandler_test.go diff --git a/examples/sockermode_handler/socketmode_handler.go b/examples/sockermode_handler/socketmode_handler.go index de085a4aa..f4cc4fd4a 100644 --- a/examples/sockermode_handler/socketmode_handler.go +++ b/examples/sockermode_handler/socketmode_handler.go @@ -53,7 +53,7 @@ func main() { //\\ EventTypeEventsAPI //\\ // Handle all EventsAPI - // socketmodeHandler.Handle(socketmode.EventTypeEventsAPI, middlewareEventsAPI) + socketmodeHandler.Handle(socketmode.EventTypeEventsAPI, middlewareEventsAPI) // Handle a specific event from EventsAPI socketmodeHandler.HandleEventsAPI(slackevents.AppMention, middlewareAppMentionEvent) diff --git a/socketmode/socketmode_eventhandler.go b/socketmode/socketmode_eventhandler.go index a34fbe0f9..97bc5a154 100644 --- a/socketmode/socketmode_eventhandler.go +++ b/socketmode/socketmode_eventhandler.go @@ -14,6 +14,8 @@ type SocketmodeHandler struct { EventMap map[EventType][]SocketmodeHandlerFunc InteractionEventMap map[slack.InteractionType][]SocketmodeHandlerFunc EventApiMap map[slackevents.EventAPIType][]SocketmodeHandlerFunc + + Default SocketmodeHandlerFunc } type SocketmodeHandlerFunc func(*Event, *Client) @@ -28,23 +30,33 @@ func NewsSocketmodeHandler(client *Client) *SocketmodeHandler { EventMap: eventMap, EventApiMap: eventApiMap, InteractionEventMap: interactioneventMap, + Default: func(e *Event, c *Client) { + log.Printf("Unexpected event type received: %v\n", e.Type) + }, } } +// Register the middleare funtion to use to handle an Event (from socketmode) func (r *SocketmodeHandler) Handle(et EventType, f SocketmodeHandlerFunc) { r.EventMap[et] = append(r.EventMap[et], f) } +// Register the middleare funtion to use to handle an Interaction func (r *SocketmodeHandler) HandleInteraction(et slack.InteractionType, f SocketmodeHandlerFunc) { r.InteractionEventMap[et] = append(r.InteractionEventMap[et], f) } +// Register the middleare funtion to use to handle an Event (from slackevents) func (r *SocketmodeHandler) HandleEventsAPI(et slackevents.EventAPIType, f SocketmodeHandlerFunc) { r.EventApiMap[et] = append(r.EventApiMap[et], f) } +// Register the middleare funtion to use as a last resort +func (r *SocketmodeHandler) HandleDefault(f SocketmodeHandlerFunc) { + r.Default = f +} + // RunSlackEventLoop receives the event via the socket -// It receives events from Slack and each is handled as needed func (r *SocketmodeHandler) RunEventLoop() { go r.runEventLoop() @@ -54,34 +66,48 @@ func (r *SocketmodeHandler) RunEventLoop() { func (r *SocketmodeHandler) runEventLoop() { for evt := range r.Client.Events { + r.dispatcher(evt) + } +} - // Some eventType can be further decomposed - switch evt.Type { - case EventTypeInteractive: - go r.Interaction(&evt) - case EventTypeEventsAPI: - go r.EventAPI(&evt) - } +func (r *SocketmodeHandler) dispatcher(evt Event) { + var ishandled bool + + // Some eventType can be further decomposed + switch evt.Type { + case EventTypeInteractive: + ishandled = r.interactionDispatcher(&evt) + case EventTypeEventsAPI: + ishandled = r.eventAPIDispatcher(&evt) + default: + ishandled = r.socketmodeDispatcher(&evt) + } - if handlers, ok := r.EventMap[evt.Type]; ok { - // If we registered an event - for _, f := range handlers { - go f(&evt, r.Client) - } - } else { - // We need to explicitely subscribe to event in the Application Dashboard - // So every event sould be handle otherwise this is an error - log.Printf("Unexpected event type received: %v\n", evt.Type) + if !ishandled { + go r.Default(&evt, r.Client) + } +} + +// Dispatch socketmode events to the registered middleware +func (r *SocketmodeHandler) socketmodeDispatcher(evt *Event) bool { + if handlers, ok := r.EventMap[evt.Type]; ok { + // If we registered an event + for _, f := range handlers { + go f(evt, r.Client) } + return true } + + return false } -func (r *SocketmodeHandler) Interaction(evt *Event) { +// Dispatch interactions to the registered middleware +func (r *SocketmodeHandler) interactionDispatcher(evt *Event) bool { interaction, ok := evt.Data.(slack.InteractionCallback) if !ok { fmt.Printf("Ignored %+v\n", evt) - return + return false } if handlers, ok := r.InteractionEventMap[interaction.Type]; ok { @@ -89,19 +115,27 @@ func (r *SocketmodeHandler) Interaction(evt *Event) { for _, f := range handlers { go f(evt, r.Client) } - } else { - // We need to explicitely subscribe to event in the Application Dashboard - // So every event sould be handle otherwise this is an error - log.Printf("Unexpected event type received: %v\n", evt.Type) + + return true + } else if handlers, ok := r.EventMap[evt.Type]; ok { + // fallback it this event is not handle by a more specific handler + + for _, f := range handlers { + go f(evt, r.Client) + } + + return true } + return false } -func (r *SocketmodeHandler) EventAPI(evt *Event) { +// Dispatch eventAPI events to the registered middleware +func (r *SocketmodeHandler) eventAPIDispatcher(evt *Event) bool { eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) if !ok { fmt.Printf("Ignored %+v\n", evt) - return + return false } innerEventType := slackevents.EventAPIType(eventsAPIEvent.InnerEvent.Type) @@ -111,10 +145,16 @@ func (r *SocketmodeHandler) EventAPI(evt *Event) { for _, f := range handlers { go f(evt, r.Client) } - } else { - // We need to explicitely subscribe to event in the Application Dashboard - // So every event sould be handle otherwise this is an error - log.Printf("Unexpected event type received: %v\n", evt.Type) + + return true + } else if handlers, ok := r.EventMap[evt.Type]; ok { + // fallback it this event is not handle by a more specific handler + for _, f := range handlers { + go f(evt, r.Client) + } + + return true } + return false } diff --git a/socketmode/socketmode_eventhandler_test.go b/socketmode/socketmode_eventhandler_test.go new file mode 100644 index 000000000..2530806df --- /dev/null +++ b/socketmode/socketmode_eventhandler_test.go @@ -0,0 +1,300 @@ +package socketmode + +import ( + "reflect" + "runtime" + "testing" + + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" +) + +func init_SocketmodeHandler() *SocketmodeHandler { + eventMap := make(map[EventType][]SocketmodeHandlerFunc) + interactioneventMap := make(map[slack.InteractionType][]SocketmodeHandlerFunc) + eventApiMap := make(map[slackevents.EventAPIType][]SocketmodeHandlerFunc) + + return &SocketmodeHandler{ + Client: &Client{}, + EventMap: eventMap, + EventApiMap: eventApiMap, + InteractionEventMap: interactioneventMap, + } +} + +type testProbe struct { + called bool +} + +func testing_wrapper(ch chan<- string, f SocketmodeHandlerFunc) SocketmodeHandlerFunc { + return SocketmodeHandlerFunc(func(e *Event, c *Client) { + f(e, c) + + var name_f string + + // test with the name of the function we called + v := reflect.ValueOf(f) + if v.Kind() == reflect.Func { + if rf := runtime.FuncForPC(v.Pointer()); rf != nil { + name_f = rf.Name() + } + } else { + name_f = v.String() + } + + ch <- name_f + }) +} + +func middleware_interaction(evt *Event, client *Client) { + //do nothing +} + +func middleware_eventapi(evt *Event, client *Client) { + //do nothing +} + +func middleware(evt *Event, client *Client) { + //do nothing +} + +func defaultmiddleware(evt *Event, client *Client) { + //do nothing +} + +func TestSocketmodeHandler_Handle(t *testing.T) { + type args struct { + evt Event + evt_type EventType + } + tests := []struct { + name string + args args + want string //what is the name of the function we want to be called + }{ + { + name: "Event Match registed function", + args: args{ + evt: Event{ + Type: EventTypeConnecting, + }, + evt_type: EventTypeConnecting, + }, + want: "github.com/slack-go/slack/socketmode.middleware", + }, { + name: "Event do not registed function", + args: args{ + evt: Event{ + Type: EventTypeConnected, + }, + evt_type: EventTypeConnecting, + }, + want: "github.com/slack-go/slack/socketmode.defaultmiddleware", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := init_SocketmodeHandler() + + c := make(chan string) + + r.Handle(tt.args.evt_type, testing_wrapper(c, middleware)) + r.HandleDefault(testing_wrapper(c, defaultmiddleware)) + + r.dispatcher(tt.args.evt) + + got := <-c + + if got != tt.want { + t.Fatalf("middleware was not called for EventTy(\"%v\"), got %v", tt.args.evt_type, got) + } + }) + } +} + +func TestSocketmodeHandler_HandleInteraction(t *testing.T) { + type args struct { + evt Event + register func(*SocketmodeHandler, chan<- string) + } + tests := []struct { + name string + args args + want string //what is the name of the function we want to be called + }{ + { + name: "Event Match registed function", + args: args{ + evt: Event{ + Type: EventTypeInteractive, + Data: slack.InteractionCallback{ + Type: slack.InteractionTypeBlockActions, + }, + }, + register: func(r *SocketmodeHandler, c chan<- string) { + r.Handle(EventTypeInteractive, testing_wrapper(c, middleware)) + r.HandleInteraction(slack.InteractionTypeBlockActions, testing_wrapper(c, middleware_interaction)) + }, + }, + want: "github.com/slack-go/slack/socketmode.middleware_interaction", + }, { + name: "Event do not Match any registed function", + args: args{ + evt: Event{ + Type: EventTypeInteractive, + Data: slack.InteractionCallback{ + Type: slack.InteractionTypeBlockActions, + }, + }, + register: func(r *SocketmodeHandler, c chan<- string) { + r.HandleInteraction(slack.InteractionTypeBlockSuggestion, testing_wrapper(c, middleware_interaction)) + }, + }, + want: "github.com/slack-go/slack/socketmode.defaultmiddleware", + }, { + name: "Event with invalid data is handled by default middleware", + args: args{ + evt: Event{ + Type: EventTypeInteractive, + Data: map[string]string{ + "brokendata": "test", + }, + }, + register: func(r *SocketmodeHandler, c chan<- string) { + r.HandleInteraction(slack.InteractionTypeBlockActions, testing_wrapper(c, middleware_interaction)) + }, + }, + want: "github.com/slack-go/slack/socketmode.defaultmiddleware", + }, { + name: "Event is handled as EventTypeInteractive", + args: args{ + evt: Event{ + Type: EventTypeInteractive, + Data: slack.InteractionCallback{ + Type: slack.InteractionTypeBlockActions, + }, + }, + register: func(r *SocketmodeHandler, c chan<- string) { + r.Handle(EventTypeInteractive, testing_wrapper(c, middleware)) + }, + }, + want: "github.com/slack-go/slack/socketmode.middleware", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := init_SocketmodeHandler() + + c := make(chan string) + + tt.args.register(r, c) + r.HandleDefault(testing_wrapper(c, defaultmiddleware)) + + r.dispatcher(tt.args.evt) + + got := <-c + + if got != tt.want { + t.Fatalf("%s was not called for EventTy(\"%v\"), got %v", tt.want, tt.args.evt.Type, got) + } + }) + } +} + +func TestSocketmodeHandler_HandleEventsAPI(t *testing.T) { + type args struct { + evt Event + register func(*SocketmodeHandler, chan<- string) + } + tests := []struct { + name string + args args + want string //what is the name of the function we want to be called + }{ + { + name: "Event Match registed function", + args: args{ + evt: Event{ + Type: EventTypeEventsAPI, + Data: slackevents.EventsAPIEvent{ + Type: "event_callback", + InnerEvent: slackevents.EventsAPIInnerEvent{ + Type: string(slackevents.AppMention), + }, + }, + }, + register: func(r *SocketmodeHandler, c chan<- string) { + r.Handle(EventTypeEventsAPI, testing_wrapper(c, middleware)) + r.HandleEventsAPI(slackevents.AppMention, testing_wrapper(c, middleware_eventapi)) + }, + }, + want: "github.com/slack-go/slack/socketmode.middleware_eventapi", + }, { + name: "Event do not Match any registed function", + args: args{ + evt: Event{ + Type: EventTypeEventsAPI, + Data: slackevents.EventsAPIEvent{ + Type: "event_callback", + InnerEvent: slackevents.EventsAPIInnerEvent{ + Type: string(slackevents.MemberJoinedChannel), + }, + }, + }, + register: func(r *SocketmodeHandler, c chan<- string) { + r.HandleEventsAPI(slackevents.AppMention, testing_wrapper(c, middleware_eventapi)) + }, + }, + want: "github.com/slack-go/slack/socketmode.defaultmiddleware", + }, { + name: "Event with invalid data is handled by default middleware", + args: args{ + evt: Event{ + Type: EventTypeEventsAPI, + Data: map[string]string{ + "brokendata": "test", + }, + }, + register: func(r *SocketmodeHandler, c chan<- string) { + r.HandleEventsAPI(slackevents.AppMention, testing_wrapper(c, middleware_eventapi)) + }, + }, + want: "github.com/slack-go/slack/socketmode.defaultmiddleware", + }, { + name: "Event is handled as EventTypeInteractive", + args: args{ + evt: Event{ + Type: EventTypeEventsAPI, + Data: slackevents.EventsAPIEvent{ + Type: "event_callback", + InnerEvent: slackevents.EventsAPIInnerEvent{ + Type: string(slackevents.MemberJoinedChannel), + }, + }, + }, + register: func(r *SocketmodeHandler, c chan<- string) { + r.Handle(EventTypeEventsAPI, testing_wrapper(c, middleware)) + }, + }, + want: "github.com/slack-go/slack/socketmode.middleware", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := init_SocketmodeHandler() + + c := make(chan string) + + tt.args.register(r, c) + r.HandleDefault(testing_wrapper(c, defaultmiddleware)) + + r.dispatcher(tt.args.evt) + + got := <-c + + if got != tt.want { + t.Fatalf("%s was not called for EventTy(\"%v\"), got %v", tt.want, tt.args.evt.Type, got) + } + }) + } +} From 2c8000f2780a0d8aac8d3b19afaa3750a4a385dd Mon Sep 17 00:00:00 2001 From: xNok Date: Fri, 26 Feb 2021 13:19:39 -0500 Subject: [PATCH 010/606] feat: added type for SocketmodeMiddlewareFunc --- socketmode/socketmode_eventhandler.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/socketmode/socketmode_eventhandler.go b/socketmode/socketmode_eventhandler.go index 97bc5a154..a1f2b69bf 100644 --- a/socketmode/socketmode_eventhandler.go +++ b/socketmode/socketmode_eventhandler.go @@ -18,8 +18,12 @@ type SocketmodeHandler struct { Default SocketmodeHandlerFunc } +// Handler have access to the event and socketmode client type SocketmodeHandlerFunc func(*Event, *Client) +// Middleware accept SocketmodeHandlerFunc, and return SocketmodeHandlerFunc +type SocketmodeMiddlewareFunc func(SocketmodeHandlerFunc) SocketmodeHandlerFunc + func NewsSocketmodeHandler(client *Client) *SocketmodeHandler { eventMap := make(map[EventType][]SocketmodeHandlerFunc) interactioneventMap := make(map[slack.InteractionType][]SocketmodeHandlerFunc) From 357e3741f9596231e11d9d592e9fb7528f5440ce Mon Sep 17 00:00:00 2001 From: xNok Date: Sat, 27 Feb 2021 18:38:16 -0500 Subject: [PATCH 011/606] fix: typos --- socketmode/socketmode_eventhandler_test.go | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/socketmode/socketmode_eventhandler_test.go b/socketmode/socketmode_eventhandler_test.go index 2530806df..858c4a35d 100644 --- a/socketmode/socketmode_eventhandler_test.go +++ b/socketmode/socketmode_eventhandler_test.go @@ -22,10 +22,6 @@ func init_SocketmodeHandler() *SocketmodeHandler { } } -type testProbe struct { - called bool -} - func testing_wrapper(ch chan<- string, f SocketmodeHandlerFunc) SocketmodeHandlerFunc { return SocketmodeHandlerFunc(func(e *Event, c *Client) { f(e, c) @@ -73,7 +69,7 @@ func TestSocketmodeHandler_Handle(t *testing.T) { want string //what is the name of the function we want to be called }{ { - name: "Event Match registed function", + name: "Event Match registered function", args: args{ evt: Event{ Type: EventTypeConnecting, @@ -82,7 +78,7 @@ func TestSocketmodeHandler_Handle(t *testing.T) { }, want: "github.com/slack-go/slack/socketmode.middleware", }, { - name: "Event do not registed function", + name: "Event do not registered function", args: args{ evt: Event{ Type: EventTypeConnected, @@ -123,7 +119,7 @@ func TestSocketmodeHandler_HandleInteraction(t *testing.T) { want string //what is the name of the function we want to be called }{ { - name: "Event Match registed function", + name: "Event Match registered function", args: args{ evt: Event{ Type: EventTypeInteractive, @@ -138,7 +134,7 @@ func TestSocketmodeHandler_HandleInteraction(t *testing.T) { }, want: "github.com/slack-go/slack/socketmode.middleware_interaction", }, { - name: "Event do not Match any registed function", + name: "Event do not Match any registered function", args: args{ evt: Event{ Type: EventTypeInteractive, @@ -212,7 +208,7 @@ func TestSocketmodeHandler_HandleEventsAPI(t *testing.T) { want string //what is the name of the function we want to be called }{ { - name: "Event Match registed function", + name: "Event Match registered function", args: args{ evt: Event{ Type: EventTypeEventsAPI, @@ -230,7 +226,7 @@ func TestSocketmodeHandler_HandleEventsAPI(t *testing.T) { }, want: "github.com/slack-go/slack/socketmode.middleware_eventapi", }, { - name: "Event do not Match any registed function", + name: "Event do not Match any registered function", args: args{ evt: Event{ Type: EventTypeEventsAPI, From e976c8a19225296f3f445ab5acd41629fd0ea2d7 Mon Sep 17 00:00:00 2001 From: xNok Date: Sun, 28 Feb 2021 01:18:38 -0500 Subject: [PATCH 012/606] fix: typos --- socketmode/socketmode_eventhandler.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/socketmode/socketmode_eventhandler.go b/socketmode/socketmode_eventhandler.go index a1f2b69bf..c024149fc 100644 --- a/socketmode/socketmode_eventhandler.go +++ b/socketmode/socketmode_eventhandler.go @@ -40,22 +40,22 @@ func NewsSocketmodeHandler(client *Client) *SocketmodeHandler { } } -// Register the middleare funtion to use to handle an Event (from socketmode) +// Register the middleare function to use to handle an Event (from socketmode) func (r *SocketmodeHandler) Handle(et EventType, f SocketmodeHandlerFunc) { r.EventMap[et] = append(r.EventMap[et], f) } -// Register the middleare funtion to use to handle an Interaction +// Register the middleare function to use to handle an Interaction func (r *SocketmodeHandler) HandleInteraction(et slack.InteractionType, f SocketmodeHandlerFunc) { r.InteractionEventMap[et] = append(r.InteractionEventMap[et], f) } -// Register the middleare funtion to use to handle an Event (from slackevents) +// Register the middleare function to use to handle an Event (from slackevents) func (r *SocketmodeHandler) HandleEventsAPI(et slackevents.EventAPIType, f SocketmodeHandlerFunc) { r.EventApiMap[et] = append(r.EventApiMap[et], f) } -// Register the middleare funtion to use as a last resort +// Register the middleare function to use as a last resort func (r *SocketmodeHandler) HandleDefault(f SocketmodeHandlerFunc) { r.Default = f } From b1318d993d1847c43dfbb9d233b7242305837d07 Mon Sep 17 00:00:00 2001 From: xNok Date: Sun, 7 Mar 2021 22:39:16 -0500 Subject: [PATCH 013/606] feat: Handle interaction block action by action ID --- .../sockermode_handler/socketmode_handler.go | 4 +- socketmode/socketmode_eventhandler.go | 85 ++++++++++++------ socketmode/socketmode_eventhandler_test.go | 87 ++++++++++++++++++- 3 files changed, 143 insertions(+), 33 deletions(-) diff --git a/examples/sockermode_handler/socketmode_handler.go b/examples/sockermode_handler/socketmode_handler.go index f4cc4fd4a..abb2a434d 100644 --- a/examples/sockermode_handler/socketmode_handler.go +++ b/examples/sockermode_handler/socketmode_handler.go @@ -116,15 +116,13 @@ func middlewareEventsAPI(evt *socketmode.Event, client *socketmode.Client) { } func middlewareAppMentionEvent(evt *socketmode.Event, client *socketmode.Client) { - fmt.Printf("middlewareAppMentionEvent: %+v\n", evt) + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) if !ok { fmt.Printf("Ignored %+v\n", evt) return } - fmt.Printf("middlewareAppMentionEvent: %+v\n", eventsAPIEvent.InnerEvent.Data) - client.Ack(*evt.Request) ev, ok := eventsAPIEvent.InnerEvent.Data.(*slackevents.AppMentionEvent) diff --git a/socketmode/socketmode_eventhandler.go b/socketmode/socketmode_eventhandler.go index c024149fc..ea9e7d1e7 100644 --- a/socketmode/socketmode_eventhandler.go +++ b/socketmode/socketmode_eventhandler.go @@ -11,9 +11,10 @@ import ( type SocketmodeHandler struct { Client *Client - EventMap map[EventType][]SocketmodeHandlerFunc - InteractionEventMap map[slack.InteractionType][]SocketmodeHandlerFunc - EventApiMap map[slackevents.EventAPIType][]SocketmodeHandlerFunc + EventMap map[EventType][]SocketmodeHandlerFunc + InteractionEventMap map[slack.InteractionType][]SocketmodeHandlerFunc + InteractionBlockActionEventMap map[string][]SocketmodeHandlerFunc + EventApiMap map[slackevents.EventAPIType][]SocketmodeHandlerFunc Default SocketmodeHandlerFunc } @@ -26,36 +27,43 @@ type SocketmodeMiddlewareFunc func(SocketmodeHandlerFunc) SocketmodeHandlerFunc func NewsSocketmodeHandler(client *Client) *SocketmodeHandler { eventMap := make(map[EventType][]SocketmodeHandlerFunc) - interactioneventMap := make(map[slack.InteractionType][]SocketmodeHandlerFunc) + interactionEventMap := make(map[slack.InteractionType][]SocketmodeHandlerFunc) + interactionBlockActionEventMap := make(map[string][]SocketmodeHandlerFunc) eventApiMap := make(map[slackevents.EventAPIType][]SocketmodeHandlerFunc) return &SocketmodeHandler{ - Client: client, - EventMap: eventMap, - EventApiMap: eventApiMap, - InteractionEventMap: interactioneventMap, + Client: client, + EventMap: eventMap, + EventApiMap: eventApiMap, + InteractionEventMap: interactionEventMap, + InteractionBlockActionEventMap: interactionBlockActionEventMap, Default: func(e *Event, c *Client) { log.Printf("Unexpected event type received: %v\n", e.Type) }, } } -// Register the middleare function to use to handle an Event (from socketmode) +// Register a middleare function to use to handle an Event (from socketmode) func (r *SocketmodeHandler) Handle(et EventType, f SocketmodeHandlerFunc) { r.EventMap[et] = append(r.EventMap[et], f) } -// Register the middleare function to use to handle an Interaction +// Register a middleare function to use to handle an Interaction func (r *SocketmodeHandler) HandleInteraction(et slack.InteractionType, f SocketmodeHandlerFunc) { r.InteractionEventMap[et] = append(r.InteractionEventMap[et], f) } -// Register the middleare function to use to handle an Event (from slackevents) +// Register a middleare function to use to handle an Interaction Block Action referenced by its ActionID +func (r *SocketmodeHandler) HandleInteractionBlockAction(action_id string, f SocketmodeHandlerFunc) { + r.InteractionBlockActionEventMap[action_id] = append(r.InteractionBlockActionEventMap[action_id], f) +} + +// Register a middleare function to use to handle an Event (from slackevents) func (r *SocketmodeHandler) HandleEventsAPI(et slackevents.EventAPIType, f SocketmodeHandlerFunc) { r.EventApiMap[et] = append(r.EventApiMap[et], f) } -// Register the middleare function to use as a last resort +// Register a middleare function to use as a last resort func (r *SocketmodeHandler) HandleDefault(f SocketmodeHandlerFunc) { r.Default = f } @@ -108,34 +116,55 @@ func (r *SocketmodeHandler) socketmodeDispatcher(evt *Event) bool { // Dispatch interactions to the registered middleware func (r *SocketmodeHandler) interactionDispatcher(evt *Event) bool { + var ishandled bool = false + interaction, ok := evt.Data.(slack.InteractionCallback) if !ok { fmt.Printf("Ignored %+v\n", evt) return false } - if handlers, ok := r.InteractionEventMap[interaction.Type]; ok { - // If we registered an event + // Level 1 - socketmode EventType + if handlers, ok := r.EventMap[evt.Type]; ok { + for _, f := range handlers { go f(evt, r.Client) } - return true - } else if handlers, ok := r.EventMap[evt.Type]; ok { - // fallback it this event is not handle by a more specific handler + ishandled = true + } + // Level 2 - interaction EventType + if handlers, ok := r.InteractionEventMap[interaction.Type]; ok { + // If we registered an event for _, f := range handlers { go f(evt, r.Client) } - return true + ishandled = true } - return false + // Level 3 - interaction with actionID + block_actions := interaction.ActionCallback.BlockActions + // outmoded approach won`t be implemented + // attachments_actions := interaction.ActionCallback.AttachmentActions + + for _, action := range block_actions { + if handlers, ok := r.InteractionBlockActionEventMap[action.ActionID]; ok { + + for _, f := range handlers { + go f(evt, r.Client) + } + + ishandled = true + } + } + return ishandled } // Dispatch eventAPI events to the registered middleware func (r *SocketmodeHandler) eventAPIDispatcher(evt *Event) bool { + var ishandled bool = false eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) if !ok { fmt.Printf("Ignored %+v\n", evt) @@ -144,21 +173,25 @@ func (r *SocketmodeHandler) eventAPIDispatcher(evt *Event) bool { innerEventType := slackevents.EventAPIType(eventsAPIEvent.InnerEvent.Type) - if handlers, ok := r.EventApiMap[innerEventType]; ok { - // If we registered an event + // Level 1 - socketmode EventType + if handlers, ok := r.EventMap[evt.Type]; ok { + // fallback it this event is not handle by a more specific handler for _, f := range handlers { go f(evt, r.Client) } - return true - } else if handlers, ok := r.EventMap[evt.Type]; ok { - // fallback it this event is not handle by a more specific handler + ishandled = true + } + + // Level 2 - EventAPI EventType + if handlers, ok := r.EventApiMap[innerEventType]; ok { + // If we registered an event for _, f := range handlers { go f(evt, r.Client) } - return true + ishandled = true } - return false + return ishandled } diff --git a/socketmode/socketmode_eventhandler_test.go b/socketmode/socketmode_eventhandler_test.go index 858c4a35d..1448a1e76 100644 --- a/socketmode/socketmode_eventhandler_test.go +++ b/socketmode/socketmode_eventhandler_test.go @@ -13,15 +13,19 @@ func init_SocketmodeHandler() *SocketmodeHandler { eventMap := make(map[EventType][]SocketmodeHandlerFunc) interactioneventMap := make(map[slack.InteractionType][]SocketmodeHandlerFunc) eventApiMap := make(map[slackevents.EventAPIType][]SocketmodeHandlerFunc) + interactionBlockActionEventMap := make(map[string][]SocketmodeHandlerFunc) return &SocketmodeHandler{ - Client: &Client{}, - EventMap: eventMap, - EventApiMap: eventApiMap, - InteractionEventMap: interactioneventMap, + Client: &Client{}, + EventMap: eventMap, + EventApiMap: eventApiMap, + InteractionEventMap: interactioneventMap, + InteractionBlockActionEventMap: interactionBlockActionEventMap, } } +// The goal of this function is to catch the name of the function that is behing called +// This let us validate that the dispatcher did its job correctly func testing_wrapper(ch chan<- string, f SocketmodeHandlerFunc) SocketmodeHandlerFunc { return SocketmodeHandlerFunc(func(e *Event, c *Client) { f(e, c) @@ -46,6 +50,10 @@ func middleware_interaction(evt *Event, client *Client) { //do nothing } +func middleware_interaction_block_action(evt *Event, client *Client) { + //do nothing +} + func middleware_eventapi(evt *Event, client *Client) { //do nothing } @@ -294,3 +302,74 @@ func TestSocketmodeHandler_HandleEventsAPI(t *testing.T) { }) } } + +func TestSocketmodeHandler_HandleInteractionBlockAction(t *testing.T) { + type args struct { + evt Event + register func(*SocketmodeHandler, chan<- string) + } + tests := []struct { + name string + args args + want string //what is the name of the function we want to be called + }{ + { + name: "Event Match registered function", + args: args{ + evt: Event{ + Type: EventTypeInteractive, + Data: slack.InteractionCallback{ + Type: slack.InteractionTypeBlockActions, + ActionCallback: slack.ActionCallbacks{ + BlockActions: []*slack.BlockAction{ + { + ActionID: "add_note", + Text: slack.TextBlockObject{ + Type: "plain_text", + Text: "Add a Stickie", + }, + }, + }, + }, + }, + }, + register: func(r *SocketmodeHandler, c chan<- string) { + r.HandleInteractionBlockAction("add_note", testing_wrapper(c, middleware_interaction_block_action)) + }, + }, + want: "github.com/slack-go/slack/socketmode.middleware_interaction_block_action", + }, { + name: "Event do not Match any registered function", + args: args{ + evt: Event{ + Type: EventTypeInteractive, + Data: slack.InteractionCallback{ + Type: slack.InteractionTypeBlockActions, + }, + }, + register: func(r *SocketmodeHandler, c chan<- string) { + r.HandleInteractionBlockAction("add_note", testing_wrapper(c, middleware_interaction_block_action)) + }, + }, + want: "github.com/slack-go/slack/socketmode.defaultmiddleware", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := init_SocketmodeHandler() + + c := make(chan string) + + tt.args.register(r, c) + r.HandleDefault(testing_wrapper(c, defaultmiddleware)) + + r.dispatcher(tt.args.evt) + + got := <-c + + if got != tt.want { + t.Fatalf("%s was not called for EventTy(\"%v\"), got %v", tt.want, tt.args.evt.Type, got) + } + }) + } +} From 28ed51b50058b9d684c99fbabab7828223a175fa Mon Sep 17 00:00:00 2001 From: xNok Date: Mon, 8 Mar 2021 23:45:40 -0500 Subject: [PATCH 014/606] fix: tests for socketmode --- socketmode/socketmode_eventhandler_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/socketmode/socketmode_eventhandler_test.go b/socketmode/socketmode_eventhandler_test.go index 1448a1e76..9d127c67d 100644 --- a/socketmode/socketmode_eventhandler_test.go +++ b/socketmode/socketmode_eventhandler_test.go @@ -136,7 +136,6 @@ func TestSocketmodeHandler_HandleInteraction(t *testing.T) { }, }, register: func(r *SocketmodeHandler, c chan<- string) { - r.Handle(EventTypeInteractive, testing_wrapper(c, middleware)) r.HandleInteraction(slack.InteractionTypeBlockActions, testing_wrapper(c, middleware_interaction)) }, }, @@ -228,7 +227,6 @@ func TestSocketmodeHandler_HandleEventsAPI(t *testing.T) { }, }, register: func(r *SocketmodeHandler, c chan<- string) { - r.Handle(EventTypeEventsAPI, testing_wrapper(c, middleware)) r.HandleEventsAPI(slackevents.AppMention, testing_wrapper(c, middleware_eventapi)) }, }, From 16e755773ca034c2b79d57f83cb784725fa4b026 Mon Sep 17 00:00:00 2001 From: xNok Date: Wed, 21 Apr 2021 18:31:26 -0400 Subject: [PATCH 015/606] fix: linting issue --- slackevents/inner_events.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 20e84fe48..d52ce95c5 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -327,9 +327,9 @@ const ( // AppHomeOpened Your Slack app home was opened AppHomeOpened = EventAPIType("app_home_opened") // AppUninstalled Your Slack app was uninstalled. - AppUninstalled = EventAPIType("app_uninstalled") + AppUninstalled = EventAPIType("app_uninstalled") // ChannelCreated is sent when a new channel is created. - ChannelCreated = EventAPIType("channel_created") + ChannelCreated = EventAPIType("channel_created") // GridMigrationFinished An enterprise grid migration has finished on this workspace. GridMigrationFinished = EventAPIType("grid_migration_finished") // GridMigrationStarted An enterprise grid migration has started on this workspace. From 6410dfe75b33743cc478e6ea2e814221451c85c4 Mon Sep 17 00:00:00 2001 From: xNok Date: Sat, 8 May 2021 14:58:32 -0400 Subject: [PATCH 016/606] feat: dedicated handler for Slash commands --- .../sockermode_handler/socketmode_handler.go | 1 + socketmode/socketmode_eventhandler.go | 43 ++++++++---- socketmode/socketmode_eventhandler_test.go | 68 +++++++++++++++++++ 3 files changed, 100 insertions(+), 12 deletions(-) diff --git a/examples/sockermode_handler/socketmode_handler.go b/examples/sockermode_handler/socketmode_handler.go index abb2a434d..0f63fb6fd 100644 --- a/examples/sockermode_handler/socketmode_handler.go +++ b/examples/sockermode_handler/socketmode_handler.go @@ -67,6 +67,7 @@ func main() { // Handle all SlashCommand socketmodeHandler.Handle(socketmode.EventTypeSlashCommand, middlewareSlashCommand) + socketmodeHandler.HandleSlashCommand(socketmode.EventTypeSlashCommand, middlewareSlashCommand) // socketmodeHandler.HandleDefault(middlewareDefault) diff --git a/socketmode/socketmode_eventhandler.go b/socketmode/socketmode_eventhandler.go index ea9e7d1e7..05503ddd8 100644 --- a/socketmode/socketmode_eventhandler.go +++ b/socketmode/socketmode_eventhandler.go @@ -15,6 +15,7 @@ type SocketmodeHandler struct { InteractionEventMap map[slack.InteractionType][]SocketmodeHandlerFunc InteractionBlockActionEventMap map[string][]SocketmodeHandlerFunc EventApiMap map[slackevents.EventAPIType][]SocketmodeHandlerFunc + SlashCommandMap map[string][]SocketmodeHandlerFunc Default SocketmodeHandlerFunc } @@ -63,6 +64,10 @@ func (r *SocketmodeHandler) HandleEventsAPI(et slackevents.EventAPIType, f Socke r.EventApiMap[et] = append(r.EventApiMap[et], f) } +func (r *SocketmodeHandler) HandleSlashCommand(command string, f SocketmodeHandlerFunc) { + r.SlashCommandMap[command] = append(r.SlashCommandMap[command], f) +} + // Register a middleare function to use as a last resort func (r *SocketmodeHandler) HandleDefault(f SocketmodeHandlerFunc) { r.Default = f @@ -91,6 +96,8 @@ func (r *SocketmodeHandler) dispatcher(evt Event) { ishandled = r.interactionDispatcher(&evt) case EventTypeEventsAPI: ishandled = r.eventAPIDispatcher(&evt) + case EventTypeSlashCommand: + ishandled = r.slashCommandDispatcher(&evt) default: ishandled = r.socketmodeDispatcher(&evt) } @@ -125,14 +132,7 @@ func (r *SocketmodeHandler) interactionDispatcher(evt *Event) bool { } // Level 1 - socketmode EventType - if handlers, ok := r.EventMap[evt.Type]; ok { - - for _, f := range handlers { - go f(evt, r.Client) - } - - ishandled = true - } + ishandled = r.socketmodeDispatcher(evt) // Level 2 - interaction EventType if handlers, ok := r.InteractionEventMap[interaction.Type]; ok { @@ -174,8 +174,11 @@ func (r *SocketmodeHandler) eventAPIDispatcher(evt *Event) bool { innerEventType := slackevents.EventAPIType(eventsAPIEvent.InnerEvent.Type) // Level 1 - socketmode EventType - if handlers, ok := r.EventMap[evt.Type]; ok { - // fallback it this event is not handle by a more specific handler + ishandled = r.socketmodeDispatcher(evt) + + // Level 2 - EventAPI EventType + if handlers, ok := r.EventApiMap[innerEventType]; ok { + // If we registered an event for _, f := range handlers { go f(evt, r.Client) } @@ -183,8 +186,23 @@ func (r *SocketmodeHandler) eventAPIDispatcher(evt *Event) bool { ishandled = true } - // Level 2 - EventAPI EventType - if handlers, ok := r.EventApiMap[innerEventType]; ok { + return ishandled +} + +// Dispatch SlashCommands events to the registered middleware +func (r *SocketmodeHandler) slashCommandDispatcher(evt *Event) bool { + var ishandled bool = false + slashCommandEvent, ok := evt.Data.(slack.SlashCommand) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + return false + } + + // Level 1 - socketmode EventType + ishandled = r.socketmodeDispatcher(evt) + + // Level 2 - SlackCommand by name + if handlers, ok := r.SlashCommandMap[slashCommandEvent.Command]; ok { // If we registered an event for _, f := range handlers { go f(evt, r.Client) @@ -194,4 +212,5 @@ func (r *SocketmodeHandler) eventAPIDispatcher(evt *Event) bool { } return ishandled + } diff --git a/socketmode/socketmode_eventhandler_test.go b/socketmode/socketmode_eventhandler_test.go index 9d127c67d..ba3664666 100644 --- a/socketmode/socketmode_eventhandler_test.go +++ b/socketmode/socketmode_eventhandler_test.go @@ -14,6 +14,7 @@ func init_SocketmodeHandler() *SocketmodeHandler { interactioneventMap := make(map[slack.InteractionType][]SocketmodeHandlerFunc) eventApiMap := make(map[slackevents.EventAPIType][]SocketmodeHandlerFunc) interactionBlockActionEventMap := make(map[string][]SocketmodeHandlerFunc) + slashCommandMap := make(map[string][]SocketmodeHandlerFunc) return &SocketmodeHandler{ Client: &Client{}, @@ -21,6 +22,7 @@ func init_SocketmodeHandler() *SocketmodeHandler { EventApiMap: eventApiMap, InteractionEventMap: interactioneventMap, InteractionBlockActionEventMap: interactionBlockActionEventMap, + SlashCommandMap: slashCommandMap, } } @@ -66,6 +68,10 @@ func defaultmiddleware(evt *Event, client *Client) { //do nothing } +func middleware_slach_command(evt *Event, client *Client) { + //do nothing +} + func TestSocketmodeHandler_Handle(t *testing.T) { type args struct { evt Event @@ -371,3 +377,65 @@ func TestSocketmodeHandler_HandleInteractionBlockAction(t *testing.T) { }) } } + +func TestSocketmodeHandler_HandleSlashCommand(t *testing.T) { + type args struct { + evt Event + register func(*SocketmodeHandler, chan<- string) + } + tests := []struct { + name string + args args + want string //what is the name of the function we want to be called + }{ + { + name: "Event Match registered function", + args: args{ + evt: Event{ + Type: EventTypeSlashCommand, + Data: slack.SlashCommand{ + Command: "/rocket", + Text: "key=value", + }, + }, + register: func(r *SocketmodeHandler, c chan<- string) { + r.HandleSlashCommand("/rocket", testing_wrapper(c, middleware_slach_command)) + }, + }, + want: "github.com/slack-go/slack/socketmode.middleware_slach_command", + }, { + name: "Event do not Match any registered function", + args: args{ + evt: Event{ + Type: EventTypeSlashCommand, + Data: slack.SlashCommand{ + Command: "/broken_rocket", + Text: "key=value", + }, + }, + register: func(r *SocketmodeHandler, c chan<- string) { + r.HandleSlashCommand("/rocket", testing_wrapper(c, middleware_slach_command)) + }, + }, + want: "github.com/slack-go/slack/socketmode.defaultmiddleware", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := init_SocketmodeHandler() + + c := make(chan string) + + tt.args.register(r, c) + r.HandleDefault(testing_wrapper(c, defaultmiddleware)) + + r.dispatcher(tt.args.evt) + + got := <-c + + if got != tt.want { + t.Fatalf("%s was not called for EventTy(\"%v\"), got %v", tt.want, tt.args.evt.Type, got) + } + }) + } +} From e8ecf5e0681955d509a1ed73e0d35753099b6891 Mon Sep 17 00:00:00 2001 From: xNok Date: Sat, 8 May 2021 15:05:32 -0400 Subject: [PATCH 017/606] fix: exemple --- examples/sockermode_handler/socketmode_handler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/sockermode_handler/socketmode_handler.go b/examples/sockermode_handler/socketmode_handler.go index 0f63fb6fd..867b53af1 100644 --- a/examples/sockermode_handler/socketmode_handler.go +++ b/examples/sockermode_handler/socketmode_handler.go @@ -67,7 +67,7 @@ func main() { // Handle all SlashCommand socketmodeHandler.Handle(socketmode.EventTypeSlashCommand, middlewareSlashCommand) - socketmodeHandler.HandleSlashCommand(socketmode.EventTypeSlashCommand, middlewareSlashCommand) + socketmodeHandler.HandleSlashCommand("/rocket", middlewareSlashCommand) // socketmodeHandler.HandleDefault(middlewareDefault) From 9bc8016012b3bc9d893dc708aa48077579df7915 Mon Sep 17 00:00:00 2001 From: xNok Date: Sat, 8 May 2021 15:28:37 -0400 Subject: [PATCH 018/606] fix: initialization controller --- socketmode/socketmode_eventhandler.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/socketmode/socketmode_eventhandler.go b/socketmode/socketmode_eventhandler.go index 05503ddd8..a78f2cc13 100644 --- a/socketmode/socketmode_eventhandler.go +++ b/socketmode/socketmode_eventhandler.go @@ -31,6 +31,7 @@ func NewsSocketmodeHandler(client *Client) *SocketmodeHandler { interactionEventMap := make(map[slack.InteractionType][]SocketmodeHandlerFunc) interactionBlockActionEventMap := make(map[string][]SocketmodeHandlerFunc) eventApiMap := make(map[slackevents.EventAPIType][]SocketmodeHandlerFunc) + slackCommandMap := make(map[string][]SocketmodeHandlerFunc) return &SocketmodeHandler{ Client: client, @@ -38,6 +39,7 @@ func NewsSocketmodeHandler(client *Client) *SocketmodeHandler { EventApiMap: eventApiMap, InteractionEventMap: interactionEventMap, InteractionBlockActionEventMap: interactionBlockActionEventMap, + SlashCommandMap: slackCommandMap, Default: func(e *Event, c *Client) { log.Printf("Unexpected event type received: %v\n", e.Type) }, From 9163026b47e51e57dabfee3ce0709e18c947acdf Mon Sep 17 00:00:00 2001 From: xNok Date: Wed, 2 Jun 2021 10:45:55 -0400 Subject: [PATCH 019/606] fix: typo in example file --- .../socketmode_handler.go | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename examples/{sockermode_handler => socketmode_handler}/socketmode_handler.go (100%) diff --git a/examples/sockermode_handler/socketmode_handler.go b/examples/socketmode_handler/socketmode_handler.go similarity index 100% rename from examples/sockermode_handler/socketmode_handler.go rename to examples/socketmode_handler/socketmode_handler.go From dd086ec5156cfdfde852cde6c98da9dbe5721a07 Mon Sep 17 00:00:00 2001 From: xNok Date: Sun, 6 Jun 2021 17:36:55 -0400 Subject: [PATCH 020/606] refact: use client logger --- socketmode/socketmode_eventhandler.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/socketmode/socketmode_eventhandler.go b/socketmode/socketmode_eventhandler.go index a78f2cc13..1b06b6361 100644 --- a/socketmode/socketmode_eventhandler.go +++ b/socketmode/socketmode_eventhandler.go @@ -2,7 +2,6 @@ package socketmode import ( "fmt" - "log" "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" @@ -41,7 +40,7 @@ func NewsSocketmodeHandler(client *Client) *SocketmodeHandler { InteractionBlockActionEventMap: interactionBlockActionEventMap, SlashCommandMap: slackCommandMap, Default: func(e *Event, c *Client) { - log.Printf("Unexpected event type received: %v\n", e.Type) + c.log.Printf("Unexpected event type received: %v\n", e.Type) }, } } From 02281d1b3540e1468cdd4880fd49b457adbe2c0a Mon Sep 17 00:00:00 2001 From: xNok Date: Sun, 6 Jun 2021 17:56:04 -0400 Subject: [PATCH 021/606] refact: panic instead of os.Exit(1) --- examples/socketmode_handler/socketmode_handler.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/examples/socketmode_handler/socketmode_handler.go b/examples/socketmode_handler/socketmode_handler.go index 867b53af1..28ad0d798 100644 --- a/examples/socketmode_handler/socketmode_handler.go +++ b/examples/socketmode_handler/socketmode_handler.go @@ -19,17 +19,16 @@ func main() { } if !strings.HasPrefix(appToken, "xapp-") { - fmt.Fprintf(os.Stderr, "SLACK_APP_TOKEN must have the prefix \"xapp-\".") + panic("SLACK_APP_TOKEN must have the prefix \"xapp-\".") } botToken := os.Getenv("SLACK_BOT_TOKEN") if botToken == "" { - fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN must be set.\n") - os.Exit(1) + panic("SLACK_BOT_TOKEN must be set.\n") } if !strings.HasPrefix(botToken, "xoxb-") { - fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN must have the prefix \"xoxb-\".") + panic("SLACK_BOT_TOKEN must have the prefix \"xoxb-\".") } api := slack.New( From cf9dd14c977ab6fc849328540c3a5acdd5106874 Mon Sep 17 00:00:00 2001 From: xNok Date: Sun, 6 Jun 2021 17:59:50 -0400 Subject: [PATCH 022/606] refact: rename HandleEventsAPI to HandleEvents --- examples/socketmode_handler/socketmode_handler.go | 2 +- socketmode/socketmode_eventhandler.go | 3 ++- socketmode/socketmode_eventhandler_test.go | 8 ++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/examples/socketmode_handler/socketmode_handler.go b/examples/socketmode_handler/socketmode_handler.go index 28ad0d798..3e83f7c0d 100644 --- a/examples/socketmode_handler/socketmode_handler.go +++ b/examples/socketmode_handler/socketmode_handler.go @@ -55,7 +55,7 @@ func main() { socketmodeHandler.Handle(socketmode.EventTypeEventsAPI, middlewareEventsAPI) // Handle a specific event from EventsAPI - socketmodeHandler.HandleEventsAPI(slackevents.AppMention, middlewareAppMentionEvent) + socketmodeHandler.HandleEvents(slackevents.AppMention, middlewareAppMentionEvent) //\\ EventTypeInteractive //\\ // Handle all Interactive Events diff --git a/socketmode/socketmode_eventhandler.go b/socketmode/socketmode_eventhandler.go index 1b06b6361..cd5bc9112 100644 --- a/socketmode/socketmode_eventhandler.go +++ b/socketmode/socketmode_eventhandler.go @@ -25,6 +25,7 @@ type SocketmodeHandlerFunc func(*Event, *Client) // Middleware accept SocketmodeHandlerFunc, and return SocketmodeHandlerFunc type SocketmodeMiddlewareFunc func(SocketmodeHandlerFunc) SocketmodeHandlerFunc +// Initialization constructor for SocketmodeHandler func NewsSocketmodeHandler(client *Client) *SocketmodeHandler { eventMap := make(map[EventType][]SocketmodeHandlerFunc) interactionEventMap := make(map[slack.InteractionType][]SocketmodeHandlerFunc) @@ -61,7 +62,7 @@ func (r *SocketmodeHandler) HandleInteractionBlockAction(action_id string, f Soc } // Register a middleare function to use to handle an Event (from slackevents) -func (r *SocketmodeHandler) HandleEventsAPI(et slackevents.EventAPIType, f SocketmodeHandlerFunc) { +func (r *SocketmodeHandler) HandleEvents(et slackevents.EventAPIType, f SocketmodeHandlerFunc) { r.EventApiMap[et] = append(r.EventApiMap[et], f) } diff --git a/socketmode/socketmode_eventhandler_test.go b/socketmode/socketmode_eventhandler_test.go index ba3664666..b66b4872a 100644 --- a/socketmode/socketmode_eventhandler_test.go +++ b/socketmode/socketmode_eventhandler_test.go @@ -210,7 +210,7 @@ func TestSocketmodeHandler_HandleInteraction(t *testing.T) { } } -func TestSocketmodeHandler_HandleEventsAPI(t *testing.T) { +func TestSocketmodeHandler_HandleEvents(t *testing.T) { type args struct { evt Event register func(*SocketmodeHandler, chan<- string) @@ -233,7 +233,7 @@ func TestSocketmodeHandler_HandleEventsAPI(t *testing.T) { }, }, register: func(r *SocketmodeHandler, c chan<- string) { - r.HandleEventsAPI(slackevents.AppMention, testing_wrapper(c, middleware_eventapi)) + r.HandleEvents(slackevents.AppMention, testing_wrapper(c, middleware_eventapi)) }, }, want: "github.com/slack-go/slack/socketmode.middleware_eventapi", @@ -250,7 +250,7 @@ func TestSocketmodeHandler_HandleEventsAPI(t *testing.T) { }, }, register: func(r *SocketmodeHandler, c chan<- string) { - r.HandleEventsAPI(slackevents.AppMention, testing_wrapper(c, middleware_eventapi)) + r.HandleEvents(slackevents.AppMention, testing_wrapper(c, middleware_eventapi)) }, }, want: "github.com/slack-go/slack/socketmode.defaultmiddleware", @@ -264,7 +264,7 @@ func TestSocketmodeHandler_HandleEventsAPI(t *testing.T) { }, }, register: func(r *SocketmodeHandler, c chan<- string) { - r.HandleEventsAPI(slackevents.AppMention, testing_wrapper(c, middleware_eventapi)) + r.HandleEvents(slackevents.AppMention, testing_wrapper(c, middleware_eventapi)) }, }, want: "github.com/slack-go/slack/socketmode.defaultmiddleware", From fb69306b2233571f2166267762c622bdeb89de3c Mon Sep 17 00:00:00 2001 From: xNok Date: Sun, 6 Jun 2021 18:01:43 -0400 Subject: [PATCH 023/606] fix: rename action_id to actionID --- socketmode/socketmode_eventhandler.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/socketmode/socketmode_eventhandler.go b/socketmode/socketmode_eventhandler.go index cd5bc9112..2af996e4e 100644 --- a/socketmode/socketmode_eventhandler.go +++ b/socketmode/socketmode_eventhandler.go @@ -57,8 +57,8 @@ func (r *SocketmodeHandler) HandleInteraction(et slack.InteractionType, f Socket } // Register a middleare function to use to handle an Interaction Block Action referenced by its ActionID -func (r *SocketmodeHandler) HandleInteractionBlockAction(action_id string, f SocketmodeHandlerFunc) { - r.InteractionBlockActionEventMap[action_id] = append(r.InteractionBlockActionEventMap[action_id], f) +func (r *SocketmodeHandler) HandleInteractionBlockAction(actionID string, f SocketmodeHandlerFunc) { + r.InteractionBlockActionEventMap[actionID] = append(r.InteractionBlockActionEventMap[actionID], f) } // Register a middleare function to use to handle an Event (from slackevents) From 430c14abd542bde10436e93efa38ccee9e9d7704 Mon Sep 17 00:00:00 2001 From: xNok Date: Sun, 6 Jun 2021 18:05:03 -0400 Subject: [PATCH 024/606] refact: rename EventAPIType to EventsAPIType --- slackevents/inner_events.go | 36 +++++++++++----------- slackevents/parsers.go | 2 +- socketmode/socketmode_eventhandler.go | 8 ++--- socketmode/socketmode_eventhandler_test.go | 2 +- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index d52ce95c5..5e39309c8 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -319,47 +319,47 @@ func (e MessageEvent) IsEdited() bool { e.Message.Edited != nil } -type EventAPIType string +type EventsAPIType string const ( // AppMention is an Events API subscribable event - AppMention = EventAPIType("app_mention") + AppMention = EventsAPIType("app_mention") // AppHomeOpened Your Slack app home was opened - AppHomeOpened = EventAPIType("app_home_opened") + AppHomeOpened = EventsAPIType("app_home_opened") // AppUninstalled Your Slack app was uninstalled. - AppUninstalled = EventAPIType("app_uninstalled") + AppUninstalled = EventsAPIType("app_uninstalled") // ChannelCreated is sent when a new channel is created. - ChannelCreated = EventAPIType("channel_created") + ChannelCreated = EventsAPIType("channel_created") // GridMigrationFinished An enterprise grid migration has finished on this workspace. - GridMigrationFinished = EventAPIType("grid_migration_finished") + GridMigrationFinished = EventsAPIType("grid_migration_finished") // GridMigrationStarted An enterprise grid migration has started on this workspace. - GridMigrationStarted = EventAPIType("grid_migration_started") + GridMigrationStarted = EventsAPIType("grid_migration_started") // LinkShared A message was posted containing one or more links relevant to your application - LinkShared = EventAPIType("link_shared") + LinkShared = EventsAPIType("link_shared") // Message A message was posted to a channel, private channel (group), im, or mim - Message = EventAPIType("message") + Message = EventsAPIType("message") // Member Joined Channel - MemberJoinedChannel = EventAPIType("member_joined_channel") + MemberJoinedChannel = EventsAPIType("member_joined_channel") // Member Left Channel - MemberLeftChannel = EventAPIType("member_left_channel") + MemberLeftChannel = EventsAPIType("member_left_channel") // PinAdded An item was pinned to a channel - PinAdded = EventAPIType("pin_added") + PinAdded = EventsAPIType("pin_added") // PinRemoved An item was unpinned from a channel - PinRemoved = EventAPIType("pin_removed") + PinRemoved = EventsAPIType("pin_removed") // ReactionAdded An reaction was added to a message - ReactionAdded = EventAPIType("reaction_added") + ReactionAdded = EventsAPIType("reaction_added") // ReactionRemoved An reaction was removed from a message - ReactionRemoved = EventAPIType("reaction_removed") + ReactionRemoved = EventsAPIType("reaction_removed") // TokensRevoked APP's API tokes are revoked - TokensRevoked = EventAPIType("tokens_revoked") + TokensRevoked = EventsAPIType("tokens_revoked") // EmojiChanged A custom emoji has been added or changed - EmojiChanged = EventAPIType("emoji_changed") + EmojiChanged = EventsAPIType("emoji_changed") ) // EventsAPIInnerEventMapping maps INNER Event API events to their corresponding struct // implementations. The structs should be instances of the unmarshalling // target for the matching event type. -var EventsAPIInnerEventMapping = map[EventAPIType]interface{}{ +var EventsAPIInnerEventMapping = map[EventsAPIType]interface{}{ AppMention: AppMentionEvent{}, AppHomeOpened: AppHomeOpenedEvent{}, AppUninstalled: AppUninstalledEvent{}, diff --git a/slackevents/parsers.go b/slackevents/parsers.go index fab7185a8..1acd7ba77 100644 --- a/slackevents/parsers.go +++ b/slackevents/parsers.go @@ -19,7 +19,7 @@ func eventsMap(t string) (interface{}, bool) { // Must parse EventsAPI FIRST as both RTM and EventsAPI // have a type: "Message" event. // TODO: Handle these cases more explicitly. - v, exists := EventsAPIInnerEventMapping[EventAPIType(t)] + v, exists := EventsAPIInnerEventMapping[EventsAPIType(t)] if exists { return v, exists } diff --git a/socketmode/socketmode_eventhandler.go b/socketmode/socketmode_eventhandler.go index 2af996e4e..b5698a191 100644 --- a/socketmode/socketmode_eventhandler.go +++ b/socketmode/socketmode_eventhandler.go @@ -13,7 +13,7 @@ type SocketmodeHandler struct { EventMap map[EventType][]SocketmodeHandlerFunc InteractionEventMap map[slack.InteractionType][]SocketmodeHandlerFunc InteractionBlockActionEventMap map[string][]SocketmodeHandlerFunc - EventApiMap map[slackevents.EventAPIType][]SocketmodeHandlerFunc + EventApiMap map[slackevents.EventsAPIType][]SocketmodeHandlerFunc SlashCommandMap map[string][]SocketmodeHandlerFunc Default SocketmodeHandlerFunc @@ -30,7 +30,7 @@ func NewsSocketmodeHandler(client *Client) *SocketmodeHandler { eventMap := make(map[EventType][]SocketmodeHandlerFunc) interactionEventMap := make(map[slack.InteractionType][]SocketmodeHandlerFunc) interactionBlockActionEventMap := make(map[string][]SocketmodeHandlerFunc) - eventApiMap := make(map[slackevents.EventAPIType][]SocketmodeHandlerFunc) + eventApiMap := make(map[slackevents.EventsAPIType][]SocketmodeHandlerFunc) slackCommandMap := make(map[string][]SocketmodeHandlerFunc) return &SocketmodeHandler{ @@ -62,7 +62,7 @@ func (r *SocketmodeHandler) HandleInteractionBlockAction(actionID string, f Sock } // Register a middleare function to use to handle an Event (from slackevents) -func (r *SocketmodeHandler) HandleEvents(et slackevents.EventAPIType, f SocketmodeHandlerFunc) { +func (r *SocketmodeHandler) HandleEvents(et slackevents.EventsAPIType, f SocketmodeHandlerFunc) { r.EventApiMap[et] = append(r.EventApiMap[et], f) } @@ -173,7 +173,7 @@ func (r *SocketmodeHandler) eventAPIDispatcher(evt *Event) bool { return false } - innerEventType := slackevents.EventAPIType(eventsAPIEvent.InnerEvent.Type) + innerEventType := slackevents.EventsAPIType(eventsAPIEvent.InnerEvent.Type) // Level 1 - socketmode EventType ishandled = r.socketmodeDispatcher(evt) diff --git a/socketmode/socketmode_eventhandler_test.go b/socketmode/socketmode_eventhandler_test.go index b66b4872a..eaba03acf 100644 --- a/socketmode/socketmode_eventhandler_test.go +++ b/socketmode/socketmode_eventhandler_test.go @@ -12,7 +12,7 @@ import ( func init_SocketmodeHandler() *SocketmodeHandler { eventMap := make(map[EventType][]SocketmodeHandlerFunc) interactioneventMap := make(map[slack.InteractionType][]SocketmodeHandlerFunc) - eventApiMap := make(map[slackevents.EventAPIType][]SocketmodeHandlerFunc) + eventApiMap := make(map[slackevents.EventsAPIType][]SocketmodeHandlerFunc) interactionBlockActionEventMap := make(map[string][]SocketmodeHandlerFunc) slashCommandMap := make(map[string][]SocketmodeHandlerFunc) From 670527ffcdf26bc974b1d585b75a23fb82e816f3 Mon Sep 17 00:00:00 2001 From: xNok Date: Mon, 7 Jun 2021 21:49:10 -0400 Subject: [PATCH 025/606] refact: update godoc --- socketmode/socketmode_eventhandler.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/socketmode/socketmode_eventhandler.go b/socketmode/socketmode_eventhandler.go index b5698a191..2e3511897 100644 --- a/socketmode/socketmode_eventhandler.go +++ b/socketmode/socketmode_eventhandler.go @@ -46,31 +46,38 @@ func NewsSocketmodeHandler(client *Client) *SocketmodeHandler { } } -// Register a middleare function to use to handle an Event (from socketmode) +// Register a middleware or handler for an Event from socketmode +// This most general entrypoint func (r *SocketmodeHandler) Handle(et EventType, f SocketmodeHandlerFunc) { r.EventMap[et] = append(r.EventMap[et], f) } -// Register a middleare function to use to handle an Interaction +// Register a middleware or handler for an Interaction +// There is several types of interactions, decated functions lets you better handle them +// See +// * HandleInteractionBlockAction +// * (Not Implemented) HandleShortcut +// * (Not Implemented) HandleView func (r *SocketmodeHandler) HandleInteraction(et slack.InteractionType, f SocketmodeHandlerFunc) { r.InteractionEventMap[et] = append(r.InteractionEventMap[et], f) } -// Register a middleare function to use to handle an Interaction Block Action referenced by its ActionID +// Register a middleware or handler for a Block Action referenced by its ActionID func (r *SocketmodeHandler) HandleInteractionBlockAction(actionID string, f SocketmodeHandlerFunc) { r.InteractionBlockActionEventMap[actionID] = append(r.InteractionBlockActionEventMap[actionID], f) } -// Register a middleare function to use to handle an Event (from slackevents) +// Register a middleware or handler for an Event (from slackevents) func (r *SocketmodeHandler) HandleEvents(et slackevents.EventsAPIType, f SocketmodeHandlerFunc) { r.EventApiMap[et] = append(r.EventApiMap[et], f) } +// Register a middleware or handler for a Slash Command func (r *SocketmodeHandler) HandleSlashCommand(command string, f SocketmodeHandlerFunc) { r.SlashCommandMap[command] = append(r.SlashCommandMap[command], f) } -// Register a middleare function to use as a last resort +// Register a middleware or handler to use as a last resort func (r *SocketmodeHandler) HandleDefault(f SocketmodeHandlerFunc) { r.Default = f } @@ -83,12 +90,14 @@ func (r *SocketmodeHandler) RunEventLoop() { r.Client.Run() } +// Call the dispatcher for each incomming event func (r *SocketmodeHandler) runEventLoop() { for evt := range r.Client.Events { r.dispatcher(evt) } } +// Dispatch events to the specialized dispatcher func (r *SocketmodeHandler) dispatcher(evt Event) { var ishandled bool From 296bd7ecfcc181991f7d3c7e1903d5fb5d973c99 Mon Sep 17 00:00:00 2001 From: xNok Date: Mon, 7 Jun 2021 21:51:02 -0400 Subject: [PATCH 026/606] refact: change the type of socketmode.apiClient from a non-pointer to a pointer --- socketmode/client.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/socketmode/client.go b/socketmode/client.go index bf611de6a..aaee1e3a9 100644 --- a/socketmode/client.go +++ b/socketmode/client.go @@ -44,7 +44,7 @@ type SocketModeMessagePayload struct { // Client's New() and call Run() to start it. Please see examples/socketmode for the usage. type Client struct { // Client is the main API, embedded - apiClient slack.Client + apiClient *slack.Client // maxPingInterval is the maximum duration elapsed after the last WebSocket PING sent from Slack // until Client considers the WebSocket connection is dead and needs to be reopened. @@ -64,5 +64,5 @@ type Client struct { // return a pointer to the slack API client func (c Client) GetApiClient() *slack.Client { - return &c.apiClient + return c.apiClient } From 9bd9782050094ba46b65bccf976b2f6d34a8f443 Mon Sep 17 00:00:00 2001 From: xNok Date: Mon, 7 Jun 2021 21:52:08 -0400 Subject: [PATCH 027/606] refact: RunEventLoop return errors that occured --- socketmode/socketmode_eventhandler.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/socketmode/socketmode_eventhandler.go b/socketmode/socketmode_eventhandler.go index 2e3511897..c2886301f 100644 --- a/socketmode/socketmode_eventhandler.go +++ b/socketmode/socketmode_eventhandler.go @@ -83,11 +83,11 @@ func (r *SocketmodeHandler) HandleDefault(f SocketmodeHandlerFunc) { } // RunSlackEventLoop receives the event via the socket -func (r *SocketmodeHandler) RunEventLoop() { +func (r *SocketmodeHandler) RunEventLoop() error { go r.runEventLoop() - r.Client.Run() + return r.Client.Run() } // Call the dispatcher for each incomming event From 20e09d5fc4be9dbe25f5e1ba0de412ecc52855dd Mon Sep 17 00:00:00 2001 From: xNok Date: Mon, 7 Jun 2021 21:53:56 -0400 Subject: [PATCH 028/606] refact: rename file to socketmode_handler --- socketmode/{socketmode_eventhandler.go => socketmode_handler.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename socketmode/{socketmode_eventhandler.go => socketmode_handler.go} (100%) diff --git a/socketmode/socketmode_eventhandler.go b/socketmode/socketmode_handler.go similarity index 100% rename from socketmode/socketmode_eventhandler.go rename to socketmode/socketmode_handler.go From 01e7385c2cb39bbb93ad3290be5ad1d8da95a6bc Mon Sep 17 00:00:00 2001 From: xNok Date: Mon, 7 Jun 2021 21:59:18 -0400 Subject: [PATCH 029/606] refact: use socketmode client's logger. --- socketmode/socketmode_handler.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/socketmode/socketmode_handler.go b/socketmode/socketmode_handler.go index c2886301f..588f41e77 100644 --- a/socketmode/socketmode_handler.go +++ b/socketmode/socketmode_handler.go @@ -138,7 +138,7 @@ func (r *SocketmodeHandler) interactionDispatcher(evt *Event) bool { interaction, ok := evt.Data.(slack.InteractionCallback) if !ok { - fmt.Printf("Ignored %+v\n", evt) + r.Client.log.Printf("Ignored %+v\n", evt) return false } @@ -178,7 +178,7 @@ func (r *SocketmodeHandler) eventAPIDispatcher(evt *Event) bool { var ishandled bool = false eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) if !ok { - fmt.Printf("Ignored %+v\n", evt) + r.Client.log.Printf("Ignored %+v\n", evt) return false } From c965498de17851bdb992138e60cde4509a21d0ae Mon Sep 17 00:00:00 2001 From: xNok Date: Mon, 7 Jun 2021 21:59:39 -0400 Subject: [PATCH 030/606] refact: rename block_actions to blockActions --- socketmode/socketmode_handler.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/socketmode/socketmode_handler.go b/socketmode/socketmode_handler.go index 588f41e77..3df6c50ed 100644 --- a/socketmode/socketmode_handler.go +++ b/socketmode/socketmode_handler.go @@ -156,11 +156,11 @@ func (r *SocketmodeHandler) interactionDispatcher(evt *Event) bool { } // Level 3 - interaction with actionID - block_actions := interaction.ActionCallback.BlockActions + blockActions := interaction.ActionCallback.BlockActions // outmoded approach won`t be implemented // attachments_actions := interaction.ActionCallback.AttachmentActions - for _, action := range block_actions { + for _, action := range blockActions { if handlers, ok := r.InteractionBlockActionEventMap[action.ActionID]; ok { for _, f := range handlers { From 0273c6db3b04b26884a2670f02c9841df5285a9f Mon Sep 17 00:00:00 2001 From: xNok Date: Mon, 7 Jun 2021 22:13:19 -0400 Subject: [PATCH 031/606] fix: side effect --- socketmode/socketmode.go | 2 +- socketmode/socketmode_handler.go | 5 +++-- ...tmode_eventhandler_test.go => socketmode_handler_test.go} | 0 3 files changed, 4 insertions(+), 3 deletions(-) rename socketmode/{socketmode_eventhandler_test.go => socketmode_handler_test.go} (100%) diff --git a/socketmode/socketmode.go b/socketmode/socketmode.go index 752be8cdf..54f4d69d8 100644 --- a/socketmode/socketmode.go +++ b/socketmode/socketmode.go @@ -106,7 +106,7 @@ func OptionLog(l logger) func(*Client) { // Slack's Websocket-based Socket Mode. func New(api *slack.Client, options ...Option) *Client { result := &Client{ - apiClient: *api, + apiClient: api, Events: make(chan Event, 50), socketModeResponses: make(chan *Response, 20), maxPingInterval: defaultMaxPingInterval, diff --git a/socketmode/socketmode_handler.go b/socketmode/socketmode_handler.go index 3df6c50ed..ab5170a0a 100644 --- a/socketmode/socketmode_handler.go +++ b/socketmode/socketmode_handler.go @@ -2,6 +2,7 @@ package socketmode import ( "fmt" + "log" "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" @@ -138,7 +139,7 @@ func (r *SocketmodeHandler) interactionDispatcher(evt *Event) bool { interaction, ok := evt.Data.(slack.InteractionCallback) if !ok { - r.Client.log.Printf("Ignored %+v\n", evt) + log.Printf("Ignored %+v\n", evt) return false } @@ -178,7 +179,7 @@ func (r *SocketmodeHandler) eventAPIDispatcher(evt *Event) bool { var ishandled bool = false eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) if !ok { - r.Client.log.Printf("Ignored %+v\n", evt) + log.Printf("Ignored %+v\n", evt) return false } diff --git a/socketmode/socketmode_eventhandler_test.go b/socketmode/socketmode_handler_test.go similarity index 100% rename from socketmode/socketmode_eventhandler_test.go rename to socketmode/socketmode_handler_test.go From 7a1949e21e48c4016c8b229431a0a8d601650909 Mon Sep 17 00:00:00 2001 From: xNok Date: Mon, 7 Jun 2021 22:21:52 -0400 Subject: [PATCH 032/606] refact: use client logger --- socketmode/socketmode_handler.go | 9 +++------ socketmode/socketmode_handler_test.go | 6 +++++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/socketmode/socketmode_handler.go b/socketmode/socketmode_handler.go index ab5170a0a..0e72f92bb 100644 --- a/socketmode/socketmode_handler.go +++ b/socketmode/socketmode_handler.go @@ -1,9 +1,6 @@ package socketmode import ( - "fmt" - "log" - "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" ) @@ -139,7 +136,7 @@ func (r *SocketmodeHandler) interactionDispatcher(evt *Event) bool { interaction, ok := evt.Data.(slack.InteractionCallback) if !ok { - log.Printf("Ignored %+v\n", evt) + r.Client.log.Printf("Ignored %+v\n", evt) return false } @@ -179,7 +176,7 @@ func (r *SocketmodeHandler) eventAPIDispatcher(evt *Event) bool { var ishandled bool = false eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) if !ok { - log.Printf("Ignored %+v\n", evt) + r.Client.log.Printf("Ignored %+v\n", evt) return false } @@ -206,7 +203,7 @@ func (r *SocketmodeHandler) slashCommandDispatcher(evt *Event) bool { var ishandled bool = false slashCommandEvent, ok := evt.Data.(slack.SlashCommand) if !ok { - fmt.Printf("Ignored %+v\n", evt) + r.Client.log.Printf("Ignored %+v\n", evt) return false } diff --git a/socketmode/socketmode_handler_test.go b/socketmode/socketmode_handler_test.go index eaba03acf..5b354745b 100644 --- a/socketmode/socketmode_handler_test.go +++ b/socketmode/socketmode_handler_test.go @@ -1,6 +1,8 @@ package socketmode import ( + "log" + "os" "reflect" "runtime" "testing" @@ -17,7 +19,9 @@ func init_SocketmodeHandler() *SocketmodeHandler { slashCommandMap := make(map[string][]SocketmodeHandlerFunc) return &SocketmodeHandler{ - Client: &Client{}, + Client: &Client{ + log: log.New(os.Stderr, "slack-go/slack/socketmode", log.LstdFlags|log.Lshortfile), + }, EventMap: eventMap, EventApiMap: eventApiMap, InteractionEventMap: interactioneventMap, From 38427ff91e1772c36ac7412db0728bd97e8a766c Mon Sep 17 00:00:00 2001 From: xNok Date: Mon, 7 Jun 2021 22:59:44 -0400 Subject: [PATCH 033/606] refact: must register unique action_id or slash commands --- socketmode/socketmode_handler.go | 54 ++++++++++++------- socketmode/socketmode_handler_test.go | 74 ++++++++++++++++++++++++++- 2 files changed, 108 insertions(+), 20 deletions(-) diff --git a/socketmode/socketmode_handler.go b/socketmode/socketmode_handler.go index 0e72f92bb..99a94a17e 100644 --- a/socketmode/socketmode_handler.go +++ b/socketmode/socketmode_handler.go @@ -8,11 +8,14 @@ import ( type SocketmodeHandler struct { Client *Client - EventMap map[EventType][]SocketmodeHandlerFunc - InteractionEventMap map[slack.InteractionType][]SocketmodeHandlerFunc - InteractionBlockActionEventMap map[string][]SocketmodeHandlerFunc - EventApiMap map[slackevents.EventsAPIType][]SocketmodeHandlerFunc - SlashCommandMap map[string][]SocketmodeHandlerFunc + //lvl 1 - the most generic type of event + EventMap map[EventType][]SocketmodeHandlerFunc + //lvl 2 - Manage event by inner type + InteractionEventMap map[slack.InteractionType][]SocketmodeHandlerFunc + EventApiMap map[slackevents.EventsAPIType][]SocketmodeHandlerFunc + //lvl 3 - the most userfriendly way of managing event + InteractionBlockActionEventMap map[string]SocketmodeHandlerFunc + SlashCommandMap map[string]SocketmodeHandlerFunc Default SocketmodeHandlerFunc } @@ -27,9 +30,10 @@ type SocketmodeMiddlewareFunc func(SocketmodeHandlerFunc) SocketmodeHandlerFunc func NewsSocketmodeHandler(client *Client) *SocketmodeHandler { eventMap := make(map[EventType][]SocketmodeHandlerFunc) interactionEventMap := make(map[slack.InteractionType][]SocketmodeHandlerFunc) - interactionBlockActionEventMap := make(map[string][]SocketmodeHandlerFunc) eventApiMap := make(map[slackevents.EventsAPIType][]SocketmodeHandlerFunc) - slackCommandMap := make(map[string][]SocketmodeHandlerFunc) + + interactionBlockActionEventMap := make(map[string]SocketmodeHandlerFunc) + slackCommandMap := make(map[string]SocketmodeHandlerFunc) return &SocketmodeHandler{ Client: client, @@ -62,7 +66,16 @@ func (r *SocketmodeHandler) HandleInteraction(et slack.InteractionType, f Socket // Register a middleware or handler for a Block Action referenced by its ActionID func (r *SocketmodeHandler) HandleInteractionBlockAction(actionID string, f SocketmodeHandlerFunc) { - r.InteractionBlockActionEventMap[actionID] = append(r.InteractionBlockActionEventMap[actionID], f) + if actionID == "" { + panic("invalid command cannot be empty") + } + if f == nil { + panic("invalid handler cannot be nil") + } + if _, exist := r.InteractionBlockActionEventMap[actionID]; exist { + panic("multiple registrations for actionID" + actionID) + } + r.InteractionBlockActionEventMap[actionID] = f } // Register a middleware or handler for an Event (from slackevents) @@ -72,7 +85,16 @@ func (r *SocketmodeHandler) HandleEvents(et slackevents.EventsAPIType, f Socketm // Register a middleware or handler for a Slash Command func (r *SocketmodeHandler) HandleSlashCommand(command string, f SocketmodeHandlerFunc) { - r.SlashCommandMap[command] = append(r.SlashCommandMap[command], f) + if command == "" { + panic("invalid command cannot be empty") + } + if f == nil { + panic("invalid handler cannot be nil") + } + if _, exist := r.SlashCommandMap[command]; exist { + panic("multiple registrations for command" + command) + } + r.SlashCommandMap[command] = f } // Register a middleware or handler to use as a last resort @@ -159,11 +181,9 @@ func (r *SocketmodeHandler) interactionDispatcher(evt *Event) bool { // attachments_actions := interaction.ActionCallback.AttachmentActions for _, action := range blockActions { - if handlers, ok := r.InteractionBlockActionEventMap[action.ActionID]; ok { + if handler, ok := r.InteractionBlockActionEventMap[action.ActionID]; ok { - for _, f := range handlers { - go f(evt, r.Client) - } + go handler(evt, r.Client) ishandled = true } @@ -211,11 +231,9 @@ func (r *SocketmodeHandler) slashCommandDispatcher(evt *Event) bool { ishandled = r.socketmodeDispatcher(evt) // Level 2 - SlackCommand by name - if handlers, ok := r.SlashCommandMap[slashCommandEvent.Command]; ok { - // If we registered an event - for _, f := range handlers { - go f(evt, r.Client) - } + if handler, ok := r.SlashCommandMap[slashCommandEvent.Command]; ok { + + go handler(evt, r.Client) ishandled = true } diff --git a/socketmode/socketmode_handler_test.go b/socketmode/socketmode_handler_test.go index 5b354745b..623e15ef9 100644 --- a/socketmode/socketmode_handler_test.go +++ b/socketmode/socketmode_handler_test.go @@ -15,8 +15,8 @@ func init_SocketmodeHandler() *SocketmodeHandler { eventMap := make(map[EventType][]SocketmodeHandlerFunc) interactioneventMap := make(map[slack.InteractionType][]SocketmodeHandlerFunc) eventApiMap := make(map[slackevents.EventsAPIType][]SocketmodeHandlerFunc) - interactionBlockActionEventMap := make(map[string][]SocketmodeHandlerFunc) - slashCommandMap := make(map[string][]SocketmodeHandlerFunc) + interactionBlockActionEventMap := make(map[string]SocketmodeHandlerFunc) + slashCommandMap := make(map[string]SocketmodeHandlerFunc) return &SocketmodeHandler{ Client: &Client{ @@ -443,3 +443,73 @@ func TestSocketmodeHandler_HandleSlashCommand(t *testing.T) { }) } } + +func TestSocketmodeHandler_Handle_errors(t *testing.T) { + type args struct { + register func(*SocketmodeHandler, chan<- string) + } + tests := []struct { + name string + args args + }{ + { + name: "Attempt to register empty command", + args: args{ + register: func(r *SocketmodeHandler, c chan<- string) { + r.HandleSlashCommand("", testing_wrapper(c, middleware_slach_command)) + }, + }, + }, { + name: "Attempt to register nil handler", + args: args{ + register: func(r *SocketmodeHandler, c chan<- string) { + r.HandleSlashCommand("/command", nil) + }, + }, + }, { + name: "Attempt to register duplicate command", + args: args{ + register: func(r *SocketmodeHandler, c chan<- string) { + r.HandleSlashCommand("/command", testing_wrapper(c, middleware_slach_command)) + r.HandleSlashCommand("/command", testing_wrapper(c, middleware_slach_command)) + }, + }, + }, { + name: "Attempt to register empty Block ActionID", + args: args{ + register: func(r *SocketmodeHandler, c chan<- string) { + r.HandleInteractionBlockAction("", testing_wrapper(c, middleware_interaction_block_action)) + }, + }, + }, { + name: "Attempt to register nil handler", + args: args{ + register: func(r *SocketmodeHandler, c chan<- string) { + r.HandleInteractionBlockAction("action_id", nil) + }, + }, + }, { + name: "Attempt to register duplicate Block ActionID", + args: args{ + register: func(r *SocketmodeHandler, c chan<- string) { + r.HandleInteractionBlockAction("action_id", testing_wrapper(c, middleware_interaction_block_action)) + r.HandleInteractionBlockAction("action_id", testing_wrapper(c, middleware_interaction_block_action)) + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := init_SocketmodeHandler() + + c := make(chan string) + + defer func() { recover() }() + + tt.args.register(r, c) + + t.Errorf("should have panicked") + + }) + } +} From c062aee4939376788d484c62d7f5483d9f129c08 Mon Sep 17 00:00:00 2001 From: xNok Date: Sun, 11 Jul 2021 16:24:48 -0400 Subject: [PATCH 034/606] fix: typo --- examples/socketmode_handler/socketmode_handler.go | 2 +- socketmode/socketmode_handler.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/socketmode_handler/socketmode_handler.go b/examples/socketmode_handler/socketmode_handler.go index 3e83f7c0d..9ec8a3006 100644 --- a/examples/socketmode_handler/socketmode_handler.go +++ b/examples/socketmode_handler/socketmode_handler.go @@ -44,7 +44,7 @@ func main() { socketmode.OptionLog(log.New(os.Stdout, "socketmode: ", log.Lshortfile|log.LstdFlags)), ) - socketmodeHandler := socketmode.NewsSocketmodeHandler(client) + socketmodeHandler := socketmode.NewSocketmodeHandler(client) socketmodeHandler.Handle(socketmode.EventTypeConnecting, middlewareConnecting) socketmodeHandler.Handle(socketmode.EventTypeConnectionError, middlewareConnectionError) diff --git a/socketmode/socketmode_handler.go b/socketmode/socketmode_handler.go index 99a94a17e..636feefa5 100644 --- a/socketmode/socketmode_handler.go +++ b/socketmode/socketmode_handler.go @@ -27,7 +27,7 @@ type SocketmodeHandlerFunc func(*Event, *Client) type SocketmodeMiddlewareFunc func(SocketmodeHandlerFunc) SocketmodeHandlerFunc // Initialization constructor for SocketmodeHandler -func NewsSocketmodeHandler(client *Client) *SocketmodeHandler { +func NewSocketmodeHandler(client *Client) *SocketmodeHandler { eventMap := make(map[EventType][]SocketmodeHandlerFunc) interactionEventMap := make(map[slack.InteractionType][]SocketmodeHandlerFunc) eventApiMap := make(map[slackevents.EventsAPIType][]SocketmodeHandlerFunc) From 369f07a8fa2a8d604a8c0a06e1b9a0188bd7a3ee Mon Sep 17 00:00:00 2001 From: deepoil Date: Tue, 7 Sep 2021 21:36:08 +0900 Subject: [PATCH 035/606] add Hint field in NewInputBlock function --- block_input.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/block_input.go b/block_input.go index 087571af4..78ffcdb81 100644 --- a/block_input.go +++ b/block_input.go @@ -19,11 +19,12 @@ func (s InputBlock) BlockType() MessageBlockType { } // NewInputBlock returns a new instance of an input block -func NewInputBlock(blockID string, label *TextBlockObject, element BlockElement) *InputBlock { +func NewInputBlock(blockID string, label, hint *TextBlockObject, element BlockElement) *InputBlock { return &InputBlock{ Type: MBTInput, BlockID: blockID, Label: label, Element: element, + Hint: hint, } } From ce3cc0c3a1e4821325ec596d67d0bd6fd843d901 Mon Sep 17 00:00:00 2001 From: deepoil Date: Tue, 7 Sep 2021 21:37:38 +0900 Subject: [PATCH 036/606] fix test process --- block_input_test.go | 4 ++-- interactions_test.go | 2 ++ views_test.go | 16 ++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/block_input_test.go b/block_input_test.go index 10c3081c1..694e57580 100644 --- a/block_input_test.go +++ b/block_input_test.go @@ -9,8 +9,8 @@ import ( func TestNewInputBlock(t *testing.T) { label := NewTextBlockObject("plain_text", "label", false, false) element := NewDatePickerBlockElement("action_id") - - inputBlock := NewInputBlock("test", label, element) + hint := NewTextBlockObject("plain_text", "hint", false, false) + inputBlock := NewInputBlock("test", label, hint, element) assert.Equal(t, string(inputBlock.Type), "input") assert.Equal(t, inputBlock.BlockID, "test") assert.Equal(t, inputBlock.Label, label) diff --git a/interactions_test.go b/interactions_test.go index f662425dd..1e91f4b83 100644 --- a/interactions_test.go +++ b/interactions_test.go @@ -256,6 +256,7 @@ func TestViewSubmissionCallback(t *testing.T) { false, false, ), + nil, &PlainTextInputBlockElement{ Type: "plain_text_input", ActionID: "ml-value", @@ -270,6 +271,7 @@ func TestViewSubmissionCallback(t *testing.T) { false, false, ), + nil, &SelectBlockElement{ Type: "conversations_select", ActionID: "target_select", diff --git a/views_test.go b/views_test.go index 1dc1438ad..d831af2cc 100644 --- a/views_test.go +++ b/views_test.go @@ -172,6 +172,10 @@ func TestSlack_OpenView(t *testing.T) { Type: PlainTextType, Text: "A simple label", }, + &TextBlockObject{ + Type: PlainTextType, + Text: "A simple hint", + }, NewPlainTextInputBlockElement( &TextBlockObject{ Type: PlainTextType, @@ -331,6 +335,10 @@ func TestSlack_View_PublishView(t *testing.T) { Type: PlainTextType, Text: "A simple label", }, + &TextBlockObject{ + Type: PlainTextType, + Text: "A simple hint", + }, NewPlainTextInputBlockElement( &TextBlockObject{ Type: PlainTextType, @@ -503,6 +511,10 @@ func TestSlack_PushView(t *testing.T) { Type: PlainTextType, Text: "A simple label", }, + &TextBlockObject{ + Type: PlainTextType, + Text: "A simple hint", + }, NewPlainTextInputBlockElement( &TextBlockObject{ Type: PlainTextType, @@ -679,6 +691,10 @@ func TestSlack_UpdateView(t *testing.T) { Type: PlainTextType, Text: "A simple label", }, + &TextBlockObject{ + Type: PlainTextType, + Text: "A simple hint", + }, NewPlainTextInputBlockElement( &TextBlockObject{ Type: PlainTextType, From 44ab244a38c59c0ec6caab87ee010cc6447965e4 Mon Sep 17 00:00:00 2001 From: deepoil Date: Tue, 7 Sep 2021 21:37:56 +0900 Subject: [PATCH 037/606] fix examples --- examples/modal/modal.go | 6 ++++-- examples/modal_users/users.go | 11 ++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/examples/modal/modal.go b/examples/modal/modal.go index 78bb60e03..e628e853f 100644 --- a/examples/modal/modal.go +++ b/examples/modal/modal.go @@ -31,15 +31,17 @@ func generateModalRequest() slack.ModalViewRequest { headerSection := slack.NewSectionBlock(headerText, nil, nil) firstNameText := slack.NewTextBlockObject("plain_text", "First Name", false, false) + firstNameHint := slack.NewTextBlockObject("plain_text", "First Name Hint", false, false) firstNamePlaceholder := slack.NewTextBlockObject("plain_text", "Enter your first name", false, false) firstNameElement := slack.NewPlainTextInputBlockElement(firstNamePlaceholder, "firstName") // Notice that blockID is a unique identifier for a block - firstName := slack.NewInputBlock("First Name", firstNameText, firstNameElement) + firstName := slack.NewInputBlock("First Name", firstNameText, firstNameHint, firstNameElement) lastNameText := slack.NewTextBlockObject("plain_text", "Last Name", false, false) + lastNameHint := slack.NewTextBlockObject("plain_text", "Last Name Hint", false, false) lastNamePlaceholder := slack.NewTextBlockObject("plain_text", "Enter your first name", false, false) lastNameElement := slack.NewPlainTextInputBlockElement(lastNamePlaceholder, "lastName") - lastName := slack.NewInputBlock("Last Name", lastNameText, lastNameElement) + lastName := slack.NewInputBlock("Last Name", lastNameText, lastNameHint, lastNameElement) blocks := slack.Blocks{ BlockSet: []slack.Block{ diff --git a/examples/modal_users/users.go b/examples/modal_users/users.go index 9706a0b9e..9fdc81368 100644 --- a/examples/modal_users/users.go +++ b/examples/modal_users/users.go @@ -20,12 +20,12 @@ func main() { // Only the inputs in input blocks will be included in view_submission’s view.state.values: https://slack.dev/java-slack-sdk/guides/modals // This means the inputs will not be interactive either because they do not trigger block_actions messages: https://api.slack.com/surfaces/modals/using#interactions channelNameText := slack.NewTextBlockObject(slack.PlainTextType, "Channel Name", false, false) + channelNameHint := slack.NewTextBlockObject(slack.PlainTextType, "Channel names may only contain lowercase letters, numbers, hyphens, and underscores, and must be 80 characters or less", false, false) channelPlaceholder := slack.NewTextBlockObject(slack.PlainTextType, "New channel name", false, false) channelNameElement := slack.NewPlainTextInputBlockElement(channelPlaceholder, "channel_name") // Slack channel names can be maximum 80 characters: https://api.slack.com/methods/conversations.create channelNameElement.MaxLength = 80 - channelNameBlock := slack.NewInputBlock("channel_name", channelNameText, channelNameElement) - channelNameBlock.Hint = slack.NewTextBlockObject(slack.PlainTextType, "Channel names may only contain lowercase letters, numbers, hyphens, and underscores, and must be 80 characters or less", false, false) + channelNameBlock := slack.NewInputBlock("channel_name", channelNameText, channelNameHint, channelNameElement) // Provide a static list of users to choose from, those provided now are just made up user IDs // Get user IDs by right clicking on them in Slack, select "Copy link", and inspect the last part of the link @@ -33,7 +33,7 @@ func main() { memberOptions := createOptionBlockObjects([]string{"U9911MMAA", "U2233KKNN", "U00112233"}, true) inviteeText := slack.NewTextBlockObject(slack.PlainTextType, "Invitee from static list", false, false) inviteeOption := slack.NewOptionsSelectBlockElement(slack.OptTypeStatic, nil, "invitee", memberOptions...) - inviteeBlock := slack.NewInputBlock("invitee", inviteeText, inviteeOption) + inviteeBlock := slack.NewInputBlock("invitee", inviteeText, nil, inviteeOption) additionalInviteeText := slack.NewTextBlockObject(slack.PlainTextType, "Invitee from complete list of users", false, false) additionalInviteeOption := slack.NewOptionsSelectBlockElement(slack.OptTypeUser, additionalInviteeText, "") @@ -42,15 +42,16 @@ func main() { checkboxTxt := slack.NewTextBlockObject(slack.PlainTextType, "Checkbox", false, false) checkboxOptions := createOptionBlockObjects([]string{"option 1", "option 2", "option 3"}, false) checkboxOptionsBlock := slack.NewCheckboxGroupsBlockElement("chkbox", checkboxOptions...) - checkboxBlock := slack.NewInputBlock("chkbox", checkboxTxt, checkboxOptionsBlock) + checkboxBlock := slack.NewInputBlock("chkbox", checkboxTxt, nil, checkboxOptionsBlock) summaryText := slack.NewTextBlockObject(slack.PlainTextType, "Summary", false, false) + summaryHint := slack.NewTextBlockObject(slack.PlainTextType, "Summary Hint", false, false) summaryPlaceholder := slack.NewTextBlockObject(slack.PlainTextType, "Summary of reason for creating channel", false, false) summaryElement := slack.NewPlainTextInputBlockElement(summaryPlaceholder, "summary") // Just set an arbitrary max length to avoid too prose summary summaryElement.MaxLength = 200 summaryElement.Multiline = true - summaryBlock := slack.NewInputBlock("summary", summaryText, summaryElement) + summaryBlock := slack.NewInputBlock("summary", summaryText, summaryHint, summaryElement) blocks := slack.Blocks{ BlockSet: []slack.Block{ From 2bb1377363e0b7b598d41c817abd18280ed6d689 Mon Sep 17 00:00:00 2001 From: Johan McGwire Date: Wed, 20 Oct 2021 20:27:29 -0400 Subject: [PATCH 038/606] Add include_num_members support to conversation.info request --- conversation.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/conversation.go b/conversation.go index 299362601..586430452 100644 --- a/conversation.go +++ b/conversation.go @@ -358,16 +358,17 @@ func (api *Client) CreateConversationContext(ctx context.Context, channelName st } // GetConversationInfo retrieves information about a conversation -func (api *Client) GetConversationInfo(channelID string, includeLocale bool) (*Channel, error) { - return api.GetConversationInfoContext(context.Background(), channelID, includeLocale) +func (api *Client) GetConversationInfo(channelID string, includeLocale, includeNumMembers bool) (*Channel, error) { + return api.GetConversationInfoContext(context.Background(), channelID, includeLocale, includeNumMembers) } // GetConversationInfoContext retrieves information about a conversation with a custom context -func (api *Client) GetConversationInfoContext(ctx context.Context, channelID string, includeLocale bool) (*Channel, error) { +func (api *Client) GetConversationInfoContext(ctx context.Context, channelID string, includeLocale, includeNumMembers bool) (*Channel, error) { values := url.Values{ - "token": {api.token}, - "channel": {channelID}, - "include_locale": {strconv.FormatBool(includeLocale)}, + "token": {api.token}, + "channel": {channelID}, + "include_locale": {strconv.FormatBool(includeLocale)}, + "include_num_members": {strconv.FormatBool(includeNumMembers)}, } response, err := api.channelRequest(ctx, "conversations.info", values) if err != nil { From 094f09633ed44bfea71e782ad02e55729b7f178c Mon Sep 17 00:00:00 2001 From: Johan McGwire Date: Wed, 20 Oct 2021 20:31:29 -0400 Subject: [PATCH 039/606] Fix test --- conversation_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conversation_test.go b/conversation_test.go index 61a4e92b1..f1a4a0dd9 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -400,7 +400,7 @@ func TestGetConversationInfo(t *testing.T) { http.HandleFunc("/conversations.info", okChannelJsonHandler) once.Do(startServer) api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - channel, err := api.GetConversationInfo("CXXXXXXXX", false) + channel, err := api.GetConversationInfo("CXXXXXXXX", false, false) if err != nil { t.Errorf("Unexpected error: %s", err) return From 2135a67cf8b3d2762233aedbfdd3d74e0f8fe1b1 Mon Sep 17 00:00:00 2001 From: xNok Date: Mon, 1 Nov 2021 23:48:40 +0100 Subject: [PATCH 040/606] fix: compatibility with access to Slack client in socketmode --- examples/socketmode_handler/socketmode_handler.go | 4 ++-- socketmode/client.go | 5 ----- socketmode/socketmode.go | 1 - 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/examples/socketmode_handler/socketmode_handler.go b/examples/socketmode_handler/socketmode_handler.go index 9ec8a3006..9991e8c3c 100644 --- a/examples/socketmode_handler/socketmode_handler.go +++ b/examples/socketmode_handler/socketmode_handler.go @@ -103,7 +103,7 @@ func middlewareEventsAPI(evt *socketmode.Event, client *socketmode.Client) { switch ev := innerEvent.Data.(type) { case *slackevents.AppMentionEvent: fmt.Printf("We have been mentionned in %v", ev.Channel) - _, _, err := client.GetApiClient().PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) + _, _, err := client.Client.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) if err != nil { fmt.Printf("failed posting message: %v", err) } @@ -132,7 +132,7 @@ func middlewareAppMentionEvent(evt *socketmode.Event, client *socketmode.Client) } fmt.Printf("We have been mentionned in %v\n", ev.Channel) - _, _, err := client.GetApiClient().PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) + _, _, err := client.Client.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) if err != nil { fmt.Printf("failed posting message: %v", err) } diff --git a/socketmode/client.go b/socketmode/client.go index 8c4f4821a..8fd0b9871 100644 --- a/socketmode/client.go +++ b/socketmode/client.go @@ -61,8 +61,3 @@ type Client struct { debug bool log ilogger } - -// return a pointer to the slack API client -func (c Client) GetApiClient() *slack.Client { - return c.apiClient -} diff --git a/socketmode/socketmode.go b/socketmode/socketmode.go index d1acdbd57..d91d422b1 100644 --- a/socketmode/socketmode.go +++ b/socketmode/socketmode.go @@ -106,7 +106,6 @@ func OptionLog(l logger) func(*Client) { // Slack's Websocket-based Socket Mode. func New(api *slack.Client, options ...Option) *Client { result := &Client{ - apiClient: api, Client: *api, Events: make(chan Event, 50), socketModeResponses: make(chan *Response, 20), From d06a99bd3ca6598a11c0dfa43596f997c339b0de Mon Sep 17 00:00:00 2001 From: xNok Date: Tue, 9 Nov 2021 00:40:40 +0100 Subject: [PATCH 041/606] doc: socketmode handler --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index dbf73d4ed..5203cf4ad 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,13 @@ See https://github.com/slack-go/slack/blob/master/examples/websocket/websocket.g See https://github.com/slack-go/slack/blob/master/examples/eventsapi/events.go +## Socketmode Event Handler (Experimental) +When using socket mode, dealing with an event can be pretty lengthy as it requires you to route the event to the right place. + +Instead, you can use `SocketmodeHandler` much like you use an HTTP handler to register which event you would like to listen to and what callback function will process that event when it occurs. + +See [./examples/socketmode_handler/socketmode_handler.go](./examples/socketmode_handler/socketmode_handler.go) ## Contributing You are more than welcome to contribute to this project. Fork and From b11c9d15de1bc1745ab64705c9f36f60767951c9 Mon Sep 17 00:00:00 2001 From: xNok Date: Tue, 9 Nov 2021 21:05:07 +0100 Subject: [PATCH 042/606] fix: trailing whitespace --- slackevents/inner_events.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 9b8d0e142..d005c84a4 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -419,29 +419,29 @@ const ( // AppUninstalled Your Slack app was uninstalled. AppUninstalled = EventsAPIType("app_uninstalled") // ChannelCreated is sent when a new channel is created. - ChannelCreated = EventsAPIType("channel_created") + ChannelCreated = EventsAPIType("channel_created") // ChannelDeleted is sent when a channel is deleted. - ChannelDeleted = EventsAPIType("channel_deleted") + ChannelDeleted = EventsAPIType("channel_deleted") // ChannelArchive is sent when a channel is archived. - ChannelArchive = EventsAPIType("channel_archive") + ChannelArchive = EventsAPIType("channel_archive") // ChannelUnarchive is sent when a channel is unarchived. - ChannelUnarchive = EventsAPIType("channel_unarchive") + ChannelUnarchive = EventsAPIType("channel_unarchive") // ChannelLeft is sent when a channel is left. - ChannelLeft = EventsAPIType("channel_left") + ChannelLeft = EventsAPIType("channel_left") // ChannelRename is sent when a channel is rename. - ChannelRename = EventsAPIType("channel_rename") + ChannelRename = EventsAPIType("channel_rename") // ChannelIDChanged is sent when a channel identifier is changed. ChannelIDChanged = "channel_id_changed" // GroupDeleted is sent when a group is deleted. - GroupDeleted = EventsAPIType("group_deleted") + GroupDeleted = EventsAPIType("group_deleted") // GroupArchive is sent when a group is archived. - GroupArchive = EventsAPIType("group_archive") + GroupArchive = EventsAPIType("group_archive") // GroupUnarchive is sent when a group is unarchived. - GroupUnarchive = EventsAPIType("group_unarchive") + GroupUnarchive = EventsAPIType("group_unarchive") // GroupLeft is sent when a group is left. - GroupLeft = EventsAPIType("group_left") + GroupLeft = EventsAPIType("group_left") // GroupRename is sent when a group is renamed. - GroupRename = EventsAPIType("group_rename") + GroupRename = EventsAPIType("group_rename") // GridMigrationFinished An enterprise grid migration has finished on this workspace. GridMigrationFinished = EventsAPIType("grid_migration_finished") // GridMigrationStarted An enterprise grid migration has started on this workspace. @@ -463,7 +463,7 @@ const ( // ReactionRemoved An reaction was removed from a message ReactionRemoved = EventsAPIType("reaction_removed") // TeamJoin A new user joined the workspace - TeamJoin = EventsAPIType("team_join") + TeamJoin = EventsAPIType("team_join") // TokensRevoked APP's API tokes are revoked TokensRevoked = EventsAPIType("tokens_revoked") // EmojiChanged A custom emoji has been added or changed From e4955fd97d47d31cf9532944a864e61879a4b4e5 Mon Sep 17 00:00:00 2001 From: Johan McGwire Date: Wed, 24 Nov 2021 10:50:24 -0500 Subject: [PATCH 043/606] Swap to struct input Signed-off-by: Johan McGwire --- conversation.go | 23 +++++++++++++++++------ conversation_test.go | 4 +++- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/conversation.go b/conversation.go index 586430452..afde30ff0 100644 --- a/conversation.go +++ b/conversation.go @@ -357,18 +357,29 @@ func (api *Client) CreateConversationContext(ctx context.Context, channelName st return &response.Channel, nil } +// GetConversationInfoInput Defines the parameters of a GetConversationInfo and GetConversationInfoContext function +type GetConversationInfoInput struct { + ChannelID string + IncludeLocale bool + IncludeNumMembers bool +} + // GetConversationInfo retrieves information about a conversation -func (api *Client) GetConversationInfo(channelID string, includeLocale, includeNumMembers bool) (*Channel, error) { - return api.GetConversationInfoContext(context.Background(), channelID, includeLocale, includeNumMembers) +func (api *Client) GetConversationInfo(input *GetConversationInfoInput) (*Channel, error) { + return api.GetConversationInfoContext(context.Background(), input) } // GetConversationInfoContext retrieves information about a conversation with a custom context -func (api *Client) GetConversationInfoContext(ctx context.Context, channelID string, includeLocale, includeNumMembers bool) (*Channel, error) { +func (api *Client) GetConversationInfoContext(ctx context.Context, input *GetConversationInfoInput) (*Channel, error) { + if input == nil { + input = &GetConversationInfoInput{} + } + values := url.Values{ "token": {api.token}, - "channel": {channelID}, - "include_locale": {strconv.FormatBool(includeLocale)}, - "include_num_members": {strconv.FormatBool(includeNumMembers)}, + "channel": {input.ChannelID}, + "include_locale": {strconv.FormatBool(input.IncludeLocale)}, + "include_num_members": {strconv.FormatBool(input.IncludeNumMembers)}, } response, err := api.channelRequest(ctx, "conversations.info", values) if err != nil { diff --git a/conversation_test.go b/conversation_test.go index f1a4a0dd9..fedba8f8b 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -400,7 +400,9 @@ func TestGetConversationInfo(t *testing.T) { http.HandleFunc("/conversations.info", okChannelJsonHandler) once.Do(startServer) api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - channel, err := api.GetConversationInfo("CXXXXXXXX", false, false) + channel, err := api.GetConversationInfo(&GetConversationInfoInput{ + ChannelID: "CXXXXXXXX", + }) if err != nil { t.Errorf("Unexpected error: %s", err) return From fd9eee81d4bf0c922ec4ce3c72c0db2fea1e6fe4 Mon Sep 17 00:00:00 2001 From: Johan McGwire Date: Sun, 13 Feb 2022 14:23:30 -0500 Subject: [PATCH 044/606] gofmt --- conversation.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/conversation.go b/conversation.go index afde30ff0..861d86aae 100644 --- a/conversation.go +++ b/conversation.go @@ -359,8 +359,8 @@ func (api *Client) CreateConversationContext(ctx context.Context, channelName st // GetConversationInfoInput Defines the parameters of a GetConversationInfo and GetConversationInfoContext function type GetConversationInfoInput struct { - ChannelID string - IncludeLocale bool + ChannelID string + IncludeLocale bool IncludeNumMembers bool } From 90afaf7cc920bfc034963d03d12fc717d507e22a Mon Sep 17 00:00:00 2001 From: Winston Durand Date: Wed, 16 Mar 2022 09:54:09 -0700 Subject: [PATCH 045/606] Add bookmarks endpoints Co-authored-by: Winston Durand --- bookmarks.go | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 bookmarks.go diff --git a/bookmarks.go b/bookmarks.go new file mode 100644 index 000000000..d1eb81872 --- /dev/null +++ b/bookmarks.go @@ -0,0 +1,153 @@ +package slack + +import ( + "context" + "net/url" +) + +type Bookmark struct { + ID string `json:"id"` + ChannelID string `json:"channel_id"` + Title string `json:"title"` + Link string `json:"link"` + Emoji string `json:"emoji"` + IconURL string `json:"icon_url"` + Type string `json:"type"` + Created JSONTime `json:"date_created"` + Updated JSONTime `json:"date_updated"` + Rank string `json:"rank"` + + LastUpdatedByUserID string `json:"last_updated_by_user_id"` + LastUpdatedByTeamID string `json:"last_updated_by_team_id"` + + ShortcutID string `json:"shortcut_id"` + EntityID string `json:"entity_id"` + AppID string `json:"app_id"` +} + +type AddBookmarkParameters struct { + Title string + Type string + Link string + Emoji string + EntityID string + ParentID string +} + +type EditBookmarkParameters struct { + Title string + Link string + Emoji string +} + +// AddBookmark adds a bookmark in a channel +func (api *Client) AddBookmark(channelID string, params AddBookmarkParameters) (Bookmark, error) { + return api.AddBookmarkContext(context.Background(), channelID, params) +} + +// AddBookmarkContext adds a bookmark in a channel with a custom context +func (api *Client) AddBookmarkContext(ctx context.Context, channelID string, params AddBookmarkParameters) (Bookmark, error) { + values := url.Values{ + "channel_id": {channelID}, + "token": {api.token}, + "title": {params.Title}, + "type": {params.Type}, + } + if params.Link != "" { + values.Set("link", params.Link) + } + if params.Emoji != "" { + values.Set("emoji", params.Emoji) + } + if params.EntityID != "" { + values.Set("entity_id", params.EntityID) + } + if params.ParentID != "" { + values.Set("parent_id", params.ParentID) + } + + response := &struct { + Bookmark Bookmark `json:"bookmark"` + SlackResponse + }{} + if err := api.postMethod(ctx, "bookmarks.add", values, response); err != nil { + return Bookmark{}, err + } + + return response.Bookmark, response.Err() +} + +// RemoveBookmark removes a bookmark from a channel +func (api *Client) RemoveBookmark(channelID, bookmarkID string) error { + return api.RemoveBookmarkContext(context.Background(), channelID, bookmarkID) +} + +// RemoveBookmarkContext removes a bookmark from a channel with a custom context +func (api *Client) RemoveBookmarkContext(ctx context.Context, channelID, bookmarkID string) error { + values := url.Values{ + "channel_id": {channelID}, + "token": {api.token}, + "bookmark_id": {bookmarkID}, + } + + response := &SlackResponse{} + if err := api.postMethod(ctx, "bookmarks.remove", values, response); err != nil { + return err + } + + return response.Err() +} + +// ListBookmarks returns all bookmarks for a channel. +func (api *Client) ListBookmarks(channelID string) ([]Bookmark, error) { + return api.ListBookmarksContext(context.Background(), channelID) +} + +// ListBookmarksContext returns all bookmarks for a channel with a custom context. +func (api *Client) ListBookmarksContext(ctx context.Context, channelID string) ([]Bookmark, error) { + values := url.Values{ + "channel_id": {channelID}, + "token": {api.token}, + } + + response := &struct { + Bookmarks []Bookmark `json:"bookmark"` + SlackResponse + }{} + err := api.postMethod(ctx, "bookmarks.list", values, response) + if err != nil { + return nil, err + } + return response.Bookmarks, response.Err() +} + +func (api *Client) EditBookmark(channelID, bookmarkID string, params EditBookmarkParameters) (Bookmark, error) { + return api.EditBookmarkContext(context.Background(), channelID, bookmarkID, params) +} + +func (api *Client) EditBookmarkContext(ctx context.Context, channelID, bookmarkID string, params EditBookmarkParameters) (Bookmark, error) { + values := url.Values{ + "channel_id": {channelID}, + "token": {api.token}, + "bookmark_id": {bookmarkID}, + } + if params.Link != "" { + values.Set("link", params.Link) + } + if params.Emoji != "" { + values.Set("emoji", params.Emoji) + } + if params.Title != "" { + values.Set("title", params.Title) + } + + response := &struct { + Bookmark Bookmark `json:"bookmark"` + SlackResponse + }{} + if err := api.postMethod(ctx, "bookmarks.edit", values, response); err != nil { + return Bookmark{}, err + } + + return response.Bookmark, response.Err() +} From 3c52fecd60bf0da96b481c69de277bca46f342cd Mon Sep 17 00:00:00 2001 From: Winston Durand Date: Wed, 16 Mar 2022 11:10:04 -0700 Subject: [PATCH 046/606] update the bookmark types --- bookmarks.go | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/bookmarks.go b/bookmarks.go index d1eb81872..6ff6483c8 100644 --- a/bookmarks.go +++ b/bookmarks.go @@ -26,10 +26,10 @@ type Bookmark struct { } type AddBookmarkParameters struct { - Title string - Type string - Link string - Emoji string + Title string // A required title for the bookmark + Type string // A required type for the bookmark + Link string // URL required for type:link + Emoji string // An optional emoji EntityID string ParentID string } @@ -40,6 +40,21 @@ type EditBookmarkParameters struct { Emoji string } +type addBookmarkReponse struct { + Bookmark Bookmark `json:"bookmark"` + SlackResponse +} + +type editBookmarkReponse struct { + Bookmark Bookmark `json:"bookmark"` + SlackResponse +} + +type listBookmarksReponse struct { + Bookmarks []Bookmark `json:"bookmarks"` + SlackResponse +} + // AddBookmark adds a bookmark in a channel func (api *Client) AddBookmark(channelID string, params AddBookmarkParameters) (Bookmark, error) { return api.AddBookmarkContext(context.Background(), channelID, params) @@ -66,10 +81,7 @@ func (api *Client) AddBookmarkContext(ctx context.Context, channelID string, par values.Set("parent_id", params.ParentID) } - response := &struct { - Bookmark Bookmark `json:"bookmark"` - SlackResponse - }{} + response := &addBookmarkReponse{} if err := api.postMethod(ctx, "bookmarks.add", values, response); err != nil { return Bookmark{}, err } @@ -110,10 +122,7 @@ func (api *Client) ListBookmarksContext(ctx context.Context, channelID string) ( "token": {api.token}, } - response := &struct { - Bookmarks []Bookmark `json:"bookmark"` - SlackResponse - }{} + response := &listBookmarksReponse{} err := api.postMethod(ctx, "bookmarks.list", values, response) if err != nil { return nil, err @@ -141,10 +150,7 @@ func (api *Client) EditBookmarkContext(ctx context.Context, channelID, bookmarkI values.Set("title", params.Title) } - response := &struct { - Bookmark Bookmark `json:"bookmark"` - SlackResponse - }{} + response := &editBookmarkReponse{} if err := api.postMethod(ctx, "bookmarks.edit", values, response); err != nil { return Bookmark{}, err } From 9c7f33b055e47f3be8f3b53f2f00bf80ee114cf4 Mon Sep 17 00:00:00 2001 From: Winston Durand Date: Wed, 16 Mar 2022 12:36:55 -0700 Subject: [PATCH 047/606] Update tests --- bookmarks.go | 14 +-- bookmarks_test.go | 237 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 bookmarks_test.go diff --git a/bookmarks.go b/bookmarks.go index 6ff6483c8..9fa529f37 100644 --- a/bookmarks.go +++ b/bookmarks.go @@ -35,9 +35,9 @@ type AddBookmarkParameters struct { } type EditBookmarkParameters struct { - Title string - Link string - Emoji string + Title *string // Change the title. Set to "" to clear + Emoji *string // Change the emoji. Set to "" to clear + Link string // Change the link } type addBookmarkReponse struct { @@ -143,11 +143,11 @@ func (api *Client) EditBookmarkContext(ctx context.Context, channelID, bookmarkI if params.Link != "" { values.Set("link", params.Link) } - if params.Emoji != "" { - values.Set("emoji", params.Emoji) + if params.Emoji != nil { + values.Set("emoji", *params.Emoji) } - if params.Title != "" { - values.Set("title", params.Title) + if params.Title != nil { + values.Set("title", *params.Title) } response := &editBookmarkReponse{} diff --git a/bookmarks_test.go b/bookmarks_test.go new file mode 100644 index 000000000..0b736085a --- /dev/null +++ b/bookmarks_test.go @@ -0,0 +1,237 @@ +package slack + +import ( + "encoding/json" + "fmt" + "net/http" + "reflect" + "testing" +) + +func getTestBookmark(channelID, bookmarkID string) Bookmark { + return Bookmark{ + ID: bookmarkID, + ChannelID: channelID, + Title: "bookmark", + Type: "link", + Link: "https://example.com", + IconURL: "https://example.com/icon.png", + } +} + +func addBookmarkLinkHandler(rw http.ResponseWriter, r *http.Request) { + channelID := r.FormValue("channel_id") + title := r.FormValue("title") + bookmarkType := r.FormValue("type") + link := r.FormValue("link") + + rw.Header().Set("Content-Type", "application/json") + + if bookmarkType == "link" && link != "" && channelID != "" && title != "" { + bookmark := getTestBookmark(channelID, "Bk123RBZG8GZ") + bookmark.Title = title + bookmark.Type = bookmarkType + bookmark.Link = link + + resp, _ := json.Marshal(&addBookmarkReponse{ + SlackResponse: SlackResponse{Ok: true}, + Bookmark: bookmark}) + rw.Write(resp) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestAddBookmarkLink(t *testing.T) { + http.HandleFunc("/bookmarks.add", addBookmarkLinkHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + params := AddBookmarkParameters{ + Title: "test", + Type: "link", + Link: "https://example.com", + } + _, err := api.AddBookmark("CXXXXXXXX", params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } +} + +func listBookmarksHandler(rw http.ResponseWriter, r *http.Request) { + channelID := r.FormValue("channel_id") + + rw.Header().Set("Content-Type", "application/json") + + if channelID != "" { + bookmarks := []Bookmark{ + getTestBookmark(channelID, "Bk001"), + getTestBookmark(channelID, "Bk002"), + getTestBookmark(channelID, "Bk003"), + getTestBookmark(channelID, "Bk004"), + } + + resp, _ := json.Marshal(&listBookmarksReponse{ + SlackResponse: SlackResponse{Ok: true}, + Bookmarks: bookmarks}) + rw.Write(resp) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestListBookmarks(t *testing.T) { + http.HandleFunc("/bookmarks.list", listBookmarksHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + channel := "CXXXXXXXX" + bookmarks, err := api.ListBookmarks(channel) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if !reflect.DeepEqual([]Bookmark{ + getTestBookmark(channel, "Bk001"), + getTestBookmark(channel, "Bk002"), + getTestBookmark(channel, "Bk003"), + getTestBookmark(channel, "Bk004"), + }, bookmarks) { + t.Fatal(ErrIncorrectResponse) + } +} + +func removeBookmarkHandler(bookmark *Bookmark) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + channelID := r.FormValue("channel_id") + bookmarkID := r.FormValue("bookmark_id") + + rw.Header().Set("Content-Type", "application/json") + + if channelID == bookmark.ChannelID && bookmarkID == bookmark.ID { + rw.Write([]byte(`{ "ok": true }`)) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } + } +} + +func TestRemoveBookmark(t *testing.T) { + channel := "CXXXXXXXX" + bookmark := getTestBookmark(channel, "BkXXXXX") + http.HandleFunc("/bookmarks.remove", removeBookmarkHandler(&bookmark)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.RemoveBookmark(channel, bookmark.ID) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } +} + +func editBookmarkHandler(bookmarks []Bookmark) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + channelID := r.FormValue("channel_id") + bookmarkID := r.FormValue("bookmark_id") + + rw.Header().Set("Content-Type", "application/json") + if err := r.ParseForm(); err != nil { + httpTestErrReply(rw, true, fmt.Sprintf("err parsing form: %s", err.Error())) + return + } + + for _, bookmark := range bookmarks { + if bookmark.ID == bookmarkID && bookmark.ChannelID == channelID { + if v := r.Form.Get("link"); v != "" { + bookmark.Link = v + } + // Emoji and title require special handling since empty string sets to null + if v := r.Form.Get("emoji"); r.Form.Has("emoji") { + bookmark.Emoji = v + } + if v := r.Form.Get("title"); r.Form.Has("title") { + bookmark.Title = v + } + resp, _ := json.Marshal(&editBookmarkReponse{ + SlackResponse: SlackResponse{Ok: true}, + Bookmark: bookmark}) + rw.Write(resp) + return + } + } + // Fail if the bookmark doesn't exist + rw.Write([]byte(`{ "ok": false, "error": "not_found" }`)) + } +} + +func TestEditBookmark(t *testing.T) { + channel := "CXXXXXXXX" + bookmarks := []Bookmark{ + getTestBookmark(channel, "Bk001"), + getTestBookmark(channel, "Bk002"), + getTestBookmark(channel, "Bk003"), + getTestBookmark(channel, "Bk004"), + } + http.HandleFunc("/bookmarks.edit", editBookmarkHandler(bookmarks)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + smileEmoji := ":smile:" + empty := "" + title := "hello, world!" + changes := []struct { + ID string + Params EditBookmarkParameters + }{ + { // add emoji + ID: "Bk001", + Params: EditBookmarkParameters{Emoji: &smileEmoji}, + }, + { // delete emoji + ID: "Bk001", + Params: EditBookmarkParameters{Emoji: &empty}, + }, + { // add title + ID: "Bk002", + Params: EditBookmarkParameters{Title: &title}, + }, + { // delete title + ID: "Bk002", + Params: EditBookmarkParameters{Title: &empty}, + }, + { // Change multiple fields at once + ID: "Bk003", + Params: EditBookmarkParameters{ + Title: &title, + Emoji: &empty, + Link: "https://example.com/changed", + }, + }, + { // noop + ID: "Bk004", + }, + } + + for _, change := range changes { + bookmark, err := api.EditBookmark(channel, change.ID, change.Params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + if change.ID != bookmark.ID { + t.Fatalf("expected to modify bookmark with ID = %s, got %s", change.ID, bookmark.ID) + } + if change.Params.Emoji != nil && bookmark.Emoji != *change.Params.Emoji { + t.Fatalf("expected bookmark.Emoji = %s, got %s", *change.Params.Emoji, bookmark.Emoji) + } + if change.Params.Title != nil && bookmark.Title != *change.Params.Title { + t.Fatalf("expected bookmark.Title = %s, got %s", *change.Params.Title, bookmark.Emoji) + } + if change.Params.Link != "" { + t.Fatalf("expected bookmark.Link = %s, got %s", change.Params.Link, bookmark.Link) + } + } + + // Cover the final case of trying to edit a bookmark which doesn't exist + bookmark, err := api.EditBookmark(channel, "BkMissing", EditBookmarkParameters{}) + if err == nil { + t.Fatalf("Expected not found error, but got bookmark %s", bookmark.ID) + } +} From 196542f41fc11aa60a5081505fc7cf35e365f7c0 Mon Sep 17 00:00:00 2001 From: Winston Durand Date: Wed, 16 Mar 2022 12:40:06 -0700 Subject: [PATCH 048/606] Fix bookmark tests --- bookmarks_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bookmarks_test.go b/bookmarks_test.go index 0b736085a..facba538e 100644 --- a/bookmarks_test.go +++ b/bookmarks_test.go @@ -224,7 +224,7 @@ func TestEditBookmark(t *testing.T) { if change.Params.Title != nil && bookmark.Title != *change.Params.Title { t.Fatalf("expected bookmark.Title = %s, got %s", *change.Params.Title, bookmark.Emoji) } - if change.Params.Link != "" { + if change.Params.Link != "" && change.Params.Link != bookmark.Link { t.Fatalf("expected bookmark.Link = %s, got %s", change.Params.Link, bookmark.Link) } } From 48020848b84a49fc3297e00b3b80b75ac1a75963 Mon Sep 17 00:00:00 2001 From: Winston Durand Date: Fri, 18 Mar 2022 09:35:26 -0700 Subject: [PATCH 049/606] remove go1.17 dependency --- bookmarks_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bookmarks_test.go b/bookmarks_test.go index facba538e..5343510fb 100644 --- a/bookmarks_test.go +++ b/bookmarks_test.go @@ -144,11 +144,11 @@ func editBookmarkHandler(bookmarks []Bookmark) func(rw http.ResponseWriter, r *h bookmark.Link = v } // Emoji and title require special handling since empty string sets to null - if v := r.Form.Get("emoji"); r.Form.Has("emoji") { - bookmark.Emoji = v + if _, ok := r.Form["emoji"]; ok { + bookmark.Emoji = r.Form.Get("emoji") } - if v := r.Form.Get("title"); r.Form.Has("title") { - bookmark.Title = v + if _, ok := r.Form["title"]; ok { + bookmark.Title = r.Form.Get("title") } resp, _ := json.Marshal(&editBookmarkReponse{ SlackResponse: SlackResponse{Ok: true}, From 8402a1d9d1e126ea30882ce25bb470693a0d3054 Mon Sep 17 00:00:00 2001 From: Justin Judd Date: Tue, 22 Mar 2022 16:40:00 -0600 Subject: [PATCH 050/606] Added Details field to Audit event entries Added the optional `details` field for the audit events. As there aren't details what all of the sub-fields are, adding different fields that we've seen in our logs. --- audit.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/audit.go b/audit.go index 041ffd77a..3e036d10f 100644 --- a/audit.go +++ b/audit.go @@ -39,6 +39,21 @@ type AuditEntry struct { UA string `json:"ua"` IPAddress string `json:"ip_address"` } `json:"context"` + Details struct { + NewValue interface{} `json:"new_value"` + PrevValue interface{} `json:"previous_value"` + PolicyName string `json:"policy_name"` + AuthenticationMode string `json:"authentication_mode"` + EntityIds string `json:"entity_ids"` + MobileOnly bool `json:"mobile_only"` + WebOnly bool `json:"web_only"` + NonSSOOnly bool `json:"non_sso_only"` + SucceededUsers string `json:"succeeded_users"` + FailedUsers string `json:"failed_users"` + ExportType string `json:"export_type"` + ExportStart string `json:"export_start_ts"` + ExportEnd string `json:"export_end_ts"` + } `json:"details"` } type AuditUser struct { From 82e0f3a144b7e1b1180a28f1e03dd4e3ce99c4bc Mon Sep 17 00:00:00 2001 From: Justin Judd Date: Tue, 22 Mar 2022 19:38:58 -0700 Subject: [PATCH 051/606] Go fmt --- audit.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/audit.go b/audit.go index 3e036d10f..df4efb44f 100644 --- a/audit.go +++ b/audit.go @@ -40,19 +40,19 @@ type AuditEntry struct { IPAddress string `json:"ip_address"` } `json:"context"` Details struct { - NewValue interface{} `json:"new_value"` - PrevValue interface{} `json:"previous_value"` - PolicyName string `json:"policy_name"` - AuthenticationMode string `json:"authentication_mode"` - EntityIds string `json:"entity_ids"` - MobileOnly bool `json:"mobile_only"` - WebOnly bool `json:"web_only"` - NonSSOOnly bool `json:"non_sso_only"` - SucceededUsers string `json:"succeeded_users"` - FailedUsers string `json:"failed_users"` - ExportType string `json:"export_type"` - ExportStart string `json:"export_start_ts"` - ExportEnd string `json:"export_end_ts"` + NewValue interface{} `json:"new_value"` + PrevValue interface{} `json:"previous_value"` + PolicyName string `json:"policy_name"` + AuthenticationMode string `json:"authentication_mode"` + EntityIds string `json:"entity_ids"` + MobileOnly bool `json:"mobile_only"` + WebOnly bool `json:"web_only"` + NonSSOOnly bool `json:"non_sso_only"` + SucceededUsers string `json:"succeeded_users"` + FailedUsers string `json:"failed_users"` + ExportType string `json:"export_type"` + ExportStart string `json:"export_start_ts"` + ExportEnd string `json:"export_end_ts"` } `json:"details"` } From 93035c946040f00b2eb69dc9a954f83a9803e931 Mon Sep 17 00:00:00 2001 From: Koichi Shiraishi Date: Wed, 23 Mar 2022 21:09:46 +0900 Subject: [PATCH 052/606] github/workflow: drop go1.15 and add go1.18 (#1048) * github/workflow: drop go1.15 and add go1.18 * all: run goimports --- .github/workflows/test.yml | 28 ++++++++++----------- examples/workflow_step/handler.go | 5 ++-- examples/workflow_step/main.go | 3 ++- examples/workflow_step/middleware.go | 3 ++- slacktest/funcs.go | 1 + slacktest/handlers.go | 1 + slacktest/handlers_test.go | 3 ++- slacktest/rtm_test.go | 3 ++- slacktest/server_test.go | 3 ++- socketmode/socket_mode_managed_conn.go | 6 ++--- socketmode/socket_mode_managed_conn_test.go | 1 + socketmode/socketmode.go | 4 +-- views_test.go | 3 ++- websocket_managed_conn.go | 6 ++--- websocket_managed_conn_test.go | 3 ++- 15 files changed, 41 insertions(+), 32 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0e9c737ad..97dacfc66 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,32 +7,30 @@ on: pull_request: jobs: - ci: - runs-on: ubuntu-latest - name: lint - steps: - - uses: actions/checkout@v2 - - name: golangci-lint - uses: golangci/golangci-lint-action@v2 - with: - version: v1.32 test: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 strategy: matrix: go: - - '1.13' - - '1.14' - - '1.15' - '1.16' - '1.17' + - '1.18' name: test go-${{ matrix.go }} steps: - - uses: actions/checkout@v2 - - uses: actions/setup-go@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-go@v3 with: go-version: ${{ matrix.go }} - name: run test run: go test -v -race ./... env: GO111MODULE: on + lint: + runs-on: ubuntu-20.04 + name: lint + steps: + - uses: actions/checkout@v3 + - name: golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + version: latest diff --git a/examples/workflow_step/handler.go b/examples/workflow_step/handler.go index f0a88b08b..b25a5be42 100644 --- a/examples/workflow_step/handler.go +++ b/examples/workflow_step/handler.go @@ -3,13 +3,14 @@ package main import ( "encoding/json" "fmt" - "github.com/slack-go/slack" - "github.com/slack-go/slack/slackevents" "io/ioutil" "log" "net/http" "net/url" "time" + + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" ) const ( diff --git a/examples/workflow_step/main.go b/examples/workflow_step/main.go index a5058bb0f..45494d086 100644 --- a/examples/workflow_step/main.go +++ b/examples/workflow_step/main.go @@ -2,10 +2,11 @@ package main import ( "fmt" - "github.com/slack-go/slack" "log" "net/http" "os" + + "github.com/slack-go/slack" ) type ( diff --git a/examples/workflow_step/middleware.go b/examples/workflow_step/middleware.go index fd7d15297..0684e50d6 100644 --- a/examples/workflow_step/middleware.go +++ b/examples/workflow_step/middleware.go @@ -2,9 +2,10 @@ package main import ( "bytes" - "github.com/slack-go/slack" "io/ioutil" "net/http" + + "github.com/slack-go/slack" ) func (v *SecretsVerifierMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { diff --git a/slacktest/funcs.go b/slacktest/funcs.go index a5b6470eb..d083af2b5 100644 --- a/slacktest/funcs.go +++ b/slacktest/funcs.go @@ -7,6 +7,7 @@ import ( "time" websocket "github.com/gorilla/websocket" + slack "github.com/slack-go/slack" ) diff --git a/slacktest/handlers.go b/slacktest/handlers.go index b3f13700c..8dfe451df 100644 --- a/slacktest/handlers.go +++ b/slacktest/handlers.go @@ -11,6 +11,7 @@ import ( "time" websocket "github.com/gorilla/websocket" + slack "github.com/slack-go/slack" ) diff --git a/slacktest/handlers_test.go b/slacktest/handlers_test.go index 9ad35f754..6678e7684 100644 --- a/slacktest/handlers_test.go +++ b/slacktest/handlers_test.go @@ -3,8 +3,9 @@ package slacktest import ( "testing" - slack "github.com/slack-go/slack" "github.com/stretchr/testify/assert" + + slack "github.com/slack-go/slack" ) func TestAuthTestHandler(t *testing.T) { diff --git a/slacktest/rtm_test.go b/slacktest/rtm_test.go index 00a59f5fc..4beab76c3 100644 --- a/slacktest/rtm_test.go +++ b/slacktest/rtm_test.go @@ -4,8 +4,9 @@ import ( "testing" "time" - "github.com/slack-go/slack" "github.com/stretchr/testify/assert" + + "github.com/slack-go/slack" ) func TestRTMInfo(t *testing.T) { diff --git a/slacktest/server_test.go b/slacktest/server_test.go index 5902282c1..ac4244471 100644 --- a/slacktest/server_test.go +++ b/slacktest/server_test.go @@ -6,8 +6,9 @@ import ( "testing" "time" - "github.com/slack-go/slack" "github.com/stretchr/testify/assert" + + "github.com/slack-go/slack" ) func TestDefaultNewServer(t *testing.T) { diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index 9373a6be0..7caad5b4f 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -11,13 +11,13 @@ import ( "sync" "time" + "github.com/gorilla/websocket" + "github.com/slack-go/slack" "github.com/slack-go/slack/internal/backoff" "github.com/slack-go/slack/internal/misc" - "github.com/slack-go/slack/slackevents" - - "github.com/gorilla/websocket" "github.com/slack-go/slack/internal/timex" + "github.com/slack-go/slack/slackevents" ) // Run is a blocking function that connects the Slack Socket Mode API and handles all incoming diff --git a/socketmode/socket_mode_managed_conn_test.go b/socketmode/socket_mode_managed_conn_test.go index 23bba7f50..542ccdb45 100644 --- a/socketmode/socket_mode_managed_conn_test.go +++ b/socketmode/socket_mode_managed_conn_test.go @@ -1,3 +1,4 @@ +//go:build go1.13 // +build go1.13 package socketmode diff --git a/socketmode/socketmode.go b/socketmode/socketmode.go index d91d422b1..1871e6763 100644 --- a/socketmode/socketmode.go +++ b/socketmode/socketmode.go @@ -6,9 +6,9 @@ import ( "os" "time" - "github.com/slack-go/slack" - "github.com/gorilla/websocket" + + "github.com/slack-go/slack" ) // EventType is the type of events that are emitted by scoketmode.Client. diff --git a/views_test.go b/views_test.go index 1dc1438ad..1915d322d 100644 --- a/views_test.go +++ b/views_test.go @@ -6,8 +6,9 @@ import ( "reflect" "testing" - "github.com/slack-go/slack/internal/errorsx" "github.com/stretchr/testify/assert" + + "github.com/slack-go/slack/internal/errorsx" ) var dummySlackErr = errorsx.String("dummy_error_from_slack") diff --git a/websocket_managed_conn.go b/websocket_managed_conn.go index 5555c3162..92536171f 100644 --- a/websocket_managed_conn.go +++ b/websocket_managed_conn.go @@ -9,11 +9,11 @@ import ( "reflect" "time" - "github.com/slack-go/slack/internal/backoff" - "github.com/slack-go/slack/internal/misc" - "github.com/gorilla/websocket" + + "github.com/slack-go/slack/internal/backoff" "github.com/slack-go/slack/internal/errorsx" + "github.com/slack-go/slack/internal/misc" "github.com/slack-go/slack/internal/timex" ) diff --git a/websocket_managed_conn_test.go b/websocket_managed_conn_test.go index bb4e3e4d5..429ce8d52 100644 --- a/websocket_managed_conn_test.go +++ b/websocket_managed_conn_test.go @@ -8,9 +8,10 @@ import ( "time" websocket "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/slack-go/slack" "github.com/slack-go/slack/slacktest" - "github.com/stretchr/testify/assert" ) const ( From 93bc872ff595bd931f08b1a5a3e06d2759e7cc64 Mon Sep 17 00:00:00 2001 From: Adrien Bustany Date: Fri, 8 Apr 2022 15:10:30 +0200 Subject: [PATCH 053/606] Add missing EventTimestamp field on some inner events --- slackevents/inner_events.go | 91 +++++++++++++++++++++---------------- 1 file changed, 53 insertions(+), 38 deletions(-) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index de98c280e..bbac0b98c 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -56,34 +56,39 @@ type ChannelCreatedEvent struct { // ChannelDeletedEvent represents the Channel deleted event type ChannelDeletedEvent struct { - Type string `json:"type"` - Channel string `json:"channel"` + Type string `json:"type"` + Channel string `json:"channel"` + EventTimestamp string `json:"event_ts"` } // ChannelArchiveEvent represents the Channel archive event type ChannelArchiveEvent struct { - Type string `json:"type"` - Channel string `json:"channel"` - User string `json:"user"` + Type string `json:"type"` + Channel string `json:"channel"` + User string `json:"user"` + EventTimestamp string `json:"event_ts"` } // ChannelUnarchiveEvent represents the Channel unarchive event type ChannelUnarchiveEvent struct { - Type string `json:"type"` - Channel string `json:"channel"` - User string `json:"user"` + Type string `json:"type"` + Channel string `json:"channel"` + User string `json:"user"` + EventTimestamp string `json:"event_ts"` } // ChannelLeftEvent represents the Channel left event type ChannelLeftEvent struct { - Type string `json:"type"` - Channel string `json:"channel"` + Type string `json:"type"` + Channel string `json:"channel"` + EventTimestamp string `json:"event_ts"` } // ChannelRenameEvent represents the Channel rename event type ChannelRenameEvent struct { - Type string `json:"type"` - Channel ChannelRenameInfo `json:"channel"` + Type string `json:"type"` + Channel ChannelRenameInfo `json:"channel"` + EventTimestamp string `json:"event_ts"` } // ChannelIDChangedEvent represents the Channel identifier changed event @@ -112,32 +117,37 @@ type ChannelRenameInfo struct { // GroupDeletedEvent represents the Group deleted event type GroupDeletedEvent struct { - Type string `json:"type"` - Channel string `json:"channel"` + Type string `json:"type"` + Channel string `json:"channel"` + EventTimestamp string `json:"event_ts"` } // GroupArchiveEvent represents the Group archive event type GroupArchiveEvent struct { - Type string `json:"type"` - Channel string `json:"channel"` + Type string `json:"type"` + Channel string `json:"channel"` + EventTimestamp string `json:"event_ts"` } // GroupUnarchiveEvent represents the Group unarchive event type GroupUnarchiveEvent struct { - Type string `json:"type"` - Channel string `json:"channel"` + Type string `json:"type"` + Channel string `json:"channel"` + EventTimestamp string `json:"event_ts"` } // GroupLeftEvent represents the Group left event type GroupLeftEvent struct { - Type string `json:"type"` - Channel string `json:"channel"` + Type string `json:"type"` + Channel string `json:"channel"` + EventTimestamp string `json:"event_ts"` } // GroupRenameEvent represents the Group rename event type GroupRenameEvent struct { - Type string `json:"type"` - Channel GroupRenameInfo `json:"channel"` + Type string `json:"type"` + Channel GroupRenameInfo `json:"channel"` + EventTimestamp string `json:"event_ts"` } // GroupRenameInfo represents the information associated with the Group rename event @@ -171,6 +181,7 @@ type LinkSharedEvent struct { MessageTimeStamp string `json:"message_ts"` ThreadTimeStamp string `json:"thread_ts"` Links []sharedLinks `json:"links"` + EventTimestamp string `json:"event_ts"` } type sharedLinks struct { @@ -227,21 +238,23 @@ type MessageEvent struct { // MemberJoinedChannelEvent A member joined a public or private channel type MemberJoinedChannelEvent struct { - Type string `json:"type"` - User string `json:"user"` - Channel string `json:"channel"` - ChannelType string `json:"channel_type"` - Team string `json:"team"` - Inviter string `json:"inviter"` + Type string `json:"type"` + User string `json:"user"` + Channel string `json:"channel"` + ChannelType string `json:"channel_type"` + Team string `json:"team"` + Inviter string `json:"inviter"` + EventTimestamp string `json:"event_ts"` } // MemberLeftChannelEvent A member left a public or private channel type MemberLeftChannelEvent struct { - Type string `json:"type"` - User string `json:"user"` - Channel string `json:"channel"` - ChannelType string `json:"channel_type"` - Team string `json:"team"` + Type string `json:"type"` + User string `json:"user"` + Channel string `json:"channel"` + ChannelType string `json:"channel_type"` + Team string `json:"team"` + EventTimestamp string `json:"event_ts"` } type pinEvent struct { @@ -281,14 +294,16 @@ type tokens struct { // TeamJoinEvent A new member joined a workspace - https://api.slack.com/events/team_join type TeamJoinEvent struct { - Type string `json:"type"` - User *slack.User `json:"user"` + Type string `json:"type"` + User *slack.User `json:"user"` + EventTimestamp string `json:"event_ts"` } -// TokensRevokedEvent APP's API tokes are revoked - https://api.slack.com/events/tokens_revoked +// TokensRevokedEvent APP's API tokens are revoked - https://api.slack.com/events/tokens_revoked type TokensRevokedEvent struct { - Type string `json:"type"` - Tokens tokens `json:"tokens"` + Type string `json:"type"` + Tokens tokens `json:"tokens"` + EventTimestamp string `json:"event_ts"` } // EmojiChangedEvent is the event of custom emoji has been added or changed From 179dac25b568c074fb071a1ed8d650cff5d0499a Mon Sep 17 00:00:00 2001 From: Adrien Bustany Date: Fri, 8 Apr 2022 15:12:02 +0200 Subject: [PATCH 054/606] Make WorkflowStepExecuteEvent.EventTimestamp name consistent with the other events The fields used to be called EventTS, but WorkflowStepExecuteEvent was the only event struct with such a field. --- slackevents/inner_events.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index bbac0b98c..c135edae7 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -328,10 +328,10 @@ type EmojiChangedEvent struct { // WorkflowStepExecuteEvent is fired, if a workflow step of your app is invoked type WorkflowStepExecuteEvent struct { - Type string `json:"type"` - CallbackID string `json:"callback_id"` - WorkflowStep EventWorkflowStep `json:"workflow_step"` - EventTS string `json:"event_ts"` + Type string `json:"type"` + CallbackID string `json:"callback_id"` + WorkflowStep EventWorkflowStep `json:"workflow_step"` + EventTimestamp string `json:"event_ts"` } type EventWorkflowStep struct { From 6361adf7c128db4ffa211fc2fecd816a0a66c031 Mon Sep 17 00:00:00 2001 From: Adrien Bustany Date: Fri, 8 Apr 2022 15:13:16 +0200 Subject: [PATCH 055/606] Don't use json.Number to encode/decode events event_ts This field should always be stored/used as a string according to [1]. This also makes the type more consistent across events. [1] https://github.com/slackhq/slack-api-docs/issues/7#issuecomment-67913241 --- slackevents/inner_events.go | 52 +++++++++++++++++------------------ socketmode/socketmode_test.go | 2 +- 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index c135edae7..1726ba619 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -3,8 +3,6 @@ package slackevents import ( - "encoding/json" - "github.com/slack-go/slack" ) @@ -16,13 +14,13 @@ type EventsAPIInnerEvent struct { // AppMentionEvent is an (inner) EventsAPI subscribable event. type AppMentionEvent struct { - Type string `json:"type"` - User string `json:"user"` - Text string `json:"text"` - TimeStamp string `json:"ts"` - ThreadTimeStamp string `json:"thread_ts"` - Channel string `json:"channel"` - EventTimeStamp json.Number `json:"event_ts"` + Type string `json:"type"` + User string `json:"user"` + Text string `json:"text"` + TimeStamp string `json:"ts"` + ThreadTimeStamp string `json:"thread_ts"` + Channel string `json:"channel"` + EventTimeStamp string `json:"event_ts"` // When Message comes from a channel that is shared between workspaces UserTeam string `json:"user_team,omitempty"` @@ -34,12 +32,12 @@ type AppMentionEvent struct { // AppHomeOpenedEvent Your Slack app home was opened. type AppHomeOpenedEvent struct { - Type string `json:"type"` - User string `json:"user"` - Channel string `json:"channel"` - EventTimeStamp json.Number `json:"event_ts"` - Tab string `json:"tab"` - View slack.View `json:"view"` + Type string `json:"type"` + User string `json:"user"` + Channel string `json:"channel"` + EventTimeStamp string `json:"event_ts"` + Tab string `json:"tab"` + View slack.View `json:"view"` } // AppUninstalledEvent Your Slack app was uninstalled. @@ -198,15 +196,15 @@ type sharedLinks struct { // TODO: Improve this so that it is not required to manually parse ChannelType type MessageEvent struct { // Basic Message Event - https://api.slack.com/events/message - ClientMsgID string `json:"client_msg_id"` - Type string `json:"type"` - User string `json:"user"` - Text string `json:"text"` - ThreadTimeStamp string `json:"thread_ts"` - TimeStamp string `json:"ts"` - Channel string `json:"channel"` - ChannelType string `json:"channel_type"` - EventTimeStamp json.Number `json:"event_ts"` + ClientMsgID string `json:"client_msg_id"` + Type string `json:"type"` + User string `json:"user"` + Text string `json:"text"` + ThreadTimeStamp string `json:"thread_ts"` + TimeStamp string `json:"ts"` + Channel string `json:"channel"` + ChannelType string `json:"channel_type"` + EventTimeStamp string `json:"event_ts"` // When Message comes from a channel that is shared between workspaces UserTeam string `json:"user_team,omitempty"` @@ -308,9 +306,9 @@ type TokensRevokedEvent struct { // EmojiChangedEvent is the event of custom emoji has been added or changed type EmojiChangedEvent struct { - Type string `json:"type"` - Subtype string `json:"subtype"` - EventTimeStamp json.Number `json:"event_ts"` + Type string `json:"type"` + Subtype string `json:"subtype"` + EventTimeStamp string `json:"event_ts"` // filled out when custom emoji added Name string `json:"name,omitempty"` diff --git a/socketmode/socketmode_test.go b/socketmode/socketmode_test.go index 015a34604..81e8f86ba 100644 --- a/socketmode/socketmode_test.go +++ b/socketmode/socketmode_test.go @@ -236,7 +236,7 @@ func TestEventParsing(t *testing.T) { TimeStamp: "1610927831.000200", ThreadTimeStamp: "", Channel: "redacted", - EventTimeStamp: json.Number("1610927831.000200"), + EventTimeStamp: "1610927831.000200", }, }, }, From c341329bff0326514484695d94a7897a1aa9d922 Mon Sep 17 00:00:00 2001 From: Leo Zhang Date: Tue, 12 Apr 2022 04:12:39 -0700 Subject: [PATCH 056/606] WithStyle should be fluent --- block_object.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/block_object.go b/block_object.go index 5ced7f92a..f70405eba 100644 --- a/block_object.go +++ b/block_object.go @@ -187,8 +187,9 @@ func (s ConfirmationBlockObject) validateType() MessageObjectType { } // WithStyle add styling to confirmation object -func (s *ConfirmationBlockObject) WithStyle(style Style) { +func (s *ConfirmationBlockObject) WithStyle(style Style) *ConfirmationBlockObject { s.Style = style + return s } // NewConfirmationBlockObject returns an instance of a new Confirmation Block Object From 2473ad3d4e1513285ca18a676324ec79c37882d5 Mon Sep 17 00:00:00 2001 From: Leo Zhang Date: Tue, 12 Apr 2022 04:14:55 -0700 Subject: [PATCH 057/606] Add a fluent WithConfirm for buttons --- block_element.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/block_element.go b/block_element.go index 21abb018a..643529ff0 100644 --- a/block_element.go +++ b/block_element.go @@ -167,6 +167,12 @@ func (s *ButtonBlockElement) WithStyle(style Style) *ButtonBlockElement { return s } +// WithConfirm adds a confirmation dialogue to the button object and returns the modified ButtonBlockElement +func (s *ButtonBlockElement) WithConfirm(confirm *ConfirmationBlockObject) *ButtonBlockElement { + s.Confirm = confirm + return s +} + // NewButtonBlockElement returns an instance of a new button element to be used within a block func NewButtonBlockElement(actionID, value string, text *TextBlockObject) *ButtonBlockElement { return &ButtonBlockElement{ From 8797bb0edae03d5a54e406a1dd1aab1b3036814f Mon Sep 17 00:00:00 2001 From: Winston Durand Date: Mon, 18 Apr 2022 11:55:12 -0700 Subject: [PATCH 058/606] Fix spelling mistakes --- bookmarks.go | 12 ++++++------ bookmarks_test.go | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/bookmarks.go b/bookmarks.go index 9fa529f37..78753507b 100644 --- a/bookmarks.go +++ b/bookmarks.go @@ -40,17 +40,17 @@ type EditBookmarkParameters struct { Link string // Change the link } -type addBookmarkReponse struct { +type addBookmarkResponse struct { Bookmark Bookmark `json:"bookmark"` SlackResponse } -type editBookmarkReponse struct { +type editBookmarkResponse struct { Bookmark Bookmark `json:"bookmark"` SlackResponse } -type listBookmarksReponse struct { +type listBookmarksResponse struct { Bookmarks []Bookmark `json:"bookmarks"` SlackResponse } @@ -81,7 +81,7 @@ func (api *Client) AddBookmarkContext(ctx context.Context, channelID string, par values.Set("parent_id", params.ParentID) } - response := &addBookmarkReponse{} + response := &addBookmarkResponse{} if err := api.postMethod(ctx, "bookmarks.add", values, response); err != nil { return Bookmark{}, err } @@ -122,7 +122,7 @@ func (api *Client) ListBookmarksContext(ctx context.Context, channelID string) ( "token": {api.token}, } - response := &listBookmarksReponse{} + response := &listBookmarksResponse{} err := api.postMethod(ctx, "bookmarks.list", values, response) if err != nil { return nil, err @@ -150,7 +150,7 @@ func (api *Client) EditBookmarkContext(ctx context.Context, channelID, bookmarkI values.Set("title", *params.Title) } - response := &editBookmarkReponse{} + response := &editBookmarkResponse{} if err := api.postMethod(ctx, "bookmarks.edit", values, response); err != nil { return Bookmark{}, err } diff --git a/bookmarks_test.go b/bookmarks_test.go index 5343510fb..43e5bb9c7 100644 --- a/bookmarks_test.go +++ b/bookmarks_test.go @@ -33,7 +33,7 @@ func addBookmarkLinkHandler(rw http.ResponseWriter, r *http.Request) { bookmark.Type = bookmarkType bookmark.Link = link - resp, _ := json.Marshal(&addBookmarkReponse{ + resp, _ := json.Marshal(&addBookmarkResponse{ SlackResponse: SlackResponse{Ok: true}, Bookmark: bookmark}) rw.Write(resp) @@ -70,7 +70,7 @@ func listBookmarksHandler(rw http.ResponseWriter, r *http.Request) { getTestBookmark(channelID, "Bk004"), } - resp, _ := json.Marshal(&listBookmarksReponse{ + resp, _ := json.Marshal(&listBookmarksResponse{ SlackResponse: SlackResponse{Ok: true}, Bookmarks: bookmarks}) rw.Write(resp) @@ -150,7 +150,7 @@ func editBookmarkHandler(bookmarks []Bookmark) func(rw http.ResponseWriter, r *h if _, ok := r.Form["title"]; ok { bookmark.Title = r.Form.Get("title") } - resp, _ := json.Marshal(&editBookmarkReponse{ + resp, _ := json.Marshal(&editBookmarkResponse{ SlackResponse: SlackResponse{Ok: true}, Bookmark: bookmark}) rw.Write(resp) From defe396d4061cfbf29f0c15df93cb71e4e2cb757 Mon Sep 17 00:00:00 2001 From: Daniel Levi-Minzi Date: Thu, 21 Apr 2022 16:21:17 -0400 Subject: [PATCH 059/606] Updated some comments and updated usage of postLocalWithMultipartResponse and postWithMultipartResponse --- remotefiles.go | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/remotefiles.go b/remotefiles.go index 5ff6f46cf..a4f61d7da 100644 --- a/remotefiles.go +++ b/remotefiles.go @@ -17,6 +17,7 @@ const ( ) // RemoteFile contains all the information for a remote file +// More information can be found here: https://api.slack.com/messaging/files/remote type RemoteFile struct { ID string `json:"id"` Created JSONTime `json:"created"` @@ -49,6 +50,12 @@ type RemoteFile struct { HasRichPreview bool `json:"has_rich_preview"` } +// RemoteFileParameters contains required and optional parameters for a remote file. +// +// ExternalID is a user defined GUID, ExternalURL is where the remote file can be accessed, +// and Title is the name of the file. +// +// More information can be found here: https://api.slack.com/methods/files.remote.add type RemoteFileParameters struct { ExternalID string // required ExternalURL string // required @@ -84,8 +91,8 @@ func (api *Client) remoteFileRequest(ctx context.Context, path string, values ur return response, response.Err() } -// AddRemoteFile adds a remote file -func (api *Client) AddRemoteFile(params RemoteFileParameters) (remotefile *RemoteFile, err error) { +// AddRemoteFile adds a remote file. Unlike regular files, remote files must be explicitly shared. +func (api *Client) AddRemoteFile(params RemoteFileParameters) (*RemoteFile, error) { return api.AddRemoteFileContext(context.Background(), params) } @@ -108,9 +115,9 @@ func (api *Client) AddRemoteFileContext(ctx context.Context, params RemoteFilePa values.Add("indexable_file_contents", params.IndexableFileContents) } if params.PreviewImage != "" { - err = postLocalWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.add", params.PreviewImage, "preview_image", values, response, api) + err = postLocalWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.add", params.PreviewImage, "preview_image", api.token, values, response, api) } else if params.PreviewImageReader != nil { - err = postWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.add", "preview.png", "preview_image", values, params.PreviewImageReader, response, api) + err = postWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.add", "preview.png", "preview_image", api.token, values, params.PreviewImageReader, response, api) } else { response, err = api.remoteFileRequest(ctx, "files.remote.add", values) } @@ -247,7 +254,7 @@ func (api *Client) UpdateRemoteFileContext(ctx context.Context, fileID string, p values.Add("indexable_file_contents", params.IndexableFileContents) } if params.PreviewImageReader != nil { - err = postWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.update", "preview.png", "preview_image", values, params.PreviewImageReader, response, api) + err = postWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.update", "preview.png", "preview_image", api.token, values, params.PreviewImageReader, response, api) } else { response, err = api.remoteFileRequest(ctx, "files.remote.update", values) } From d11b061985b9bb30a144323d649729b224236f9f Mon Sep 17 00:00:00 2001 From: Naoki Kanatani Date: Fri, 22 Apr 2022 12:50:46 +0900 Subject: [PATCH 060/606] Goodbye vendor dir --- vendor/github.com/davecgh/go-spew/LICENSE | 15 - .../github.com/davecgh/go-spew/spew/bypass.go | 145 -- .../davecgh/go-spew/spew/bypasssafe.go | 38 - .../github.com/davecgh/go-spew/spew/common.go | 341 ---- .../github.com/davecgh/go-spew/spew/config.go | 306 ---- vendor/github.com/davecgh/go-spew/spew/doc.go | 211 --- .../github.com/davecgh/go-spew/spew/dump.go | 509 ------ .../github.com/davecgh/go-spew/spew/format.go | 419 ----- .../github.com/davecgh/go-spew/spew/spew.go | 148 -- vendor/github.com/go-test/deep/.gitignore | 2 - vendor/github.com/go-test/deep/.travis.yml | 13 - vendor/github.com/go-test/deep/CHANGES.md | 30 - vendor/github.com/go-test/deep/LICENSE | 21 - vendor/github.com/go-test/deep/README.md | 51 - vendor/github.com/go-test/deep/deep.go | 376 ----- vendor/github.com/go-test/deep/go.mod | 1 - vendor/github.com/google/go-cmp/LICENSE | 27 - .../github.com/google/go-cmp/cmp/compare.go | 665 -------- .../google/go-cmp/cmp/export_panic.go | 16 - .../google/go-cmp/cmp/export_unsafe.go | 36 - .../go-cmp/cmp/internal/diff/debug_disable.go | 18 - .../go-cmp/cmp/internal/diff/debug_enable.go | 123 -- .../google/go-cmp/cmp/internal/diff/diff.go | 398 ----- .../google/go-cmp/cmp/internal/flags/flags.go | 9 - .../go-cmp/cmp/internal/function/func.go | 99 -- .../google/go-cmp/cmp/internal/value/name.go | 164 -- .../cmp/internal/value/pointer_purego.go | 34 - .../cmp/internal/value/pointer_unsafe.go | 37 - .../google/go-cmp/cmp/internal/value/sort.go | 106 -- .../google/go-cmp/cmp/internal/value/zero.go | 48 - .../github.com/google/go-cmp/cmp/options.go | 552 ------- vendor/github.com/google/go-cmp/cmp/path.go | 378 ----- vendor/github.com/google/go-cmp/cmp/report.go | 54 - .../google/go-cmp/cmp/report_compare.go | 432 ----- .../google/go-cmp/cmp/report_references.go | 264 ---- .../google/go-cmp/cmp/report_reflect.go | 403 ----- .../google/go-cmp/cmp/report_slices.go | 613 -------- .../google/go-cmp/cmp/report_text.go | 431 ----- .../google/go-cmp/cmp/report_value.go | 121 -- .../github.com/gorilla/websocket/.gitignore | 25 - vendor/github.com/gorilla/websocket/AUTHORS | 9 - vendor/github.com/gorilla/websocket/LICENSE | 22 - vendor/github.com/gorilla/websocket/README.md | 64 - vendor/github.com/gorilla/websocket/client.go | 395 ----- .../gorilla/websocket/client_clone.go | 16 - .../gorilla/websocket/client_clone_legacy.go | 38 - .../gorilla/websocket/compression.go | 148 -- vendor/github.com/gorilla/websocket/conn.go | 1201 -------------- .../gorilla/websocket/conn_write.go | 15 - .../gorilla/websocket/conn_write_legacy.go | 18 - vendor/github.com/gorilla/websocket/doc.go | 227 --- vendor/github.com/gorilla/websocket/go.mod | 3 - vendor/github.com/gorilla/websocket/go.sum | 0 vendor/github.com/gorilla/websocket/join.go | 42 - vendor/github.com/gorilla/websocket/json.go | 60 - vendor/github.com/gorilla/websocket/mask.go | 54 - .../github.com/gorilla/websocket/mask_safe.go | 15 - .../github.com/gorilla/websocket/prepared.go | 102 -- vendor/github.com/gorilla/websocket/proxy.go | 77 - vendor/github.com/gorilla/websocket/server.go | 363 ----- vendor/github.com/gorilla/websocket/trace.go | 19 - .../github.com/gorilla/websocket/trace_17.go | 12 - vendor/github.com/gorilla/websocket/util.go | 283 ---- .../gorilla/websocket/x_net_proxy.go | 473 ------ vendor/github.com/pmezard/go-difflib/LICENSE | 27 - .../pmezard/go-difflib/difflib/difflib.go | 772 --------- vendor/github.com/stretchr/testify/LICENSE | 22 - .../testify/assert/assertion_format.go | 484 ------ .../testify/assert/assertion_format.go.tmpl | 5 - .../testify/assert/assertion_forward.go | 956 ----------- .../testify/assert/assertion_forward.go.tmpl | 5 - .../stretchr/testify/assert/assertions.go | 1394 ----------------- .../github.com/stretchr/testify/assert/doc.go | 45 - .../stretchr/testify/assert/errors.go | 10 - .../testify/assert/forward_assertions.go | 16 - .../testify/assert/http_assertions.go | 143 -- vendor/modules.txt | 22 - 77 files changed, 15236 deletions(-) delete mode 100644 vendor/github.com/davecgh/go-spew/LICENSE delete mode 100644 vendor/github.com/davecgh/go-spew/spew/bypass.go delete mode 100644 vendor/github.com/davecgh/go-spew/spew/bypasssafe.go delete mode 100644 vendor/github.com/davecgh/go-spew/spew/common.go delete mode 100644 vendor/github.com/davecgh/go-spew/spew/config.go delete mode 100644 vendor/github.com/davecgh/go-spew/spew/doc.go delete mode 100644 vendor/github.com/davecgh/go-spew/spew/dump.go delete mode 100644 vendor/github.com/davecgh/go-spew/spew/format.go delete mode 100644 vendor/github.com/davecgh/go-spew/spew/spew.go delete mode 100644 vendor/github.com/go-test/deep/.gitignore delete mode 100644 vendor/github.com/go-test/deep/.travis.yml delete mode 100644 vendor/github.com/go-test/deep/CHANGES.md delete mode 100644 vendor/github.com/go-test/deep/LICENSE delete mode 100644 vendor/github.com/go-test/deep/README.md delete mode 100644 vendor/github.com/go-test/deep/deep.go delete mode 100644 vendor/github.com/go-test/deep/go.mod delete mode 100644 vendor/github.com/google/go-cmp/LICENSE delete mode 100644 vendor/github.com/google/go-cmp/cmp/compare.go delete mode 100644 vendor/github.com/google/go-cmp/cmp/export_panic.go delete mode 100644 vendor/github.com/google/go-cmp/cmp/export_unsafe.go delete mode 100644 vendor/github.com/google/go-cmp/cmp/internal/diff/debug_disable.go delete mode 100644 vendor/github.com/google/go-cmp/cmp/internal/diff/debug_enable.go delete mode 100644 vendor/github.com/google/go-cmp/cmp/internal/diff/diff.go delete mode 100644 vendor/github.com/google/go-cmp/cmp/internal/flags/flags.go delete mode 100644 vendor/github.com/google/go-cmp/cmp/internal/function/func.go delete mode 100644 vendor/github.com/google/go-cmp/cmp/internal/value/name.go delete mode 100644 vendor/github.com/google/go-cmp/cmp/internal/value/pointer_purego.go delete mode 100644 vendor/github.com/google/go-cmp/cmp/internal/value/pointer_unsafe.go delete mode 100644 vendor/github.com/google/go-cmp/cmp/internal/value/sort.go delete mode 100644 vendor/github.com/google/go-cmp/cmp/internal/value/zero.go delete mode 100644 vendor/github.com/google/go-cmp/cmp/options.go delete mode 100644 vendor/github.com/google/go-cmp/cmp/path.go delete mode 100644 vendor/github.com/google/go-cmp/cmp/report.go delete mode 100644 vendor/github.com/google/go-cmp/cmp/report_compare.go delete mode 100644 vendor/github.com/google/go-cmp/cmp/report_references.go delete mode 100644 vendor/github.com/google/go-cmp/cmp/report_reflect.go delete mode 100644 vendor/github.com/google/go-cmp/cmp/report_slices.go delete mode 100644 vendor/github.com/google/go-cmp/cmp/report_text.go delete mode 100644 vendor/github.com/google/go-cmp/cmp/report_value.go delete mode 100644 vendor/github.com/gorilla/websocket/.gitignore delete mode 100644 vendor/github.com/gorilla/websocket/AUTHORS delete mode 100644 vendor/github.com/gorilla/websocket/LICENSE delete mode 100644 vendor/github.com/gorilla/websocket/README.md delete mode 100644 vendor/github.com/gorilla/websocket/client.go delete mode 100644 vendor/github.com/gorilla/websocket/client_clone.go delete mode 100644 vendor/github.com/gorilla/websocket/client_clone_legacy.go delete mode 100644 vendor/github.com/gorilla/websocket/compression.go delete mode 100644 vendor/github.com/gorilla/websocket/conn.go delete mode 100644 vendor/github.com/gorilla/websocket/conn_write.go delete mode 100644 vendor/github.com/gorilla/websocket/conn_write_legacy.go delete mode 100644 vendor/github.com/gorilla/websocket/doc.go delete mode 100644 vendor/github.com/gorilla/websocket/go.mod delete mode 100644 vendor/github.com/gorilla/websocket/go.sum delete mode 100644 vendor/github.com/gorilla/websocket/join.go delete mode 100644 vendor/github.com/gorilla/websocket/json.go delete mode 100644 vendor/github.com/gorilla/websocket/mask.go delete mode 100644 vendor/github.com/gorilla/websocket/mask_safe.go delete mode 100644 vendor/github.com/gorilla/websocket/prepared.go delete mode 100644 vendor/github.com/gorilla/websocket/proxy.go delete mode 100644 vendor/github.com/gorilla/websocket/server.go delete mode 100644 vendor/github.com/gorilla/websocket/trace.go delete mode 100644 vendor/github.com/gorilla/websocket/trace_17.go delete mode 100644 vendor/github.com/gorilla/websocket/util.go delete mode 100644 vendor/github.com/gorilla/websocket/x_net_proxy.go delete mode 100644 vendor/github.com/pmezard/go-difflib/LICENSE delete mode 100644 vendor/github.com/pmezard/go-difflib/difflib/difflib.go delete mode 100644 vendor/github.com/stretchr/testify/LICENSE delete mode 100644 vendor/github.com/stretchr/testify/assert/assertion_format.go delete mode 100644 vendor/github.com/stretchr/testify/assert/assertion_format.go.tmpl delete mode 100644 vendor/github.com/stretchr/testify/assert/assertion_forward.go delete mode 100644 vendor/github.com/stretchr/testify/assert/assertion_forward.go.tmpl delete mode 100644 vendor/github.com/stretchr/testify/assert/assertions.go delete mode 100644 vendor/github.com/stretchr/testify/assert/doc.go delete mode 100644 vendor/github.com/stretchr/testify/assert/errors.go delete mode 100644 vendor/github.com/stretchr/testify/assert/forward_assertions.go delete mode 100644 vendor/github.com/stretchr/testify/assert/http_assertions.go delete mode 100644 vendor/modules.txt diff --git a/vendor/github.com/davecgh/go-spew/LICENSE b/vendor/github.com/davecgh/go-spew/LICENSE deleted file mode 100644 index bc52e96f2..000000000 --- a/vendor/github.com/davecgh/go-spew/LICENSE +++ /dev/null @@ -1,15 +0,0 @@ -ISC License - -Copyright (c) 2012-2016 Dave Collins - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/vendor/github.com/davecgh/go-spew/spew/bypass.go b/vendor/github.com/davecgh/go-spew/spew/bypass.go deleted file mode 100644 index 792994785..000000000 --- a/vendor/github.com/davecgh/go-spew/spew/bypass.go +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright (c) 2015-2016 Dave Collins -// -// Permission to use, copy, modify, and distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -// NOTE: Due to the following build constraints, this file will only be compiled -// when the code is not running on Google App Engine, compiled by GopherJS, and -// "-tags safe" is not added to the go build command line. The "disableunsafe" -// tag is deprecated and thus should not be used. -// Go versions prior to 1.4 are disabled because they use a different layout -// for interfaces which make the implementation of unsafeReflectValue more complex. -// +build !js,!appengine,!safe,!disableunsafe,go1.4 - -package spew - -import ( - "reflect" - "unsafe" -) - -const ( - // UnsafeDisabled is a build-time constant which specifies whether or - // not access to the unsafe package is available. - UnsafeDisabled = false - - // ptrSize is the size of a pointer on the current arch. - ptrSize = unsafe.Sizeof((*byte)(nil)) -) - -type flag uintptr - -var ( - // flagRO indicates whether the value field of a reflect.Value - // is read-only. - flagRO flag - - // flagAddr indicates whether the address of the reflect.Value's - // value may be taken. - flagAddr flag -) - -// flagKindMask holds the bits that make up the kind -// part of the flags field. In all the supported versions, -// it is in the lower 5 bits. -const flagKindMask = flag(0x1f) - -// Different versions of Go have used different -// bit layouts for the flags type. This table -// records the known combinations. -var okFlags = []struct { - ro, addr flag -}{{ - // From Go 1.4 to 1.5 - ro: 1 << 5, - addr: 1 << 7, -}, { - // Up to Go tip. - ro: 1<<5 | 1<<6, - addr: 1 << 8, -}} - -var flagValOffset = func() uintptr { - field, ok := reflect.TypeOf(reflect.Value{}).FieldByName("flag") - if !ok { - panic("reflect.Value has no flag field") - } - return field.Offset -}() - -// flagField returns a pointer to the flag field of a reflect.Value. -func flagField(v *reflect.Value) *flag { - return (*flag)(unsafe.Pointer(uintptr(unsafe.Pointer(v)) + flagValOffset)) -} - -// unsafeReflectValue converts the passed reflect.Value into a one that bypasses -// the typical safety restrictions preventing access to unaddressable and -// unexported data. It works by digging the raw pointer to the underlying -// value out of the protected value and generating a new unprotected (unsafe) -// reflect.Value to it. -// -// This allows us to check for implementations of the Stringer and error -// interfaces to be used for pretty printing ordinarily unaddressable and -// inaccessible values such as unexported struct fields. -func unsafeReflectValue(v reflect.Value) reflect.Value { - if !v.IsValid() || (v.CanInterface() && v.CanAddr()) { - return v - } - flagFieldPtr := flagField(&v) - *flagFieldPtr &^= flagRO - *flagFieldPtr |= flagAddr - return v -} - -// Sanity checks against future reflect package changes -// to the type or semantics of the Value.flag field. -func init() { - field, ok := reflect.TypeOf(reflect.Value{}).FieldByName("flag") - if !ok { - panic("reflect.Value has no flag field") - } - if field.Type.Kind() != reflect.TypeOf(flag(0)).Kind() { - panic("reflect.Value flag field has changed kind") - } - type t0 int - var t struct { - A t0 - // t0 will have flagEmbedRO set. - t0 - // a will have flagStickyRO set - a t0 - } - vA := reflect.ValueOf(t).FieldByName("A") - va := reflect.ValueOf(t).FieldByName("a") - vt0 := reflect.ValueOf(t).FieldByName("t0") - - // Infer flagRO from the difference between the flags - // for the (otherwise identical) fields in t. - flagPublic := *flagField(&vA) - flagWithRO := *flagField(&va) | *flagField(&vt0) - flagRO = flagPublic ^ flagWithRO - - // Infer flagAddr from the difference between a value - // taken from a pointer and not. - vPtrA := reflect.ValueOf(&t).Elem().FieldByName("A") - flagNoPtr := *flagField(&vA) - flagPtr := *flagField(&vPtrA) - flagAddr = flagNoPtr ^ flagPtr - - // Check that the inferred flags tally with one of the known versions. - for _, f := range okFlags { - if flagRO == f.ro && flagAddr == f.addr { - return - } - } - panic("reflect.Value read-only flag has changed semantics") -} diff --git a/vendor/github.com/davecgh/go-spew/spew/bypasssafe.go b/vendor/github.com/davecgh/go-spew/spew/bypasssafe.go deleted file mode 100644 index 205c28d68..000000000 --- a/vendor/github.com/davecgh/go-spew/spew/bypasssafe.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) 2015-2016 Dave Collins -// -// Permission to use, copy, modify, and distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -// NOTE: Due to the following build constraints, this file will only be compiled -// when the code is running on Google App Engine, compiled by GopherJS, or -// "-tags safe" is added to the go build command line. The "disableunsafe" -// tag is deprecated and thus should not be used. -// +build js appengine safe disableunsafe !go1.4 - -package spew - -import "reflect" - -const ( - // UnsafeDisabled is a build-time constant which specifies whether or - // not access to the unsafe package is available. - UnsafeDisabled = true -) - -// unsafeReflectValue typically converts the passed reflect.Value into a one -// that bypasses the typical safety restrictions preventing access to -// unaddressable and unexported data. However, doing this relies on access to -// the unsafe package. This is a stub version which simply returns the passed -// reflect.Value when the unsafe package is not available. -func unsafeReflectValue(v reflect.Value) reflect.Value { - return v -} diff --git a/vendor/github.com/davecgh/go-spew/spew/common.go b/vendor/github.com/davecgh/go-spew/spew/common.go deleted file mode 100644 index 1be8ce945..000000000 --- a/vendor/github.com/davecgh/go-spew/spew/common.go +++ /dev/null @@ -1,341 +0,0 @@ -/* - * Copyright (c) 2013-2016 Dave Collins - * - * Permission to use, copy, modify, and distribute this software for any - * purpose with or without fee is hereby granted, provided that the above - * copyright notice and this permission notice appear in all copies. - * - * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES - * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF - * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR - * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES - * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN - * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF - * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - */ - -package spew - -import ( - "bytes" - "fmt" - "io" - "reflect" - "sort" - "strconv" -) - -// Some constants in the form of bytes to avoid string overhead. This mirrors -// the technique used in the fmt package. -var ( - panicBytes = []byte("(PANIC=") - plusBytes = []byte("+") - iBytes = []byte("i") - trueBytes = []byte("true") - falseBytes = []byte("false") - interfaceBytes = []byte("(interface {})") - commaNewlineBytes = []byte(",\n") - newlineBytes = []byte("\n") - openBraceBytes = []byte("{") - openBraceNewlineBytes = []byte("{\n") - closeBraceBytes = []byte("}") - asteriskBytes = []byte("*") - colonBytes = []byte(":") - colonSpaceBytes = []byte(": ") - openParenBytes = []byte("(") - closeParenBytes = []byte(")") - spaceBytes = []byte(" ") - pointerChainBytes = []byte("->") - nilAngleBytes = []byte("") - maxNewlineBytes = []byte("\n") - maxShortBytes = []byte("") - circularBytes = []byte("") - circularShortBytes = []byte("") - invalidAngleBytes = []byte("") - openBracketBytes = []byte("[") - closeBracketBytes = []byte("]") - percentBytes = []byte("%") - precisionBytes = []byte(".") - openAngleBytes = []byte("<") - closeAngleBytes = []byte(">") - openMapBytes = []byte("map[") - closeMapBytes = []byte("]") - lenEqualsBytes = []byte("len=") - capEqualsBytes = []byte("cap=") -) - -// hexDigits is used to map a decimal value to a hex digit. -var hexDigits = "0123456789abcdef" - -// catchPanic handles any panics that might occur during the handleMethods -// calls. -func catchPanic(w io.Writer, v reflect.Value) { - if err := recover(); err != nil { - w.Write(panicBytes) - fmt.Fprintf(w, "%v", err) - w.Write(closeParenBytes) - } -} - -// handleMethods attempts to call the Error and String methods on the underlying -// type the passed reflect.Value represents and outputes the result to Writer w. -// -// It handles panics in any called methods by catching and displaying the error -// as the formatted value. -func handleMethods(cs *ConfigState, w io.Writer, v reflect.Value) (handled bool) { - // We need an interface to check if the type implements the error or - // Stringer interface. However, the reflect package won't give us an - // interface on certain things like unexported struct fields in order - // to enforce visibility rules. We use unsafe, when it's available, - // to bypass these restrictions since this package does not mutate the - // values. - if !v.CanInterface() { - if UnsafeDisabled { - return false - } - - v = unsafeReflectValue(v) - } - - // Choose whether or not to do error and Stringer interface lookups against - // the base type or a pointer to the base type depending on settings. - // Technically calling one of these methods with a pointer receiver can - // mutate the value, however, types which choose to satisify an error or - // Stringer interface with a pointer receiver should not be mutating their - // state inside these interface methods. - if !cs.DisablePointerMethods && !UnsafeDisabled && !v.CanAddr() { - v = unsafeReflectValue(v) - } - if v.CanAddr() { - v = v.Addr() - } - - // Is it an error or Stringer? - switch iface := v.Interface().(type) { - case error: - defer catchPanic(w, v) - if cs.ContinueOnMethod { - w.Write(openParenBytes) - w.Write([]byte(iface.Error())) - w.Write(closeParenBytes) - w.Write(spaceBytes) - return false - } - - w.Write([]byte(iface.Error())) - return true - - case fmt.Stringer: - defer catchPanic(w, v) - if cs.ContinueOnMethod { - w.Write(openParenBytes) - w.Write([]byte(iface.String())) - w.Write(closeParenBytes) - w.Write(spaceBytes) - return false - } - w.Write([]byte(iface.String())) - return true - } - return false -} - -// printBool outputs a boolean value as true or false to Writer w. -func printBool(w io.Writer, val bool) { - if val { - w.Write(trueBytes) - } else { - w.Write(falseBytes) - } -} - -// printInt outputs a signed integer value to Writer w. -func printInt(w io.Writer, val int64, base int) { - w.Write([]byte(strconv.FormatInt(val, base))) -} - -// printUint outputs an unsigned integer value to Writer w. -func printUint(w io.Writer, val uint64, base int) { - w.Write([]byte(strconv.FormatUint(val, base))) -} - -// printFloat outputs a floating point value using the specified precision, -// which is expected to be 32 or 64bit, to Writer w. -func printFloat(w io.Writer, val float64, precision int) { - w.Write([]byte(strconv.FormatFloat(val, 'g', -1, precision))) -} - -// printComplex outputs a complex value using the specified float precision -// for the real and imaginary parts to Writer w. -func printComplex(w io.Writer, c complex128, floatPrecision int) { - r := real(c) - w.Write(openParenBytes) - w.Write([]byte(strconv.FormatFloat(r, 'g', -1, floatPrecision))) - i := imag(c) - if i >= 0 { - w.Write(plusBytes) - } - w.Write([]byte(strconv.FormatFloat(i, 'g', -1, floatPrecision))) - w.Write(iBytes) - w.Write(closeParenBytes) -} - -// printHexPtr outputs a uintptr formatted as hexadecimal with a leading '0x' -// prefix to Writer w. -func printHexPtr(w io.Writer, p uintptr) { - // Null pointer. - num := uint64(p) - if num == 0 { - w.Write(nilAngleBytes) - return - } - - // Max uint64 is 16 bytes in hex + 2 bytes for '0x' prefix - buf := make([]byte, 18) - - // It's simpler to construct the hex string right to left. - base := uint64(16) - i := len(buf) - 1 - for num >= base { - buf[i] = hexDigits[num%base] - num /= base - i-- - } - buf[i] = hexDigits[num] - - // Add '0x' prefix. - i-- - buf[i] = 'x' - i-- - buf[i] = '0' - - // Strip unused leading bytes. - buf = buf[i:] - w.Write(buf) -} - -// valuesSorter implements sort.Interface to allow a slice of reflect.Value -// elements to be sorted. -type valuesSorter struct { - values []reflect.Value - strings []string // either nil or same len and values - cs *ConfigState -} - -// newValuesSorter initializes a valuesSorter instance, which holds a set of -// surrogate keys on which the data should be sorted. It uses flags in -// ConfigState to decide if and how to populate those surrogate keys. -func newValuesSorter(values []reflect.Value, cs *ConfigState) sort.Interface { - vs := &valuesSorter{values: values, cs: cs} - if canSortSimply(vs.values[0].Kind()) { - return vs - } - if !cs.DisableMethods { - vs.strings = make([]string, len(values)) - for i := range vs.values { - b := bytes.Buffer{} - if !handleMethods(cs, &b, vs.values[i]) { - vs.strings = nil - break - } - vs.strings[i] = b.String() - } - } - if vs.strings == nil && cs.SpewKeys { - vs.strings = make([]string, len(values)) - for i := range vs.values { - vs.strings[i] = Sprintf("%#v", vs.values[i].Interface()) - } - } - return vs -} - -// canSortSimply tests whether a reflect.Kind is a primitive that can be sorted -// directly, or whether it should be considered for sorting by surrogate keys -// (if the ConfigState allows it). -func canSortSimply(kind reflect.Kind) bool { - // This switch parallels valueSortLess, except for the default case. - switch kind { - case reflect.Bool: - return true - case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int: - return true - case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint: - return true - case reflect.Float32, reflect.Float64: - return true - case reflect.String: - return true - case reflect.Uintptr: - return true - case reflect.Array: - return true - } - return false -} - -// Len returns the number of values in the slice. It is part of the -// sort.Interface implementation. -func (s *valuesSorter) Len() int { - return len(s.values) -} - -// Swap swaps the values at the passed indices. It is part of the -// sort.Interface implementation. -func (s *valuesSorter) Swap(i, j int) { - s.values[i], s.values[j] = s.values[j], s.values[i] - if s.strings != nil { - s.strings[i], s.strings[j] = s.strings[j], s.strings[i] - } -} - -// valueSortLess returns whether the first value should sort before the second -// value. It is used by valueSorter.Less as part of the sort.Interface -// implementation. -func valueSortLess(a, b reflect.Value) bool { - switch a.Kind() { - case reflect.Bool: - return !a.Bool() && b.Bool() - case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int: - return a.Int() < b.Int() - case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint: - return a.Uint() < b.Uint() - case reflect.Float32, reflect.Float64: - return a.Float() < b.Float() - case reflect.String: - return a.String() < b.String() - case reflect.Uintptr: - return a.Uint() < b.Uint() - case reflect.Array: - // Compare the contents of both arrays. - l := a.Len() - for i := 0; i < l; i++ { - av := a.Index(i) - bv := b.Index(i) - if av.Interface() == bv.Interface() { - continue - } - return valueSortLess(av, bv) - } - } - return a.String() < b.String() -} - -// Less returns whether the value at index i should sort before the -// value at index j. It is part of the sort.Interface implementation. -func (s *valuesSorter) Less(i, j int) bool { - if s.strings == nil { - return valueSortLess(s.values[i], s.values[j]) - } - return s.strings[i] < s.strings[j] -} - -// sortValues is a sort function that handles both native types and any type that -// can be converted to error or Stringer. Other inputs are sorted according to -// their Value.String() value to ensure display stability. -func sortValues(values []reflect.Value, cs *ConfigState) { - if len(values) == 0 { - return - } - sort.Sort(newValuesSorter(values, cs)) -} diff --git a/vendor/github.com/davecgh/go-spew/spew/config.go b/vendor/github.com/davecgh/go-spew/spew/config.go deleted file mode 100644 index 2e3d22f31..000000000 --- a/vendor/github.com/davecgh/go-spew/spew/config.go +++ /dev/null @@ -1,306 +0,0 @@ -/* - * Copyright (c) 2013-2016 Dave Collins - * - * Permission to use, copy, modify, and distribute this software for any - * purpose with or without fee is hereby granted, provided that the above - * copyright notice and this permission notice appear in all copies. - * - * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES - * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF - * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR - * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES - * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN - * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF - * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - */ - -package spew - -import ( - "bytes" - "fmt" - "io" - "os" -) - -// ConfigState houses the configuration options used by spew to format and -// display values. There is a global instance, Config, that is used to control -// all top-level Formatter and Dump functionality. Each ConfigState instance -// provides methods equivalent to the top-level functions. -// -// The zero value for ConfigState provides no indentation. You would typically -// want to set it to a space or a tab. -// -// Alternatively, you can use NewDefaultConfig to get a ConfigState instance -// with default settings. See the documentation of NewDefaultConfig for default -// values. -type ConfigState struct { - // Indent specifies the string to use for each indentation level. The - // global config instance that all top-level functions use set this to a - // single space by default. If you would like more indentation, you might - // set this to a tab with "\t" or perhaps two spaces with " ". - Indent string - - // MaxDepth controls the maximum number of levels to descend into nested - // data structures. The default, 0, means there is no limit. - // - // NOTE: Circular data structures are properly detected, so it is not - // necessary to set this value unless you specifically want to limit deeply - // nested data structures. - MaxDepth int - - // DisableMethods specifies whether or not error and Stringer interfaces are - // invoked for types that implement them. - DisableMethods bool - - // DisablePointerMethods specifies whether or not to check for and invoke - // error and Stringer interfaces on types which only accept a pointer - // receiver when the current type is not a pointer. - // - // NOTE: This might be an unsafe action since calling one of these methods - // with a pointer receiver could technically mutate the value, however, - // in practice, types which choose to satisify an error or Stringer - // interface with a pointer receiver should not be mutating their state - // inside these interface methods. As a result, this option relies on - // access to the unsafe package, so it will not have any effect when - // running in environments without access to the unsafe package such as - // Google App Engine or with the "safe" build tag specified. - DisablePointerMethods bool - - // DisablePointerAddresses specifies whether to disable the printing of - // pointer addresses. This is useful when diffing data structures in tests. - DisablePointerAddresses bool - - // DisableCapacities specifies whether to disable the printing of capacities - // for arrays, slices, maps and channels. This is useful when diffing - // data structures in tests. - DisableCapacities bool - - // ContinueOnMethod specifies whether or not recursion should continue once - // a custom error or Stringer interface is invoked. The default, false, - // means it will print the results of invoking the custom error or Stringer - // interface and return immediately instead of continuing to recurse into - // the internals of the data type. - // - // NOTE: This flag does not have any effect if method invocation is disabled - // via the DisableMethods or DisablePointerMethods options. - ContinueOnMethod bool - - // SortKeys specifies map keys should be sorted before being printed. Use - // this to have a more deterministic, diffable output. Note that only - // native types (bool, int, uint, floats, uintptr and string) and types - // that support the error or Stringer interfaces (if methods are - // enabled) are supported, with other types sorted according to the - // reflect.Value.String() output which guarantees display stability. - SortKeys bool - - // SpewKeys specifies that, as a last resort attempt, map keys should - // be spewed to strings and sorted by those strings. This is only - // considered if SortKeys is true. - SpewKeys bool -} - -// Config is the active configuration of the top-level functions. -// The configuration can be changed by modifying the contents of spew.Config. -var Config = ConfigState{Indent: " "} - -// Errorf is a wrapper for fmt.Errorf that treats each argument as if it were -// passed with a Formatter interface returned by c.NewFormatter. It returns -// the formatted string as a value that satisfies error. See NewFormatter -// for formatting details. -// -// This function is shorthand for the following syntax: -// -// fmt.Errorf(format, c.NewFormatter(a), c.NewFormatter(b)) -func (c *ConfigState) Errorf(format string, a ...interface{}) (err error) { - return fmt.Errorf(format, c.convertArgs(a)...) -} - -// Fprint is a wrapper for fmt.Fprint that treats each argument as if it were -// passed with a Formatter interface returned by c.NewFormatter. It returns -// the number of bytes written and any write error encountered. See -// NewFormatter for formatting details. -// -// This function is shorthand for the following syntax: -// -// fmt.Fprint(w, c.NewFormatter(a), c.NewFormatter(b)) -func (c *ConfigState) Fprint(w io.Writer, a ...interface{}) (n int, err error) { - return fmt.Fprint(w, c.convertArgs(a)...) -} - -// Fprintf is a wrapper for fmt.Fprintf that treats each argument as if it were -// passed with a Formatter interface returned by c.NewFormatter. It returns -// the number of bytes written and any write error encountered. See -// NewFormatter for formatting details. -// -// This function is shorthand for the following syntax: -// -// fmt.Fprintf(w, format, c.NewFormatter(a), c.NewFormatter(b)) -func (c *ConfigState) Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) { - return fmt.Fprintf(w, format, c.convertArgs(a)...) -} - -// Fprintln is a wrapper for fmt.Fprintln that treats each argument as if it -// passed with a Formatter interface returned by c.NewFormatter. See -// NewFormatter for formatting details. -// -// This function is shorthand for the following syntax: -// -// fmt.Fprintln(w, c.NewFormatter(a), c.NewFormatter(b)) -func (c *ConfigState) Fprintln(w io.Writer, a ...interface{}) (n int, err error) { - return fmt.Fprintln(w, c.convertArgs(a)...) -} - -// Print is a wrapper for fmt.Print that treats each argument as if it were -// passed with a Formatter interface returned by c.NewFormatter. It returns -// the number of bytes written and any write error encountered. See -// NewFormatter for formatting details. -// -// This function is shorthand for the following syntax: -// -// fmt.Print(c.NewFormatter(a), c.NewFormatter(b)) -func (c *ConfigState) Print(a ...interface{}) (n int, err error) { - return fmt.Print(c.convertArgs(a)...) -} - -// Printf is a wrapper for fmt.Printf that treats each argument as if it were -// passed with a Formatter interface returned by c.NewFormatter. It returns -// the number of bytes written and any write error encountered. See -// NewFormatter for formatting details. -// -// This function is shorthand for the following syntax: -// -// fmt.Printf(format, c.NewFormatter(a), c.NewFormatter(b)) -func (c *ConfigState) Printf(format string, a ...interface{}) (n int, err error) { - return fmt.Printf(format, c.convertArgs(a)...) -} - -// Println is a wrapper for fmt.Println that treats each argument as if it were -// passed with a Formatter interface returned by c.NewFormatter. It returns -// the number of bytes written and any write error encountered. See -// NewFormatter for formatting details. -// -// This function is shorthand for the following syntax: -// -// fmt.Println(c.NewFormatter(a), c.NewFormatter(b)) -func (c *ConfigState) Println(a ...interface{}) (n int, err error) { - return fmt.Println(c.convertArgs(a)...) -} - -// Sprint is a wrapper for fmt.Sprint that treats each argument as if it were -// passed with a Formatter interface returned by c.NewFormatter. It returns -// the resulting string. See NewFormatter for formatting details. -// -// This function is shorthand for the following syntax: -// -// fmt.Sprint(c.NewFormatter(a), c.NewFormatter(b)) -func (c *ConfigState) Sprint(a ...interface{}) string { - return fmt.Sprint(c.convertArgs(a)...) -} - -// Sprintf is a wrapper for fmt.Sprintf that treats each argument as if it were -// passed with a Formatter interface returned by c.NewFormatter. It returns -// the resulting string. See NewFormatter for formatting details. -// -// This function is shorthand for the following syntax: -// -// fmt.Sprintf(format, c.NewFormatter(a), c.NewFormatter(b)) -func (c *ConfigState) Sprintf(format string, a ...interface{}) string { - return fmt.Sprintf(format, c.convertArgs(a)...) -} - -// Sprintln is a wrapper for fmt.Sprintln that treats each argument as if it -// were passed with a Formatter interface returned by c.NewFormatter. It -// returns the resulting string. See NewFormatter for formatting details. -// -// This function is shorthand for the following syntax: -// -// fmt.Sprintln(c.NewFormatter(a), c.NewFormatter(b)) -func (c *ConfigState) Sprintln(a ...interface{}) string { - return fmt.Sprintln(c.convertArgs(a)...) -} - -/* -NewFormatter returns a custom formatter that satisfies the fmt.Formatter -interface. As a result, it integrates cleanly with standard fmt package -printing functions. The formatter is useful for inline printing of smaller data -types similar to the standard %v format specifier. - -The custom formatter only responds to the %v (most compact), %+v (adds pointer -addresses), %#v (adds types), and %#+v (adds types and pointer addresses) verb -combinations. Any other verbs such as %x and %q will be sent to the the -standard fmt package for formatting. In addition, the custom formatter ignores -the width and precision arguments (however they will still work on the format -specifiers not handled by the custom formatter). - -Typically this function shouldn't be called directly. It is much easier to make -use of the custom formatter by calling one of the convenience functions such as -c.Printf, c.Println, or c.Printf. -*/ -func (c *ConfigState) NewFormatter(v interface{}) fmt.Formatter { - return newFormatter(c, v) -} - -// Fdump formats and displays the passed arguments to io.Writer w. It formats -// exactly the same as Dump. -func (c *ConfigState) Fdump(w io.Writer, a ...interface{}) { - fdump(c, w, a...) -} - -/* -Dump displays the passed parameters to standard out with newlines, customizable -indentation, and additional debug information such as complete types and all -pointer addresses used to indirect to the final value. It provides the -following features over the built-in printing facilities provided by the fmt -package: - - * Pointers are dereferenced and followed - * Circular data structures are detected and handled properly - * Custom Stringer/error interfaces are optionally invoked, including - on unexported types - * Custom types which only implement the Stringer/error interfaces via - a pointer receiver are optionally invoked when passing non-pointer - variables - * Byte arrays and slices are dumped like the hexdump -C command which - includes offsets, byte values in hex, and ASCII output - -The configuration options are controlled by modifying the public members -of c. See ConfigState for options documentation. - -See Fdump if you would prefer dumping to an arbitrary io.Writer or Sdump to -get the formatted result as a string. -*/ -func (c *ConfigState) Dump(a ...interface{}) { - fdump(c, os.Stdout, a...) -} - -// Sdump returns a string with the passed arguments formatted exactly the same -// as Dump. -func (c *ConfigState) Sdump(a ...interface{}) string { - var buf bytes.Buffer - fdump(c, &buf, a...) - return buf.String() -} - -// convertArgs accepts a slice of arguments and returns a slice of the same -// length with each argument converted to a spew Formatter interface using -// the ConfigState associated with s. -func (c *ConfigState) convertArgs(args []interface{}) (formatters []interface{}) { - formatters = make([]interface{}, len(args)) - for index, arg := range args { - formatters[index] = newFormatter(c, arg) - } - return formatters -} - -// NewDefaultConfig returns a ConfigState with the following default settings. -// -// Indent: " " -// MaxDepth: 0 -// DisableMethods: false -// DisablePointerMethods: false -// ContinueOnMethod: false -// SortKeys: false -func NewDefaultConfig() *ConfigState { - return &ConfigState{Indent: " "} -} diff --git a/vendor/github.com/davecgh/go-spew/spew/doc.go b/vendor/github.com/davecgh/go-spew/spew/doc.go deleted file mode 100644 index aacaac6f1..000000000 --- a/vendor/github.com/davecgh/go-spew/spew/doc.go +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Copyright (c) 2013-2016 Dave Collins - * - * Permission to use, copy, modify, and distribute this software for any - * purpose with or without fee is hereby granted, provided that the above - * copyright notice and this permission notice appear in all copies. - * - * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES - * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF - * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR - * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES - * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN - * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF - * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - */ - -/* -Package spew implements a deep pretty printer for Go data structures to aid in -debugging. - -A quick overview of the additional features spew provides over the built-in -printing facilities for Go data types are as follows: - - * Pointers are dereferenced and followed - * Circular data structures are detected and handled properly - * Custom Stringer/error interfaces are optionally invoked, including - on unexported types - * Custom types which only implement the Stringer/error interfaces via - a pointer receiver are optionally invoked when passing non-pointer - variables - * Byte arrays and slices are dumped like the hexdump -C command which - includes offsets, byte values in hex, and ASCII output (only when using - Dump style) - -There are two different approaches spew allows for dumping Go data structures: - - * Dump style which prints with newlines, customizable indentation, - and additional debug information such as types and all pointer addresses - used to indirect to the final value - * A custom Formatter interface that integrates cleanly with the standard fmt - package and replaces %v, %+v, %#v, and %#+v to provide inline printing - similar to the default %v while providing the additional functionality - outlined above and passing unsupported format verbs such as %x and %q - along to fmt - -Quick Start - -This section demonstrates how to quickly get started with spew. See the -sections below for further details on formatting and configuration options. - -To dump a variable with full newlines, indentation, type, and pointer -information use Dump, Fdump, or Sdump: - spew.Dump(myVar1, myVar2, ...) - spew.Fdump(someWriter, myVar1, myVar2, ...) - str := spew.Sdump(myVar1, myVar2, ...) - -Alternatively, if you would prefer to use format strings with a compacted inline -printing style, use the convenience wrappers Printf, Fprintf, etc with -%v (most compact), %+v (adds pointer addresses), %#v (adds types), or -%#+v (adds types and pointer addresses): - spew.Printf("myVar1: %v -- myVar2: %+v", myVar1, myVar2) - spew.Printf("myVar3: %#v -- myVar4: %#+v", myVar3, myVar4) - spew.Fprintf(someWriter, "myVar1: %v -- myVar2: %+v", myVar1, myVar2) - spew.Fprintf(someWriter, "myVar3: %#v -- myVar4: %#+v", myVar3, myVar4) - -Configuration Options - -Configuration of spew is handled by fields in the ConfigState type. For -convenience, all of the top-level functions use a global state available -via the spew.Config global. - -It is also possible to create a ConfigState instance that provides methods -equivalent to the top-level functions. This allows concurrent configuration -options. See the ConfigState documentation for more details. - -The following configuration options are available: - * Indent - String to use for each indentation level for Dump functions. - It is a single space by default. A popular alternative is "\t". - - * MaxDepth - Maximum number of levels to descend into nested data structures. - There is no limit by default. - - * DisableMethods - Disables invocation of error and Stringer interface methods. - Method invocation is enabled by default. - - * DisablePointerMethods - Disables invocation of error and Stringer interface methods on types - which only accept pointer receivers from non-pointer variables. - Pointer method invocation is enabled by default. - - * DisablePointerAddresses - DisablePointerAddresses specifies whether to disable the printing of - pointer addresses. This is useful when diffing data structures in tests. - - * DisableCapacities - DisableCapacities specifies whether to disable the printing of - capacities for arrays, slices, maps and channels. This is useful when - diffing data structures in tests. - - * ContinueOnMethod - Enables recursion into types after invoking error and Stringer interface - methods. Recursion after method invocation is disabled by default. - - * SortKeys - Specifies map keys should be sorted before being printed. Use - this to have a more deterministic, diffable output. Note that - only native types (bool, int, uint, floats, uintptr and string) - and types which implement error or Stringer interfaces are - supported with other types sorted according to the - reflect.Value.String() output which guarantees display - stability. Natural map order is used by default. - - * SpewKeys - Specifies that, as a last resort attempt, map keys should be - spewed to strings and sorted by those strings. This is only - considered if SortKeys is true. - -Dump Usage - -Simply call spew.Dump with a list of variables you want to dump: - - spew.Dump(myVar1, myVar2, ...) - -You may also call spew.Fdump if you would prefer to output to an arbitrary -io.Writer. For example, to dump to standard error: - - spew.Fdump(os.Stderr, myVar1, myVar2, ...) - -A third option is to call spew.Sdump to get the formatted output as a string: - - str := spew.Sdump(myVar1, myVar2, ...) - -Sample Dump Output - -See the Dump example for details on the setup of the types and variables being -shown here. - - (main.Foo) { - unexportedField: (*main.Bar)(0xf84002e210)({ - flag: (main.Flag) flagTwo, - data: (uintptr) - }), - ExportedField: (map[interface {}]interface {}) (len=1) { - (string) (len=3) "one": (bool) true - } - } - -Byte (and uint8) arrays and slices are displayed uniquely like the hexdump -C -command as shown. - ([]uint8) (len=32 cap=32) { - 00000000 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 |............... | - 00000010 21 22 23 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 30 |!"#$%&'()*+,-./0| - 00000020 31 32 |12| - } - -Custom Formatter - -Spew provides a custom formatter that implements the fmt.Formatter interface -so that it integrates cleanly with standard fmt package printing functions. The -formatter is useful for inline printing of smaller data types similar to the -standard %v format specifier. - -The custom formatter only responds to the %v (most compact), %+v (adds pointer -addresses), %#v (adds types), or %#+v (adds types and pointer addresses) verb -combinations. Any other verbs such as %x and %q will be sent to the the -standard fmt package for formatting. In addition, the custom formatter ignores -the width and precision arguments (however they will still work on the format -specifiers not handled by the custom formatter). - -Custom Formatter Usage - -The simplest way to make use of the spew custom formatter is to call one of the -convenience functions such as spew.Printf, spew.Println, or spew.Printf. The -functions have syntax you are most likely already familiar with: - - spew.Printf("myVar1: %v -- myVar2: %+v", myVar1, myVar2) - spew.Printf("myVar3: %#v -- myVar4: %#+v", myVar3, myVar4) - spew.Println(myVar, myVar2) - spew.Fprintf(os.Stderr, "myVar1: %v -- myVar2: %+v", myVar1, myVar2) - spew.Fprintf(os.Stderr, "myVar3: %#v -- myVar4: %#+v", myVar3, myVar4) - -See the Index for the full list convenience functions. - -Sample Formatter Output - -Double pointer to a uint8: - %v: <**>5 - %+v: <**>(0xf8400420d0->0xf8400420c8)5 - %#v: (**uint8)5 - %#+v: (**uint8)(0xf8400420d0->0xf8400420c8)5 - -Pointer to circular struct with a uint8 field and a pointer to itself: - %v: <*>{1 <*>} - %+v: <*>(0xf84003e260){ui8:1 c:<*>(0xf84003e260)} - %#v: (*main.circular){ui8:(uint8)1 c:(*main.circular)} - %#+v: (*main.circular)(0xf84003e260){ui8:(uint8)1 c:(*main.circular)(0xf84003e260)} - -See the Printf example for details on the setup of variables being shown -here. - -Errors - -Since it is possible for custom Stringer/error interfaces to panic, spew -detects them and handles them internally by printing the panic information -inline with the output. Since spew is intended to provide deep pretty printing -capabilities on structures, it intentionally does not return any errors. -*/ -package spew diff --git a/vendor/github.com/davecgh/go-spew/spew/dump.go b/vendor/github.com/davecgh/go-spew/spew/dump.go deleted file mode 100644 index f78d89fc1..000000000 --- a/vendor/github.com/davecgh/go-spew/spew/dump.go +++ /dev/null @@ -1,509 +0,0 @@ -/* - * Copyright (c) 2013-2016 Dave Collins - * - * Permission to use, copy, modify, and distribute this software for any - * purpose with or without fee is hereby granted, provided that the above - * copyright notice and this permission notice appear in all copies. - * - * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES - * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF - * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR - * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES - * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN - * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF - * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - */ - -package spew - -import ( - "bytes" - "encoding/hex" - "fmt" - "io" - "os" - "reflect" - "regexp" - "strconv" - "strings" -) - -var ( - // uint8Type is a reflect.Type representing a uint8. It is used to - // convert cgo types to uint8 slices for hexdumping. - uint8Type = reflect.TypeOf(uint8(0)) - - // cCharRE is a regular expression that matches a cgo char. - // It is used to detect character arrays to hexdump them. - cCharRE = regexp.MustCompile(`^.*\._Ctype_char$`) - - // cUnsignedCharRE is a regular expression that matches a cgo unsigned - // char. It is used to detect unsigned character arrays to hexdump - // them. - cUnsignedCharRE = regexp.MustCompile(`^.*\._Ctype_unsignedchar$`) - - // cUint8tCharRE is a regular expression that matches a cgo uint8_t. - // It is used to detect uint8_t arrays to hexdump them. - cUint8tCharRE = regexp.MustCompile(`^.*\._Ctype_uint8_t$`) -) - -// dumpState contains information about the state of a dump operation. -type dumpState struct { - w io.Writer - depth int - pointers map[uintptr]int - ignoreNextType bool - ignoreNextIndent bool - cs *ConfigState -} - -// indent performs indentation according to the depth level and cs.Indent -// option. -func (d *dumpState) indent() { - if d.ignoreNextIndent { - d.ignoreNextIndent = false - return - } - d.w.Write(bytes.Repeat([]byte(d.cs.Indent), d.depth)) -} - -// unpackValue returns values inside of non-nil interfaces when possible. -// This is useful for data types like structs, arrays, slices, and maps which -// can contain varying types packed inside an interface. -func (d *dumpState) unpackValue(v reflect.Value) reflect.Value { - if v.Kind() == reflect.Interface && !v.IsNil() { - v = v.Elem() - } - return v -} - -// dumpPtr handles formatting of pointers by indirecting them as necessary. -func (d *dumpState) dumpPtr(v reflect.Value) { - // Remove pointers at or below the current depth from map used to detect - // circular refs. - for k, depth := range d.pointers { - if depth >= d.depth { - delete(d.pointers, k) - } - } - - // Keep list of all dereferenced pointers to show later. - pointerChain := make([]uintptr, 0) - - // Figure out how many levels of indirection there are by dereferencing - // pointers and unpacking interfaces down the chain while detecting circular - // references. - nilFound := false - cycleFound := false - indirects := 0 - ve := v - for ve.Kind() == reflect.Ptr { - if ve.IsNil() { - nilFound = true - break - } - indirects++ - addr := ve.Pointer() - pointerChain = append(pointerChain, addr) - if pd, ok := d.pointers[addr]; ok && pd < d.depth { - cycleFound = true - indirects-- - break - } - d.pointers[addr] = d.depth - - ve = ve.Elem() - if ve.Kind() == reflect.Interface { - if ve.IsNil() { - nilFound = true - break - } - ve = ve.Elem() - } - } - - // Display type information. - d.w.Write(openParenBytes) - d.w.Write(bytes.Repeat(asteriskBytes, indirects)) - d.w.Write([]byte(ve.Type().String())) - d.w.Write(closeParenBytes) - - // Display pointer information. - if !d.cs.DisablePointerAddresses && len(pointerChain) > 0 { - d.w.Write(openParenBytes) - for i, addr := range pointerChain { - if i > 0 { - d.w.Write(pointerChainBytes) - } - printHexPtr(d.w, addr) - } - d.w.Write(closeParenBytes) - } - - // Display dereferenced value. - d.w.Write(openParenBytes) - switch { - case nilFound: - d.w.Write(nilAngleBytes) - - case cycleFound: - d.w.Write(circularBytes) - - default: - d.ignoreNextType = true - d.dump(ve) - } - d.w.Write(closeParenBytes) -} - -// dumpSlice handles formatting of arrays and slices. Byte (uint8 under -// reflection) arrays and slices are dumped in hexdump -C fashion. -func (d *dumpState) dumpSlice(v reflect.Value) { - // Determine whether this type should be hex dumped or not. Also, - // for types which should be hexdumped, try to use the underlying data - // first, then fall back to trying to convert them to a uint8 slice. - var buf []uint8 - doConvert := false - doHexDump := false - numEntries := v.Len() - if numEntries > 0 { - vt := v.Index(0).Type() - vts := vt.String() - switch { - // C types that need to be converted. - case cCharRE.MatchString(vts): - fallthrough - case cUnsignedCharRE.MatchString(vts): - fallthrough - case cUint8tCharRE.MatchString(vts): - doConvert = true - - // Try to use existing uint8 slices and fall back to converting - // and copying if that fails. - case vt.Kind() == reflect.Uint8: - // We need an addressable interface to convert the type - // to a byte slice. However, the reflect package won't - // give us an interface on certain things like - // unexported struct fields in order to enforce - // visibility rules. We use unsafe, when available, to - // bypass these restrictions since this package does not - // mutate the values. - vs := v - if !vs.CanInterface() || !vs.CanAddr() { - vs = unsafeReflectValue(vs) - } - if !UnsafeDisabled { - vs = vs.Slice(0, numEntries) - - // Use the existing uint8 slice if it can be - // type asserted. - iface := vs.Interface() - if slice, ok := iface.([]uint8); ok { - buf = slice - doHexDump = true - break - } - } - - // The underlying data needs to be converted if it can't - // be type asserted to a uint8 slice. - doConvert = true - } - - // Copy and convert the underlying type if needed. - if doConvert && vt.ConvertibleTo(uint8Type) { - // Convert and copy each element into a uint8 byte - // slice. - buf = make([]uint8, numEntries) - for i := 0; i < numEntries; i++ { - vv := v.Index(i) - buf[i] = uint8(vv.Convert(uint8Type).Uint()) - } - doHexDump = true - } - } - - // Hexdump the entire slice as needed. - if doHexDump { - indent := strings.Repeat(d.cs.Indent, d.depth) - str := indent + hex.Dump(buf) - str = strings.Replace(str, "\n", "\n"+indent, -1) - str = strings.TrimRight(str, d.cs.Indent) - d.w.Write([]byte(str)) - return - } - - // Recursively call dump for each item. - for i := 0; i < numEntries; i++ { - d.dump(d.unpackValue(v.Index(i))) - if i < (numEntries - 1) { - d.w.Write(commaNewlineBytes) - } else { - d.w.Write(newlineBytes) - } - } -} - -// dump is the main workhorse for dumping a value. It uses the passed reflect -// value to figure out what kind of object we are dealing with and formats it -// appropriately. It is a recursive function, however circular data structures -// are detected and handled properly. -func (d *dumpState) dump(v reflect.Value) { - // Handle invalid reflect values immediately. - kind := v.Kind() - if kind == reflect.Invalid { - d.w.Write(invalidAngleBytes) - return - } - - // Handle pointers specially. - if kind == reflect.Ptr { - d.indent() - d.dumpPtr(v) - return - } - - // Print type information unless already handled elsewhere. - if !d.ignoreNextType { - d.indent() - d.w.Write(openParenBytes) - d.w.Write([]byte(v.Type().String())) - d.w.Write(closeParenBytes) - d.w.Write(spaceBytes) - } - d.ignoreNextType = false - - // Display length and capacity if the built-in len and cap functions - // work with the value's kind and the len/cap itself is non-zero. - valueLen, valueCap := 0, 0 - switch v.Kind() { - case reflect.Array, reflect.Slice, reflect.Chan: - valueLen, valueCap = v.Len(), v.Cap() - case reflect.Map, reflect.String: - valueLen = v.Len() - } - if valueLen != 0 || !d.cs.DisableCapacities && valueCap != 0 { - d.w.Write(openParenBytes) - if valueLen != 0 { - d.w.Write(lenEqualsBytes) - printInt(d.w, int64(valueLen), 10) - } - if !d.cs.DisableCapacities && valueCap != 0 { - if valueLen != 0 { - d.w.Write(spaceBytes) - } - d.w.Write(capEqualsBytes) - printInt(d.w, int64(valueCap), 10) - } - d.w.Write(closeParenBytes) - d.w.Write(spaceBytes) - } - - // Call Stringer/error interfaces if they exist and the handle methods flag - // is enabled - if !d.cs.DisableMethods { - if (kind != reflect.Invalid) && (kind != reflect.Interface) { - if handled := handleMethods(d.cs, d.w, v); handled { - return - } - } - } - - switch kind { - case reflect.Invalid: - // Do nothing. We should never get here since invalid has already - // been handled above. - - case reflect.Bool: - printBool(d.w, v.Bool()) - - case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int: - printInt(d.w, v.Int(), 10) - - case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint: - printUint(d.w, v.Uint(), 10) - - case reflect.Float32: - printFloat(d.w, v.Float(), 32) - - case reflect.Float64: - printFloat(d.w, v.Float(), 64) - - case reflect.Complex64: - printComplex(d.w, v.Complex(), 32) - - case reflect.Complex128: - printComplex(d.w, v.Complex(), 64) - - case reflect.Slice: - if v.IsNil() { - d.w.Write(nilAngleBytes) - break - } - fallthrough - - case reflect.Array: - d.w.Write(openBraceNewlineBytes) - d.depth++ - if (d.cs.MaxDepth != 0) && (d.depth > d.cs.MaxDepth) { - d.indent() - d.w.Write(maxNewlineBytes) - } else { - d.dumpSlice(v) - } - d.depth-- - d.indent() - d.w.Write(closeBraceBytes) - - case reflect.String: - d.w.Write([]byte(strconv.Quote(v.String()))) - - case reflect.Interface: - // The only time we should get here is for nil interfaces due to - // unpackValue calls. - if v.IsNil() { - d.w.Write(nilAngleBytes) - } - - case reflect.Ptr: - // Do nothing. We should never get here since pointers have already - // been handled above. - - case reflect.Map: - // nil maps should be indicated as different than empty maps - if v.IsNil() { - d.w.Write(nilAngleBytes) - break - } - - d.w.Write(openBraceNewlineBytes) - d.depth++ - if (d.cs.MaxDepth != 0) && (d.depth > d.cs.MaxDepth) { - d.indent() - d.w.Write(maxNewlineBytes) - } else { - numEntries := v.Len() - keys := v.MapKeys() - if d.cs.SortKeys { - sortValues(keys, d.cs) - } - for i, key := range keys { - d.dump(d.unpackValue(key)) - d.w.Write(colonSpaceBytes) - d.ignoreNextIndent = true - d.dump(d.unpackValue(v.MapIndex(key))) - if i < (numEntries - 1) { - d.w.Write(commaNewlineBytes) - } else { - d.w.Write(newlineBytes) - } - } - } - d.depth-- - d.indent() - d.w.Write(closeBraceBytes) - - case reflect.Struct: - d.w.Write(openBraceNewlineBytes) - d.depth++ - if (d.cs.MaxDepth != 0) && (d.depth > d.cs.MaxDepth) { - d.indent() - d.w.Write(maxNewlineBytes) - } else { - vt := v.Type() - numFields := v.NumField() - for i := 0; i < numFields; i++ { - d.indent() - vtf := vt.Field(i) - d.w.Write([]byte(vtf.Name)) - d.w.Write(colonSpaceBytes) - d.ignoreNextIndent = true - d.dump(d.unpackValue(v.Field(i))) - if i < (numFields - 1) { - d.w.Write(commaNewlineBytes) - } else { - d.w.Write(newlineBytes) - } - } - } - d.depth-- - d.indent() - d.w.Write(closeBraceBytes) - - case reflect.Uintptr: - printHexPtr(d.w, uintptr(v.Uint())) - - case reflect.UnsafePointer, reflect.Chan, reflect.Func: - printHexPtr(d.w, v.Pointer()) - - // There were not any other types at the time this code was written, but - // fall back to letting the default fmt package handle it in case any new - // types are added. - default: - if v.CanInterface() { - fmt.Fprintf(d.w, "%v", v.Interface()) - } else { - fmt.Fprintf(d.w, "%v", v.String()) - } - } -} - -// fdump is a helper function to consolidate the logic from the various public -// methods which take varying writers and config states. -func fdump(cs *ConfigState, w io.Writer, a ...interface{}) { - for _, arg := range a { - if arg == nil { - w.Write(interfaceBytes) - w.Write(spaceBytes) - w.Write(nilAngleBytes) - w.Write(newlineBytes) - continue - } - - d := dumpState{w: w, cs: cs} - d.pointers = make(map[uintptr]int) - d.dump(reflect.ValueOf(arg)) - d.w.Write(newlineBytes) - } -} - -// Fdump formats and displays the passed arguments to io.Writer w. It formats -// exactly the same as Dump. -func Fdump(w io.Writer, a ...interface{}) { - fdump(&Config, w, a...) -} - -// Sdump returns a string with the passed arguments formatted exactly the same -// as Dump. -func Sdump(a ...interface{}) string { - var buf bytes.Buffer - fdump(&Config, &buf, a...) - return buf.String() -} - -/* -Dump displays the passed parameters to standard out with newlines, customizable -indentation, and additional debug information such as complete types and all -pointer addresses used to indirect to the final value. It provides the -following features over the built-in printing facilities provided by the fmt -package: - - * Pointers are dereferenced and followed - * Circular data structures are detected and handled properly - * Custom Stringer/error interfaces are optionally invoked, including - on unexported types - * Custom types which only implement the Stringer/error interfaces via - a pointer receiver are optionally invoked when passing non-pointer - variables - * Byte arrays and slices are dumped like the hexdump -C command which - includes offsets, byte values in hex, and ASCII output - -The configuration options are controlled by an exported package global, -spew.Config. See ConfigState for options documentation. - -See Fdump if you would prefer dumping to an arbitrary io.Writer or Sdump to -get the formatted result as a string. -*/ -func Dump(a ...interface{}) { - fdump(&Config, os.Stdout, a...) -} diff --git a/vendor/github.com/davecgh/go-spew/spew/format.go b/vendor/github.com/davecgh/go-spew/spew/format.go deleted file mode 100644 index b04edb7d7..000000000 --- a/vendor/github.com/davecgh/go-spew/spew/format.go +++ /dev/null @@ -1,419 +0,0 @@ -/* - * Copyright (c) 2013-2016 Dave Collins - * - * Permission to use, copy, modify, and distribute this software for any - * purpose with or without fee is hereby granted, provided that the above - * copyright notice and this permission notice appear in all copies. - * - * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES - * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF - * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR - * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES - * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN - * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF - * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - */ - -package spew - -import ( - "bytes" - "fmt" - "reflect" - "strconv" - "strings" -) - -// supportedFlags is a list of all the character flags supported by fmt package. -const supportedFlags = "0-+# " - -// formatState implements the fmt.Formatter interface and contains information -// about the state of a formatting operation. The NewFormatter function can -// be used to get a new Formatter which can be used directly as arguments -// in standard fmt package printing calls. -type formatState struct { - value interface{} - fs fmt.State - depth int - pointers map[uintptr]int - ignoreNextType bool - cs *ConfigState -} - -// buildDefaultFormat recreates the original format string without precision -// and width information to pass in to fmt.Sprintf in the case of an -// unrecognized type. Unless new types are added to the language, this -// function won't ever be called. -func (f *formatState) buildDefaultFormat() (format string) { - buf := bytes.NewBuffer(percentBytes) - - for _, flag := range supportedFlags { - if f.fs.Flag(int(flag)) { - buf.WriteRune(flag) - } - } - - buf.WriteRune('v') - - format = buf.String() - return format -} - -// constructOrigFormat recreates the original format string including precision -// and width information to pass along to the standard fmt package. This allows -// automatic deferral of all format strings this package doesn't support. -func (f *formatState) constructOrigFormat(verb rune) (format string) { - buf := bytes.NewBuffer(percentBytes) - - for _, flag := range supportedFlags { - if f.fs.Flag(int(flag)) { - buf.WriteRune(flag) - } - } - - if width, ok := f.fs.Width(); ok { - buf.WriteString(strconv.Itoa(width)) - } - - if precision, ok := f.fs.Precision(); ok { - buf.Write(precisionBytes) - buf.WriteString(strconv.Itoa(precision)) - } - - buf.WriteRune(verb) - - format = buf.String() - return format -} - -// unpackValue returns values inside of non-nil interfaces when possible and -// ensures that types for values which have been unpacked from an interface -// are displayed when the show types flag is also set. -// This is useful for data types like structs, arrays, slices, and maps which -// can contain varying types packed inside an interface. -func (f *formatState) unpackValue(v reflect.Value) reflect.Value { - if v.Kind() == reflect.Interface { - f.ignoreNextType = false - if !v.IsNil() { - v = v.Elem() - } - } - return v -} - -// formatPtr handles formatting of pointers by indirecting them as necessary. -func (f *formatState) formatPtr(v reflect.Value) { - // Display nil if top level pointer is nil. - showTypes := f.fs.Flag('#') - if v.IsNil() && (!showTypes || f.ignoreNextType) { - f.fs.Write(nilAngleBytes) - return - } - - // Remove pointers at or below the current depth from map used to detect - // circular refs. - for k, depth := range f.pointers { - if depth >= f.depth { - delete(f.pointers, k) - } - } - - // Keep list of all dereferenced pointers to possibly show later. - pointerChain := make([]uintptr, 0) - - // Figure out how many levels of indirection there are by derferencing - // pointers and unpacking interfaces down the chain while detecting circular - // references. - nilFound := false - cycleFound := false - indirects := 0 - ve := v - for ve.Kind() == reflect.Ptr { - if ve.IsNil() { - nilFound = true - break - } - indirects++ - addr := ve.Pointer() - pointerChain = append(pointerChain, addr) - if pd, ok := f.pointers[addr]; ok && pd < f.depth { - cycleFound = true - indirects-- - break - } - f.pointers[addr] = f.depth - - ve = ve.Elem() - if ve.Kind() == reflect.Interface { - if ve.IsNil() { - nilFound = true - break - } - ve = ve.Elem() - } - } - - // Display type or indirection level depending on flags. - if showTypes && !f.ignoreNextType { - f.fs.Write(openParenBytes) - f.fs.Write(bytes.Repeat(asteriskBytes, indirects)) - f.fs.Write([]byte(ve.Type().String())) - f.fs.Write(closeParenBytes) - } else { - if nilFound || cycleFound { - indirects += strings.Count(ve.Type().String(), "*") - } - f.fs.Write(openAngleBytes) - f.fs.Write([]byte(strings.Repeat("*", indirects))) - f.fs.Write(closeAngleBytes) - } - - // Display pointer information depending on flags. - if f.fs.Flag('+') && (len(pointerChain) > 0) { - f.fs.Write(openParenBytes) - for i, addr := range pointerChain { - if i > 0 { - f.fs.Write(pointerChainBytes) - } - printHexPtr(f.fs, addr) - } - f.fs.Write(closeParenBytes) - } - - // Display dereferenced value. - switch { - case nilFound: - f.fs.Write(nilAngleBytes) - - case cycleFound: - f.fs.Write(circularShortBytes) - - default: - f.ignoreNextType = true - f.format(ve) - } -} - -// format is the main workhorse for providing the Formatter interface. It -// uses the passed reflect value to figure out what kind of object we are -// dealing with and formats it appropriately. It is a recursive function, -// however circular data structures are detected and handled properly. -func (f *formatState) format(v reflect.Value) { - // Handle invalid reflect values immediately. - kind := v.Kind() - if kind == reflect.Invalid { - f.fs.Write(invalidAngleBytes) - return - } - - // Handle pointers specially. - if kind == reflect.Ptr { - f.formatPtr(v) - return - } - - // Print type information unless already handled elsewhere. - if !f.ignoreNextType && f.fs.Flag('#') { - f.fs.Write(openParenBytes) - f.fs.Write([]byte(v.Type().String())) - f.fs.Write(closeParenBytes) - } - f.ignoreNextType = false - - // Call Stringer/error interfaces if they exist and the handle methods - // flag is enabled. - if !f.cs.DisableMethods { - if (kind != reflect.Invalid) && (kind != reflect.Interface) { - if handled := handleMethods(f.cs, f.fs, v); handled { - return - } - } - } - - switch kind { - case reflect.Invalid: - // Do nothing. We should never get here since invalid has already - // been handled above. - - case reflect.Bool: - printBool(f.fs, v.Bool()) - - case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int: - printInt(f.fs, v.Int(), 10) - - case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint: - printUint(f.fs, v.Uint(), 10) - - case reflect.Float32: - printFloat(f.fs, v.Float(), 32) - - case reflect.Float64: - printFloat(f.fs, v.Float(), 64) - - case reflect.Complex64: - printComplex(f.fs, v.Complex(), 32) - - case reflect.Complex128: - printComplex(f.fs, v.Complex(), 64) - - case reflect.Slice: - if v.IsNil() { - f.fs.Write(nilAngleBytes) - break - } - fallthrough - - case reflect.Array: - f.fs.Write(openBracketBytes) - f.depth++ - if (f.cs.MaxDepth != 0) && (f.depth > f.cs.MaxDepth) { - f.fs.Write(maxShortBytes) - } else { - numEntries := v.Len() - for i := 0; i < numEntries; i++ { - if i > 0 { - f.fs.Write(spaceBytes) - } - f.ignoreNextType = true - f.format(f.unpackValue(v.Index(i))) - } - } - f.depth-- - f.fs.Write(closeBracketBytes) - - case reflect.String: - f.fs.Write([]byte(v.String())) - - case reflect.Interface: - // The only time we should get here is for nil interfaces due to - // unpackValue calls. - if v.IsNil() { - f.fs.Write(nilAngleBytes) - } - - case reflect.Ptr: - // Do nothing. We should never get here since pointers have already - // been handled above. - - case reflect.Map: - // nil maps should be indicated as different than empty maps - if v.IsNil() { - f.fs.Write(nilAngleBytes) - break - } - - f.fs.Write(openMapBytes) - f.depth++ - if (f.cs.MaxDepth != 0) && (f.depth > f.cs.MaxDepth) { - f.fs.Write(maxShortBytes) - } else { - keys := v.MapKeys() - if f.cs.SortKeys { - sortValues(keys, f.cs) - } - for i, key := range keys { - if i > 0 { - f.fs.Write(spaceBytes) - } - f.ignoreNextType = true - f.format(f.unpackValue(key)) - f.fs.Write(colonBytes) - f.ignoreNextType = true - f.format(f.unpackValue(v.MapIndex(key))) - } - } - f.depth-- - f.fs.Write(closeMapBytes) - - case reflect.Struct: - numFields := v.NumField() - f.fs.Write(openBraceBytes) - f.depth++ - if (f.cs.MaxDepth != 0) && (f.depth > f.cs.MaxDepth) { - f.fs.Write(maxShortBytes) - } else { - vt := v.Type() - for i := 0; i < numFields; i++ { - if i > 0 { - f.fs.Write(spaceBytes) - } - vtf := vt.Field(i) - if f.fs.Flag('+') || f.fs.Flag('#') { - f.fs.Write([]byte(vtf.Name)) - f.fs.Write(colonBytes) - } - f.format(f.unpackValue(v.Field(i))) - } - } - f.depth-- - f.fs.Write(closeBraceBytes) - - case reflect.Uintptr: - printHexPtr(f.fs, uintptr(v.Uint())) - - case reflect.UnsafePointer, reflect.Chan, reflect.Func: - printHexPtr(f.fs, v.Pointer()) - - // There were not any other types at the time this code was written, but - // fall back to letting the default fmt package handle it if any get added. - default: - format := f.buildDefaultFormat() - if v.CanInterface() { - fmt.Fprintf(f.fs, format, v.Interface()) - } else { - fmt.Fprintf(f.fs, format, v.String()) - } - } -} - -// Format satisfies the fmt.Formatter interface. See NewFormatter for usage -// details. -func (f *formatState) Format(fs fmt.State, verb rune) { - f.fs = fs - - // Use standard formatting for verbs that are not v. - if verb != 'v' { - format := f.constructOrigFormat(verb) - fmt.Fprintf(fs, format, f.value) - return - } - - if f.value == nil { - if fs.Flag('#') { - fs.Write(interfaceBytes) - } - fs.Write(nilAngleBytes) - return - } - - f.format(reflect.ValueOf(f.value)) -} - -// newFormatter is a helper function to consolidate the logic from the various -// public methods which take varying config states. -func newFormatter(cs *ConfigState, v interface{}) fmt.Formatter { - fs := &formatState{value: v, cs: cs} - fs.pointers = make(map[uintptr]int) - return fs -} - -/* -NewFormatter returns a custom formatter that satisfies the fmt.Formatter -interface. As a result, it integrates cleanly with standard fmt package -printing functions. The formatter is useful for inline printing of smaller data -types similar to the standard %v format specifier. - -The custom formatter only responds to the %v (most compact), %+v (adds pointer -addresses), %#v (adds types), or %#+v (adds types and pointer addresses) verb -combinations. Any other verbs such as %x and %q will be sent to the the -standard fmt package for formatting. In addition, the custom formatter ignores -the width and precision arguments (however they will still work on the format -specifiers not handled by the custom formatter). - -Typically this function shouldn't be called directly. It is much easier to make -use of the custom formatter by calling one of the convenience functions such as -Printf, Println, or Fprintf. -*/ -func NewFormatter(v interface{}) fmt.Formatter { - return newFormatter(&Config, v) -} diff --git a/vendor/github.com/davecgh/go-spew/spew/spew.go b/vendor/github.com/davecgh/go-spew/spew/spew.go deleted file mode 100644 index 32c0e3388..000000000 --- a/vendor/github.com/davecgh/go-spew/spew/spew.go +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright (c) 2013-2016 Dave Collins - * - * Permission to use, copy, modify, and distribute this software for any - * purpose with or without fee is hereby granted, provided that the above - * copyright notice and this permission notice appear in all copies. - * - * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES - * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF - * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR - * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES - * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN - * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF - * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - */ - -package spew - -import ( - "fmt" - "io" -) - -// Errorf is a wrapper for fmt.Errorf that treats each argument as if it were -// passed with a default Formatter interface returned by NewFormatter. It -// returns the formatted string as a value that satisfies error. See -// NewFormatter for formatting details. -// -// This function is shorthand for the following syntax: -// -// fmt.Errorf(format, spew.NewFormatter(a), spew.NewFormatter(b)) -func Errorf(format string, a ...interface{}) (err error) { - return fmt.Errorf(format, convertArgs(a)...) -} - -// Fprint is a wrapper for fmt.Fprint that treats each argument as if it were -// passed with a default Formatter interface returned by NewFormatter. It -// returns the number of bytes written and any write error encountered. See -// NewFormatter for formatting details. -// -// This function is shorthand for the following syntax: -// -// fmt.Fprint(w, spew.NewFormatter(a), spew.NewFormatter(b)) -func Fprint(w io.Writer, a ...interface{}) (n int, err error) { - return fmt.Fprint(w, convertArgs(a)...) -} - -// Fprintf is a wrapper for fmt.Fprintf that treats each argument as if it were -// passed with a default Formatter interface returned by NewFormatter. It -// returns the number of bytes written and any write error encountered. See -// NewFormatter for formatting details. -// -// This function is shorthand for the following syntax: -// -// fmt.Fprintf(w, format, spew.NewFormatter(a), spew.NewFormatter(b)) -func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) { - return fmt.Fprintf(w, format, convertArgs(a)...) -} - -// Fprintln is a wrapper for fmt.Fprintln that treats each argument as if it -// passed with a default Formatter interface returned by NewFormatter. See -// NewFormatter for formatting details. -// -// This function is shorthand for the following syntax: -// -// fmt.Fprintln(w, spew.NewFormatter(a), spew.NewFormatter(b)) -func Fprintln(w io.Writer, a ...interface{}) (n int, err error) { - return fmt.Fprintln(w, convertArgs(a)...) -} - -// Print is a wrapper for fmt.Print that treats each argument as if it were -// passed with a default Formatter interface returned by NewFormatter. It -// returns the number of bytes written and any write error encountered. See -// NewFormatter for formatting details. -// -// This function is shorthand for the following syntax: -// -// fmt.Print(spew.NewFormatter(a), spew.NewFormatter(b)) -func Print(a ...interface{}) (n int, err error) { - return fmt.Print(convertArgs(a)...) -} - -// Printf is a wrapper for fmt.Printf that treats each argument as if it were -// passed with a default Formatter interface returned by NewFormatter. It -// returns the number of bytes written and any write error encountered. See -// NewFormatter for formatting details. -// -// This function is shorthand for the following syntax: -// -// fmt.Printf(format, spew.NewFormatter(a), spew.NewFormatter(b)) -func Printf(format string, a ...interface{}) (n int, err error) { - return fmt.Printf(format, convertArgs(a)...) -} - -// Println is a wrapper for fmt.Println that treats each argument as if it were -// passed with a default Formatter interface returned by NewFormatter. It -// returns the number of bytes written and any write error encountered. See -// NewFormatter for formatting details. -// -// This function is shorthand for the following syntax: -// -// fmt.Println(spew.NewFormatter(a), spew.NewFormatter(b)) -func Println(a ...interface{}) (n int, err error) { - return fmt.Println(convertArgs(a)...) -} - -// Sprint is a wrapper for fmt.Sprint that treats each argument as if it were -// passed with a default Formatter interface returned by NewFormatter. It -// returns the resulting string. See NewFormatter for formatting details. -// -// This function is shorthand for the following syntax: -// -// fmt.Sprint(spew.NewFormatter(a), spew.NewFormatter(b)) -func Sprint(a ...interface{}) string { - return fmt.Sprint(convertArgs(a)...) -} - -// Sprintf is a wrapper for fmt.Sprintf that treats each argument as if it were -// passed with a default Formatter interface returned by NewFormatter. It -// returns the resulting string. See NewFormatter for formatting details. -// -// This function is shorthand for the following syntax: -// -// fmt.Sprintf(format, spew.NewFormatter(a), spew.NewFormatter(b)) -func Sprintf(format string, a ...interface{}) string { - return fmt.Sprintf(format, convertArgs(a)...) -} - -// Sprintln is a wrapper for fmt.Sprintln that treats each argument as if it -// were passed with a default Formatter interface returned by NewFormatter. It -// returns the resulting string. See NewFormatter for formatting details. -// -// This function is shorthand for the following syntax: -// -// fmt.Sprintln(spew.NewFormatter(a), spew.NewFormatter(b)) -func Sprintln(a ...interface{}) string { - return fmt.Sprintln(convertArgs(a)...) -} - -// convertArgs accepts a slice of arguments and returns a slice of the same -// length with each argument converted to a default spew Formatter interface. -func convertArgs(args []interface{}) (formatters []interface{}) { - formatters = make([]interface{}, len(args)) - for index, arg := range args { - formatters[index] = NewFormatter(arg) - } - return formatters -} diff --git a/vendor/github.com/go-test/deep/.gitignore b/vendor/github.com/go-test/deep/.gitignore deleted file mode 100644 index 53f12f0f0..000000000 --- a/vendor/github.com/go-test/deep/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -*.swp -*.out diff --git a/vendor/github.com/go-test/deep/.travis.yml b/vendor/github.com/go-test/deep/.travis.yml deleted file mode 100644 index df3972fc9..000000000 --- a/vendor/github.com/go-test/deep/.travis.yml +++ /dev/null @@ -1,13 +0,0 @@ -language: go - -go: - - "1.10" - - "1.11" - - "1.12" - -before_install: - - go get github.com/mattn/goveralls - - go get golang.org/x/tools/cover - -script: - - $HOME/gopath/bin/goveralls -service=travis-ci diff --git a/vendor/github.com/go-test/deep/CHANGES.md b/vendor/github.com/go-test/deep/CHANGES.md deleted file mode 100644 index 00f072b18..000000000 --- a/vendor/github.com/go-test/deep/CHANGES.md +++ /dev/null @@ -1,30 +0,0 @@ -# go-test/deep Changelog - -## v1.0.4 released 2019-09-15 - -* Added \`deep:"-"\` structure field tag to ignore field (PR #38) (@flga) - -## v1.0.3 released 2019-08-18 - -* Fixed issue #31: panic on typed primitives that implement error interface - -## v1.0.2 released 2019-07-14 - -* Enabled Go module (@radeksimko) -* Changed supported and tested Go versions: 1.10, 1.11, and 1.12 (dropped 1.9) -* Changed Error equality: additional struct fields are compared too (PR #29) (@andrewmostello) -* Fixed typos and ineffassign issues (PR #25) (@tariq1890) -* Fixed diff order for nil comparison (PR #16) (@gmarik) -* Fixed slice equality when slices are extracted from the same array (PR #11) (@risteli) -* Fixed test spelling and messages (PR #19) (@sofuture) -* Fixed issue #15: panic on comparing struct with anonymous time.Time -* Fixed issue #18: Panic when comparing structs with time.Time value and CompareUnexportedFields is true -* Fixed issue #21: Set default MaxDepth = 0 (disabled) (PR #23) - -## v1.0.1 released 2018-01-28 - -* Fixed issue #12: Arrays are not properly compared (@samlitowitz) - -## v1.0.0 releaesd 2017-10-27 - -* First release diff --git a/vendor/github.com/go-test/deep/LICENSE b/vendor/github.com/go-test/deep/LICENSE deleted file mode 100644 index 228ef16f7..000000000 --- a/vendor/github.com/go-test/deep/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright 2015-2017 Daniel Nichter - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/vendor/github.com/go-test/deep/README.md b/vendor/github.com/go-test/deep/README.md deleted file mode 100644 index 3b78eac7c..000000000 --- a/vendor/github.com/go-test/deep/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# Deep Variable Equality for Humans - -[![Go Report Card](https://goreportcard.com/badge/github.com/go-test/deep)](https://goreportcard.com/report/github.com/go-test/deep) [![Build Status](https://travis-ci.org/go-test/deep.svg?branch=master)](https://travis-ci.org/go-test/deep) [![Coverage Status](https://coveralls.io/repos/github/go-test/deep/badge.svg?branch=master)](https://coveralls.io/github/go-test/deep?branch=master) [![GoDoc](https://godoc.org/github.com/go-test/deep?status.svg)](https://godoc.org/github.com/go-test/deep) - -This package provides a single function: `deep.Equal`. It's like [reflect.DeepEqual](http://golang.org/pkg/reflect/#DeepEqual) but much friendlier to humans (or any sentient being) for two reason: - -* `deep.Equal` returns a list of differences -* `deep.Equal` does not compare unexported fields (by default) - -`reflect.DeepEqual` is good (like all things Golang!), but it's a game of [Hunt the Wumpus](https://en.wikipedia.org/wiki/Hunt_the_Wumpus). For large maps, slices, and structs, finding the difference is difficult. - -`deep.Equal` doesn't play games with you, it lists the differences: - -```go -package main_test - -import ( - "testing" - "github.com/go-test/deep" -) - -type T struct { - Name string - Numbers []float64 -} - -func TestDeepEqual(t *testing.T) { - // Can you spot the difference? - t1 := T{ - Name: "Isabella", - Numbers: []float64{1.13459, 2.29343, 3.010100010}, - } - t2 := T{ - Name: "Isabella", - Numbers: []float64{1.13459, 2.29843, 3.010100010}, - } - - if diff := deep.Equal(t1, t2); diff != nil { - t.Error(diff) - } -} -``` - - -``` -$ go test ---- FAIL: TestDeepEqual (0.00s) - main_test.go:25: [Numbers.slice[1]: 2.29343 != 2.29843] -``` - -The difference is in `Numbers.slice[1]`: the two values aren't equal using Go `==`. diff --git a/vendor/github.com/go-test/deep/deep.go b/vendor/github.com/go-test/deep/deep.go deleted file mode 100644 index 7f8789512..000000000 --- a/vendor/github.com/go-test/deep/deep.go +++ /dev/null @@ -1,376 +0,0 @@ -// Package deep provides function deep.Equal which is like reflect.DeepEqual but -// returns a list of differences. This is helpful when comparing complex types -// like structures and maps. -package deep - -import ( - "errors" - "fmt" - "log" - "reflect" - "strings" -) - -var ( - // FloatPrecision is the number of decimal places to round float values - // to when comparing. - FloatPrecision = 10 - - // MaxDiff specifies the maximum number of differences to return. - MaxDiff = 10 - - // MaxDepth specifies the maximum levels of a struct to recurse into, - // if greater than zero. If zero, there is no limit. - MaxDepth = 0 - - // LogErrors causes errors to be logged to STDERR when true. - LogErrors = false - - // CompareUnexportedFields causes unexported struct fields, like s in - // T{s int}, to be compared when true. - CompareUnexportedFields = false -) - -var ( - // ErrMaxRecursion is logged when MaxDepth is reached. - ErrMaxRecursion = errors.New("recursed to MaxDepth") - - // ErrTypeMismatch is logged when Equal passed two different types of values. - ErrTypeMismatch = errors.New("variables are different reflect.Type") - - // ErrNotHandled is logged when a primitive Go kind is not handled. - ErrNotHandled = errors.New("cannot compare the reflect.Kind") -) - -type cmp struct { - diff []string - buff []string - floatFormat string -} - -var errorType = reflect.TypeOf((*error)(nil)).Elem() - -// Equal compares variables a and b, recursing into their structure up to -// MaxDepth levels deep (if greater than zero), and returns a list of differences, -// or nil if there are none. Some differences may not be found if an error is -// also returned. -// -// If a type has an Equal method, like time.Equal, it is called to check for -// equality. -// -// When comparing a struct, if a field has the tag `deep:"-"` then it will be -// ignored. -func Equal(a, b interface{}) []string { - aVal := reflect.ValueOf(a) - bVal := reflect.ValueOf(b) - c := &cmp{ - diff: []string{}, - buff: []string{}, - floatFormat: fmt.Sprintf("%%.%df", FloatPrecision), - } - if a == nil && b == nil { - return nil - } else if a == nil && b != nil { - c.saveDiff("", b) - } else if a != nil && b == nil { - c.saveDiff(a, "") - } - if len(c.diff) > 0 { - return c.diff - } - - c.equals(aVal, bVal, 0) - if len(c.diff) > 0 { - return c.diff // diffs - } - return nil // no diffs -} - -func (c *cmp) equals(a, b reflect.Value, level int) { - if MaxDepth > 0 && level > MaxDepth { - logError(ErrMaxRecursion) - return - } - - // Check if one value is nil, e.g. T{x: *X} and T.x is nil - if !a.IsValid() || !b.IsValid() { - if a.IsValid() && !b.IsValid() { - c.saveDiff(a.Type(), "") - } else if !a.IsValid() && b.IsValid() { - c.saveDiff("", b.Type()) - } - return - } - - // If different types, they can't be equal - aType := a.Type() - bType := b.Type() - if aType != bType { - c.saveDiff(aType, bType) - logError(ErrTypeMismatch) - return - } - - // Primitive https://golang.org/pkg/reflect/#Kind - aKind := a.Kind() - bKind := b.Kind() - - // Do a and b have underlying elements? Yes if they're ptr or interface. - aElem := aKind == reflect.Ptr || aKind == reflect.Interface - bElem := bKind == reflect.Ptr || bKind == reflect.Interface - - // If both types implement the error interface, compare the error strings. - // This must be done before dereferencing because the interface is on a - // pointer receiver. Re https://github.com/go-test/deep/issues/31, a/b might - // be primitive kinds; see TestErrorPrimitiveKind. - if aType.Implements(errorType) && bType.Implements(errorType) { - if (!aElem || !a.IsNil()) && (!bElem || !b.IsNil()) { - aString := a.MethodByName("Error").Call(nil)[0].String() - bString := b.MethodByName("Error").Call(nil)[0].String() - if aString != bString { - c.saveDiff(aString, bString) - return - } - } - } - - // Dereference pointers and interface{} - if aElem || bElem { - if aElem { - a = a.Elem() - } - if bElem { - b = b.Elem() - } - c.equals(a, b, level+1) - return - } - - switch aKind { - - ///////////////////////////////////////////////////////////////////// - // Iterable kinds - ///////////////////////////////////////////////////////////////////// - - case reflect.Struct: - /* - The variables are structs like: - type T struct { - FirstName string - LastName string - } - Type = .T, Kind = reflect.Struct - - Iterate through the fields (FirstName, LastName), recurse into their values. - */ - - // Types with an Equal() method, like time.Time, only if struct field - // is exported (CanInterface) - if eqFunc := a.MethodByName("Equal"); eqFunc.IsValid() && eqFunc.CanInterface() { - // Handle https://github.com/go-test/deep/issues/15: - // Don't call T.Equal if the method is from an embedded struct, like: - // type Foo struct { time.Time } - // First, we'll encounter Equal(Ttime, time.Time) but if we pass b - // as the 2nd arg we'll panic: "Call using pkg.Foo as type time.Time" - // As far as I can tell, there's no way to see that the method is from - // time.Time not Foo. So we check the type of the 1st (0) arg and skip - // unless it's b type. Later, we'll encounter the time.Time anonymous/ - // embedded field and then we'll have Equal(time.Time, time.Time). - funcType := eqFunc.Type() - if funcType.NumIn() == 1 && funcType.In(0) == bType { - retVals := eqFunc.Call([]reflect.Value{b}) - if !retVals[0].Bool() { - c.saveDiff(a, b) - } - return - } - } - - for i := 0; i < a.NumField(); i++ { - if aType.Field(i).PkgPath != "" && !CompareUnexportedFields { - continue // skip unexported field, e.g. s in type T struct {s string} - } - - if aType.Field(i).Tag.Get("deep") == "-" { - continue // field wants to be ignored - } - - c.push(aType.Field(i).Name) // push field name to buff - - // Get the Value for each field, e.g. FirstName has Type = string, - // Kind = reflect.String. - af := a.Field(i) - bf := b.Field(i) - - // Recurse to compare the field values - c.equals(af, bf, level+1) - - c.pop() // pop field name from buff - - if len(c.diff) >= MaxDiff { - break - } - } - case reflect.Map: - /* - The variables are maps like: - map[string]int{ - "foo": 1, - "bar": 2, - } - Type = map[string]int, Kind = reflect.Map - - Or: - type T map[string]int{} - Type = .T, Kind = reflect.Map - - Iterate through the map keys (foo, bar), recurse into their values. - */ - - if a.IsNil() || b.IsNil() { - if a.IsNil() && !b.IsNil() { - c.saveDiff("", b) - } else if !a.IsNil() && b.IsNil() { - c.saveDiff(a, "") - } - return - } - - if a.Pointer() == b.Pointer() { - return - } - - for _, key := range a.MapKeys() { - c.push(fmt.Sprintf("map[%s]", key)) - - aVal := a.MapIndex(key) - bVal := b.MapIndex(key) - if bVal.IsValid() { - c.equals(aVal, bVal, level+1) - } else { - c.saveDiff(aVal, "") - } - - c.pop() - - if len(c.diff) >= MaxDiff { - return - } - } - - for _, key := range b.MapKeys() { - if aVal := a.MapIndex(key); aVal.IsValid() { - continue - } - - c.push(fmt.Sprintf("map[%s]", key)) - c.saveDiff("", b.MapIndex(key)) - c.pop() - if len(c.diff) >= MaxDiff { - return - } - } - case reflect.Array: - n := a.Len() - for i := 0; i < n; i++ { - c.push(fmt.Sprintf("array[%d]", i)) - c.equals(a.Index(i), b.Index(i), level+1) - c.pop() - if len(c.diff) >= MaxDiff { - break - } - } - case reflect.Slice: - if a.IsNil() || b.IsNil() { - if a.IsNil() && !b.IsNil() { - c.saveDiff("", b) - } else if !a.IsNil() && b.IsNil() { - c.saveDiff(a, "") - } - return - } - - aLen := a.Len() - bLen := b.Len() - - if a.Pointer() == b.Pointer() && aLen == bLen { - return - } - - n := aLen - if bLen > aLen { - n = bLen - } - for i := 0; i < n; i++ { - c.push(fmt.Sprintf("slice[%d]", i)) - if i < aLen && i < bLen { - c.equals(a.Index(i), b.Index(i), level+1) - } else if i < aLen { - c.saveDiff(a.Index(i), "") - } else { - c.saveDiff("", b.Index(i)) - } - c.pop() - if len(c.diff) >= MaxDiff { - break - } - } - - ///////////////////////////////////////////////////////////////////// - // Primitive kinds - ///////////////////////////////////////////////////////////////////// - - case reflect.Float32, reflect.Float64: - // Avoid 0.04147685731961082 != 0.041476857319611 - // 6 decimal places is close enough - aval := fmt.Sprintf(c.floatFormat, a.Float()) - bval := fmt.Sprintf(c.floatFormat, b.Float()) - if aval != bval { - c.saveDiff(a.Float(), b.Float()) - } - case reflect.Bool: - if a.Bool() != b.Bool() { - c.saveDiff(a.Bool(), b.Bool()) - } - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - if a.Int() != b.Int() { - c.saveDiff(a.Int(), b.Int()) - } - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - if a.Uint() != b.Uint() { - c.saveDiff(a.Uint(), b.Uint()) - } - case reflect.String: - if a.String() != b.String() { - c.saveDiff(a.String(), b.String()) - } - - default: - logError(ErrNotHandled) - } -} - -func (c *cmp) push(name string) { - c.buff = append(c.buff, name) -} - -func (c *cmp) pop() { - if len(c.buff) > 0 { - c.buff = c.buff[0 : len(c.buff)-1] - } -} - -func (c *cmp) saveDiff(aval, bval interface{}) { - if len(c.buff) > 0 { - varName := strings.Join(c.buff, ".") - c.diff = append(c.diff, fmt.Sprintf("%s: %v != %v", varName, aval, bval)) - } else { - c.diff = append(c.diff, fmt.Sprintf("%v != %v", aval, bval)) - } -} - -func logError(err error) { - if LogErrors { - log.Println(err) - } -} diff --git a/vendor/github.com/go-test/deep/go.mod b/vendor/github.com/go-test/deep/go.mod deleted file mode 100644 index 6e8ca1d2b..000000000 --- a/vendor/github.com/go-test/deep/go.mod +++ /dev/null @@ -1 +0,0 @@ -module github.com/go-test/deep diff --git a/vendor/github.com/google/go-cmp/LICENSE b/vendor/github.com/google/go-cmp/LICENSE deleted file mode 100644 index 32017f8fa..000000000 --- a/vendor/github.com/google/go-cmp/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -Copyright (c) 2017 The Go Authors. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/google/go-cmp/cmp/compare.go b/vendor/github.com/google/go-cmp/cmp/compare.go deleted file mode 100644 index 2a5446762..000000000 --- a/vendor/github.com/google/go-cmp/cmp/compare.go +++ /dev/null @@ -1,665 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// Package cmp determines equality of values. -// -// This package is intended to be a more powerful and safer alternative to -// reflect.DeepEqual for comparing whether two values are semantically equal. -// It is intended to only be used in tests, as performance is not a goal and -// it may panic if it cannot compare the values. Its propensity towards -// panicking means that its unsuitable for production environments where a -// spurious panic may be fatal. -// -// The primary features of cmp are: -// -// • When the default behavior of equality does not suit the needs of the test, -// custom equality functions can override the equality operation. -// For example, an equality function may report floats as equal so long as they -// are within some tolerance of each other. -// -// • Types that have an Equal method may use that method to determine equality. -// This allows package authors to determine the equality operation for the types -// that they define. -// -// • If no custom equality functions are used and no Equal method is defined, -// equality is determined by recursively comparing the primitive kinds on both -// values, much like reflect.DeepEqual. Unlike reflect.DeepEqual, unexported -// fields are not compared by default; they result in panics unless suppressed -// by using an Ignore option (see cmpopts.IgnoreUnexported) or explicitly -// compared using the Exporter option. -package cmp - -import ( - "fmt" - "reflect" - "strings" - - "github.com/google/go-cmp/cmp/internal/diff" - "github.com/google/go-cmp/cmp/internal/function" - "github.com/google/go-cmp/cmp/internal/value" -) - -// Equal reports whether x and y are equal by recursively applying the -// following rules in the given order to x and y and all of their sub-values: -// -// • Let S be the set of all Ignore, Transformer, and Comparer options that -// remain after applying all path filters, value filters, and type filters. -// If at least one Ignore exists in S, then the comparison is ignored. -// If the number of Transformer and Comparer options in S is greater than one, -// then Equal panics because it is ambiguous which option to use. -// If S contains a single Transformer, then use that to transform the current -// values and recursively call Equal on the output values. -// If S contains a single Comparer, then use that to compare the current values. -// Otherwise, evaluation proceeds to the next rule. -// -// • If the values have an Equal method of the form "(T) Equal(T) bool" or -// "(T) Equal(I) bool" where T is assignable to I, then use the result of -// x.Equal(y) even if x or y is nil. Otherwise, no such method exists and -// evaluation proceeds to the next rule. -// -// • Lastly, try to compare x and y based on their basic kinds. -// Simple kinds like booleans, integers, floats, complex numbers, strings, and -// channels are compared using the equivalent of the == operator in Go. -// Functions are only equal if they are both nil, otherwise they are unequal. -// -// Structs are equal if recursively calling Equal on all fields report equal. -// If a struct contains unexported fields, Equal panics unless an Ignore option -// (e.g., cmpopts.IgnoreUnexported) ignores that field or the Exporter option -// explicitly permits comparing the unexported field. -// -// Slices are equal if they are both nil or both non-nil, where recursively -// calling Equal on all non-ignored slice or array elements report equal. -// Empty non-nil slices and nil slices are not equal; to equate empty slices, -// consider using cmpopts.EquateEmpty. -// -// Maps are equal if they are both nil or both non-nil, where recursively -// calling Equal on all non-ignored map entries report equal. -// Map keys are equal according to the == operator. -// To use custom comparisons for map keys, consider using cmpopts.SortMaps. -// Empty non-nil maps and nil maps are not equal; to equate empty maps, -// consider using cmpopts.EquateEmpty. -// -// Pointers and interfaces are equal if they are both nil or both non-nil, -// where they have the same underlying concrete type and recursively -// calling Equal on the underlying values reports equal. -// -// Before recursing into a pointer, slice element, or map, the current path -// is checked to detect whether the address has already been visited. -// If there is a cycle, then the pointed at values are considered equal -// only if both addresses were previously visited in the same path step. -func Equal(x, y interface{}, opts ...Option) bool { - s := newState(opts) - s.compareAny(rootStep(x, y)) - return s.result.Equal() -} - -// Diff returns a human-readable report of the differences between two values: -// y - x. It returns an empty string if and only if Equal returns true for the -// same input values and options. -// -// The output is displayed as a literal in pseudo-Go syntax. -// At the start of each line, a "-" prefix indicates an element removed from x, -// a "+" prefix to indicates an element added from y, and the lack of a prefix -// indicates an element common to both x and y. If possible, the output -// uses fmt.Stringer.String or error.Error methods to produce more humanly -// readable outputs. In such cases, the string is prefixed with either an -// 's' or 'e' character, respectively, to indicate that the method was called. -// -// Do not depend on this output being stable. If you need the ability to -// programmatically interpret the difference, consider using a custom Reporter. -func Diff(x, y interface{}, opts ...Option) string { - s := newState(opts) - - // Optimization: If there are no other reporters, we can optimize for the - // common case where the result is equal (and thus no reported difference). - // This avoids the expensive construction of a difference tree. - if len(s.reporters) == 0 { - s.compareAny(rootStep(x, y)) - if s.result.Equal() { - return "" - } - s.result = diff.Result{} // Reset results - } - - r := new(defaultReporter) - s.reporters = append(s.reporters, reporter{r}) - s.compareAny(rootStep(x, y)) - d := r.String() - if (d == "") != s.result.Equal() { - panic("inconsistent difference and equality results") - } - return d -} - -// rootStep constructs the first path step. If x and y have differing types, -// then they are stored within an empty interface type. -func rootStep(x, y interface{}) PathStep { - vx := reflect.ValueOf(x) - vy := reflect.ValueOf(y) - - // If the inputs are different types, auto-wrap them in an empty interface - // so that they have the same parent type. - var t reflect.Type - if !vx.IsValid() || !vy.IsValid() || vx.Type() != vy.Type() { - t = reflect.TypeOf((*interface{})(nil)).Elem() - if vx.IsValid() { - vvx := reflect.New(t).Elem() - vvx.Set(vx) - vx = vvx - } - if vy.IsValid() { - vvy := reflect.New(t).Elem() - vvy.Set(vy) - vy = vvy - } - } else { - t = vx.Type() - } - - return &pathStep{t, vx, vy} -} - -type state struct { - // These fields represent the "comparison state". - // Calling statelessCompare must not result in observable changes to these. - result diff.Result // The current result of comparison - curPath Path // The current path in the value tree - curPtrs pointerPath // The current set of visited pointers - reporters []reporter // Optional reporters - - // recChecker checks for infinite cycles applying the same set of - // transformers upon the output of itself. - recChecker recChecker - - // dynChecker triggers pseudo-random checks for option correctness. - // It is safe for statelessCompare to mutate this value. - dynChecker dynChecker - - // These fields, once set by processOption, will not change. - exporters []exporter // List of exporters for structs with unexported fields - opts Options // List of all fundamental and filter options -} - -func newState(opts []Option) *state { - // Always ensure a validator option exists to validate the inputs. - s := &state{opts: Options{validator{}}} - s.curPtrs.Init() - s.processOption(Options(opts)) - return s -} - -func (s *state) processOption(opt Option) { - switch opt := opt.(type) { - case nil: - case Options: - for _, o := range opt { - s.processOption(o) - } - case coreOption: - type filtered interface { - isFiltered() bool - } - if fopt, ok := opt.(filtered); ok && !fopt.isFiltered() { - panic(fmt.Sprintf("cannot use an unfiltered option: %v", opt)) - } - s.opts = append(s.opts, opt) - case exporter: - s.exporters = append(s.exporters, opt) - case reporter: - s.reporters = append(s.reporters, opt) - default: - panic(fmt.Sprintf("unknown option %T", opt)) - } -} - -// statelessCompare compares two values and returns the result. -// This function is stateless in that it does not alter the current result, -// or output to any registered reporters. -func (s *state) statelessCompare(step PathStep) diff.Result { - // We do not save and restore curPath and curPtrs because all of the - // compareX methods should properly push and pop from them. - // It is an implementation bug if the contents of the paths differ from - // when calling this function to when returning from it. - - oldResult, oldReporters := s.result, s.reporters - s.result = diff.Result{} // Reset result - s.reporters = nil // Remove reporters to avoid spurious printouts - s.compareAny(step) - res := s.result - s.result, s.reporters = oldResult, oldReporters - return res -} - -func (s *state) compareAny(step PathStep) { - // Update the path stack. - s.curPath.push(step) - defer s.curPath.pop() - for _, r := range s.reporters { - r.PushStep(step) - defer r.PopStep() - } - s.recChecker.Check(s.curPath) - - // Cycle-detection for slice elements (see NOTE in compareSlice). - t := step.Type() - vx, vy := step.Values() - if si, ok := step.(SliceIndex); ok && si.isSlice && vx.IsValid() && vy.IsValid() { - px, py := vx.Addr(), vy.Addr() - if eq, visited := s.curPtrs.Push(px, py); visited { - s.report(eq, reportByCycle) - return - } - defer s.curPtrs.Pop(px, py) - } - - // Rule 1: Check whether an option applies on this node in the value tree. - if s.tryOptions(t, vx, vy) { - return - } - - // Rule 2: Check whether the type has a valid Equal method. - if s.tryMethod(t, vx, vy) { - return - } - - // Rule 3: Compare based on the underlying kind. - switch t.Kind() { - case reflect.Bool: - s.report(vx.Bool() == vy.Bool(), 0) - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - s.report(vx.Int() == vy.Int(), 0) - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: - s.report(vx.Uint() == vy.Uint(), 0) - case reflect.Float32, reflect.Float64: - s.report(vx.Float() == vy.Float(), 0) - case reflect.Complex64, reflect.Complex128: - s.report(vx.Complex() == vy.Complex(), 0) - case reflect.String: - s.report(vx.String() == vy.String(), 0) - case reflect.Chan, reflect.UnsafePointer: - s.report(vx.Pointer() == vy.Pointer(), 0) - case reflect.Func: - s.report(vx.IsNil() && vy.IsNil(), 0) - case reflect.Struct: - s.compareStruct(t, vx, vy) - case reflect.Slice, reflect.Array: - s.compareSlice(t, vx, vy) - case reflect.Map: - s.compareMap(t, vx, vy) - case reflect.Ptr: - s.comparePtr(t, vx, vy) - case reflect.Interface: - s.compareInterface(t, vx, vy) - default: - panic(fmt.Sprintf("%v kind not handled", t.Kind())) - } -} - -func (s *state) tryOptions(t reflect.Type, vx, vy reflect.Value) bool { - // Evaluate all filters and apply the remaining options. - if opt := s.opts.filter(s, t, vx, vy); opt != nil { - opt.apply(s, vx, vy) - return true - } - return false -} - -func (s *state) tryMethod(t reflect.Type, vx, vy reflect.Value) bool { - // Check if this type even has an Equal method. - m, ok := t.MethodByName("Equal") - if !ok || !function.IsType(m.Type, function.EqualAssignable) { - return false - } - - eq := s.callTTBFunc(m.Func, vx, vy) - s.report(eq, reportByMethod) - return true -} - -func (s *state) callTRFunc(f, v reflect.Value, step Transform) reflect.Value { - if !s.dynChecker.Next() { - return f.Call([]reflect.Value{v})[0] - } - - // Run the function twice and ensure that we get the same results back. - // We run in goroutines so that the race detector (if enabled) can detect - // unsafe mutations to the input. - c := make(chan reflect.Value) - go detectRaces(c, f, v) - got := <-c - want := f.Call([]reflect.Value{v})[0] - if step.vx, step.vy = got, want; !s.statelessCompare(step).Equal() { - // To avoid false-positives with non-reflexive equality operations, - // we sanity check whether a value is equal to itself. - if step.vx, step.vy = want, want; !s.statelessCompare(step).Equal() { - return want - } - panic(fmt.Sprintf("non-deterministic function detected: %s", function.NameOf(f))) - } - return want -} - -func (s *state) callTTBFunc(f, x, y reflect.Value) bool { - if !s.dynChecker.Next() { - return f.Call([]reflect.Value{x, y})[0].Bool() - } - - // Swapping the input arguments is sufficient to check that - // f is symmetric and deterministic. - // We run in goroutines so that the race detector (if enabled) can detect - // unsafe mutations to the input. - c := make(chan reflect.Value) - go detectRaces(c, f, y, x) - got := <-c - want := f.Call([]reflect.Value{x, y})[0].Bool() - if !got.IsValid() || got.Bool() != want { - panic(fmt.Sprintf("non-deterministic or non-symmetric function detected: %s", function.NameOf(f))) - } - return want -} - -func detectRaces(c chan<- reflect.Value, f reflect.Value, vs ...reflect.Value) { - var ret reflect.Value - defer func() { - recover() // Ignore panics, let the other call to f panic instead - c <- ret - }() - ret = f.Call(vs)[0] -} - -func (s *state) compareStruct(t reflect.Type, vx, vy reflect.Value) { - var addr bool - var vax, vay reflect.Value // Addressable versions of vx and vy - - var mayForce, mayForceInit bool - step := StructField{&structField{}} - for i := 0; i < t.NumField(); i++ { - step.typ = t.Field(i).Type - step.vx = vx.Field(i) - step.vy = vy.Field(i) - step.name = t.Field(i).Name - step.idx = i - step.unexported = !isExported(step.name) - if step.unexported { - if step.name == "_" { - continue - } - // Defer checking of unexported fields until later to give an - // Ignore a chance to ignore the field. - if !vax.IsValid() || !vay.IsValid() { - // For retrieveUnexportedField to work, the parent struct must - // be addressable. Create a new copy of the values if - // necessary to make them addressable. - addr = vx.CanAddr() || vy.CanAddr() - vax = makeAddressable(vx) - vay = makeAddressable(vy) - } - if !mayForceInit { - for _, xf := range s.exporters { - mayForce = mayForce || xf(t) - } - mayForceInit = true - } - step.mayForce = mayForce - step.paddr = addr - step.pvx = vax - step.pvy = vay - step.field = t.Field(i) - } - s.compareAny(step) - } -} - -func (s *state) compareSlice(t reflect.Type, vx, vy reflect.Value) { - isSlice := t.Kind() == reflect.Slice - if isSlice && (vx.IsNil() || vy.IsNil()) { - s.report(vx.IsNil() && vy.IsNil(), 0) - return - } - - // NOTE: It is incorrect to call curPtrs.Push on the slice header pointer - // since slices represents a list of pointers, rather than a single pointer. - // The pointer checking logic must be handled on a per-element basis - // in compareAny. - // - // A slice header (see reflect.SliceHeader) in Go is a tuple of a starting - // pointer P, a length N, and a capacity C. Supposing each slice element has - // a memory size of M, then the slice is equivalent to the list of pointers: - // [P+i*M for i in range(N)] - // - // For example, v[:0] and v[:1] are slices with the same starting pointer, - // but they are clearly different values. Using the slice pointer alone - // violates the assumption that equal pointers implies equal values. - - step := SliceIndex{&sliceIndex{pathStep: pathStep{typ: t.Elem()}, isSlice: isSlice}} - withIndexes := func(ix, iy int) SliceIndex { - if ix >= 0 { - step.vx, step.xkey = vx.Index(ix), ix - } else { - step.vx, step.xkey = reflect.Value{}, -1 - } - if iy >= 0 { - step.vy, step.ykey = vy.Index(iy), iy - } else { - step.vy, step.ykey = reflect.Value{}, -1 - } - return step - } - - // Ignore options are able to ignore missing elements in a slice. - // However, detecting these reliably requires an optimal differencing - // algorithm, for which diff.Difference is not. - // - // Instead, we first iterate through both slices to detect which elements - // would be ignored if standing alone. The index of non-discarded elements - // are stored in a separate slice, which diffing is then performed on. - var indexesX, indexesY []int - var ignoredX, ignoredY []bool - for ix := 0; ix < vx.Len(); ix++ { - ignored := s.statelessCompare(withIndexes(ix, -1)).NumDiff == 0 - if !ignored { - indexesX = append(indexesX, ix) - } - ignoredX = append(ignoredX, ignored) - } - for iy := 0; iy < vy.Len(); iy++ { - ignored := s.statelessCompare(withIndexes(-1, iy)).NumDiff == 0 - if !ignored { - indexesY = append(indexesY, iy) - } - ignoredY = append(ignoredY, ignored) - } - - // Compute an edit-script for slices vx and vy (excluding ignored elements). - edits := diff.Difference(len(indexesX), len(indexesY), func(ix, iy int) diff.Result { - return s.statelessCompare(withIndexes(indexesX[ix], indexesY[iy])) - }) - - // Replay the ignore-scripts and the edit-script. - var ix, iy int - for ix < vx.Len() || iy < vy.Len() { - var e diff.EditType - switch { - case ix < len(ignoredX) && ignoredX[ix]: - e = diff.UniqueX - case iy < len(ignoredY) && ignoredY[iy]: - e = diff.UniqueY - default: - e, edits = edits[0], edits[1:] - } - switch e { - case diff.UniqueX: - s.compareAny(withIndexes(ix, -1)) - ix++ - case diff.UniqueY: - s.compareAny(withIndexes(-1, iy)) - iy++ - default: - s.compareAny(withIndexes(ix, iy)) - ix++ - iy++ - } - } -} - -func (s *state) compareMap(t reflect.Type, vx, vy reflect.Value) { - if vx.IsNil() || vy.IsNil() { - s.report(vx.IsNil() && vy.IsNil(), 0) - return - } - - // Cycle-detection for maps. - if eq, visited := s.curPtrs.Push(vx, vy); visited { - s.report(eq, reportByCycle) - return - } - defer s.curPtrs.Pop(vx, vy) - - // We combine and sort the two map keys so that we can perform the - // comparisons in a deterministic order. - step := MapIndex{&mapIndex{pathStep: pathStep{typ: t.Elem()}}} - for _, k := range value.SortKeys(append(vx.MapKeys(), vy.MapKeys()...)) { - step.vx = vx.MapIndex(k) - step.vy = vy.MapIndex(k) - step.key = k - if !step.vx.IsValid() && !step.vy.IsValid() { - // It is possible for both vx and vy to be invalid if the - // key contained a NaN value in it. - // - // Even with the ability to retrieve NaN keys in Go 1.12, - // there still isn't a sensible way to compare the values since - // a NaN key may map to multiple unordered values. - // The most reasonable way to compare NaNs would be to compare the - // set of values. However, this is impossible to do efficiently - // since set equality is provably an O(n^2) operation given only - // an Equal function. If we had a Less function or Hash function, - // this could be done in O(n*log(n)) or O(n), respectively. - // - // Rather than adding complex logic to deal with NaNs, make it - // the user's responsibility to compare such obscure maps. - const help = "consider providing a Comparer to compare the map" - panic(fmt.Sprintf("%#v has map key with NaNs\n%s", s.curPath, help)) - } - s.compareAny(step) - } -} - -func (s *state) comparePtr(t reflect.Type, vx, vy reflect.Value) { - if vx.IsNil() || vy.IsNil() { - s.report(vx.IsNil() && vy.IsNil(), 0) - return - } - - // Cycle-detection for pointers. - if eq, visited := s.curPtrs.Push(vx, vy); visited { - s.report(eq, reportByCycle) - return - } - defer s.curPtrs.Pop(vx, vy) - - vx, vy = vx.Elem(), vy.Elem() - s.compareAny(Indirect{&indirect{pathStep{t.Elem(), vx, vy}}}) -} - -func (s *state) compareInterface(t reflect.Type, vx, vy reflect.Value) { - if vx.IsNil() || vy.IsNil() { - s.report(vx.IsNil() && vy.IsNil(), 0) - return - } - vx, vy = vx.Elem(), vy.Elem() - if vx.Type() != vy.Type() { - s.report(false, 0) - return - } - s.compareAny(TypeAssertion{&typeAssertion{pathStep{vx.Type(), vx, vy}}}) -} - -func (s *state) report(eq bool, rf resultFlags) { - if rf&reportByIgnore == 0 { - if eq { - s.result.NumSame++ - rf |= reportEqual - } else { - s.result.NumDiff++ - rf |= reportUnequal - } - } - for _, r := range s.reporters { - r.Report(Result{flags: rf}) - } -} - -// recChecker tracks the state needed to periodically perform checks that -// user provided transformers are not stuck in an infinitely recursive cycle. -type recChecker struct{ next int } - -// Check scans the Path for any recursive transformers and panics when any -// recursive transformers are detected. Note that the presence of a -// recursive Transformer does not necessarily imply an infinite cycle. -// As such, this check only activates after some minimal number of path steps. -func (rc *recChecker) Check(p Path) { - const minLen = 1 << 16 - if rc.next == 0 { - rc.next = minLen - } - if len(p) < rc.next { - return - } - rc.next <<= 1 - - // Check whether the same transformer has appeared at least twice. - var ss []string - m := map[Option]int{} - for _, ps := range p { - if t, ok := ps.(Transform); ok { - t := t.Option() - if m[t] == 1 { // Transformer was used exactly once before - tf := t.(*transformer).fnc.Type() - ss = append(ss, fmt.Sprintf("%v: %v => %v", t, tf.In(0), tf.Out(0))) - } - m[t]++ - } - } - if len(ss) > 0 { - const warning = "recursive set of Transformers detected" - const help = "consider using cmpopts.AcyclicTransformer" - set := strings.Join(ss, "\n\t") - panic(fmt.Sprintf("%s:\n\t%s\n%s", warning, set, help)) - } -} - -// dynChecker tracks the state needed to periodically perform checks that -// user provided functions are symmetric and deterministic. -// The zero value is safe for immediate use. -type dynChecker struct{ curr, next int } - -// Next increments the state and reports whether a check should be performed. -// -// Checks occur every Nth function call, where N is a triangular number: -// 0 1 3 6 10 15 21 28 36 45 55 66 78 91 105 120 136 153 171 190 ... -// See https://en.wikipedia.org/wiki/Triangular_number -// -// This sequence ensures that the cost of checks drops significantly as -// the number of functions calls grows larger. -func (dc *dynChecker) Next() bool { - ok := dc.curr == dc.next - if ok { - dc.curr = 0 - dc.next++ - } - dc.curr++ - return ok -} - -// makeAddressable returns a value that is always addressable. -// It returns the input verbatim if it is already addressable, -// otherwise it creates a new value and returns an addressable copy. -func makeAddressable(v reflect.Value) reflect.Value { - if v.CanAddr() { - return v - } - vc := reflect.New(v.Type()).Elem() - vc.Set(v) - return vc -} diff --git a/vendor/github.com/google/go-cmp/cmp/export_panic.go b/vendor/github.com/google/go-cmp/cmp/export_panic.go deleted file mode 100644 index ae851fe53..000000000 --- a/vendor/github.com/google/go-cmp/cmp/export_panic.go +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -//go:build purego -// +build purego - -package cmp - -import "reflect" - -const supportExporters = false - -func retrieveUnexportedField(reflect.Value, reflect.StructField, bool) reflect.Value { - panic("no support for forcibly accessing unexported fields") -} diff --git a/vendor/github.com/google/go-cmp/cmp/export_unsafe.go b/vendor/github.com/google/go-cmp/cmp/export_unsafe.go deleted file mode 100644 index e2c0f74e8..000000000 --- a/vendor/github.com/google/go-cmp/cmp/export_unsafe.go +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -//go:build !purego -// +build !purego - -package cmp - -import ( - "reflect" - "unsafe" -) - -const supportExporters = true - -// retrieveUnexportedField uses unsafe to forcibly retrieve any field from -// a struct such that the value has read-write permissions. -// -// The parent struct, v, must be addressable, while f must be a StructField -// describing the field to retrieve. If addr is false, -// then the returned value will be shallowed copied to be non-addressable. -func retrieveUnexportedField(v reflect.Value, f reflect.StructField, addr bool) reflect.Value { - ve := reflect.NewAt(f.Type, unsafe.Pointer(uintptr(unsafe.Pointer(v.UnsafeAddr()))+f.Offset)).Elem() - if !addr { - // A field is addressable if and only if the struct is addressable. - // If the original parent value was not addressable, shallow copy the - // value to make it non-addressable to avoid leaking an implementation - // detail of how forcibly exporting a field works. - if ve.Kind() == reflect.Interface && ve.IsNil() { - return reflect.Zero(f.Type) - } - return reflect.ValueOf(ve.Interface()).Convert(f.Type) - } - return ve -} diff --git a/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_disable.go b/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_disable.go deleted file mode 100644 index 36062a604..000000000 --- a/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_disable.go +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -//go:build !cmp_debug -// +build !cmp_debug - -package diff - -var debug debugger - -type debugger struct{} - -func (debugger) Begin(_, _ int, f EqualFunc, _, _ *EditScript) EqualFunc { - return f -} -func (debugger) Update() {} -func (debugger) Finish() {} diff --git a/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_enable.go b/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_enable.go deleted file mode 100644 index a3b97a1ad..000000000 --- a/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_enable.go +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -//go:build cmp_debug -// +build cmp_debug - -package diff - -import ( - "fmt" - "strings" - "sync" - "time" -) - -// The algorithm can be seen running in real-time by enabling debugging: -// go test -tags=cmp_debug -v -// -// Example output: -// === RUN TestDifference/#34 -// ┌───────────────────────────────┐ -// │ \ · · · · · · · · · · · · · · │ -// │ · # · · · · · · · · · · · · · │ -// │ · \ · · · · · · · · · · · · · │ -// │ · · \ · · · · · · · · · · · · │ -// │ · · · X # · · · · · · · · · · │ -// │ · · · # \ · · · · · · · · · · │ -// │ · · · · · # # · · · · · · · · │ -// │ · · · · · # \ · · · · · · · · │ -// │ · · · · · · · \ · · · · · · · │ -// │ · · · · · · · · \ · · · · · · │ -// │ · · · · · · · · · \ · · · · · │ -// │ · · · · · · · · · · \ · · # · │ -// │ · · · · · · · · · · · \ # # · │ -// │ · · · · · · · · · · · # # # · │ -// │ · · · · · · · · · · # # # # · │ -// │ · · · · · · · · · # # # # # · │ -// │ · · · · · · · · · · · · · · \ │ -// └───────────────────────────────┘ -// [.Y..M.XY......YXYXY.|] -// -// The grid represents the edit-graph where the horizontal axis represents -// list X and the vertical axis represents list Y. The start of the two lists -// is the top-left, while the ends are the bottom-right. The '·' represents -// an unexplored node in the graph. The '\' indicates that the two symbols -// from list X and Y are equal. The 'X' indicates that two symbols are similar -// (but not exactly equal) to each other. The '#' indicates that the two symbols -// are different (and not similar). The algorithm traverses this graph trying to -// make the paths starting in the top-left and the bottom-right connect. -// -// The series of '.', 'X', 'Y', and 'M' characters at the bottom represents -// the currently established path from the forward and reverse searches, -// separated by a '|' character. - -const ( - updateDelay = 100 * time.Millisecond - finishDelay = 500 * time.Millisecond - ansiTerminal = true // ANSI escape codes used to move terminal cursor -) - -var debug debugger - -type debugger struct { - sync.Mutex - p1, p2 EditScript - fwdPath, revPath *EditScript - grid []byte - lines int -} - -func (dbg *debugger) Begin(nx, ny int, f EqualFunc, p1, p2 *EditScript) EqualFunc { - dbg.Lock() - dbg.fwdPath, dbg.revPath = p1, p2 - top := "┌─" + strings.Repeat("──", nx) + "┐\n" - row := "│ " + strings.Repeat("· ", nx) + "│\n" - btm := "└─" + strings.Repeat("──", nx) + "┘\n" - dbg.grid = []byte(top + strings.Repeat(row, ny) + btm) - dbg.lines = strings.Count(dbg.String(), "\n") - fmt.Print(dbg) - - // Wrap the EqualFunc so that we can intercept each result. - return func(ix, iy int) (r Result) { - cell := dbg.grid[len(top)+iy*len(row):][len("│ ")+len("· ")*ix:][:len("·")] - for i := range cell { - cell[i] = 0 // Zero out the multiple bytes of UTF-8 middle-dot - } - switch r = f(ix, iy); { - case r.Equal(): - cell[0] = '\\' - case r.Similar(): - cell[0] = 'X' - default: - cell[0] = '#' - } - return - } -} - -func (dbg *debugger) Update() { - dbg.print(updateDelay) -} - -func (dbg *debugger) Finish() { - dbg.print(finishDelay) - dbg.Unlock() -} - -func (dbg *debugger) String() string { - dbg.p1, dbg.p2 = *dbg.fwdPath, dbg.p2[:0] - for i := len(*dbg.revPath) - 1; i >= 0; i-- { - dbg.p2 = append(dbg.p2, (*dbg.revPath)[i]) - } - return fmt.Sprintf("%s[%v|%v]\n\n", dbg.grid, dbg.p1, dbg.p2) -} - -func (dbg *debugger) print(d time.Duration) { - if ansiTerminal { - fmt.Printf("\x1b[%dA", dbg.lines) // Reset terminal cursor - } - fmt.Print(dbg) - time.Sleep(d) -} diff --git a/vendor/github.com/google/go-cmp/cmp/internal/diff/diff.go b/vendor/github.com/google/go-cmp/cmp/internal/diff/diff.go deleted file mode 100644 index bc196b16c..000000000 --- a/vendor/github.com/google/go-cmp/cmp/internal/diff/diff.go +++ /dev/null @@ -1,398 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// Package diff implements an algorithm for producing edit-scripts. -// The edit-script is a sequence of operations needed to transform one list -// of symbols into another (or vice-versa). The edits allowed are insertions, -// deletions, and modifications. The summation of all edits is called the -// Levenshtein distance as this problem is well-known in computer science. -// -// This package prioritizes performance over accuracy. That is, the run time -// is more important than obtaining a minimal Levenshtein distance. -package diff - -import ( - "math/rand" - "time" - - "github.com/google/go-cmp/cmp/internal/flags" -) - -// EditType represents a single operation within an edit-script. -type EditType uint8 - -const ( - // Identity indicates that a symbol pair is identical in both list X and Y. - Identity EditType = iota - // UniqueX indicates that a symbol only exists in X and not Y. - UniqueX - // UniqueY indicates that a symbol only exists in Y and not X. - UniqueY - // Modified indicates that a symbol pair is a modification of each other. - Modified -) - -// EditScript represents the series of differences between two lists. -type EditScript []EditType - -// String returns a human-readable string representing the edit-script where -// Identity, UniqueX, UniqueY, and Modified are represented by the -// '.', 'X', 'Y', and 'M' characters, respectively. -func (es EditScript) String() string { - b := make([]byte, len(es)) - for i, e := range es { - switch e { - case Identity: - b[i] = '.' - case UniqueX: - b[i] = 'X' - case UniqueY: - b[i] = 'Y' - case Modified: - b[i] = 'M' - default: - panic("invalid edit-type") - } - } - return string(b) -} - -// stats returns a histogram of the number of each type of edit operation. -func (es EditScript) stats() (s struct{ NI, NX, NY, NM int }) { - for _, e := range es { - switch e { - case Identity: - s.NI++ - case UniqueX: - s.NX++ - case UniqueY: - s.NY++ - case Modified: - s.NM++ - default: - panic("invalid edit-type") - } - } - return -} - -// Dist is the Levenshtein distance and is guaranteed to be 0 if and only if -// lists X and Y are equal. -func (es EditScript) Dist() int { return len(es) - es.stats().NI } - -// LenX is the length of the X list. -func (es EditScript) LenX() int { return len(es) - es.stats().NY } - -// LenY is the length of the Y list. -func (es EditScript) LenY() int { return len(es) - es.stats().NX } - -// EqualFunc reports whether the symbols at indexes ix and iy are equal. -// When called by Difference, the index is guaranteed to be within nx and ny. -type EqualFunc func(ix int, iy int) Result - -// Result is the result of comparison. -// NumSame is the number of sub-elements that are equal. -// NumDiff is the number of sub-elements that are not equal. -type Result struct{ NumSame, NumDiff int } - -// BoolResult returns a Result that is either Equal or not Equal. -func BoolResult(b bool) Result { - if b { - return Result{NumSame: 1} // Equal, Similar - } else { - return Result{NumDiff: 2} // Not Equal, not Similar - } -} - -// Equal indicates whether the symbols are equal. Two symbols are equal -// if and only if NumDiff == 0. If Equal, then they are also Similar. -func (r Result) Equal() bool { return r.NumDiff == 0 } - -// Similar indicates whether two symbols are similar and may be represented -// by using the Modified type. As a special case, we consider binary comparisons -// (i.e., those that return Result{1, 0} or Result{0, 1}) to be similar. -// -// The exact ratio of NumSame to NumDiff to determine similarity may change. -func (r Result) Similar() bool { - // Use NumSame+1 to offset NumSame so that binary comparisons are similar. - return r.NumSame+1 >= r.NumDiff -} - -var randBool = rand.New(rand.NewSource(time.Now().Unix())).Intn(2) == 0 - -// Difference reports whether two lists of lengths nx and ny are equal -// given the definition of equality provided as f. -// -// This function returns an edit-script, which is a sequence of operations -// needed to convert one list into the other. The following invariants for -// the edit-script are maintained: -// • eq == (es.Dist()==0) -// • nx == es.LenX() -// • ny == es.LenY() -// -// This algorithm is not guaranteed to be an optimal solution (i.e., one that -// produces an edit-script with a minimal Levenshtein distance). This algorithm -// favors performance over optimality. The exact output is not guaranteed to -// be stable and may change over time. -func Difference(nx, ny int, f EqualFunc) (es EditScript) { - // This algorithm is based on traversing what is known as an "edit-graph". - // See Figure 1 from "An O(ND) Difference Algorithm and Its Variations" - // by Eugene W. Myers. Since D can be as large as N itself, this is - // effectively O(N^2). Unlike the algorithm from that paper, we are not - // interested in the optimal path, but at least some "decent" path. - // - // For example, let X and Y be lists of symbols: - // X = [A B C A B B A] - // Y = [C B A B A C] - // - // The edit-graph can be drawn as the following: - // A B C A B B A - // ┌─────────────┐ - // C │_|_|\|_|_|_|_│ 0 - // B │_|\|_|_|\|\|_│ 1 - // A │\|_|_|\|_|_|\│ 2 - // B │_|\|_|_|\|\|_│ 3 - // A │\|_|_|\|_|_|\│ 4 - // C │ | |\| | | | │ 5 - // └─────────────┘ 6 - // 0 1 2 3 4 5 6 7 - // - // List X is written along the horizontal axis, while list Y is written - // along the vertical axis. At any point on this grid, if the symbol in - // list X matches the corresponding symbol in list Y, then a '\' is drawn. - // The goal of any minimal edit-script algorithm is to find a path from the - // top-left corner to the bottom-right corner, while traveling through the - // fewest horizontal or vertical edges. - // A horizontal edge is equivalent to inserting a symbol from list X. - // A vertical edge is equivalent to inserting a symbol from list Y. - // A diagonal edge is equivalent to a matching symbol between both X and Y. - - // Invariants: - // • 0 ≤ fwdPath.X ≤ (fwdFrontier.X, revFrontier.X) ≤ revPath.X ≤ nx - // • 0 ≤ fwdPath.Y ≤ (fwdFrontier.Y, revFrontier.Y) ≤ revPath.Y ≤ ny - // - // In general: - // • fwdFrontier.X < revFrontier.X - // • fwdFrontier.Y < revFrontier.Y - // Unless, it is time for the algorithm to terminate. - fwdPath := path{+1, point{0, 0}, make(EditScript, 0, (nx+ny)/2)} - revPath := path{-1, point{nx, ny}, make(EditScript, 0)} - fwdFrontier := fwdPath.point // Forward search frontier - revFrontier := revPath.point // Reverse search frontier - - // Search budget bounds the cost of searching for better paths. - // The longest sequence of non-matching symbols that can be tolerated is - // approximately the square-root of the search budget. - searchBudget := 4 * (nx + ny) // O(n) - - // Running the tests with the "cmp_debug" build tag prints a visualization - // of the algorithm running in real-time. This is educational for - // understanding how the algorithm works. See debug_enable.go. - f = debug.Begin(nx, ny, f, &fwdPath.es, &revPath.es) - - // The algorithm below is a greedy, meet-in-the-middle algorithm for - // computing sub-optimal edit-scripts between two lists. - // - // The algorithm is approximately as follows: - // • Searching for differences switches back-and-forth between - // a search that starts at the beginning (the top-left corner), and - // a search that starts at the end (the bottom-right corner). The goal of - // the search is connect with the search from the opposite corner. - // • As we search, we build a path in a greedy manner, where the first - // match seen is added to the path (this is sub-optimal, but provides a - // decent result in practice). When matches are found, we try the next pair - // of symbols in the lists and follow all matches as far as possible. - // • When searching for matches, we search along a diagonal going through - // through the "frontier" point. If no matches are found, we advance the - // frontier towards the opposite corner. - // • This algorithm terminates when either the X coordinates or the - // Y coordinates of the forward and reverse frontier points ever intersect. - - // This algorithm is correct even if searching only in the forward direction - // or in the reverse direction. We do both because it is commonly observed - // that two lists commonly differ because elements were added to the front - // or end of the other list. - // - // Non-deterministically start with either the forward or reverse direction - // to introduce some deliberate instability so that we have the flexibility - // to change this algorithm in the future. - if flags.Deterministic || randBool { - goto forwardSearch - } else { - goto reverseSearch - } - -forwardSearch: - { - // Forward search from the beginning. - if fwdFrontier.X >= revFrontier.X || fwdFrontier.Y >= revFrontier.Y || searchBudget == 0 { - goto finishSearch - } - for stop1, stop2, i := false, false, 0; !(stop1 && stop2) && searchBudget > 0; i++ { - // Search in a diagonal pattern for a match. - z := zigzag(i) - p := point{fwdFrontier.X + z, fwdFrontier.Y - z} - switch { - case p.X >= revPath.X || p.Y < fwdPath.Y: - stop1 = true // Hit top-right corner - case p.Y >= revPath.Y || p.X < fwdPath.X: - stop2 = true // Hit bottom-left corner - case f(p.X, p.Y).Equal(): - // Match found, so connect the path to this point. - fwdPath.connect(p, f) - fwdPath.append(Identity) - // Follow sequence of matches as far as possible. - for fwdPath.X < revPath.X && fwdPath.Y < revPath.Y { - if !f(fwdPath.X, fwdPath.Y).Equal() { - break - } - fwdPath.append(Identity) - } - fwdFrontier = fwdPath.point - stop1, stop2 = true, true - default: - searchBudget-- // Match not found - } - debug.Update() - } - // Advance the frontier towards reverse point. - if revPath.X-fwdFrontier.X >= revPath.Y-fwdFrontier.Y { - fwdFrontier.X++ - } else { - fwdFrontier.Y++ - } - goto reverseSearch - } - -reverseSearch: - { - // Reverse search from the end. - if fwdFrontier.X >= revFrontier.X || fwdFrontier.Y >= revFrontier.Y || searchBudget == 0 { - goto finishSearch - } - for stop1, stop2, i := false, false, 0; !(stop1 && stop2) && searchBudget > 0; i++ { - // Search in a diagonal pattern for a match. - z := zigzag(i) - p := point{revFrontier.X - z, revFrontier.Y + z} - switch { - case fwdPath.X >= p.X || revPath.Y < p.Y: - stop1 = true // Hit bottom-left corner - case fwdPath.Y >= p.Y || revPath.X < p.X: - stop2 = true // Hit top-right corner - case f(p.X-1, p.Y-1).Equal(): - // Match found, so connect the path to this point. - revPath.connect(p, f) - revPath.append(Identity) - // Follow sequence of matches as far as possible. - for fwdPath.X < revPath.X && fwdPath.Y < revPath.Y { - if !f(revPath.X-1, revPath.Y-1).Equal() { - break - } - revPath.append(Identity) - } - revFrontier = revPath.point - stop1, stop2 = true, true - default: - searchBudget-- // Match not found - } - debug.Update() - } - // Advance the frontier towards forward point. - if revFrontier.X-fwdPath.X >= revFrontier.Y-fwdPath.Y { - revFrontier.X-- - } else { - revFrontier.Y-- - } - goto forwardSearch - } - -finishSearch: - // Join the forward and reverse paths and then append the reverse path. - fwdPath.connect(revPath.point, f) - for i := len(revPath.es) - 1; i >= 0; i-- { - t := revPath.es[i] - revPath.es = revPath.es[:i] - fwdPath.append(t) - } - debug.Finish() - return fwdPath.es -} - -type path struct { - dir int // +1 if forward, -1 if reverse - point // Leading point of the EditScript path - es EditScript -} - -// connect appends any necessary Identity, Modified, UniqueX, or UniqueY types -// to the edit-script to connect p.point to dst. -func (p *path) connect(dst point, f EqualFunc) { - if p.dir > 0 { - // Connect in forward direction. - for dst.X > p.X && dst.Y > p.Y { - switch r := f(p.X, p.Y); { - case r.Equal(): - p.append(Identity) - case r.Similar(): - p.append(Modified) - case dst.X-p.X >= dst.Y-p.Y: - p.append(UniqueX) - default: - p.append(UniqueY) - } - } - for dst.X > p.X { - p.append(UniqueX) - } - for dst.Y > p.Y { - p.append(UniqueY) - } - } else { - // Connect in reverse direction. - for p.X > dst.X && p.Y > dst.Y { - switch r := f(p.X-1, p.Y-1); { - case r.Equal(): - p.append(Identity) - case r.Similar(): - p.append(Modified) - case p.Y-dst.Y >= p.X-dst.X: - p.append(UniqueY) - default: - p.append(UniqueX) - } - } - for p.X > dst.X { - p.append(UniqueX) - } - for p.Y > dst.Y { - p.append(UniqueY) - } - } -} - -func (p *path) append(t EditType) { - p.es = append(p.es, t) - switch t { - case Identity, Modified: - p.add(p.dir, p.dir) - case UniqueX: - p.add(p.dir, 0) - case UniqueY: - p.add(0, p.dir) - } - debug.Update() -} - -type point struct{ X, Y int } - -func (p *point) add(dx, dy int) { p.X += dx; p.Y += dy } - -// zigzag maps a consecutive sequence of integers to a zig-zag sequence. -// [0 1 2 3 4 5 ...] => [0 -1 +1 -2 +2 ...] -func zigzag(x int) int { - if x&1 != 0 { - x = ^x - } - return x >> 1 -} diff --git a/vendor/github.com/google/go-cmp/cmp/internal/flags/flags.go b/vendor/github.com/google/go-cmp/cmp/internal/flags/flags.go deleted file mode 100644 index d8e459c9b..000000000 --- a/vendor/github.com/google/go-cmp/cmp/internal/flags/flags.go +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright 2019, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package flags - -// Deterministic controls whether the output of Diff should be deterministic. -// This is only used for testing. -var Deterministic bool diff --git a/vendor/github.com/google/go-cmp/cmp/internal/function/func.go b/vendor/github.com/google/go-cmp/cmp/internal/function/func.go deleted file mode 100644 index d127d4362..000000000 --- a/vendor/github.com/google/go-cmp/cmp/internal/function/func.go +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// Package function provides functionality for identifying function types. -package function - -import ( - "reflect" - "regexp" - "runtime" - "strings" -) - -type funcType int - -const ( - _ funcType = iota - - tbFunc // func(T) bool - ttbFunc // func(T, T) bool - trbFunc // func(T, R) bool - tibFunc // func(T, I) bool - trFunc // func(T) R - - Equal = ttbFunc // func(T, T) bool - EqualAssignable = tibFunc // func(T, I) bool; encapsulates func(T, T) bool - Transformer = trFunc // func(T) R - ValueFilter = ttbFunc // func(T, T) bool - Less = ttbFunc // func(T, T) bool - ValuePredicate = tbFunc // func(T) bool - KeyValuePredicate = trbFunc // func(T, R) bool -) - -var boolType = reflect.TypeOf(true) - -// IsType reports whether the reflect.Type is of the specified function type. -func IsType(t reflect.Type, ft funcType) bool { - if t == nil || t.Kind() != reflect.Func || t.IsVariadic() { - return false - } - ni, no := t.NumIn(), t.NumOut() - switch ft { - case tbFunc: // func(T) bool - if ni == 1 && no == 1 && t.Out(0) == boolType { - return true - } - case ttbFunc: // func(T, T) bool - if ni == 2 && no == 1 && t.In(0) == t.In(1) && t.Out(0) == boolType { - return true - } - case trbFunc: // func(T, R) bool - if ni == 2 && no == 1 && t.Out(0) == boolType { - return true - } - case tibFunc: // func(T, I) bool - if ni == 2 && no == 1 && t.In(0).AssignableTo(t.In(1)) && t.Out(0) == boolType { - return true - } - case trFunc: // func(T) R - if ni == 1 && no == 1 { - return true - } - } - return false -} - -var lastIdentRx = regexp.MustCompile(`[_\p{L}][_\p{L}\p{N}]*$`) - -// NameOf returns the name of the function value. -func NameOf(v reflect.Value) string { - fnc := runtime.FuncForPC(v.Pointer()) - if fnc == nil { - return "" - } - fullName := fnc.Name() // e.g., "long/path/name/mypkg.(*MyType).(long/path/name/mypkg.myMethod)-fm" - - // Method closures have a "-fm" suffix. - fullName = strings.TrimSuffix(fullName, "-fm") - - var name string - for len(fullName) > 0 { - inParen := strings.HasSuffix(fullName, ")") - fullName = strings.TrimSuffix(fullName, ")") - - s := lastIdentRx.FindString(fullName) - if s == "" { - break - } - name = s + "." + name - fullName = strings.TrimSuffix(fullName, s) - - if i := strings.LastIndexByte(fullName, '('); inParen && i >= 0 { - fullName = fullName[:i] - } - fullName = strings.TrimSuffix(fullName, ".") - } - return strings.TrimSuffix(name, ".") -} diff --git a/vendor/github.com/google/go-cmp/cmp/internal/value/name.go b/vendor/github.com/google/go-cmp/cmp/internal/value/name.go deleted file mode 100644 index 7b498bb2c..000000000 --- a/vendor/github.com/google/go-cmp/cmp/internal/value/name.go +++ /dev/null @@ -1,164 +0,0 @@ -// Copyright 2020, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package value - -import ( - "reflect" - "strconv" -) - -var anyType = reflect.TypeOf((*interface{})(nil)).Elem() - -// TypeString is nearly identical to reflect.Type.String, -// but has an additional option to specify that full type names be used. -func TypeString(t reflect.Type, qualified bool) string { - return string(appendTypeName(nil, t, qualified, false)) -} - -func appendTypeName(b []byte, t reflect.Type, qualified, elideFunc bool) []byte { - // BUG: Go reflection provides no way to disambiguate two named types - // of the same name and within the same package, - // but declared within the namespace of different functions. - - // Use the "any" alias instead of "interface{}" for better readability. - if t == anyType { - return append(b, "any"...) - } - - // Named type. - if t.Name() != "" { - if qualified && t.PkgPath() != "" { - b = append(b, '"') - b = append(b, t.PkgPath()...) - b = append(b, '"') - b = append(b, '.') - b = append(b, t.Name()...) - } else { - b = append(b, t.String()...) - } - return b - } - - // Unnamed type. - switch k := t.Kind(); k { - case reflect.Bool, reflect.String, reflect.UnsafePointer, - reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, - reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128: - b = append(b, k.String()...) - case reflect.Chan: - if t.ChanDir() == reflect.RecvDir { - b = append(b, "<-"...) - } - b = append(b, "chan"...) - if t.ChanDir() == reflect.SendDir { - b = append(b, "<-"...) - } - b = append(b, ' ') - b = appendTypeName(b, t.Elem(), qualified, false) - case reflect.Func: - if !elideFunc { - b = append(b, "func"...) - } - b = append(b, '(') - for i := 0; i < t.NumIn(); i++ { - if i > 0 { - b = append(b, ", "...) - } - if i == t.NumIn()-1 && t.IsVariadic() { - b = append(b, "..."...) - b = appendTypeName(b, t.In(i).Elem(), qualified, false) - } else { - b = appendTypeName(b, t.In(i), qualified, false) - } - } - b = append(b, ')') - switch t.NumOut() { - case 0: - // Do nothing - case 1: - b = append(b, ' ') - b = appendTypeName(b, t.Out(0), qualified, false) - default: - b = append(b, " ("...) - for i := 0; i < t.NumOut(); i++ { - if i > 0 { - b = append(b, ", "...) - } - b = appendTypeName(b, t.Out(i), qualified, false) - } - b = append(b, ')') - } - case reflect.Struct: - b = append(b, "struct{ "...) - for i := 0; i < t.NumField(); i++ { - if i > 0 { - b = append(b, "; "...) - } - sf := t.Field(i) - if !sf.Anonymous { - if qualified && sf.PkgPath != "" { - b = append(b, '"') - b = append(b, sf.PkgPath...) - b = append(b, '"') - b = append(b, '.') - } - b = append(b, sf.Name...) - b = append(b, ' ') - } - b = appendTypeName(b, sf.Type, qualified, false) - if sf.Tag != "" { - b = append(b, ' ') - b = strconv.AppendQuote(b, string(sf.Tag)) - } - } - if b[len(b)-1] == ' ' { - b = b[:len(b)-1] - } else { - b = append(b, ' ') - } - b = append(b, '}') - case reflect.Slice, reflect.Array: - b = append(b, '[') - if k == reflect.Array { - b = strconv.AppendUint(b, uint64(t.Len()), 10) - } - b = append(b, ']') - b = appendTypeName(b, t.Elem(), qualified, false) - case reflect.Map: - b = append(b, "map["...) - b = appendTypeName(b, t.Key(), qualified, false) - b = append(b, ']') - b = appendTypeName(b, t.Elem(), qualified, false) - case reflect.Ptr: - b = append(b, '*') - b = appendTypeName(b, t.Elem(), qualified, false) - case reflect.Interface: - b = append(b, "interface{ "...) - for i := 0; i < t.NumMethod(); i++ { - if i > 0 { - b = append(b, "; "...) - } - m := t.Method(i) - if qualified && m.PkgPath != "" { - b = append(b, '"') - b = append(b, m.PkgPath...) - b = append(b, '"') - b = append(b, '.') - } - b = append(b, m.Name...) - b = appendTypeName(b, m.Type, qualified, true) - } - if b[len(b)-1] == ' ' { - b = b[:len(b)-1] - } else { - b = append(b, ' ') - } - b = append(b, '}') - default: - panic("invalid kind: " + k.String()) - } - return b -} diff --git a/vendor/github.com/google/go-cmp/cmp/internal/value/pointer_purego.go b/vendor/github.com/google/go-cmp/cmp/internal/value/pointer_purego.go deleted file mode 100644 index 1a71bfcbd..000000000 --- a/vendor/github.com/google/go-cmp/cmp/internal/value/pointer_purego.go +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2018, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -//go:build purego -// +build purego - -package value - -import "reflect" - -// Pointer is an opaque typed pointer and is guaranteed to be comparable. -type Pointer struct { - p uintptr - t reflect.Type -} - -// PointerOf returns a Pointer from v, which must be a -// reflect.Ptr, reflect.Slice, or reflect.Map. -func PointerOf(v reflect.Value) Pointer { - // NOTE: Storing a pointer as an uintptr is technically incorrect as it - // assumes that the GC implementation does not use a moving collector. - return Pointer{v.Pointer(), v.Type()} -} - -// IsNil reports whether the pointer is nil. -func (p Pointer) IsNil() bool { - return p.p == 0 -} - -// Uintptr returns the pointer as a uintptr. -func (p Pointer) Uintptr() uintptr { - return p.p -} diff --git a/vendor/github.com/google/go-cmp/cmp/internal/value/pointer_unsafe.go b/vendor/github.com/google/go-cmp/cmp/internal/value/pointer_unsafe.go deleted file mode 100644 index 16e6860af..000000000 --- a/vendor/github.com/google/go-cmp/cmp/internal/value/pointer_unsafe.go +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright 2018, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -//go:build !purego -// +build !purego - -package value - -import ( - "reflect" - "unsafe" -) - -// Pointer is an opaque typed pointer and is guaranteed to be comparable. -type Pointer struct { - p unsafe.Pointer - t reflect.Type -} - -// PointerOf returns a Pointer from v, which must be a -// reflect.Ptr, reflect.Slice, or reflect.Map. -func PointerOf(v reflect.Value) Pointer { - // The proper representation of a pointer is unsafe.Pointer, - // which is necessary if the GC ever uses a moving collector. - return Pointer{unsafe.Pointer(v.Pointer()), v.Type()} -} - -// IsNil reports whether the pointer is nil. -func (p Pointer) IsNil() bool { - return p.p == nil -} - -// Uintptr returns the pointer as a uintptr. -func (p Pointer) Uintptr() uintptr { - return uintptr(p.p) -} diff --git a/vendor/github.com/google/go-cmp/cmp/internal/value/sort.go b/vendor/github.com/google/go-cmp/cmp/internal/value/sort.go deleted file mode 100644 index 98533b036..000000000 --- a/vendor/github.com/google/go-cmp/cmp/internal/value/sort.go +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package value - -import ( - "fmt" - "math" - "reflect" - "sort" -) - -// SortKeys sorts a list of map keys, deduplicating keys if necessary. -// The type of each value must be comparable. -func SortKeys(vs []reflect.Value) []reflect.Value { - if len(vs) == 0 { - return vs - } - - // Sort the map keys. - sort.SliceStable(vs, func(i, j int) bool { return isLess(vs[i], vs[j]) }) - - // Deduplicate keys (fails for NaNs). - vs2 := vs[:1] - for _, v := range vs[1:] { - if isLess(vs2[len(vs2)-1], v) { - vs2 = append(vs2, v) - } - } - return vs2 -} - -// isLess is a generic function for sorting arbitrary map keys. -// The inputs must be of the same type and must be comparable. -func isLess(x, y reflect.Value) bool { - switch x.Type().Kind() { - case reflect.Bool: - return !x.Bool() && y.Bool() - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return x.Int() < y.Int() - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: - return x.Uint() < y.Uint() - case reflect.Float32, reflect.Float64: - // NOTE: This does not sort -0 as less than +0 - // since Go maps treat -0 and +0 as equal keys. - fx, fy := x.Float(), y.Float() - return fx < fy || math.IsNaN(fx) && !math.IsNaN(fy) - case reflect.Complex64, reflect.Complex128: - cx, cy := x.Complex(), y.Complex() - rx, ix, ry, iy := real(cx), imag(cx), real(cy), imag(cy) - if rx == ry || (math.IsNaN(rx) && math.IsNaN(ry)) { - return ix < iy || math.IsNaN(ix) && !math.IsNaN(iy) - } - return rx < ry || math.IsNaN(rx) && !math.IsNaN(ry) - case reflect.Ptr, reflect.UnsafePointer, reflect.Chan: - return x.Pointer() < y.Pointer() - case reflect.String: - return x.String() < y.String() - case reflect.Array: - for i := 0; i < x.Len(); i++ { - if isLess(x.Index(i), y.Index(i)) { - return true - } - if isLess(y.Index(i), x.Index(i)) { - return false - } - } - return false - case reflect.Struct: - for i := 0; i < x.NumField(); i++ { - if isLess(x.Field(i), y.Field(i)) { - return true - } - if isLess(y.Field(i), x.Field(i)) { - return false - } - } - return false - case reflect.Interface: - vx, vy := x.Elem(), y.Elem() - if !vx.IsValid() || !vy.IsValid() { - return !vx.IsValid() && vy.IsValid() - } - tx, ty := vx.Type(), vy.Type() - if tx == ty { - return isLess(x.Elem(), y.Elem()) - } - if tx.Kind() != ty.Kind() { - return vx.Kind() < vy.Kind() - } - if tx.String() != ty.String() { - return tx.String() < ty.String() - } - if tx.PkgPath() != ty.PkgPath() { - return tx.PkgPath() < ty.PkgPath() - } - // This can happen in rare situations, so we fallback to just comparing - // the unique pointer for a reflect.Type. This guarantees deterministic - // ordering within a program, but it is obviously not stable. - return reflect.ValueOf(vx.Type()).Pointer() < reflect.ValueOf(vy.Type()).Pointer() - default: - // Must be Func, Map, or Slice; which are not comparable. - panic(fmt.Sprintf("%T is not comparable", x.Type())) - } -} diff --git a/vendor/github.com/google/go-cmp/cmp/internal/value/zero.go b/vendor/github.com/google/go-cmp/cmp/internal/value/zero.go deleted file mode 100644 index 9147a2997..000000000 --- a/vendor/github.com/google/go-cmp/cmp/internal/value/zero.go +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package value - -import ( - "math" - "reflect" -) - -// IsZero reports whether v is the zero value. -// This does not rely on Interface and so can be used on unexported fields. -func IsZero(v reflect.Value) bool { - switch v.Kind() { - case reflect.Bool: - return v.Bool() == false - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return v.Int() == 0 - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: - return v.Uint() == 0 - case reflect.Float32, reflect.Float64: - return math.Float64bits(v.Float()) == 0 - case reflect.Complex64, reflect.Complex128: - return math.Float64bits(real(v.Complex())) == 0 && math.Float64bits(imag(v.Complex())) == 0 - case reflect.String: - return v.String() == "" - case reflect.UnsafePointer: - return v.Pointer() == 0 - case reflect.Chan, reflect.Func, reflect.Interface, reflect.Ptr, reflect.Map, reflect.Slice: - return v.IsNil() - case reflect.Array: - for i := 0; i < v.Len(); i++ { - if !IsZero(v.Index(i)) { - return false - } - } - return true - case reflect.Struct: - for i := 0; i < v.NumField(); i++ { - if !IsZero(v.Field(i)) { - return false - } - } - return true - } - return false -} diff --git a/vendor/github.com/google/go-cmp/cmp/options.go b/vendor/github.com/google/go-cmp/cmp/options.go deleted file mode 100644 index e57b9eb53..000000000 --- a/vendor/github.com/google/go-cmp/cmp/options.go +++ /dev/null @@ -1,552 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package cmp - -import ( - "fmt" - "reflect" - "regexp" - "strings" - - "github.com/google/go-cmp/cmp/internal/function" -) - -// Option configures for specific behavior of Equal and Diff. In particular, -// the fundamental Option functions (Ignore, Transformer, and Comparer), -// configure how equality is determined. -// -// The fundamental options may be composed with filters (FilterPath and -// FilterValues) to control the scope over which they are applied. -// -// The cmp/cmpopts package provides helper functions for creating options that -// may be used with Equal and Diff. -type Option interface { - // filter applies all filters and returns the option that remains. - // Each option may only read s.curPath and call s.callTTBFunc. - // - // An Options is returned only if multiple comparers or transformers - // can apply simultaneously and will only contain values of those types - // or sub-Options containing values of those types. - filter(s *state, t reflect.Type, vx, vy reflect.Value) applicableOption -} - -// applicableOption represents the following types: -// Fundamental: ignore | validator | *comparer | *transformer -// Grouping: Options -type applicableOption interface { - Option - - // apply executes the option, which may mutate s or panic. - apply(s *state, vx, vy reflect.Value) -} - -// coreOption represents the following types: -// Fundamental: ignore | validator | *comparer | *transformer -// Filters: *pathFilter | *valuesFilter -type coreOption interface { - Option - isCore() -} - -type core struct{} - -func (core) isCore() {} - -// Options is a list of Option values that also satisfies the Option interface. -// Helper comparison packages may return an Options value when packing multiple -// Option values into a single Option. When this package processes an Options, -// it will be implicitly expanded into a flat list. -// -// Applying a filter on an Options is equivalent to applying that same filter -// on all individual options held within. -type Options []Option - -func (opts Options) filter(s *state, t reflect.Type, vx, vy reflect.Value) (out applicableOption) { - for _, opt := range opts { - switch opt := opt.filter(s, t, vx, vy); opt.(type) { - case ignore: - return ignore{} // Only ignore can short-circuit evaluation - case validator: - out = validator{} // Takes precedence over comparer or transformer - case *comparer, *transformer, Options: - switch out.(type) { - case nil: - out = opt - case validator: - // Keep validator - case *comparer, *transformer, Options: - out = Options{out, opt} // Conflicting comparers or transformers - } - } - } - return out -} - -func (opts Options) apply(s *state, _, _ reflect.Value) { - const warning = "ambiguous set of applicable options" - const help = "consider using filters to ensure at most one Comparer or Transformer may apply" - var ss []string - for _, opt := range flattenOptions(nil, opts) { - ss = append(ss, fmt.Sprint(opt)) - } - set := strings.Join(ss, "\n\t") - panic(fmt.Sprintf("%s at %#v:\n\t%s\n%s", warning, s.curPath, set, help)) -} - -func (opts Options) String() string { - var ss []string - for _, opt := range opts { - ss = append(ss, fmt.Sprint(opt)) - } - return fmt.Sprintf("Options{%s}", strings.Join(ss, ", ")) -} - -// FilterPath returns a new Option where opt is only evaluated if filter f -// returns true for the current Path in the value tree. -// -// This filter is called even if a slice element or map entry is missing and -// provides an opportunity to ignore such cases. The filter function must be -// symmetric such that the filter result is identical regardless of whether the -// missing value is from x or y. -// -// The option passed in may be an Ignore, Transformer, Comparer, Options, or -// a previously filtered Option. -func FilterPath(f func(Path) bool, opt Option) Option { - if f == nil { - panic("invalid path filter function") - } - if opt := normalizeOption(opt); opt != nil { - return &pathFilter{fnc: f, opt: opt} - } - return nil -} - -type pathFilter struct { - core - fnc func(Path) bool - opt Option -} - -func (f pathFilter) filter(s *state, t reflect.Type, vx, vy reflect.Value) applicableOption { - if f.fnc(s.curPath) { - return f.opt.filter(s, t, vx, vy) - } - return nil -} - -func (f pathFilter) String() string { - return fmt.Sprintf("FilterPath(%s, %v)", function.NameOf(reflect.ValueOf(f.fnc)), f.opt) -} - -// FilterValues returns a new Option where opt is only evaluated if filter f, -// which is a function of the form "func(T, T) bool", returns true for the -// current pair of values being compared. If either value is invalid or -// the type of the values is not assignable to T, then this filter implicitly -// returns false. -// -// The filter function must be -// symmetric (i.e., agnostic to the order of the inputs) and -// deterministic (i.e., produces the same result when given the same inputs). -// If T is an interface, it is possible that f is called with two values with -// different concrete types that both implement T. -// -// The option passed in may be an Ignore, Transformer, Comparer, Options, or -// a previously filtered Option. -func FilterValues(f interface{}, opt Option) Option { - v := reflect.ValueOf(f) - if !function.IsType(v.Type(), function.ValueFilter) || v.IsNil() { - panic(fmt.Sprintf("invalid values filter function: %T", f)) - } - if opt := normalizeOption(opt); opt != nil { - vf := &valuesFilter{fnc: v, opt: opt} - if ti := v.Type().In(0); ti.Kind() != reflect.Interface || ti.NumMethod() > 0 { - vf.typ = ti - } - return vf - } - return nil -} - -type valuesFilter struct { - core - typ reflect.Type // T - fnc reflect.Value // func(T, T) bool - opt Option -} - -func (f valuesFilter) filter(s *state, t reflect.Type, vx, vy reflect.Value) applicableOption { - if !vx.IsValid() || !vx.CanInterface() || !vy.IsValid() || !vy.CanInterface() { - return nil - } - if (f.typ == nil || t.AssignableTo(f.typ)) && s.callTTBFunc(f.fnc, vx, vy) { - return f.opt.filter(s, t, vx, vy) - } - return nil -} - -func (f valuesFilter) String() string { - return fmt.Sprintf("FilterValues(%s, %v)", function.NameOf(f.fnc), f.opt) -} - -// Ignore is an Option that causes all comparisons to be ignored. -// This value is intended to be combined with FilterPath or FilterValues. -// It is an error to pass an unfiltered Ignore option to Equal. -func Ignore() Option { return ignore{} } - -type ignore struct{ core } - -func (ignore) isFiltered() bool { return false } -func (ignore) filter(_ *state, _ reflect.Type, _, _ reflect.Value) applicableOption { return ignore{} } -func (ignore) apply(s *state, _, _ reflect.Value) { s.report(true, reportByIgnore) } -func (ignore) String() string { return "Ignore()" } - -// validator is a sentinel Option type to indicate that some options could not -// be evaluated due to unexported fields, missing slice elements, or -// missing map entries. Both values are validator only for unexported fields. -type validator struct{ core } - -func (validator) filter(_ *state, _ reflect.Type, vx, vy reflect.Value) applicableOption { - if !vx.IsValid() || !vy.IsValid() { - return validator{} - } - if !vx.CanInterface() || !vy.CanInterface() { - return validator{} - } - return nil -} -func (validator) apply(s *state, vx, vy reflect.Value) { - // Implies missing slice element or map entry. - if !vx.IsValid() || !vy.IsValid() { - s.report(vx.IsValid() == vy.IsValid(), 0) - return - } - - // Unable to Interface implies unexported field without visibility access. - if !vx.CanInterface() || !vy.CanInterface() { - help := "consider using a custom Comparer; if you control the implementation of type, you can also consider using an Exporter, AllowUnexported, or cmpopts.IgnoreUnexported" - var name string - if t := s.curPath.Index(-2).Type(); t.Name() != "" { - // Named type with unexported fields. - name = fmt.Sprintf("%q.%v", t.PkgPath(), t.Name()) // e.g., "path/to/package".MyType - if _, ok := reflect.New(t).Interface().(error); ok { - help = "consider using cmpopts.EquateErrors to compare error values" - } - } else { - // Unnamed type with unexported fields. Derive PkgPath from field. - var pkgPath string - for i := 0; i < t.NumField() && pkgPath == ""; i++ { - pkgPath = t.Field(i).PkgPath - } - name = fmt.Sprintf("%q.(%v)", pkgPath, t.String()) // e.g., "path/to/package".(struct { a int }) - } - panic(fmt.Sprintf("cannot handle unexported field at %#v:\n\t%v\n%s", s.curPath, name, help)) - } - - panic("not reachable") -} - -// identRx represents a valid identifier according to the Go specification. -const identRx = `[_\p{L}][_\p{L}\p{N}]*` - -var identsRx = regexp.MustCompile(`^` + identRx + `(\.` + identRx + `)*$`) - -// Transformer returns an Option that applies a transformation function that -// converts values of a certain type into that of another. -// -// The transformer f must be a function "func(T) R" that converts values of -// type T to those of type R and is implicitly filtered to input values -// assignable to T. The transformer must not mutate T in any way. -// -// To help prevent some cases of infinite recursive cycles applying the -// same transform to the output of itself (e.g., in the case where the -// input and output types are the same), an implicit filter is added such that -// a transformer is applicable only if that exact transformer is not already -// in the tail of the Path since the last non-Transform step. -// For situations where the implicit filter is still insufficient, -// consider using cmpopts.AcyclicTransformer, which adds a filter -// to prevent the transformer from being recursively applied upon itself. -// -// The name is a user provided label that is used as the Transform.Name in the -// transformation PathStep (and eventually shown in the Diff output). -// The name must be a valid identifier or qualified identifier in Go syntax. -// If empty, an arbitrary name is used. -func Transformer(name string, f interface{}) Option { - v := reflect.ValueOf(f) - if !function.IsType(v.Type(), function.Transformer) || v.IsNil() { - panic(fmt.Sprintf("invalid transformer function: %T", f)) - } - if name == "" { - name = function.NameOf(v) - if !identsRx.MatchString(name) { - name = "λ" // Lambda-symbol as placeholder name - } - } else if !identsRx.MatchString(name) { - panic(fmt.Sprintf("invalid name: %q", name)) - } - tr := &transformer{name: name, fnc: reflect.ValueOf(f)} - if ti := v.Type().In(0); ti.Kind() != reflect.Interface || ti.NumMethod() > 0 { - tr.typ = ti - } - return tr -} - -type transformer struct { - core - name string - typ reflect.Type // T - fnc reflect.Value // func(T) R -} - -func (tr *transformer) isFiltered() bool { return tr.typ != nil } - -func (tr *transformer) filter(s *state, t reflect.Type, _, _ reflect.Value) applicableOption { - for i := len(s.curPath) - 1; i >= 0; i-- { - if t, ok := s.curPath[i].(Transform); !ok { - break // Hit most recent non-Transform step - } else if tr == t.trans { - return nil // Cannot directly use same Transform - } - } - if tr.typ == nil || t.AssignableTo(tr.typ) { - return tr - } - return nil -} - -func (tr *transformer) apply(s *state, vx, vy reflect.Value) { - step := Transform{&transform{pathStep{typ: tr.fnc.Type().Out(0)}, tr}} - vvx := s.callTRFunc(tr.fnc, vx, step) - vvy := s.callTRFunc(tr.fnc, vy, step) - step.vx, step.vy = vvx, vvy - s.compareAny(step) -} - -func (tr transformer) String() string { - return fmt.Sprintf("Transformer(%s, %s)", tr.name, function.NameOf(tr.fnc)) -} - -// Comparer returns an Option that determines whether two values are equal -// to each other. -// -// The comparer f must be a function "func(T, T) bool" and is implicitly -// filtered to input values assignable to T. If T is an interface, it is -// possible that f is called with two values of different concrete types that -// both implement T. -// -// The equality function must be: -// • Symmetric: equal(x, y) == equal(y, x) -// • Deterministic: equal(x, y) == equal(x, y) -// • Pure: equal(x, y) does not modify x or y -func Comparer(f interface{}) Option { - v := reflect.ValueOf(f) - if !function.IsType(v.Type(), function.Equal) || v.IsNil() { - panic(fmt.Sprintf("invalid comparer function: %T", f)) - } - cm := &comparer{fnc: v} - if ti := v.Type().In(0); ti.Kind() != reflect.Interface || ti.NumMethod() > 0 { - cm.typ = ti - } - return cm -} - -type comparer struct { - core - typ reflect.Type // T - fnc reflect.Value // func(T, T) bool -} - -func (cm *comparer) isFiltered() bool { return cm.typ != nil } - -func (cm *comparer) filter(_ *state, t reflect.Type, _, _ reflect.Value) applicableOption { - if cm.typ == nil || t.AssignableTo(cm.typ) { - return cm - } - return nil -} - -func (cm *comparer) apply(s *state, vx, vy reflect.Value) { - eq := s.callTTBFunc(cm.fnc, vx, vy) - s.report(eq, reportByFunc) -} - -func (cm comparer) String() string { - return fmt.Sprintf("Comparer(%s)", function.NameOf(cm.fnc)) -} - -// Exporter returns an Option that specifies whether Equal is allowed to -// introspect into the unexported fields of certain struct types. -// -// Users of this option must understand that comparing on unexported fields -// from external packages is not safe since changes in the internal -// implementation of some external package may cause the result of Equal -// to unexpectedly change. However, it may be valid to use this option on types -// defined in an internal package where the semantic meaning of an unexported -// field is in the control of the user. -// -// In many cases, a custom Comparer should be used instead that defines -// equality as a function of the public API of a type rather than the underlying -// unexported implementation. -// -// For example, the reflect.Type documentation defines equality to be determined -// by the == operator on the interface (essentially performing a shallow pointer -// comparison) and most attempts to compare *regexp.Regexp types are interested -// in only checking that the regular expression strings are equal. -// Both of these are accomplished using Comparers: -// -// Comparer(func(x, y reflect.Type) bool { return x == y }) -// Comparer(func(x, y *regexp.Regexp) bool { return x.String() == y.String() }) -// -// In other cases, the cmpopts.IgnoreUnexported option can be used to ignore -// all unexported fields on specified struct types. -func Exporter(f func(reflect.Type) bool) Option { - if !supportExporters { - panic("Exporter is not supported on purego builds") - } - return exporter(f) -} - -type exporter func(reflect.Type) bool - -func (exporter) filter(_ *state, _ reflect.Type, _, _ reflect.Value) applicableOption { - panic("not implemented") -} - -// AllowUnexported returns an Options that allows Equal to forcibly introspect -// unexported fields of the specified struct types. -// -// See Exporter for the proper use of this option. -func AllowUnexported(types ...interface{}) Option { - m := make(map[reflect.Type]bool) - for _, typ := range types { - t := reflect.TypeOf(typ) - if t.Kind() != reflect.Struct { - panic(fmt.Sprintf("invalid struct type: %T", typ)) - } - m[t] = true - } - return exporter(func(t reflect.Type) bool { return m[t] }) -} - -// Result represents the comparison result for a single node and -// is provided by cmp when calling Result (see Reporter). -type Result struct { - _ [0]func() // Make Result incomparable - flags resultFlags -} - -// Equal reports whether the node was determined to be equal or not. -// As a special case, ignored nodes are considered equal. -func (r Result) Equal() bool { - return r.flags&(reportEqual|reportByIgnore) != 0 -} - -// ByIgnore reports whether the node is equal because it was ignored. -// This never reports true if Equal reports false. -func (r Result) ByIgnore() bool { - return r.flags&reportByIgnore != 0 -} - -// ByMethod reports whether the Equal method determined equality. -func (r Result) ByMethod() bool { - return r.flags&reportByMethod != 0 -} - -// ByFunc reports whether a Comparer function determined equality. -func (r Result) ByFunc() bool { - return r.flags&reportByFunc != 0 -} - -// ByCycle reports whether a reference cycle was detected. -func (r Result) ByCycle() bool { - return r.flags&reportByCycle != 0 -} - -type resultFlags uint - -const ( - _ resultFlags = (1 << iota) / 2 - - reportEqual - reportUnequal - reportByIgnore - reportByMethod - reportByFunc - reportByCycle -) - -// Reporter is an Option that can be passed to Equal. When Equal traverses -// the value trees, it calls PushStep as it descends into each node in the -// tree and PopStep as it ascend out of the node. The leaves of the tree are -// either compared (determined to be equal or not equal) or ignored and reported -// as such by calling the Report method. -func Reporter(r interface { - // PushStep is called when a tree-traversal operation is performed. - // The PathStep itself is only valid until the step is popped. - // The PathStep.Values are valid for the duration of the entire traversal - // and must not be mutated. - // - // Equal always calls PushStep at the start to provide an operation-less - // PathStep used to report the root values. - // - // Within a slice, the exact set of inserted, removed, or modified elements - // is unspecified and may change in future implementations. - // The entries of a map are iterated through in an unspecified order. - PushStep(PathStep) - - // Report is called exactly once on leaf nodes to report whether the - // comparison identified the node as equal, unequal, or ignored. - // A leaf node is one that is immediately preceded by and followed by - // a pair of PushStep and PopStep calls. - Report(Result) - - // PopStep ascends back up the value tree. - // There is always a matching pop call for every push call. - PopStep() -}) Option { - return reporter{r} -} - -type reporter struct{ reporterIface } -type reporterIface interface { - PushStep(PathStep) - Report(Result) - PopStep() -} - -func (reporter) filter(_ *state, _ reflect.Type, _, _ reflect.Value) applicableOption { - panic("not implemented") -} - -// normalizeOption normalizes the input options such that all Options groups -// are flattened and groups with a single element are reduced to that element. -// Only coreOptions and Options containing coreOptions are allowed. -func normalizeOption(src Option) Option { - switch opts := flattenOptions(nil, Options{src}); len(opts) { - case 0: - return nil - case 1: - return opts[0] - default: - return opts - } -} - -// flattenOptions copies all options in src to dst as a flat list. -// Only coreOptions and Options containing coreOptions are allowed. -func flattenOptions(dst, src Options) Options { - for _, opt := range src { - switch opt := opt.(type) { - case nil: - continue - case Options: - dst = flattenOptions(dst, opt) - case coreOption: - dst = append(dst, opt) - default: - panic(fmt.Sprintf("invalid option type: %T", opt)) - } - } - return dst -} diff --git a/vendor/github.com/google/go-cmp/cmp/path.go b/vendor/github.com/google/go-cmp/cmp/path.go deleted file mode 100644 index c71003463..000000000 --- a/vendor/github.com/google/go-cmp/cmp/path.go +++ /dev/null @@ -1,378 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package cmp - -import ( - "fmt" - "reflect" - "strings" - "unicode" - "unicode/utf8" - - "github.com/google/go-cmp/cmp/internal/value" -) - -// Path is a list of PathSteps describing the sequence of operations to get -// from some root type to the current position in the value tree. -// The first Path element is always an operation-less PathStep that exists -// simply to identify the initial type. -// -// When traversing structs with embedded structs, the embedded struct will -// always be accessed as a field before traversing the fields of the -// embedded struct themselves. That is, an exported field from the -// embedded struct will never be accessed directly from the parent struct. -type Path []PathStep - -// PathStep is a union-type for specific operations to traverse -// a value's tree structure. Users of this package never need to implement -// these types as values of this type will be returned by this package. -// -// Implementations of this interface are -// StructField, SliceIndex, MapIndex, Indirect, TypeAssertion, and Transform. -type PathStep interface { - String() string - - // Type is the resulting type after performing the path step. - Type() reflect.Type - - // Values is the resulting values after performing the path step. - // The type of each valid value is guaranteed to be identical to Type. - // - // In some cases, one or both may be invalid or have restrictions: - // • For StructField, both are not interface-able if the current field - // is unexported and the struct type is not explicitly permitted by - // an Exporter to traverse unexported fields. - // • For SliceIndex, one may be invalid if an element is missing from - // either the x or y slice. - // • For MapIndex, one may be invalid if an entry is missing from - // either the x or y map. - // - // The provided values must not be mutated. - Values() (vx, vy reflect.Value) -} - -var ( - _ PathStep = StructField{} - _ PathStep = SliceIndex{} - _ PathStep = MapIndex{} - _ PathStep = Indirect{} - _ PathStep = TypeAssertion{} - _ PathStep = Transform{} -) - -func (pa *Path) push(s PathStep) { - *pa = append(*pa, s) -} - -func (pa *Path) pop() { - *pa = (*pa)[:len(*pa)-1] -} - -// Last returns the last PathStep in the Path. -// If the path is empty, this returns a non-nil PathStep that reports a nil Type. -func (pa Path) Last() PathStep { - return pa.Index(-1) -} - -// Index returns the ith step in the Path and supports negative indexing. -// A negative index starts counting from the tail of the Path such that -1 -// refers to the last step, -2 refers to the second-to-last step, and so on. -// If index is invalid, this returns a non-nil PathStep that reports a nil Type. -func (pa Path) Index(i int) PathStep { - if i < 0 { - i = len(pa) + i - } - if i < 0 || i >= len(pa) { - return pathStep{} - } - return pa[i] -} - -// String returns the simplified path to a node. -// The simplified path only contains struct field accesses. -// -// For example: -// MyMap.MySlices.MyField -func (pa Path) String() string { - var ss []string - for _, s := range pa { - if _, ok := s.(StructField); ok { - ss = append(ss, s.String()) - } - } - return strings.TrimPrefix(strings.Join(ss, ""), ".") -} - -// GoString returns the path to a specific node using Go syntax. -// -// For example: -// (*root.MyMap["key"].(*mypkg.MyStruct).MySlices)[2][3].MyField -func (pa Path) GoString() string { - var ssPre, ssPost []string - var numIndirect int - for i, s := range pa { - var nextStep PathStep - if i+1 < len(pa) { - nextStep = pa[i+1] - } - switch s := s.(type) { - case Indirect: - numIndirect++ - pPre, pPost := "(", ")" - switch nextStep.(type) { - case Indirect: - continue // Next step is indirection, so let them batch up - case StructField: - numIndirect-- // Automatic indirection on struct fields - case nil: - pPre, pPost = "", "" // Last step; no need for parenthesis - } - if numIndirect > 0 { - ssPre = append(ssPre, pPre+strings.Repeat("*", numIndirect)) - ssPost = append(ssPost, pPost) - } - numIndirect = 0 - continue - case Transform: - ssPre = append(ssPre, s.trans.name+"(") - ssPost = append(ssPost, ")") - continue - } - ssPost = append(ssPost, s.String()) - } - for i, j := 0, len(ssPre)-1; i < j; i, j = i+1, j-1 { - ssPre[i], ssPre[j] = ssPre[j], ssPre[i] - } - return strings.Join(ssPre, "") + strings.Join(ssPost, "") -} - -type pathStep struct { - typ reflect.Type - vx, vy reflect.Value -} - -func (ps pathStep) Type() reflect.Type { return ps.typ } -func (ps pathStep) Values() (vx, vy reflect.Value) { return ps.vx, ps.vy } -func (ps pathStep) String() string { - if ps.typ == nil { - return "" - } - s := ps.typ.String() - if s == "" || strings.ContainsAny(s, "{}\n") { - return "root" // Type too simple or complex to print - } - return fmt.Sprintf("{%s}", s) -} - -// StructField represents a struct field access on a field called Name. -type StructField struct{ *structField } -type structField struct { - pathStep - name string - idx int - - // These fields are used for forcibly accessing an unexported field. - // pvx, pvy, and field are only valid if unexported is true. - unexported bool - mayForce bool // Forcibly allow visibility - paddr bool // Was parent addressable? - pvx, pvy reflect.Value // Parent values (always addressable) - field reflect.StructField // Field information -} - -func (sf StructField) Type() reflect.Type { return sf.typ } -func (sf StructField) Values() (vx, vy reflect.Value) { - if !sf.unexported { - return sf.vx, sf.vy // CanInterface reports true - } - - // Forcibly obtain read-write access to an unexported struct field. - if sf.mayForce { - vx = retrieveUnexportedField(sf.pvx, sf.field, sf.paddr) - vy = retrieveUnexportedField(sf.pvy, sf.field, sf.paddr) - return vx, vy // CanInterface reports true - } - return sf.vx, sf.vy // CanInterface reports false -} -func (sf StructField) String() string { return fmt.Sprintf(".%s", sf.name) } - -// Name is the field name. -func (sf StructField) Name() string { return sf.name } - -// Index is the index of the field in the parent struct type. -// See reflect.Type.Field. -func (sf StructField) Index() int { return sf.idx } - -// SliceIndex is an index operation on a slice or array at some index Key. -type SliceIndex struct{ *sliceIndex } -type sliceIndex struct { - pathStep - xkey, ykey int - isSlice bool // False for reflect.Array -} - -func (si SliceIndex) Type() reflect.Type { return si.typ } -func (si SliceIndex) Values() (vx, vy reflect.Value) { return si.vx, si.vy } -func (si SliceIndex) String() string { - switch { - case si.xkey == si.ykey: - return fmt.Sprintf("[%d]", si.xkey) - case si.ykey == -1: - // [5->?] means "I don't know where X[5] went" - return fmt.Sprintf("[%d->?]", si.xkey) - case si.xkey == -1: - // [?->3] means "I don't know where Y[3] came from" - return fmt.Sprintf("[?->%d]", si.ykey) - default: - // [5->3] means "X[5] moved to Y[3]" - return fmt.Sprintf("[%d->%d]", si.xkey, si.ykey) - } -} - -// Key is the index key; it may return -1 if in a split state -func (si SliceIndex) Key() int { - if si.xkey != si.ykey { - return -1 - } - return si.xkey -} - -// SplitKeys are the indexes for indexing into slices in the -// x and y values, respectively. These indexes may differ due to the -// insertion or removal of an element in one of the slices, causing -// all of the indexes to be shifted. If an index is -1, then that -// indicates that the element does not exist in the associated slice. -// -// Key is guaranteed to return -1 if and only if the indexes returned -// by SplitKeys are not the same. SplitKeys will never return -1 for -// both indexes. -func (si SliceIndex) SplitKeys() (ix, iy int) { return si.xkey, si.ykey } - -// MapIndex is an index operation on a map at some index Key. -type MapIndex struct{ *mapIndex } -type mapIndex struct { - pathStep - key reflect.Value -} - -func (mi MapIndex) Type() reflect.Type { return mi.typ } -func (mi MapIndex) Values() (vx, vy reflect.Value) { return mi.vx, mi.vy } -func (mi MapIndex) String() string { return fmt.Sprintf("[%#v]", mi.key) } - -// Key is the value of the map key. -func (mi MapIndex) Key() reflect.Value { return mi.key } - -// Indirect represents pointer indirection on the parent type. -type Indirect struct{ *indirect } -type indirect struct { - pathStep -} - -func (in Indirect) Type() reflect.Type { return in.typ } -func (in Indirect) Values() (vx, vy reflect.Value) { return in.vx, in.vy } -func (in Indirect) String() string { return "*" } - -// TypeAssertion represents a type assertion on an interface. -type TypeAssertion struct{ *typeAssertion } -type typeAssertion struct { - pathStep -} - -func (ta TypeAssertion) Type() reflect.Type { return ta.typ } -func (ta TypeAssertion) Values() (vx, vy reflect.Value) { return ta.vx, ta.vy } -func (ta TypeAssertion) String() string { return fmt.Sprintf(".(%v)", ta.typ) } - -// Transform is a transformation from the parent type to the current type. -type Transform struct{ *transform } -type transform struct { - pathStep - trans *transformer -} - -func (tf Transform) Type() reflect.Type { return tf.typ } -func (tf Transform) Values() (vx, vy reflect.Value) { return tf.vx, tf.vy } -func (tf Transform) String() string { return fmt.Sprintf("%s()", tf.trans.name) } - -// Name is the name of the Transformer. -func (tf Transform) Name() string { return tf.trans.name } - -// Func is the function pointer to the transformer function. -func (tf Transform) Func() reflect.Value { return tf.trans.fnc } - -// Option returns the originally constructed Transformer option. -// The == operator can be used to detect the exact option used. -func (tf Transform) Option() Option { return tf.trans } - -// pointerPath represents a dual-stack of pointers encountered when -// recursively traversing the x and y values. This data structure supports -// detection of cycles and determining whether the cycles are equal. -// In Go, cycles can occur via pointers, slices, and maps. -// -// The pointerPath uses a map to represent a stack; where descension into a -// pointer pushes the address onto the stack, and ascension from a pointer -// pops the address from the stack. Thus, when traversing into a pointer from -// reflect.Ptr, reflect.Slice element, or reflect.Map, we can detect cycles -// by checking whether the pointer has already been visited. The cycle detection -// uses a separate stack for the x and y values. -// -// If a cycle is detected we need to determine whether the two pointers -// should be considered equal. The definition of equality chosen by Equal -// requires two graphs to have the same structure. To determine this, both the -// x and y values must have a cycle where the previous pointers were also -// encountered together as a pair. -// -// Semantically, this is equivalent to augmenting Indirect, SliceIndex, and -// MapIndex with pointer information for the x and y values. -// Suppose px and py are two pointers to compare, we then search the -// Path for whether px was ever encountered in the Path history of x, and -// similarly so with py. If either side has a cycle, the comparison is only -// equal if both px and py have a cycle resulting from the same PathStep. -// -// Using a map as a stack is more performant as we can perform cycle detection -// in O(1) instead of O(N) where N is len(Path). -type pointerPath struct { - // mx is keyed by x pointers, where the value is the associated y pointer. - mx map[value.Pointer]value.Pointer - // my is keyed by y pointers, where the value is the associated x pointer. - my map[value.Pointer]value.Pointer -} - -func (p *pointerPath) Init() { - p.mx = make(map[value.Pointer]value.Pointer) - p.my = make(map[value.Pointer]value.Pointer) -} - -// Push indicates intent to descend into pointers vx and vy where -// visited reports whether either has been seen before. If visited before, -// equal reports whether both pointers were encountered together. -// Pop must be called if and only if the pointers were never visited. -// -// The pointers vx and vy must be a reflect.Ptr, reflect.Slice, or reflect.Map -// and be non-nil. -func (p pointerPath) Push(vx, vy reflect.Value) (equal, visited bool) { - px := value.PointerOf(vx) - py := value.PointerOf(vy) - _, ok1 := p.mx[px] - _, ok2 := p.my[py] - if ok1 || ok2 { - equal = p.mx[px] == py && p.my[py] == px // Pointers paired together - return equal, true - } - p.mx[px] = py - p.my[py] = px - return false, false -} - -// Pop ascends from pointers vx and vy. -func (p pointerPath) Pop(vx, vy reflect.Value) { - delete(p.mx, value.PointerOf(vx)) - delete(p.my, value.PointerOf(vy)) -} - -// isExported reports whether the identifier is exported. -func isExported(id string) bool { - r, _ := utf8.DecodeRuneInString(id) - return unicode.IsUpper(r) -} diff --git a/vendor/github.com/google/go-cmp/cmp/report.go b/vendor/github.com/google/go-cmp/cmp/report.go deleted file mode 100644 index f43cd12eb..000000000 --- a/vendor/github.com/google/go-cmp/cmp/report.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package cmp - -// defaultReporter implements the reporter interface. -// -// As Equal serially calls the PushStep, Report, and PopStep methods, the -// defaultReporter constructs a tree-based representation of the compared value -// and the result of each comparison (see valueNode). -// -// When the String method is called, the FormatDiff method transforms the -// valueNode tree into a textNode tree, which is a tree-based representation -// of the textual output (see textNode). -// -// Lastly, the textNode.String method produces the final report as a string. -type defaultReporter struct { - root *valueNode - curr *valueNode -} - -func (r *defaultReporter) PushStep(ps PathStep) { - r.curr = r.curr.PushStep(ps) - if r.root == nil { - r.root = r.curr - } -} -func (r *defaultReporter) Report(rs Result) { - r.curr.Report(rs) -} -func (r *defaultReporter) PopStep() { - r.curr = r.curr.PopStep() -} - -// String provides a full report of the differences detected as a structured -// literal in pseudo-Go syntax. String may only be called after the entire tree -// has been traversed. -func (r *defaultReporter) String() string { - assert(r.root != nil && r.curr == nil) - if r.root.NumDiff == 0 { - return "" - } - ptrs := new(pointerReferences) - text := formatOptions{}.FormatDiff(r.root, ptrs) - resolveReferences(text) - return text.String() -} - -func assert(ok bool) { - if !ok { - panic("assertion failure") - } -} diff --git a/vendor/github.com/google/go-cmp/cmp/report_compare.go b/vendor/github.com/google/go-cmp/cmp/report_compare.go deleted file mode 100644 index 104bb3053..000000000 --- a/vendor/github.com/google/go-cmp/cmp/report_compare.go +++ /dev/null @@ -1,432 +0,0 @@ -// Copyright 2019, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package cmp - -import ( - "fmt" - "reflect" - - "github.com/google/go-cmp/cmp/internal/value" -) - -// numContextRecords is the number of surrounding equal records to print. -const numContextRecords = 2 - -type diffMode byte - -const ( - diffUnknown diffMode = 0 - diffIdentical diffMode = ' ' - diffRemoved diffMode = '-' - diffInserted diffMode = '+' -) - -type typeMode int - -const ( - // emitType always prints the type. - emitType typeMode = iota - // elideType never prints the type. - elideType - // autoType prints the type only for composite kinds - // (i.e., structs, slices, arrays, and maps). - autoType -) - -type formatOptions struct { - // DiffMode controls the output mode of FormatDiff. - // - // If diffUnknown, then produce a diff of the x and y values. - // If diffIdentical, then emit values as if they were equal. - // If diffRemoved, then only emit x values (ignoring y values). - // If diffInserted, then only emit y values (ignoring x values). - DiffMode diffMode - - // TypeMode controls whether to print the type for the current node. - // - // As a general rule of thumb, we always print the type of the next node - // after an interface, and always elide the type of the next node after - // a slice or map node. - TypeMode typeMode - - // formatValueOptions are options specific to printing reflect.Values. - formatValueOptions -} - -func (opts formatOptions) WithDiffMode(d diffMode) formatOptions { - opts.DiffMode = d - return opts -} -func (opts formatOptions) WithTypeMode(t typeMode) formatOptions { - opts.TypeMode = t - return opts -} -func (opts formatOptions) WithVerbosity(level int) formatOptions { - opts.VerbosityLevel = level - opts.LimitVerbosity = true - return opts -} -func (opts formatOptions) verbosity() uint { - switch { - case opts.VerbosityLevel < 0: - return 0 - case opts.VerbosityLevel > 16: - return 16 // some reasonable maximum to avoid shift overflow - default: - return uint(opts.VerbosityLevel) - } -} - -const maxVerbosityPreset = 6 - -// verbosityPreset modifies the verbosity settings given an index -// between 0 and maxVerbosityPreset, inclusive. -func verbosityPreset(opts formatOptions, i int) formatOptions { - opts.VerbosityLevel = int(opts.verbosity()) + 2*i - if i > 0 { - opts.AvoidStringer = true - } - if i >= maxVerbosityPreset { - opts.PrintAddresses = true - opts.QualifiedNames = true - } - return opts -} - -// FormatDiff converts a valueNode tree into a textNode tree, where the later -// is a textual representation of the differences detected in the former. -func (opts formatOptions) FormatDiff(v *valueNode, ptrs *pointerReferences) (out textNode) { - if opts.DiffMode == diffIdentical { - opts = opts.WithVerbosity(1) - } else if opts.verbosity() < 3 { - opts = opts.WithVerbosity(3) - } - - // Check whether we have specialized formatting for this node. - // This is not necessary, but helpful for producing more readable outputs. - if opts.CanFormatDiffSlice(v) { - return opts.FormatDiffSlice(v) - } - - var parentKind reflect.Kind - if v.parent != nil && v.parent.TransformerName == "" { - parentKind = v.parent.Type.Kind() - } - - // For leaf nodes, format the value based on the reflect.Values alone. - if v.MaxDepth == 0 { - switch opts.DiffMode { - case diffUnknown, diffIdentical: - // Format Equal. - if v.NumDiff == 0 { - outx := opts.FormatValue(v.ValueX, parentKind, ptrs) - outy := opts.FormatValue(v.ValueY, parentKind, ptrs) - if v.NumIgnored > 0 && v.NumSame == 0 { - return textEllipsis - } else if outx.Len() < outy.Len() { - return outx - } else { - return outy - } - } - - // Format unequal. - assert(opts.DiffMode == diffUnknown) - var list textList - outx := opts.WithTypeMode(elideType).FormatValue(v.ValueX, parentKind, ptrs) - outy := opts.WithTypeMode(elideType).FormatValue(v.ValueY, parentKind, ptrs) - for i := 0; i <= maxVerbosityPreset && outx != nil && outy != nil && outx.Equal(outy); i++ { - opts2 := verbosityPreset(opts, i).WithTypeMode(elideType) - outx = opts2.FormatValue(v.ValueX, parentKind, ptrs) - outy = opts2.FormatValue(v.ValueY, parentKind, ptrs) - } - if outx != nil { - list = append(list, textRecord{Diff: '-', Value: outx}) - } - if outy != nil { - list = append(list, textRecord{Diff: '+', Value: outy}) - } - return opts.WithTypeMode(emitType).FormatType(v.Type, list) - case diffRemoved: - return opts.FormatValue(v.ValueX, parentKind, ptrs) - case diffInserted: - return opts.FormatValue(v.ValueY, parentKind, ptrs) - default: - panic("invalid diff mode") - } - } - - // Register slice element to support cycle detection. - if parentKind == reflect.Slice { - ptrRefs := ptrs.PushPair(v.ValueX, v.ValueY, opts.DiffMode, true) - defer ptrs.Pop() - defer func() { out = wrapTrunkReferences(ptrRefs, out) }() - } - - // Descend into the child value node. - if v.TransformerName != "" { - out := opts.WithTypeMode(emitType).FormatDiff(v.Value, ptrs) - out = &textWrap{Prefix: "Inverse(" + v.TransformerName + ", ", Value: out, Suffix: ")"} - return opts.FormatType(v.Type, out) - } else { - switch k := v.Type.Kind(); k { - case reflect.Struct, reflect.Array, reflect.Slice: - out = opts.formatDiffList(v.Records, k, ptrs) - out = opts.FormatType(v.Type, out) - case reflect.Map: - // Register map to support cycle detection. - ptrRefs := ptrs.PushPair(v.ValueX, v.ValueY, opts.DiffMode, false) - defer ptrs.Pop() - - out = opts.formatDiffList(v.Records, k, ptrs) - out = wrapTrunkReferences(ptrRefs, out) - out = opts.FormatType(v.Type, out) - case reflect.Ptr: - // Register pointer to support cycle detection. - ptrRefs := ptrs.PushPair(v.ValueX, v.ValueY, opts.DiffMode, false) - defer ptrs.Pop() - - out = opts.FormatDiff(v.Value, ptrs) - out = wrapTrunkReferences(ptrRefs, out) - out = &textWrap{Prefix: "&", Value: out} - case reflect.Interface: - out = opts.WithTypeMode(emitType).FormatDiff(v.Value, ptrs) - default: - panic(fmt.Sprintf("%v cannot have children", k)) - } - return out - } -} - -func (opts formatOptions) formatDiffList(recs []reportRecord, k reflect.Kind, ptrs *pointerReferences) textNode { - // Derive record name based on the data structure kind. - var name string - var formatKey func(reflect.Value) string - switch k { - case reflect.Struct: - name = "field" - opts = opts.WithTypeMode(autoType) - formatKey = func(v reflect.Value) string { return v.String() } - case reflect.Slice, reflect.Array: - name = "element" - opts = opts.WithTypeMode(elideType) - formatKey = func(reflect.Value) string { return "" } - case reflect.Map: - name = "entry" - opts = opts.WithTypeMode(elideType) - formatKey = func(v reflect.Value) string { return formatMapKey(v, false, ptrs) } - } - - maxLen := -1 - if opts.LimitVerbosity { - if opts.DiffMode == diffIdentical { - maxLen = ((1 << opts.verbosity()) >> 1) << 2 // 0, 4, 8, 16, 32, etc... - } else { - maxLen = (1 << opts.verbosity()) << 1 // 2, 4, 8, 16, 32, 64, etc... - } - opts.VerbosityLevel-- - } - - // Handle unification. - switch opts.DiffMode { - case diffIdentical, diffRemoved, diffInserted: - var list textList - var deferredEllipsis bool // Add final "..." to indicate records were dropped - for _, r := range recs { - if len(list) == maxLen { - deferredEllipsis = true - break - } - - // Elide struct fields that are zero value. - if k == reflect.Struct { - var isZero bool - switch opts.DiffMode { - case diffIdentical: - isZero = value.IsZero(r.Value.ValueX) || value.IsZero(r.Value.ValueY) - case diffRemoved: - isZero = value.IsZero(r.Value.ValueX) - case diffInserted: - isZero = value.IsZero(r.Value.ValueY) - } - if isZero { - continue - } - } - // Elide ignored nodes. - if r.Value.NumIgnored > 0 && r.Value.NumSame+r.Value.NumDiff == 0 { - deferredEllipsis = !(k == reflect.Slice || k == reflect.Array) - if !deferredEllipsis { - list.AppendEllipsis(diffStats{}) - } - continue - } - if out := opts.FormatDiff(r.Value, ptrs); out != nil { - list = append(list, textRecord{Key: formatKey(r.Key), Value: out}) - } - } - if deferredEllipsis { - list.AppendEllipsis(diffStats{}) - } - return &textWrap{Prefix: "{", Value: list, Suffix: "}"} - case diffUnknown: - default: - panic("invalid diff mode") - } - - // Handle differencing. - var numDiffs int - var list textList - var keys []reflect.Value // invariant: len(list) == len(keys) - groups := coalesceAdjacentRecords(name, recs) - maxGroup := diffStats{Name: name} - for i, ds := range groups { - if maxLen >= 0 && numDiffs >= maxLen { - maxGroup = maxGroup.Append(ds) - continue - } - - // Handle equal records. - if ds.NumDiff() == 0 { - // Compute the number of leading and trailing records to print. - var numLo, numHi int - numEqual := ds.NumIgnored + ds.NumIdentical - for numLo < numContextRecords && numLo+numHi < numEqual && i != 0 { - if r := recs[numLo].Value; r.NumIgnored > 0 && r.NumSame+r.NumDiff == 0 { - break - } - numLo++ - } - for numHi < numContextRecords && numLo+numHi < numEqual && i != len(groups)-1 { - if r := recs[numEqual-numHi-1].Value; r.NumIgnored > 0 && r.NumSame+r.NumDiff == 0 { - break - } - numHi++ - } - if numEqual-(numLo+numHi) == 1 && ds.NumIgnored == 0 { - numHi++ // Avoid pointless coalescing of a single equal record - } - - // Format the equal values. - for _, r := range recs[:numLo] { - out := opts.WithDiffMode(diffIdentical).FormatDiff(r.Value, ptrs) - list = append(list, textRecord{Key: formatKey(r.Key), Value: out}) - keys = append(keys, r.Key) - } - if numEqual > numLo+numHi { - ds.NumIdentical -= numLo + numHi - list.AppendEllipsis(ds) - for len(keys) < len(list) { - keys = append(keys, reflect.Value{}) - } - } - for _, r := range recs[numEqual-numHi : numEqual] { - out := opts.WithDiffMode(diffIdentical).FormatDiff(r.Value, ptrs) - list = append(list, textRecord{Key: formatKey(r.Key), Value: out}) - keys = append(keys, r.Key) - } - recs = recs[numEqual:] - continue - } - - // Handle unequal records. - for _, r := range recs[:ds.NumDiff()] { - switch { - case opts.CanFormatDiffSlice(r.Value): - out := opts.FormatDiffSlice(r.Value) - list = append(list, textRecord{Key: formatKey(r.Key), Value: out}) - keys = append(keys, r.Key) - case r.Value.NumChildren == r.Value.MaxDepth: - outx := opts.WithDiffMode(diffRemoved).FormatDiff(r.Value, ptrs) - outy := opts.WithDiffMode(diffInserted).FormatDiff(r.Value, ptrs) - for i := 0; i <= maxVerbosityPreset && outx != nil && outy != nil && outx.Equal(outy); i++ { - opts2 := verbosityPreset(opts, i) - outx = opts2.WithDiffMode(diffRemoved).FormatDiff(r.Value, ptrs) - outy = opts2.WithDiffMode(diffInserted).FormatDiff(r.Value, ptrs) - } - if outx != nil { - list = append(list, textRecord{Diff: diffRemoved, Key: formatKey(r.Key), Value: outx}) - keys = append(keys, r.Key) - } - if outy != nil { - list = append(list, textRecord{Diff: diffInserted, Key: formatKey(r.Key), Value: outy}) - keys = append(keys, r.Key) - } - default: - out := opts.FormatDiff(r.Value, ptrs) - list = append(list, textRecord{Key: formatKey(r.Key), Value: out}) - keys = append(keys, r.Key) - } - } - recs = recs[ds.NumDiff():] - numDiffs += ds.NumDiff() - } - if maxGroup.IsZero() { - assert(len(recs) == 0) - } else { - list.AppendEllipsis(maxGroup) - for len(keys) < len(list) { - keys = append(keys, reflect.Value{}) - } - } - assert(len(list) == len(keys)) - - // For maps, the default formatting logic uses fmt.Stringer which may - // produce ambiguous output. Avoid calling String to disambiguate. - if k == reflect.Map { - var ambiguous bool - seenKeys := map[string]reflect.Value{} - for i, currKey := range keys { - if currKey.IsValid() { - strKey := list[i].Key - prevKey, seen := seenKeys[strKey] - if seen && prevKey.CanInterface() && currKey.CanInterface() { - ambiguous = prevKey.Interface() != currKey.Interface() - if ambiguous { - break - } - } - seenKeys[strKey] = currKey - } - } - if ambiguous { - for i, k := range keys { - if k.IsValid() { - list[i].Key = formatMapKey(k, true, ptrs) - } - } - } - } - - return &textWrap{Prefix: "{", Value: list, Suffix: "}"} -} - -// coalesceAdjacentRecords coalesces the list of records into groups of -// adjacent equal, or unequal counts. -func coalesceAdjacentRecords(name string, recs []reportRecord) (groups []diffStats) { - var prevCase int // Arbitrary index into which case last occurred - lastStats := func(i int) *diffStats { - if prevCase != i { - groups = append(groups, diffStats{Name: name}) - prevCase = i - } - return &groups[len(groups)-1] - } - for _, r := range recs { - switch rv := r.Value; { - case rv.NumIgnored > 0 && rv.NumSame+rv.NumDiff == 0: - lastStats(1).NumIgnored++ - case rv.NumDiff == 0: - lastStats(1).NumIdentical++ - case rv.NumDiff > 0 && !rv.ValueY.IsValid(): - lastStats(2).NumRemoved++ - case rv.NumDiff > 0 && !rv.ValueX.IsValid(): - lastStats(2).NumInserted++ - default: - lastStats(2).NumModified++ - } - } - return groups -} diff --git a/vendor/github.com/google/go-cmp/cmp/report_references.go b/vendor/github.com/google/go-cmp/cmp/report_references.go deleted file mode 100644 index be31b33a9..000000000 --- a/vendor/github.com/google/go-cmp/cmp/report_references.go +++ /dev/null @@ -1,264 +0,0 @@ -// Copyright 2020, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package cmp - -import ( - "fmt" - "reflect" - "strings" - - "github.com/google/go-cmp/cmp/internal/flags" - "github.com/google/go-cmp/cmp/internal/value" -) - -const ( - pointerDelimPrefix = "⟪" - pointerDelimSuffix = "⟫" -) - -// formatPointer prints the address of the pointer. -func formatPointer(p value.Pointer, withDelims bool) string { - v := p.Uintptr() - if flags.Deterministic { - v = 0xdeadf00f // Only used for stable testing purposes - } - if withDelims { - return pointerDelimPrefix + formatHex(uint64(v)) + pointerDelimSuffix - } - return formatHex(uint64(v)) -} - -// pointerReferences is a stack of pointers visited so far. -type pointerReferences [][2]value.Pointer - -func (ps *pointerReferences) PushPair(vx, vy reflect.Value, d diffMode, deref bool) (pp [2]value.Pointer) { - if deref && vx.IsValid() { - vx = vx.Addr() - } - if deref && vy.IsValid() { - vy = vy.Addr() - } - switch d { - case diffUnknown, diffIdentical: - pp = [2]value.Pointer{value.PointerOf(vx), value.PointerOf(vy)} - case diffRemoved: - pp = [2]value.Pointer{value.PointerOf(vx), value.Pointer{}} - case diffInserted: - pp = [2]value.Pointer{value.Pointer{}, value.PointerOf(vy)} - } - *ps = append(*ps, pp) - return pp -} - -func (ps *pointerReferences) Push(v reflect.Value) (p value.Pointer, seen bool) { - p = value.PointerOf(v) - for _, pp := range *ps { - if p == pp[0] || p == pp[1] { - return p, true - } - } - *ps = append(*ps, [2]value.Pointer{p, p}) - return p, false -} - -func (ps *pointerReferences) Pop() { - *ps = (*ps)[:len(*ps)-1] -} - -// trunkReferences is metadata for a textNode indicating that the sub-tree -// represents the value for either pointer in a pair of references. -type trunkReferences struct{ pp [2]value.Pointer } - -// trunkReference is metadata for a textNode indicating that the sub-tree -// represents the value for the given pointer reference. -type trunkReference struct{ p value.Pointer } - -// leafReference is metadata for a textNode indicating that the value is -// truncated as it refers to another part of the tree (i.e., a trunk). -type leafReference struct{ p value.Pointer } - -func wrapTrunkReferences(pp [2]value.Pointer, s textNode) textNode { - switch { - case pp[0].IsNil(): - return &textWrap{Value: s, Metadata: trunkReference{pp[1]}} - case pp[1].IsNil(): - return &textWrap{Value: s, Metadata: trunkReference{pp[0]}} - case pp[0] == pp[1]: - return &textWrap{Value: s, Metadata: trunkReference{pp[0]}} - default: - return &textWrap{Value: s, Metadata: trunkReferences{pp}} - } -} -func wrapTrunkReference(p value.Pointer, printAddress bool, s textNode) textNode { - var prefix string - if printAddress { - prefix = formatPointer(p, true) - } - return &textWrap{Prefix: prefix, Value: s, Metadata: trunkReference{p}} -} -func makeLeafReference(p value.Pointer, printAddress bool) textNode { - out := &textWrap{Prefix: "(", Value: textEllipsis, Suffix: ")"} - var prefix string - if printAddress { - prefix = formatPointer(p, true) - } - return &textWrap{Prefix: prefix, Value: out, Metadata: leafReference{p}} -} - -// resolveReferences walks the textNode tree searching for any leaf reference -// metadata and resolves each against the corresponding trunk references. -// Since pointer addresses in memory are not particularly readable to the user, -// it replaces each pointer value with an arbitrary and unique reference ID. -func resolveReferences(s textNode) { - var walkNodes func(textNode, func(textNode)) - walkNodes = func(s textNode, f func(textNode)) { - f(s) - switch s := s.(type) { - case *textWrap: - walkNodes(s.Value, f) - case textList: - for _, r := range s { - walkNodes(r.Value, f) - } - } - } - - // Collect all trunks and leaves with reference metadata. - var trunks, leaves []*textWrap - walkNodes(s, func(s textNode) { - if s, ok := s.(*textWrap); ok { - switch s.Metadata.(type) { - case leafReference: - leaves = append(leaves, s) - case trunkReference, trunkReferences: - trunks = append(trunks, s) - } - } - }) - - // No leaf references to resolve. - if len(leaves) == 0 { - return - } - - // Collect the set of all leaf references to resolve. - leafPtrs := make(map[value.Pointer]bool) - for _, leaf := range leaves { - leafPtrs[leaf.Metadata.(leafReference).p] = true - } - - // Collect the set of trunk pointers that are always paired together. - // This allows us to assign a single ID to both pointers for brevity. - // If a pointer in a pair ever occurs by itself or as a different pair, - // then the pair is broken. - pairedTrunkPtrs := make(map[value.Pointer]value.Pointer) - unpair := func(p value.Pointer) { - if !pairedTrunkPtrs[p].IsNil() { - pairedTrunkPtrs[pairedTrunkPtrs[p]] = value.Pointer{} // invalidate other half - } - pairedTrunkPtrs[p] = value.Pointer{} // invalidate this half - } - for _, trunk := range trunks { - switch p := trunk.Metadata.(type) { - case trunkReference: - unpair(p.p) // standalone pointer cannot be part of a pair - case trunkReferences: - p0, ok0 := pairedTrunkPtrs[p.pp[0]] - p1, ok1 := pairedTrunkPtrs[p.pp[1]] - switch { - case !ok0 && !ok1: - // Register the newly seen pair. - pairedTrunkPtrs[p.pp[0]] = p.pp[1] - pairedTrunkPtrs[p.pp[1]] = p.pp[0] - case ok0 && ok1 && p0 == p.pp[1] && p1 == p.pp[0]: - // Exact pair already seen; do nothing. - default: - // Pair conflicts with some other pair; break all pairs. - unpair(p.pp[0]) - unpair(p.pp[1]) - } - } - } - - // Correlate each pointer referenced by leaves to a unique identifier, - // and print the IDs for each trunk that matches those pointers. - var nextID uint - ptrIDs := make(map[value.Pointer]uint) - newID := func() uint { - id := nextID - nextID++ - return id - } - for _, trunk := range trunks { - switch p := trunk.Metadata.(type) { - case trunkReference: - if print := leafPtrs[p.p]; print { - id, ok := ptrIDs[p.p] - if !ok { - id = newID() - ptrIDs[p.p] = id - } - trunk.Prefix = updateReferencePrefix(trunk.Prefix, formatReference(id)) - } - case trunkReferences: - print0 := leafPtrs[p.pp[0]] - print1 := leafPtrs[p.pp[1]] - if print0 || print1 { - id0, ok0 := ptrIDs[p.pp[0]] - id1, ok1 := ptrIDs[p.pp[1]] - isPair := pairedTrunkPtrs[p.pp[0]] == p.pp[1] && pairedTrunkPtrs[p.pp[1]] == p.pp[0] - if isPair { - var id uint - assert(ok0 == ok1) // must be seen together or not at all - if ok0 { - assert(id0 == id1) // must have the same ID - id = id0 - } else { - id = newID() - ptrIDs[p.pp[0]] = id - ptrIDs[p.pp[1]] = id - } - trunk.Prefix = updateReferencePrefix(trunk.Prefix, formatReference(id)) - } else { - if print0 && !ok0 { - id0 = newID() - ptrIDs[p.pp[0]] = id0 - } - if print1 && !ok1 { - id1 = newID() - ptrIDs[p.pp[1]] = id1 - } - switch { - case print0 && print1: - trunk.Prefix = updateReferencePrefix(trunk.Prefix, formatReference(id0)+","+formatReference(id1)) - case print0: - trunk.Prefix = updateReferencePrefix(trunk.Prefix, formatReference(id0)) - case print1: - trunk.Prefix = updateReferencePrefix(trunk.Prefix, formatReference(id1)) - } - } - } - } - } - - // Update all leaf references with the unique identifier. - for _, leaf := range leaves { - if id, ok := ptrIDs[leaf.Metadata.(leafReference).p]; ok { - leaf.Prefix = updateReferencePrefix(leaf.Prefix, formatReference(id)) - } - } -} - -func formatReference(id uint) string { - return fmt.Sprintf("ref#%d", id) -} - -func updateReferencePrefix(prefix, ref string) string { - if prefix == "" { - return pointerDelimPrefix + ref + pointerDelimSuffix - } - suffix := strings.TrimPrefix(prefix, pointerDelimPrefix) - return pointerDelimPrefix + ref + ": " + suffix -} diff --git a/vendor/github.com/google/go-cmp/cmp/report_reflect.go b/vendor/github.com/google/go-cmp/cmp/report_reflect.go deleted file mode 100644 index 76c04fdbd..000000000 --- a/vendor/github.com/google/go-cmp/cmp/report_reflect.go +++ /dev/null @@ -1,403 +0,0 @@ -// Copyright 2019, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package cmp - -import ( - "bytes" - "fmt" - "reflect" - "strconv" - "strings" - "unicode" - "unicode/utf8" - - "github.com/google/go-cmp/cmp/internal/value" -) - -type formatValueOptions struct { - // AvoidStringer controls whether to avoid calling custom stringer - // methods like error.Error or fmt.Stringer.String. - AvoidStringer bool - - // PrintAddresses controls whether to print the address of all pointers, - // slice elements, and maps. - PrintAddresses bool - - // QualifiedNames controls whether FormatType uses the fully qualified name - // (including the full package path as opposed to just the package name). - QualifiedNames bool - - // VerbosityLevel controls the amount of output to produce. - // A higher value produces more output. A value of zero or lower produces - // no output (represented using an ellipsis). - // If LimitVerbosity is false, then the level is treated as infinite. - VerbosityLevel int - - // LimitVerbosity specifies that formatting should respect VerbosityLevel. - LimitVerbosity bool -} - -// FormatType prints the type as if it were wrapping s. -// This may return s as-is depending on the current type and TypeMode mode. -func (opts formatOptions) FormatType(t reflect.Type, s textNode) textNode { - // Check whether to emit the type or not. - switch opts.TypeMode { - case autoType: - switch t.Kind() { - case reflect.Struct, reflect.Slice, reflect.Array, reflect.Map: - if s.Equal(textNil) { - return s - } - default: - return s - } - if opts.DiffMode == diffIdentical { - return s // elide type for identical nodes - } - case elideType: - return s - } - - // Determine the type label, applying special handling for unnamed types. - typeName := value.TypeString(t, opts.QualifiedNames) - if t.Name() == "" { - // According to Go grammar, certain type literals contain symbols that - // do not strongly bind to the next lexicographical token (e.g., *T). - switch t.Kind() { - case reflect.Chan, reflect.Func, reflect.Ptr: - typeName = "(" + typeName + ")" - } - } - return &textWrap{Prefix: typeName, Value: wrapParens(s)} -} - -// wrapParens wraps s with a set of parenthesis, but avoids it if the -// wrapped node itself is already surrounded by a pair of parenthesis or braces. -// It handles unwrapping one level of pointer-reference nodes. -func wrapParens(s textNode) textNode { - var refNode *textWrap - if s2, ok := s.(*textWrap); ok { - // Unwrap a single pointer reference node. - switch s2.Metadata.(type) { - case leafReference, trunkReference, trunkReferences: - refNode = s2 - if s3, ok := refNode.Value.(*textWrap); ok { - s2 = s3 - } - } - - // Already has delimiters that make parenthesis unnecessary. - hasParens := strings.HasPrefix(s2.Prefix, "(") && strings.HasSuffix(s2.Suffix, ")") - hasBraces := strings.HasPrefix(s2.Prefix, "{") && strings.HasSuffix(s2.Suffix, "}") - if hasParens || hasBraces { - return s - } - } - if refNode != nil { - refNode.Value = &textWrap{Prefix: "(", Value: refNode.Value, Suffix: ")"} - return s - } - return &textWrap{Prefix: "(", Value: s, Suffix: ")"} -} - -// FormatValue prints the reflect.Value, taking extra care to avoid descending -// into pointers already in ptrs. As pointers are visited, ptrs is also updated. -func (opts formatOptions) FormatValue(v reflect.Value, parentKind reflect.Kind, ptrs *pointerReferences) (out textNode) { - if !v.IsValid() { - return nil - } - t := v.Type() - - // Check slice element for cycles. - if parentKind == reflect.Slice { - ptrRef, visited := ptrs.Push(v.Addr()) - if visited { - return makeLeafReference(ptrRef, false) - } - defer ptrs.Pop() - defer func() { out = wrapTrunkReference(ptrRef, false, out) }() - } - - // Check whether there is an Error or String method to call. - if !opts.AvoidStringer && v.CanInterface() { - // Avoid calling Error or String methods on nil receivers since many - // implementations crash when doing so. - if (t.Kind() != reflect.Ptr && t.Kind() != reflect.Interface) || !v.IsNil() { - var prefix, strVal string - func() { - // Swallow and ignore any panics from String or Error. - defer func() { recover() }() - switch v := v.Interface().(type) { - case error: - strVal = v.Error() - prefix = "e" - case fmt.Stringer: - strVal = v.String() - prefix = "s" - } - }() - if prefix != "" { - return opts.formatString(prefix, strVal) - } - } - } - - // Check whether to explicitly wrap the result with the type. - var skipType bool - defer func() { - if !skipType { - out = opts.FormatType(t, out) - } - }() - - switch t.Kind() { - case reflect.Bool: - return textLine(fmt.Sprint(v.Bool())) - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return textLine(fmt.Sprint(v.Int())) - case reflect.Uint, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return textLine(fmt.Sprint(v.Uint())) - case reflect.Uint8: - if parentKind == reflect.Slice || parentKind == reflect.Array { - return textLine(formatHex(v.Uint())) - } - return textLine(fmt.Sprint(v.Uint())) - case reflect.Uintptr: - return textLine(formatHex(v.Uint())) - case reflect.Float32, reflect.Float64: - return textLine(fmt.Sprint(v.Float())) - case reflect.Complex64, reflect.Complex128: - return textLine(fmt.Sprint(v.Complex())) - case reflect.String: - return opts.formatString("", v.String()) - case reflect.UnsafePointer, reflect.Chan, reflect.Func: - return textLine(formatPointer(value.PointerOf(v), true)) - case reflect.Struct: - var list textList - v := makeAddressable(v) // needed for retrieveUnexportedField - maxLen := v.NumField() - if opts.LimitVerbosity { - maxLen = ((1 << opts.verbosity()) >> 1) << 2 // 0, 4, 8, 16, 32, etc... - opts.VerbosityLevel-- - } - for i := 0; i < v.NumField(); i++ { - vv := v.Field(i) - if value.IsZero(vv) { - continue // Elide fields with zero values - } - if len(list) == maxLen { - list.AppendEllipsis(diffStats{}) - break - } - sf := t.Field(i) - if supportExporters && !isExported(sf.Name) { - vv = retrieveUnexportedField(v, sf, true) - } - s := opts.WithTypeMode(autoType).FormatValue(vv, t.Kind(), ptrs) - list = append(list, textRecord{Key: sf.Name, Value: s}) - } - return &textWrap{Prefix: "{", Value: list, Suffix: "}"} - case reflect.Slice: - if v.IsNil() { - return textNil - } - - // Check whether this is a []byte of text data. - if t.Elem() == reflect.TypeOf(byte(0)) { - b := v.Bytes() - isPrintSpace := func(r rune) bool { return unicode.IsPrint(r) || unicode.IsSpace(r) } - if len(b) > 0 && utf8.Valid(b) && len(bytes.TrimFunc(b, isPrintSpace)) == 0 { - out = opts.formatString("", string(b)) - skipType = true - return opts.WithTypeMode(emitType).FormatType(t, out) - } - } - - fallthrough - case reflect.Array: - maxLen := v.Len() - if opts.LimitVerbosity { - maxLen = ((1 << opts.verbosity()) >> 1) << 2 // 0, 4, 8, 16, 32, etc... - opts.VerbosityLevel-- - } - var list textList - for i := 0; i < v.Len(); i++ { - if len(list) == maxLen { - list.AppendEllipsis(diffStats{}) - break - } - s := opts.WithTypeMode(elideType).FormatValue(v.Index(i), t.Kind(), ptrs) - list = append(list, textRecord{Value: s}) - } - - out = &textWrap{Prefix: "{", Value: list, Suffix: "}"} - if t.Kind() == reflect.Slice && opts.PrintAddresses { - header := fmt.Sprintf("ptr:%v, len:%d, cap:%d", formatPointer(value.PointerOf(v), false), v.Len(), v.Cap()) - out = &textWrap{Prefix: pointerDelimPrefix + header + pointerDelimSuffix, Value: out} - } - return out - case reflect.Map: - if v.IsNil() { - return textNil - } - - // Check pointer for cycles. - ptrRef, visited := ptrs.Push(v) - if visited { - return makeLeafReference(ptrRef, opts.PrintAddresses) - } - defer ptrs.Pop() - - maxLen := v.Len() - if opts.LimitVerbosity { - maxLen = ((1 << opts.verbosity()) >> 1) << 2 // 0, 4, 8, 16, 32, etc... - opts.VerbosityLevel-- - } - var list textList - for _, k := range value.SortKeys(v.MapKeys()) { - if len(list) == maxLen { - list.AppendEllipsis(diffStats{}) - break - } - sk := formatMapKey(k, false, ptrs) - sv := opts.WithTypeMode(elideType).FormatValue(v.MapIndex(k), t.Kind(), ptrs) - list = append(list, textRecord{Key: sk, Value: sv}) - } - - out = &textWrap{Prefix: "{", Value: list, Suffix: "}"} - out = wrapTrunkReference(ptrRef, opts.PrintAddresses, out) - return out - case reflect.Ptr: - if v.IsNil() { - return textNil - } - - // Check pointer for cycles. - ptrRef, visited := ptrs.Push(v) - if visited { - out = makeLeafReference(ptrRef, opts.PrintAddresses) - return &textWrap{Prefix: "&", Value: out} - } - defer ptrs.Pop() - - skipType = true // Let the underlying value print the type instead - out = opts.FormatValue(v.Elem(), t.Kind(), ptrs) - out = wrapTrunkReference(ptrRef, opts.PrintAddresses, out) - out = &textWrap{Prefix: "&", Value: out} - return out - case reflect.Interface: - if v.IsNil() { - return textNil - } - // Interfaces accept different concrete types, - // so configure the underlying value to explicitly print the type. - skipType = true // Print the concrete type instead - return opts.WithTypeMode(emitType).FormatValue(v.Elem(), t.Kind(), ptrs) - default: - panic(fmt.Sprintf("%v kind not handled", v.Kind())) - } -} - -func (opts formatOptions) formatString(prefix, s string) textNode { - maxLen := len(s) - maxLines := strings.Count(s, "\n") + 1 - if opts.LimitVerbosity { - maxLen = (1 << opts.verbosity()) << 5 // 32, 64, 128, 256, etc... - maxLines = (1 << opts.verbosity()) << 2 // 4, 8, 16, 32, 64, etc... - } - - // For multiline strings, use the triple-quote syntax, - // but only use it when printing removed or inserted nodes since - // we only want the extra verbosity for those cases. - lines := strings.Split(strings.TrimSuffix(s, "\n"), "\n") - isTripleQuoted := len(lines) >= 4 && (opts.DiffMode == '-' || opts.DiffMode == '+') - for i := 0; i < len(lines) && isTripleQuoted; i++ { - lines[i] = strings.TrimPrefix(strings.TrimSuffix(lines[i], "\r"), "\r") // trim leading/trailing carriage returns for legacy Windows endline support - isPrintable := func(r rune) bool { - return unicode.IsPrint(r) || r == '\t' // specially treat tab as printable - } - line := lines[i] - isTripleQuoted = !strings.HasPrefix(strings.TrimPrefix(line, prefix), `"""`) && !strings.HasPrefix(line, "...") && strings.TrimFunc(line, isPrintable) == "" && len(line) <= maxLen - } - if isTripleQuoted { - var list textList - list = append(list, textRecord{Diff: opts.DiffMode, Value: textLine(prefix + `"""`), ElideComma: true}) - for i, line := range lines { - if numElided := len(lines) - i; i == maxLines-1 && numElided > 1 { - comment := commentString(fmt.Sprintf("%d elided lines", numElided)) - list = append(list, textRecord{Diff: opts.DiffMode, Value: textEllipsis, ElideComma: true, Comment: comment}) - break - } - list = append(list, textRecord{Diff: opts.DiffMode, Value: textLine(line), ElideComma: true}) - } - list = append(list, textRecord{Diff: opts.DiffMode, Value: textLine(prefix + `"""`), ElideComma: true}) - return &textWrap{Prefix: "(", Value: list, Suffix: ")"} - } - - // Format the string as a single-line quoted string. - if len(s) > maxLen+len(textEllipsis) { - return textLine(prefix + formatString(s[:maxLen]) + string(textEllipsis)) - } - return textLine(prefix + formatString(s)) -} - -// formatMapKey formats v as if it were a map key. -// The result is guaranteed to be a single line. -func formatMapKey(v reflect.Value, disambiguate bool, ptrs *pointerReferences) string { - var opts formatOptions - opts.DiffMode = diffIdentical - opts.TypeMode = elideType - opts.PrintAddresses = disambiguate - opts.AvoidStringer = disambiguate - opts.QualifiedNames = disambiguate - opts.VerbosityLevel = maxVerbosityPreset - opts.LimitVerbosity = true - s := opts.FormatValue(v, reflect.Map, ptrs).String() - return strings.TrimSpace(s) -} - -// formatString prints s as a double-quoted or backtick-quoted string. -func formatString(s string) string { - // Use quoted string if it the same length as a raw string literal. - // Otherwise, attempt to use the raw string form. - qs := strconv.Quote(s) - if len(qs) == 1+len(s)+1 { - return qs - } - - // Disallow newlines to ensure output is a single line. - // Only allow printable runes for readability purposes. - rawInvalid := func(r rune) bool { - return r == '`' || r == '\n' || !(unicode.IsPrint(r) || r == '\t') - } - if utf8.ValidString(s) && strings.IndexFunc(s, rawInvalid) < 0 { - return "`" + s + "`" - } - return qs -} - -// formatHex prints u as a hexadecimal integer in Go notation. -func formatHex(u uint64) string { - var f string - switch { - case u <= 0xff: - f = "0x%02x" - case u <= 0xffff: - f = "0x%04x" - case u <= 0xffffff: - f = "0x%06x" - case u <= 0xffffffff: - f = "0x%08x" - case u <= 0xffffffffff: - f = "0x%010x" - case u <= 0xffffffffffff: - f = "0x%012x" - case u <= 0xffffffffffffff: - f = "0x%014x" - case u <= 0xffffffffffffffff: - f = "0x%016x" - } - return fmt.Sprintf(f, u) -} diff --git a/vendor/github.com/google/go-cmp/cmp/report_slices.go b/vendor/github.com/google/go-cmp/cmp/report_slices.go deleted file mode 100644 index 68b5c1ae1..000000000 --- a/vendor/github.com/google/go-cmp/cmp/report_slices.go +++ /dev/null @@ -1,613 +0,0 @@ -// Copyright 2019, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package cmp - -import ( - "bytes" - "fmt" - "math" - "reflect" - "strconv" - "strings" - "unicode" - "unicode/utf8" - - "github.com/google/go-cmp/cmp/internal/diff" -) - -// CanFormatDiffSlice reports whether we support custom formatting for nodes -// that are slices of primitive kinds or strings. -func (opts formatOptions) CanFormatDiffSlice(v *valueNode) bool { - switch { - case opts.DiffMode != diffUnknown: - return false // Must be formatting in diff mode - case v.NumDiff == 0: - return false // No differences detected - case !v.ValueX.IsValid() || !v.ValueY.IsValid(): - return false // Both values must be valid - case v.NumIgnored > 0: - return false // Some ignore option was used - case v.NumTransformed > 0: - return false // Some transform option was used - case v.NumCompared > 1: - return false // More than one comparison was used - case v.NumCompared == 1 && v.Type.Name() != "": - // The need for cmp to check applicability of options on every element - // in a slice is a significant performance detriment for large []byte. - // The workaround is to specify Comparer(bytes.Equal), - // which enables cmp to compare []byte more efficiently. - // If they differ, we still want to provide batched diffing. - // The logic disallows named types since they tend to have their own - // String method, with nicer formatting than what this provides. - return false - } - - // Check whether this is an interface with the same concrete types. - t := v.Type - vx, vy := v.ValueX, v.ValueY - if t.Kind() == reflect.Interface && !vx.IsNil() && !vy.IsNil() && vx.Elem().Type() == vy.Elem().Type() { - vx, vy = vx.Elem(), vy.Elem() - t = vx.Type() - } - - // Check whether we provide specialized diffing for this type. - switch t.Kind() { - case reflect.String: - case reflect.Array, reflect.Slice: - // Only slices of primitive types have specialized handling. - switch t.Elem().Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, - reflect.Bool, reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128: - default: - return false - } - - // Both slice values have to be non-empty. - if t.Kind() == reflect.Slice && (vx.Len() == 0 || vy.Len() == 0) { - return false - } - - // If a sufficient number of elements already differ, - // use specialized formatting even if length requirement is not met. - if v.NumDiff > v.NumSame { - return true - } - default: - return false - } - - // Use specialized string diffing for longer slices or strings. - const minLength = 32 - return vx.Len() >= minLength && vy.Len() >= minLength -} - -// FormatDiffSlice prints a diff for the slices (or strings) represented by v. -// This provides custom-tailored logic to make printing of differences in -// textual strings and slices of primitive kinds more readable. -func (opts formatOptions) FormatDiffSlice(v *valueNode) textNode { - assert(opts.DiffMode == diffUnknown) - t, vx, vy := v.Type, v.ValueX, v.ValueY - if t.Kind() == reflect.Interface { - vx, vy = vx.Elem(), vy.Elem() - t = vx.Type() - opts = opts.WithTypeMode(emitType) - } - - // Auto-detect the type of the data. - var sx, sy string - var ssx, ssy []string - var isString, isMostlyText, isPureLinedText, isBinary bool - switch { - case t.Kind() == reflect.String: - sx, sy = vx.String(), vy.String() - isString = true - case t.Kind() == reflect.Slice && t.Elem() == reflect.TypeOf(byte(0)): - sx, sy = string(vx.Bytes()), string(vy.Bytes()) - isString = true - case t.Kind() == reflect.Array: - // Arrays need to be addressable for slice operations to work. - vx2, vy2 := reflect.New(t).Elem(), reflect.New(t).Elem() - vx2.Set(vx) - vy2.Set(vy) - vx, vy = vx2, vy2 - } - if isString { - var numTotalRunes, numValidRunes, numLines, lastLineIdx, maxLineLen int - for i, r := range sx + sy { - numTotalRunes++ - if (unicode.IsPrint(r) || unicode.IsSpace(r)) && r != utf8.RuneError { - numValidRunes++ - } - if r == '\n' { - if maxLineLen < i-lastLineIdx { - maxLineLen = i - lastLineIdx - } - lastLineIdx = i + 1 - numLines++ - } - } - isPureText := numValidRunes == numTotalRunes - isMostlyText = float64(numValidRunes) > math.Floor(0.90*float64(numTotalRunes)) - isPureLinedText = isPureText && numLines >= 4 && maxLineLen <= 1024 - isBinary = !isMostlyText - - // Avoid diffing by lines if it produces a significantly more complex - // edit script than diffing by bytes. - if isPureLinedText { - ssx = strings.Split(sx, "\n") - ssy = strings.Split(sy, "\n") - esLines := diff.Difference(len(ssx), len(ssy), func(ix, iy int) diff.Result { - return diff.BoolResult(ssx[ix] == ssy[iy]) - }) - esBytes := diff.Difference(len(sx), len(sy), func(ix, iy int) diff.Result { - return diff.BoolResult(sx[ix] == sy[iy]) - }) - efficiencyLines := float64(esLines.Dist()) / float64(len(esLines)) - efficiencyBytes := float64(esBytes.Dist()) / float64(len(esBytes)) - isPureLinedText = efficiencyLines < 4*efficiencyBytes - } - } - - // Format the string into printable records. - var list textList - var delim string - switch { - // If the text appears to be multi-lined text, - // then perform differencing across individual lines. - case isPureLinedText: - list = opts.formatDiffSlice( - reflect.ValueOf(ssx), reflect.ValueOf(ssy), 1, "line", - func(v reflect.Value, d diffMode) textRecord { - s := formatString(v.Index(0).String()) - return textRecord{Diff: d, Value: textLine(s)} - }, - ) - delim = "\n" - - // If possible, use a custom triple-quote (""") syntax for printing - // differences in a string literal. This format is more readable, - // but has edge-cases where differences are visually indistinguishable. - // This format is avoided under the following conditions: - // • A line starts with `"""` - // • A line starts with "..." - // • A line contains non-printable characters - // • Adjacent different lines differ only by whitespace - // - // For example: - // """ - // ... // 3 identical lines - // foo - // bar - // - baz - // + BAZ - // """ - isTripleQuoted := true - prevRemoveLines := map[string]bool{} - prevInsertLines := map[string]bool{} - var list2 textList - list2 = append(list2, textRecord{Value: textLine(`"""`), ElideComma: true}) - for _, r := range list { - if !r.Value.Equal(textEllipsis) { - line, _ := strconv.Unquote(string(r.Value.(textLine))) - line = strings.TrimPrefix(strings.TrimSuffix(line, "\r"), "\r") // trim leading/trailing carriage returns for legacy Windows endline support - normLine := strings.Map(func(r rune) rune { - if unicode.IsSpace(r) { - return -1 // drop whitespace to avoid visually indistinguishable output - } - return r - }, line) - isPrintable := func(r rune) bool { - return unicode.IsPrint(r) || r == '\t' // specially treat tab as printable - } - isTripleQuoted = !strings.HasPrefix(line, `"""`) && !strings.HasPrefix(line, "...") && strings.TrimFunc(line, isPrintable) == "" - switch r.Diff { - case diffRemoved: - isTripleQuoted = isTripleQuoted && !prevInsertLines[normLine] - prevRemoveLines[normLine] = true - case diffInserted: - isTripleQuoted = isTripleQuoted && !prevRemoveLines[normLine] - prevInsertLines[normLine] = true - } - if !isTripleQuoted { - break - } - r.Value = textLine(line) - r.ElideComma = true - } - if !(r.Diff == diffRemoved || r.Diff == diffInserted) { // start a new non-adjacent difference group - prevRemoveLines = map[string]bool{} - prevInsertLines = map[string]bool{} - } - list2 = append(list2, r) - } - if r := list2[len(list2)-1]; r.Diff == diffIdentical && len(r.Value.(textLine)) == 0 { - list2 = list2[:len(list2)-1] // elide single empty line at the end - } - list2 = append(list2, textRecord{Value: textLine(`"""`), ElideComma: true}) - if isTripleQuoted { - var out textNode = &textWrap{Prefix: "(", Value: list2, Suffix: ")"} - switch t.Kind() { - case reflect.String: - if t != reflect.TypeOf(string("")) { - out = opts.FormatType(t, out) - } - case reflect.Slice: - // Always emit type for slices since the triple-quote syntax - // looks like a string (not a slice). - opts = opts.WithTypeMode(emitType) - out = opts.FormatType(t, out) - } - return out - } - - // If the text appears to be single-lined text, - // then perform differencing in approximately fixed-sized chunks. - // The output is printed as quoted strings. - case isMostlyText: - list = opts.formatDiffSlice( - reflect.ValueOf(sx), reflect.ValueOf(sy), 64, "byte", - func(v reflect.Value, d diffMode) textRecord { - s := formatString(v.String()) - return textRecord{Diff: d, Value: textLine(s)} - }, - ) - - // If the text appears to be binary data, - // then perform differencing in approximately fixed-sized chunks. - // The output is inspired by hexdump. - case isBinary: - list = opts.formatDiffSlice( - reflect.ValueOf(sx), reflect.ValueOf(sy), 16, "byte", - func(v reflect.Value, d diffMode) textRecord { - var ss []string - for i := 0; i < v.Len(); i++ { - ss = append(ss, formatHex(v.Index(i).Uint())) - } - s := strings.Join(ss, ", ") - comment := commentString(fmt.Sprintf("%c|%v|", d, formatASCII(v.String()))) - return textRecord{Diff: d, Value: textLine(s), Comment: comment} - }, - ) - - // For all other slices of primitive types, - // then perform differencing in approximately fixed-sized chunks. - // The size of each chunk depends on the width of the element kind. - default: - var chunkSize int - if t.Elem().Kind() == reflect.Bool { - chunkSize = 16 - } else { - switch t.Elem().Bits() { - case 8: - chunkSize = 16 - case 16: - chunkSize = 12 - case 32: - chunkSize = 8 - default: - chunkSize = 8 - } - } - list = opts.formatDiffSlice( - vx, vy, chunkSize, t.Elem().Kind().String(), - func(v reflect.Value, d diffMode) textRecord { - var ss []string - for i := 0; i < v.Len(); i++ { - switch t.Elem().Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - ss = append(ss, fmt.Sprint(v.Index(i).Int())) - case reflect.Uint, reflect.Uint16, reflect.Uint32, reflect.Uint64: - ss = append(ss, fmt.Sprint(v.Index(i).Uint())) - case reflect.Uint8, reflect.Uintptr: - ss = append(ss, formatHex(v.Index(i).Uint())) - case reflect.Bool, reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128: - ss = append(ss, fmt.Sprint(v.Index(i).Interface())) - } - } - s := strings.Join(ss, ", ") - return textRecord{Diff: d, Value: textLine(s)} - }, - ) - } - - // Wrap the output with appropriate type information. - var out textNode = &textWrap{Prefix: "{", Value: list, Suffix: "}"} - if !isMostlyText { - // The "{...}" byte-sequence literal is not valid Go syntax for strings. - // Emit the type for extra clarity (e.g. "string{...}"). - if t.Kind() == reflect.String { - opts = opts.WithTypeMode(emitType) - } - return opts.FormatType(t, out) - } - switch t.Kind() { - case reflect.String: - out = &textWrap{Prefix: "strings.Join(", Value: out, Suffix: fmt.Sprintf(", %q)", delim)} - if t != reflect.TypeOf(string("")) { - out = opts.FormatType(t, out) - } - case reflect.Slice: - out = &textWrap{Prefix: "bytes.Join(", Value: out, Suffix: fmt.Sprintf(", %q)", delim)} - if t != reflect.TypeOf([]byte(nil)) { - out = opts.FormatType(t, out) - } - } - return out -} - -// formatASCII formats s as an ASCII string. -// This is useful for printing binary strings in a semi-legible way. -func formatASCII(s string) string { - b := bytes.Repeat([]byte{'.'}, len(s)) - for i := 0; i < len(s); i++ { - if ' ' <= s[i] && s[i] <= '~' { - b[i] = s[i] - } - } - return string(b) -} - -func (opts formatOptions) formatDiffSlice( - vx, vy reflect.Value, chunkSize int, name string, - makeRec func(reflect.Value, diffMode) textRecord, -) (list textList) { - eq := func(ix, iy int) bool { - return vx.Index(ix).Interface() == vy.Index(iy).Interface() - } - es := diff.Difference(vx.Len(), vy.Len(), func(ix, iy int) diff.Result { - return diff.BoolResult(eq(ix, iy)) - }) - - appendChunks := func(v reflect.Value, d diffMode) int { - n0 := v.Len() - for v.Len() > 0 { - n := chunkSize - if n > v.Len() { - n = v.Len() - } - list = append(list, makeRec(v.Slice(0, n), d)) - v = v.Slice(n, v.Len()) - } - return n0 - v.Len() - } - - var numDiffs int - maxLen := -1 - if opts.LimitVerbosity { - maxLen = (1 << opts.verbosity()) << 2 // 4, 8, 16, 32, 64, etc... - opts.VerbosityLevel-- - } - - groups := coalesceAdjacentEdits(name, es) - groups = coalesceInterveningIdentical(groups, chunkSize/4) - groups = cleanupSurroundingIdentical(groups, eq) - maxGroup := diffStats{Name: name} - for i, ds := range groups { - if maxLen >= 0 && numDiffs >= maxLen { - maxGroup = maxGroup.Append(ds) - continue - } - - // Print equal. - if ds.NumDiff() == 0 { - // Compute the number of leading and trailing equal bytes to print. - var numLo, numHi int - numEqual := ds.NumIgnored + ds.NumIdentical - for numLo < chunkSize*numContextRecords && numLo+numHi < numEqual && i != 0 { - numLo++ - } - for numHi < chunkSize*numContextRecords && numLo+numHi < numEqual && i != len(groups)-1 { - numHi++ - } - if numEqual-(numLo+numHi) <= chunkSize && ds.NumIgnored == 0 { - numHi = numEqual - numLo // Avoid pointless coalescing of single equal row - } - - // Print the equal bytes. - appendChunks(vx.Slice(0, numLo), diffIdentical) - if numEqual > numLo+numHi { - ds.NumIdentical -= numLo + numHi - list.AppendEllipsis(ds) - } - appendChunks(vx.Slice(numEqual-numHi, numEqual), diffIdentical) - vx = vx.Slice(numEqual, vx.Len()) - vy = vy.Slice(numEqual, vy.Len()) - continue - } - - // Print unequal. - len0 := len(list) - nx := appendChunks(vx.Slice(0, ds.NumIdentical+ds.NumRemoved+ds.NumModified), diffRemoved) - vx = vx.Slice(nx, vx.Len()) - ny := appendChunks(vy.Slice(0, ds.NumIdentical+ds.NumInserted+ds.NumModified), diffInserted) - vy = vy.Slice(ny, vy.Len()) - numDiffs += len(list) - len0 - } - if maxGroup.IsZero() { - assert(vx.Len() == 0 && vy.Len() == 0) - } else { - list.AppendEllipsis(maxGroup) - } - return list -} - -// coalesceAdjacentEdits coalesces the list of edits into groups of adjacent -// equal or unequal counts. -// -// Example: -// -// Input: "..XXY...Y" -// Output: [ -// {NumIdentical: 2}, -// {NumRemoved: 2, NumInserted 1}, -// {NumIdentical: 3}, -// {NumInserted: 1}, -// ] -// -func coalesceAdjacentEdits(name string, es diff.EditScript) (groups []diffStats) { - var prevMode byte - lastStats := func(mode byte) *diffStats { - if prevMode != mode { - groups = append(groups, diffStats{Name: name}) - prevMode = mode - } - return &groups[len(groups)-1] - } - for _, e := range es { - switch e { - case diff.Identity: - lastStats('=').NumIdentical++ - case diff.UniqueX: - lastStats('!').NumRemoved++ - case diff.UniqueY: - lastStats('!').NumInserted++ - case diff.Modified: - lastStats('!').NumModified++ - } - } - return groups -} - -// coalesceInterveningIdentical coalesces sufficiently short (<= windowSize) -// equal groups into adjacent unequal groups that currently result in a -// dual inserted/removed printout. This acts as a high-pass filter to smooth -// out high-frequency changes within the windowSize. -// -// Example: -// -// WindowSize: 16, -// Input: [ -// {NumIdentical: 61}, // group 0 -// {NumRemoved: 3, NumInserted: 1}, // group 1 -// {NumIdentical: 6}, // ├── coalesce -// {NumInserted: 2}, // ├── coalesce -// {NumIdentical: 1}, // ├── coalesce -// {NumRemoved: 9}, // └── coalesce -// {NumIdentical: 64}, // group 2 -// {NumRemoved: 3, NumInserted: 1}, // group 3 -// {NumIdentical: 6}, // ├── coalesce -// {NumInserted: 2}, // ├── coalesce -// {NumIdentical: 1}, // ├── coalesce -// {NumRemoved: 7}, // ├── coalesce -// {NumIdentical: 1}, // ├── coalesce -// {NumRemoved: 2}, // └── coalesce -// {NumIdentical: 63}, // group 4 -// ] -// Output: [ -// {NumIdentical: 61}, -// {NumIdentical: 7, NumRemoved: 12, NumInserted: 3}, -// {NumIdentical: 64}, -// {NumIdentical: 8, NumRemoved: 12, NumInserted: 3}, -// {NumIdentical: 63}, -// ] -// -func coalesceInterveningIdentical(groups []diffStats, windowSize int) []diffStats { - groups, groupsOrig := groups[:0], groups - for i, ds := range groupsOrig { - if len(groups) >= 2 && ds.NumDiff() > 0 { - prev := &groups[len(groups)-2] // Unequal group - curr := &groups[len(groups)-1] // Equal group - next := &groupsOrig[i] // Unequal group - hadX, hadY := prev.NumRemoved > 0, prev.NumInserted > 0 - hasX, hasY := next.NumRemoved > 0, next.NumInserted > 0 - if ((hadX || hasX) && (hadY || hasY)) && curr.NumIdentical <= windowSize { - *prev = prev.Append(*curr).Append(*next) - groups = groups[:len(groups)-1] // Truncate off equal group - continue - } - } - groups = append(groups, ds) - } - return groups -} - -// cleanupSurroundingIdentical scans through all unequal groups, and -// moves any leading sequence of equal elements to the preceding equal group and -// moves and trailing sequence of equal elements to the succeeding equal group. -// -// This is necessary since coalesceInterveningIdentical may coalesce edit groups -// together such that leading/trailing spans of equal elements becomes possible. -// Note that this can occur even with an optimal diffing algorithm. -// -// Example: -// -// Input: [ -// {NumIdentical: 61}, -// {NumIdentical: 1 , NumRemoved: 11, NumInserted: 2}, // assume 3 leading identical elements -// {NumIdentical: 67}, -// {NumIdentical: 7, NumRemoved: 12, NumInserted: 3}, // assume 10 trailing identical elements -// {NumIdentical: 54}, -// ] -// Output: [ -// {NumIdentical: 64}, // incremented by 3 -// {NumRemoved: 9}, -// {NumIdentical: 67}, -// {NumRemoved: 9}, -// {NumIdentical: 64}, // incremented by 10 -// ] -// -func cleanupSurroundingIdentical(groups []diffStats, eq func(i, j int) bool) []diffStats { - var ix, iy int // indexes into sequence x and y - for i, ds := range groups { - // Handle equal group. - if ds.NumDiff() == 0 { - ix += ds.NumIdentical - iy += ds.NumIdentical - continue - } - - // Handle unequal group. - nx := ds.NumIdentical + ds.NumRemoved + ds.NumModified - ny := ds.NumIdentical + ds.NumInserted + ds.NumModified - var numLeadingIdentical, numTrailingIdentical int - for j := 0; j < nx && j < ny && eq(ix+j, iy+j); j++ { - numLeadingIdentical++ - } - for j := 0; j < nx && j < ny && eq(ix+nx-1-j, iy+ny-1-j); j++ { - numTrailingIdentical++ - } - if numIdentical := numLeadingIdentical + numTrailingIdentical; numIdentical > 0 { - if numLeadingIdentical > 0 { - // Remove leading identical span from this group and - // insert it into the preceding group. - if i-1 >= 0 { - groups[i-1].NumIdentical += numLeadingIdentical - } else { - // No preceding group exists, so prepend a new group, - // but do so after we finish iterating over all groups. - defer func() { - groups = append([]diffStats{{Name: groups[0].Name, NumIdentical: numLeadingIdentical}}, groups...) - }() - } - // Increment indexes since the preceding group would have handled this. - ix += numLeadingIdentical - iy += numLeadingIdentical - } - if numTrailingIdentical > 0 { - // Remove trailing identical span from this group and - // insert it into the succeeding group. - if i+1 < len(groups) { - groups[i+1].NumIdentical += numTrailingIdentical - } else { - // No succeeding group exists, so append a new group, - // but do so after we finish iterating over all groups. - defer func() { - groups = append(groups, diffStats{Name: groups[len(groups)-1].Name, NumIdentical: numTrailingIdentical}) - }() - } - // Do not increment indexes since the succeeding group will handle this. - } - - // Update this group since some identical elements were removed. - nx -= numIdentical - ny -= numIdentical - groups[i] = diffStats{Name: ds.Name, NumRemoved: nx, NumInserted: ny} - } - ix += nx - iy += ny - } - return groups -} diff --git a/vendor/github.com/google/go-cmp/cmp/report_text.go b/vendor/github.com/google/go-cmp/cmp/report_text.go deleted file mode 100644 index 0fd46d7ff..000000000 --- a/vendor/github.com/google/go-cmp/cmp/report_text.go +++ /dev/null @@ -1,431 +0,0 @@ -// Copyright 2019, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package cmp - -import ( - "bytes" - "fmt" - "math/rand" - "strings" - "time" - "unicode/utf8" - - "github.com/google/go-cmp/cmp/internal/flags" -) - -var randBool = rand.New(rand.NewSource(time.Now().Unix())).Intn(2) == 0 - -const maxColumnLength = 80 - -type indentMode int - -func (n indentMode) appendIndent(b []byte, d diffMode) []byte { - // The output of Diff is documented as being unstable to provide future - // flexibility in changing the output for more humanly readable reports. - // This logic intentionally introduces instability to the exact output - // so that users can detect accidental reliance on stability early on, - // rather than much later when an actual change to the format occurs. - if flags.Deterministic || randBool { - // Use regular spaces (U+0020). - switch d { - case diffUnknown, diffIdentical: - b = append(b, " "...) - case diffRemoved: - b = append(b, "- "...) - case diffInserted: - b = append(b, "+ "...) - } - } else { - // Use non-breaking spaces (U+00a0). - switch d { - case diffUnknown, diffIdentical: - b = append(b, "  "...) - case diffRemoved: - b = append(b, "- "...) - case diffInserted: - b = append(b, "+ "...) - } - } - return repeatCount(n).appendChar(b, '\t') -} - -type repeatCount int - -func (n repeatCount) appendChar(b []byte, c byte) []byte { - for ; n > 0; n-- { - b = append(b, c) - } - return b -} - -// textNode is a simplified tree-based representation of structured text. -// Possible node types are textWrap, textList, or textLine. -type textNode interface { - // Len reports the length in bytes of a single-line version of the tree. - // Nested textRecord.Diff and textRecord.Comment fields are ignored. - Len() int - // Equal reports whether the two trees are structurally identical. - // Nested textRecord.Diff and textRecord.Comment fields are compared. - Equal(textNode) bool - // String returns the string representation of the text tree. - // It is not guaranteed that len(x.String()) == x.Len(), - // nor that x.String() == y.String() implies that x.Equal(y). - String() string - - // formatCompactTo formats the contents of the tree as a single-line string - // to the provided buffer. Any nested textRecord.Diff and textRecord.Comment - // fields are ignored. - // - // However, not all nodes in the tree should be collapsed as a single-line. - // If a node can be collapsed as a single-line, it is replaced by a textLine - // node. Since the top-level node cannot replace itself, this also returns - // the current node itself. - // - // This does not mutate the receiver. - formatCompactTo([]byte, diffMode) ([]byte, textNode) - // formatExpandedTo formats the contents of the tree as a multi-line string - // to the provided buffer. In order for column alignment to operate well, - // formatCompactTo must be called before calling formatExpandedTo. - formatExpandedTo([]byte, diffMode, indentMode) []byte -} - -// textWrap is a wrapper that concatenates a prefix and/or a suffix -// to the underlying node. -type textWrap struct { - Prefix string // e.g., "bytes.Buffer{" - Value textNode // textWrap | textList | textLine - Suffix string // e.g., "}" - Metadata interface{} // arbitrary metadata; has no effect on formatting -} - -func (s *textWrap) Len() int { - return len(s.Prefix) + s.Value.Len() + len(s.Suffix) -} -func (s1 *textWrap) Equal(s2 textNode) bool { - if s2, ok := s2.(*textWrap); ok { - return s1.Prefix == s2.Prefix && s1.Value.Equal(s2.Value) && s1.Suffix == s2.Suffix - } - return false -} -func (s *textWrap) String() string { - var d diffMode - var n indentMode - _, s2 := s.formatCompactTo(nil, d) - b := n.appendIndent(nil, d) // Leading indent - b = s2.formatExpandedTo(b, d, n) // Main body - b = append(b, '\n') // Trailing newline - return string(b) -} -func (s *textWrap) formatCompactTo(b []byte, d diffMode) ([]byte, textNode) { - n0 := len(b) // Original buffer length - b = append(b, s.Prefix...) - b, s.Value = s.Value.formatCompactTo(b, d) - b = append(b, s.Suffix...) - if _, ok := s.Value.(textLine); ok { - return b, textLine(b[n0:]) - } - return b, s -} -func (s *textWrap) formatExpandedTo(b []byte, d diffMode, n indentMode) []byte { - b = append(b, s.Prefix...) - b = s.Value.formatExpandedTo(b, d, n) - b = append(b, s.Suffix...) - return b -} - -// textList is a comma-separated list of textWrap or textLine nodes. -// The list may be formatted as multi-lines or single-line at the discretion -// of the textList.formatCompactTo method. -type textList []textRecord -type textRecord struct { - Diff diffMode // e.g., 0 or '-' or '+' - Key string // e.g., "MyField" - Value textNode // textWrap | textLine - ElideComma bool // avoid trailing comma - Comment fmt.Stringer // e.g., "6 identical fields" -} - -// AppendEllipsis appends a new ellipsis node to the list if none already -// exists at the end. If cs is non-zero it coalesces the statistics with the -// previous diffStats. -func (s *textList) AppendEllipsis(ds diffStats) { - hasStats := !ds.IsZero() - if len(*s) == 0 || !(*s)[len(*s)-1].Value.Equal(textEllipsis) { - if hasStats { - *s = append(*s, textRecord{Value: textEllipsis, ElideComma: true, Comment: ds}) - } else { - *s = append(*s, textRecord{Value: textEllipsis, ElideComma: true}) - } - return - } - if hasStats { - (*s)[len(*s)-1].Comment = (*s)[len(*s)-1].Comment.(diffStats).Append(ds) - } -} - -func (s textList) Len() (n int) { - for i, r := range s { - n += len(r.Key) - if r.Key != "" { - n += len(": ") - } - n += r.Value.Len() - if i < len(s)-1 { - n += len(", ") - } - } - return n -} - -func (s1 textList) Equal(s2 textNode) bool { - if s2, ok := s2.(textList); ok { - if len(s1) != len(s2) { - return false - } - for i := range s1 { - r1, r2 := s1[i], s2[i] - if !(r1.Diff == r2.Diff && r1.Key == r2.Key && r1.Value.Equal(r2.Value) && r1.Comment == r2.Comment) { - return false - } - } - return true - } - return false -} - -func (s textList) String() string { - return (&textWrap{Prefix: "{", Value: s, Suffix: "}"}).String() -} - -func (s textList) formatCompactTo(b []byte, d diffMode) ([]byte, textNode) { - s = append(textList(nil), s...) // Avoid mutating original - - // Determine whether we can collapse this list as a single line. - n0 := len(b) // Original buffer length - var multiLine bool - for i, r := range s { - if r.Diff == diffInserted || r.Diff == diffRemoved { - multiLine = true - } - b = append(b, r.Key...) - if r.Key != "" { - b = append(b, ": "...) - } - b, s[i].Value = r.Value.formatCompactTo(b, d|r.Diff) - if _, ok := s[i].Value.(textLine); !ok { - multiLine = true - } - if r.Comment != nil { - multiLine = true - } - if i < len(s)-1 { - b = append(b, ", "...) - } - } - // Force multi-lined output when printing a removed/inserted node that - // is sufficiently long. - if (d == diffInserted || d == diffRemoved) && len(b[n0:]) > maxColumnLength { - multiLine = true - } - if !multiLine { - return b, textLine(b[n0:]) - } - return b, s -} - -func (s textList) formatExpandedTo(b []byte, d diffMode, n indentMode) []byte { - alignKeyLens := s.alignLens( - func(r textRecord) bool { - _, isLine := r.Value.(textLine) - return r.Key == "" || !isLine - }, - func(r textRecord) int { return utf8.RuneCountInString(r.Key) }, - ) - alignValueLens := s.alignLens( - func(r textRecord) bool { - _, isLine := r.Value.(textLine) - return !isLine || r.Value.Equal(textEllipsis) || r.Comment == nil - }, - func(r textRecord) int { return utf8.RuneCount(r.Value.(textLine)) }, - ) - - // Format lists of simple lists in a batched form. - // If the list is sequence of only textLine values, - // then batch multiple values on a single line. - var isSimple bool - for _, r := range s { - _, isLine := r.Value.(textLine) - isSimple = r.Diff == 0 && r.Key == "" && isLine && r.Comment == nil - if !isSimple { - break - } - } - if isSimple { - n++ - var batch []byte - emitBatch := func() { - if len(batch) > 0 { - b = n.appendIndent(append(b, '\n'), d) - b = append(b, bytes.TrimRight(batch, " ")...) - batch = batch[:0] - } - } - for _, r := range s { - line := r.Value.(textLine) - if len(batch)+len(line)+len(", ") > maxColumnLength { - emitBatch() - } - batch = append(batch, line...) - batch = append(batch, ", "...) - } - emitBatch() - n-- - return n.appendIndent(append(b, '\n'), d) - } - - // Format the list as a multi-lined output. - n++ - for i, r := range s { - b = n.appendIndent(append(b, '\n'), d|r.Diff) - if r.Key != "" { - b = append(b, r.Key+": "...) - } - b = alignKeyLens[i].appendChar(b, ' ') - - b = r.Value.formatExpandedTo(b, d|r.Diff, n) - if !r.ElideComma { - b = append(b, ',') - } - b = alignValueLens[i].appendChar(b, ' ') - - if r.Comment != nil { - b = append(b, " // "+r.Comment.String()...) - } - } - n-- - - return n.appendIndent(append(b, '\n'), d) -} - -func (s textList) alignLens( - skipFunc func(textRecord) bool, - lenFunc func(textRecord) int, -) []repeatCount { - var startIdx, endIdx, maxLen int - lens := make([]repeatCount, len(s)) - for i, r := range s { - if skipFunc(r) { - for j := startIdx; j < endIdx && j < len(s); j++ { - lens[j] = repeatCount(maxLen - lenFunc(s[j])) - } - startIdx, endIdx, maxLen = i+1, i+1, 0 - } else { - if maxLen < lenFunc(r) { - maxLen = lenFunc(r) - } - endIdx = i + 1 - } - } - for j := startIdx; j < endIdx && j < len(s); j++ { - lens[j] = repeatCount(maxLen - lenFunc(s[j])) - } - return lens -} - -// textLine is a single-line segment of text and is always a leaf node -// in the textNode tree. -type textLine []byte - -var ( - textNil = textLine("nil") - textEllipsis = textLine("...") -) - -func (s textLine) Len() int { - return len(s) -} -func (s1 textLine) Equal(s2 textNode) bool { - if s2, ok := s2.(textLine); ok { - return bytes.Equal([]byte(s1), []byte(s2)) - } - return false -} -func (s textLine) String() string { - return string(s) -} -func (s textLine) formatCompactTo(b []byte, d diffMode) ([]byte, textNode) { - return append(b, s...), s -} -func (s textLine) formatExpandedTo(b []byte, _ diffMode, _ indentMode) []byte { - return append(b, s...) -} - -type diffStats struct { - Name string - NumIgnored int - NumIdentical int - NumRemoved int - NumInserted int - NumModified int -} - -func (s diffStats) IsZero() bool { - s.Name = "" - return s == diffStats{} -} - -func (s diffStats) NumDiff() int { - return s.NumRemoved + s.NumInserted + s.NumModified -} - -func (s diffStats) Append(ds diffStats) diffStats { - assert(s.Name == ds.Name) - s.NumIgnored += ds.NumIgnored - s.NumIdentical += ds.NumIdentical - s.NumRemoved += ds.NumRemoved - s.NumInserted += ds.NumInserted - s.NumModified += ds.NumModified - return s -} - -// String prints a humanly-readable summary of coalesced records. -// -// Example: -// diffStats{Name: "Field", NumIgnored: 5}.String() => "5 ignored fields" -func (s diffStats) String() string { - var ss []string - var sum int - labels := [...]string{"ignored", "identical", "removed", "inserted", "modified"} - counts := [...]int{s.NumIgnored, s.NumIdentical, s.NumRemoved, s.NumInserted, s.NumModified} - for i, n := range counts { - if n > 0 { - ss = append(ss, fmt.Sprintf("%d %v", n, labels[i])) - } - sum += n - } - - // Pluralize the name (adjusting for some obscure English grammar rules). - name := s.Name - if sum > 1 { - name += "s" - if strings.HasSuffix(name, "ys") { - name = name[:len(name)-2] + "ies" // e.g., "entrys" => "entries" - } - } - - // Format the list according to English grammar (with Oxford comma). - switch n := len(ss); n { - case 0: - return "" - case 1, 2: - return strings.Join(ss, " and ") + " " + name - default: - return strings.Join(ss[:n-1], ", ") + ", and " + ss[n-1] + " " + name - } -} - -type commentString string - -func (s commentString) String() string { return string(s) } diff --git a/vendor/github.com/google/go-cmp/cmp/report_value.go b/vendor/github.com/google/go-cmp/cmp/report_value.go deleted file mode 100644 index 668d470fd..000000000 --- a/vendor/github.com/google/go-cmp/cmp/report_value.go +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright 2019, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package cmp - -import "reflect" - -// valueNode represents a single node within a report, which is a -// structured representation of the value tree, containing information -// regarding which nodes are equal or not. -type valueNode struct { - parent *valueNode - - Type reflect.Type - ValueX reflect.Value - ValueY reflect.Value - - // NumSame is the number of leaf nodes that are equal. - // All descendants are equal only if NumDiff is 0. - NumSame int - // NumDiff is the number of leaf nodes that are not equal. - NumDiff int - // NumIgnored is the number of leaf nodes that are ignored. - NumIgnored int - // NumCompared is the number of leaf nodes that were compared - // using an Equal method or Comparer function. - NumCompared int - // NumTransformed is the number of non-leaf nodes that were transformed. - NumTransformed int - // NumChildren is the number of transitive descendants of this node. - // This counts from zero; thus, leaf nodes have no descendants. - NumChildren int - // MaxDepth is the maximum depth of the tree. This counts from zero; - // thus, leaf nodes have a depth of zero. - MaxDepth int - - // Records is a list of struct fields, slice elements, or map entries. - Records []reportRecord // If populated, implies Value is not populated - - // Value is the result of a transformation, pointer indirect, of - // type assertion. - Value *valueNode // If populated, implies Records is not populated - - // TransformerName is the name of the transformer. - TransformerName string // If non-empty, implies Value is populated -} -type reportRecord struct { - Key reflect.Value // Invalid for slice element - Value *valueNode -} - -func (parent *valueNode) PushStep(ps PathStep) (child *valueNode) { - vx, vy := ps.Values() - child = &valueNode{parent: parent, Type: ps.Type(), ValueX: vx, ValueY: vy} - switch s := ps.(type) { - case StructField: - assert(parent.Value == nil) - parent.Records = append(parent.Records, reportRecord{Key: reflect.ValueOf(s.Name()), Value: child}) - case SliceIndex: - assert(parent.Value == nil) - parent.Records = append(parent.Records, reportRecord{Value: child}) - case MapIndex: - assert(parent.Value == nil) - parent.Records = append(parent.Records, reportRecord{Key: s.Key(), Value: child}) - case Indirect: - assert(parent.Value == nil && parent.Records == nil) - parent.Value = child - case TypeAssertion: - assert(parent.Value == nil && parent.Records == nil) - parent.Value = child - case Transform: - assert(parent.Value == nil && parent.Records == nil) - parent.Value = child - parent.TransformerName = s.Name() - parent.NumTransformed++ - default: - assert(parent == nil) // Must be the root step - } - return child -} - -func (r *valueNode) Report(rs Result) { - assert(r.MaxDepth == 0) // May only be called on leaf nodes - - if rs.ByIgnore() { - r.NumIgnored++ - } else { - if rs.Equal() { - r.NumSame++ - } else { - r.NumDiff++ - } - } - assert(r.NumSame+r.NumDiff+r.NumIgnored == 1) - - if rs.ByMethod() { - r.NumCompared++ - } - if rs.ByFunc() { - r.NumCompared++ - } - assert(r.NumCompared <= 1) -} - -func (child *valueNode) PopStep() (parent *valueNode) { - if child.parent == nil { - return nil - } - parent = child.parent - parent.NumSame += child.NumSame - parent.NumDiff += child.NumDiff - parent.NumIgnored += child.NumIgnored - parent.NumCompared += child.NumCompared - parent.NumTransformed += child.NumTransformed - parent.NumChildren += child.NumChildren + 1 - if parent.MaxDepth < child.MaxDepth+1 { - parent.MaxDepth = child.MaxDepth + 1 - } - return parent -} diff --git a/vendor/github.com/gorilla/websocket/.gitignore b/vendor/github.com/gorilla/websocket/.gitignore deleted file mode 100644 index cd3fcd1ef..000000000 --- a/vendor/github.com/gorilla/websocket/.gitignore +++ /dev/null @@ -1,25 +0,0 @@ -# Compiled Object files, Static and Dynamic libs (Shared Objects) -*.o -*.a -*.so - -# Folders -_obj -_test - -# Architecture specific extensions/prefixes -*.[568vq] -[568vq].out - -*.cgo1.go -*.cgo2.c -_cgo_defun.c -_cgo_gotypes.go -_cgo_export.* - -_testmain.go - -*.exe - -.idea/ -*.iml diff --git a/vendor/github.com/gorilla/websocket/AUTHORS b/vendor/github.com/gorilla/websocket/AUTHORS deleted file mode 100644 index 1931f4006..000000000 --- a/vendor/github.com/gorilla/websocket/AUTHORS +++ /dev/null @@ -1,9 +0,0 @@ -# This is the official list of Gorilla WebSocket authors for copyright -# purposes. -# -# Please keep the list sorted. - -Gary Burd -Google LLC (https://opensource.google.com/) -Joachim Bauch - diff --git a/vendor/github.com/gorilla/websocket/LICENSE b/vendor/github.com/gorilla/websocket/LICENSE deleted file mode 100644 index 9171c9722..000000000 --- a/vendor/github.com/gorilla/websocket/LICENSE +++ /dev/null @@ -1,22 +0,0 @@ -Copyright (c) 2013 The Gorilla WebSocket Authors. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - - Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - - Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/gorilla/websocket/README.md b/vendor/github.com/gorilla/websocket/README.md deleted file mode 100644 index 19aa2e75c..000000000 --- a/vendor/github.com/gorilla/websocket/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# Gorilla WebSocket - -[![GoDoc](https://godoc.org/github.com/gorilla/websocket?status.svg)](https://godoc.org/github.com/gorilla/websocket) -[![CircleCI](https://circleci.com/gh/gorilla/websocket.svg?style=svg)](https://circleci.com/gh/gorilla/websocket) - -Gorilla WebSocket is a [Go](http://golang.org/) implementation of the -[WebSocket](http://www.rfc-editor.org/rfc/rfc6455.txt) protocol. - -### Documentation - -* [API Reference](https://pkg.go.dev/github.com/gorilla/websocket?tab=doc) -* [Chat example](https://github.com/gorilla/websocket/tree/master/examples/chat) -* [Command example](https://github.com/gorilla/websocket/tree/master/examples/command) -* [Client and server example](https://github.com/gorilla/websocket/tree/master/examples/echo) -* [File watch example](https://github.com/gorilla/websocket/tree/master/examples/filewatch) - -### Status - -The Gorilla WebSocket package provides a complete and tested implementation of -the [WebSocket](http://www.rfc-editor.org/rfc/rfc6455.txt) protocol. The -package API is stable. - -### Installation - - go get github.com/gorilla/websocket - -### Protocol Compliance - -The Gorilla WebSocket package passes the server tests in the [Autobahn Test -Suite](https://github.com/crossbario/autobahn-testsuite) using the application in the [examples/autobahn -subdirectory](https://github.com/gorilla/websocket/tree/master/examples/autobahn). - -### Gorilla WebSocket compared with other packages - - - - - - - - - - - - - - - - - - -
    github.com/gorillagolang.org/x/net
    RFC 6455 Features
    Passes Autobahn Test SuiteYesNo
    Receive fragmented messageYesNo, see note 1
    Send close messageYesNo
    Send pings and receive pongsYesNo
    Get the type of a received data messageYesYes, see note 2
    Other Features
    Compression ExtensionsExperimentalNo
    Read message using io.ReaderYesNo, see note 3
    Write message using io.WriteCloserYesNo, see note 3
    - -Notes: - -1. Large messages are fragmented in [Chrome's new WebSocket implementation](http://www.ietf.org/mail-archive/web/hybi/current/msg10503.html). -2. The application can get the type of a received data message by implementing - a [Codec marshal](http://godoc.org/golang.org/x/net/websocket#Codec.Marshal) - function. -3. The go.net io.Reader and io.Writer operate across WebSocket frame boundaries. - Read returns when the input buffer is full or a frame boundary is - encountered. Each call to Write sends a single frame message. The Gorilla - io.Reader and io.WriteCloser operate on a single WebSocket message. - diff --git a/vendor/github.com/gorilla/websocket/client.go b/vendor/github.com/gorilla/websocket/client.go deleted file mode 100644 index 962c06a39..000000000 --- a/vendor/github.com/gorilla/websocket/client.go +++ /dev/null @@ -1,395 +0,0 @@ -// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package websocket - -import ( - "bytes" - "context" - "crypto/tls" - "errors" - "io" - "io/ioutil" - "net" - "net/http" - "net/http/httptrace" - "net/url" - "strings" - "time" -) - -// ErrBadHandshake is returned when the server response to opening handshake is -// invalid. -var ErrBadHandshake = errors.New("websocket: bad handshake") - -var errInvalidCompression = errors.New("websocket: invalid compression negotiation") - -// NewClient creates a new client connection using the given net connection. -// The URL u specifies the host and request URI. Use requestHeader to specify -// the origin (Origin), subprotocols (Sec-WebSocket-Protocol) and cookies -// (Cookie). Use the response.Header to get the selected subprotocol -// (Sec-WebSocket-Protocol) and cookies (Set-Cookie). -// -// If the WebSocket handshake fails, ErrBadHandshake is returned along with a -// non-nil *http.Response so that callers can handle redirects, authentication, -// etc. -// -// Deprecated: Use Dialer instead. -func NewClient(netConn net.Conn, u *url.URL, requestHeader http.Header, readBufSize, writeBufSize int) (c *Conn, response *http.Response, err error) { - d := Dialer{ - ReadBufferSize: readBufSize, - WriteBufferSize: writeBufSize, - NetDial: func(net, addr string) (net.Conn, error) { - return netConn, nil - }, - } - return d.Dial(u.String(), requestHeader) -} - -// A Dialer contains options for connecting to WebSocket server. -type Dialer struct { - // NetDial specifies the dial function for creating TCP connections. If - // NetDial is nil, net.Dial is used. - NetDial func(network, addr string) (net.Conn, error) - - // NetDialContext specifies the dial function for creating TCP connections. If - // NetDialContext is nil, net.DialContext is used. - NetDialContext func(ctx context.Context, network, addr string) (net.Conn, error) - - // Proxy specifies a function to return a proxy for a given - // Request. If the function returns a non-nil error, the - // request is aborted with the provided error. - // If Proxy is nil or returns a nil *URL, no proxy is used. - Proxy func(*http.Request) (*url.URL, error) - - // TLSClientConfig specifies the TLS configuration to use with tls.Client. - // If nil, the default configuration is used. - TLSClientConfig *tls.Config - - // HandshakeTimeout specifies the duration for the handshake to complete. - HandshakeTimeout time.Duration - - // ReadBufferSize and WriteBufferSize specify I/O buffer sizes in bytes. If a buffer - // size is zero, then a useful default size is used. The I/O buffer sizes - // do not limit the size of the messages that can be sent or received. - ReadBufferSize, WriteBufferSize int - - // WriteBufferPool is a pool of buffers for write operations. If the value - // is not set, then write buffers are allocated to the connection for the - // lifetime of the connection. - // - // A pool is most useful when the application has a modest volume of writes - // across a large number of connections. - // - // Applications should use a single pool for each unique value of - // WriteBufferSize. - WriteBufferPool BufferPool - - // Subprotocols specifies the client's requested subprotocols. - Subprotocols []string - - // EnableCompression specifies if the client should attempt to negotiate - // per message compression (RFC 7692). Setting this value to true does not - // guarantee that compression will be supported. Currently only "no context - // takeover" modes are supported. - EnableCompression bool - - // Jar specifies the cookie jar. - // If Jar is nil, cookies are not sent in requests and ignored - // in responses. - Jar http.CookieJar -} - -// Dial creates a new client connection by calling DialContext with a background context. -func (d *Dialer) Dial(urlStr string, requestHeader http.Header) (*Conn, *http.Response, error) { - return d.DialContext(context.Background(), urlStr, requestHeader) -} - -var errMalformedURL = errors.New("malformed ws or wss URL") - -func hostPortNoPort(u *url.URL) (hostPort, hostNoPort string) { - hostPort = u.Host - hostNoPort = u.Host - if i := strings.LastIndex(u.Host, ":"); i > strings.LastIndex(u.Host, "]") { - hostNoPort = hostNoPort[:i] - } else { - switch u.Scheme { - case "wss": - hostPort += ":443" - case "https": - hostPort += ":443" - default: - hostPort += ":80" - } - } - return hostPort, hostNoPort -} - -// DefaultDialer is a dialer with all fields set to the default values. -var DefaultDialer = &Dialer{ - Proxy: http.ProxyFromEnvironment, - HandshakeTimeout: 45 * time.Second, -} - -// nilDialer is dialer to use when receiver is nil. -var nilDialer = *DefaultDialer - -// DialContext creates a new client connection. Use requestHeader to specify the -// origin (Origin), subprotocols (Sec-WebSocket-Protocol) and cookies (Cookie). -// Use the response.Header to get the selected subprotocol -// (Sec-WebSocket-Protocol) and cookies (Set-Cookie). -// -// The context will be used in the request and in the Dialer. -// -// If the WebSocket handshake fails, ErrBadHandshake is returned along with a -// non-nil *http.Response so that callers can handle redirects, authentication, -// etcetera. The response body may not contain the entire response and does not -// need to be closed by the application. -func (d *Dialer) DialContext(ctx context.Context, urlStr string, requestHeader http.Header) (*Conn, *http.Response, error) { - if d == nil { - d = &nilDialer - } - - challengeKey, err := generateChallengeKey() - if err != nil { - return nil, nil, err - } - - u, err := url.Parse(urlStr) - if err != nil { - return nil, nil, err - } - - switch u.Scheme { - case "ws": - u.Scheme = "http" - case "wss": - u.Scheme = "https" - default: - return nil, nil, errMalformedURL - } - - if u.User != nil { - // User name and password are not allowed in websocket URIs. - return nil, nil, errMalformedURL - } - - req := &http.Request{ - Method: "GET", - URL: u, - Proto: "HTTP/1.1", - ProtoMajor: 1, - ProtoMinor: 1, - Header: make(http.Header), - Host: u.Host, - } - req = req.WithContext(ctx) - - // Set the cookies present in the cookie jar of the dialer - if d.Jar != nil { - for _, cookie := range d.Jar.Cookies(u) { - req.AddCookie(cookie) - } - } - - // Set the request headers using the capitalization for names and values in - // RFC examples. Although the capitalization shouldn't matter, there are - // servers that depend on it. The Header.Set method is not used because the - // method canonicalizes the header names. - req.Header["Upgrade"] = []string{"websocket"} - req.Header["Connection"] = []string{"Upgrade"} - req.Header["Sec-WebSocket-Key"] = []string{challengeKey} - req.Header["Sec-WebSocket-Version"] = []string{"13"} - if len(d.Subprotocols) > 0 { - req.Header["Sec-WebSocket-Protocol"] = []string{strings.Join(d.Subprotocols, ", ")} - } - for k, vs := range requestHeader { - switch { - case k == "Host": - if len(vs) > 0 { - req.Host = vs[0] - } - case k == "Upgrade" || - k == "Connection" || - k == "Sec-Websocket-Key" || - k == "Sec-Websocket-Version" || - k == "Sec-Websocket-Extensions" || - (k == "Sec-Websocket-Protocol" && len(d.Subprotocols) > 0): - return nil, nil, errors.New("websocket: duplicate header not allowed: " + k) - case k == "Sec-Websocket-Protocol": - req.Header["Sec-WebSocket-Protocol"] = vs - default: - req.Header[k] = vs - } - } - - if d.EnableCompression { - req.Header["Sec-WebSocket-Extensions"] = []string{"permessage-deflate; server_no_context_takeover; client_no_context_takeover"} - } - - if d.HandshakeTimeout != 0 { - var cancel func() - ctx, cancel = context.WithTimeout(ctx, d.HandshakeTimeout) - defer cancel() - } - - // Get network dial function. - var netDial func(network, add string) (net.Conn, error) - - if d.NetDialContext != nil { - netDial = func(network, addr string) (net.Conn, error) { - return d.NetDialContext(ctx, network, addr) - } - } else if d.NetDial != nil { - netDial = d.NetDial - } else { - netDialer := &net.Dialer{} - netDial = func(network, addr string) (net.Conn, error) { - return netDialer.DialContext(ctx, network, addr) - } - } - - // If needed, wrap the dial function to set the connection deadline. - if deadline, ok := ctx.Deadline(); ok { - forwardDial := netDial - netDial = func(network, addr string) (net.Conn, error) { - c, err := forwardDial(network, addr) - if err != nil { - return nil, err - } - err = c.SetDeadline(deadline) - if err != nil { - c.Close() - return nil, err - } - return c, nil - } - } - - // If needed, wrap the dial function to connect through a proxy. - if d.Proxy != nil { - proxyURL, err := d.Proxy(req) - if err != nil { - return nil, nil, err - } - if proxyURL != nil { - dialer, err := proxy_FromURL(proxyURL, netDialerFunc(netDial)) - if err != nil { - return nil, nil, err - } - netDial = dialer.Dial - } - } - - hostPort, hostNoPort := hostPortNoPort(u) - trace := httptrace.ContextClientTrace(ctx) - if trace != nil && trace.GetConn != nil { - trace.GetConn(hostPort) - } - - netConn, err := netDial("tcp", hostPort) - if trace != nil && trace.GotConn != nil { - trace.GotConn(httptrace.GotConnInfo{ - Conn: netConn, - }) - } - if err != nil { - return nil, nil, err - } - - defer func() { - if netConn != nil { - netConn.Close() - } - }() - - if u.Scheme == "https" { - cfg := cloneTLSConfig(d.TLSClientConfig) - if cfg.ServerName == "" { - cfg.ServerName = hostNoPort - } - tlsConn := tls.Client(netConn, cfg) - netConn = tlsConn - - var err error - if trace != nil { - err = doHandshakeWithTrace(trace, tlsConn, cfg) - } else { - err = doHandshake(tlsConn, cfg) - } - - if err != nil { - return nil, nil, err - } - } - - conn := newConn(netConn, false, d.ReadBufferSize, d.WriteBufferSize, d.WriteBufferPool, nil, nil) - - if err := req.Write(netConn); err != nil { - return nil, nil, err - } - - if trace != nil && trace.GotFirstResponseByte != nil { - if peek, err := conn.br.Peek(1); err == nil && len(peek) == 1 { - trace.GotFirstResponseByte() - } - } - - resp, err := http.ReadResponse(conn.br, req) - if err != nil { - return nil, nil, err - } - - if d.Jar != nil { - if rc := resp.Cookies(); len(rc) > 0 { - d.Jar.SetCookies(u, rc) - } - } - - if resp.StatusCode != 101 || - !strings.EqualFold(resp.Header.Get("Upgrade"), "websocket") || - !strings.EqualFold(resp.Header.Get("Connection"), "upgrade") || - resp.Header.Get("Sec-Websocket-Accept") != computeAcceptKey(challengeKey) { - // Before closing the network connection on return from this - // function, slurp up some of the response to aid application - // debugging. - buf := make([]byte, 1024) - n, _ := io.ReadFull(resp.Body, buf) - resp.Body = ioutil.NopCloser(bytes.NewReader(buf[:n])) - return nil, resp, ErrBadHandshake - } - - for _, ext := range parseExtensions(resp.Header) { - if ext[""] != "permessage-deflate" { - continue - } - _, snct := ext["server_no_context_takeover"] - _, cnct := ext["client_no_context_takeover"] - if !snct || !cnct { - return nil, resp, errInvalidCompression - } - conn.newCompressionWriter = compressNoContextTakeover - conn.newDecompressionReader = decompressNoContextTakeover - break - } - - resp.Body = ioutil.NopCloser(bytes.NewReader([]byte{})) - conn.subprotocol = resp.Header.Get("Sec-Websocket-Protocol") - - netConn.SetDeadline(time.Time{}) - netConn = nil // to avoid close in defer. - return conn, resp, nil -} - -func doHandshake(tlsConn *tls.Conn, cfg *tls.Config) error { - if err := tlsConn.Handshake(); err != nil { - return err - } - if !cfg.InsecureSkipVerify { - if err := tlsConn.VerifyHostname(cfg.ServerName); err != nil { - return err - } - } - return nil -} diff --git a/vendor/github.com/gorilla/websocket/client_clone.go b/vendor/github.com/gorilla/websocket/client_clone.go deleted file mode 100644 index 4f0d94372..000000000 --- a/vendor/github.com/gorilla/websocket/client_clone.go +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// +build go1.8 - -package websocket - -import "crypto/tls" - -func cloneTLSConfig(cfg *tls.Config) *tls.Config { - if cfg == nil { - return &tls.Config{} - } - return cfg.Clone() -} diff --git a/vendor/github.com/gorilla/websocket/client_clone_legacy.go b/vendor/github.com/gorilla/websocket/client_clone_legacy.go deleted file mode 100644 index babb007fb..000000000 --- a/vendor/github.com/gorilla/websocket/client_clone_legacy.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// +build !go1.8 - -package websocket - -import "crypto/tls" - -// cloneTLSConfig clones all public fields except the fields -// SessionTicketsDisabled and SessionTicketKey. This avoids copying the -// sync.Mutex in the sync.Once and makes it safe to call cloneTLSConfig on a -// config in active use. -func cloneTLSConfig(cfg *tls.Config) *tls.Config { - if cfg == nil { - return &tls.Config{} - } - return &tls.Config{ - Rand: cfg.Rand, - Time: cfg.Time, - Certificates: cfg.Certificates, - NameToCertificate: cfg.NameToCertificate, - GetCertificate: cfg.GetCertificate, - RootCAs: cfg.RootCAs, - NextProtos: cfg.NextProtos, - ServerName: cfg.ServerName, - ClientAuth: cfg.ClientAuth, - ClientCAs: cfg.ClientCAs, - InsecureSkipVerify: cfg.InsecureSkipVerify, - CipherSuites: cfg.CipherSuites, - PreferServerCipherSuites: cfg.PreferServerCipherSuites, - ClientSessionCache: cfg.ClientSessionCache, - MinVersion: cfg.MinVersion, - MaxVersion: cfg.MaxVersion, - CurvePreferences: cfg.CurvePreferences, - } -} diff --git a/vendor/github.com/gorilla/websocket/compression.go b/vendor/github.com/gorilla/websocket/compression.go deleted file mode 100644 index 813ffb1e8..000000000 --- a/vendor/github.com/gorilla/websocket/compression.go +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright 2017 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package websocket - -import ( - "compress/flate" - "errors" - "io" - "strings" - "sync" -) - -const ( - minCompressionLevel = -2 // flate.HuffmanOnly not defined in Go < 1.6 - maxCompressionLevel = flate.BestCompression - defaultCompressionLevel = 1 -) - -var ( - flateWriterPools [maxCompressionLevel - minCompressionLevel + 1]sync.Pool - flateReaderPool = sync.Pool{New: func() interface{} { - return flate.NewReader(nil) - }} -) - -func decompressNoContextTakeover(r io.Reader) io.ReadCloser { - const tail = - // Add four bytes as specified in RFC - "\x00\x00\xff\xff" + - // Add final block to squelch unexpected EOF error from flate reader. - "\x01\x00\x00\xff\xff" - - fr, _ := flateReaderPool.Get().(io.ReadCloser) - fr.(flate.Resetter).Reset(io.MultiReader(r, strings.NewReader(tail)), nil) - return &flateReadWrapper{fr} -} - -func isValidCompressionLevel(level int) bool { - return minCompressionLevel <= level && level <= maxCompressionLevel -} - -func compressNoContextTakeover(w io.WriteCloser, level int) io.WriteCloser { - p := &flateWriterPools[level-minCompressionLevel] - tw := &truncWriter{w: w} - fw, _ := p.Get().(*flate.Writer) - if fw == nil { - fw, _ = flate.NewWriter(tw, level) - } else { - fw.Reset(tw) - } - return &flateWriteWrapper{fw: fw, tw: tw, p: p} -} - -// truncWriter is an io.Writer that writes all but the last four bytes of the -// stream to another io.Writer. -type truncWriter struct { - w io.WriteCloser - n int - p [4]byte -} - -func (w *truncWriter) Write(p []byte) (int, error) { - n := 0 - - // fill buffer first for simplicity. - if w.n < len(w.p) { - n = copy(w.p[w.n:], p) - p = p[n:] - w.n += n - if len(p) == 0 { - return n, nil - } - } - - m := len(p) - if m > len(w.p) { - m = len(w.p) - } - - if nn, err := w.w.Write(w.p[:m]); err != nil { - return n + nn, err - } - - copy(w.p[:], w.p[m:]) - copy(w.p[len(w.p)-m:], p[len(p)-m:]) - nn, err := w.w.Write(p[:len(p)-m]) - return n + nn, err -} - -type flateWriteWrapper struct { - fw *flate.Writer - tw *truncWriter - p *sync.Pool -} - -func (w *flateWriteWrapper) Write(p []byte) (int, error) { - if w.fw == nil { - return 0, errWriteClosed - } - return w.fw.Write(p) -} - -func (w *flateWriteWrapper) Close() error { - if w.fw == nil { - return errWriteClosed - } - err1 := w.fw.Flush() - w.p.Put(w.fw) - w.fw = nil - if w.tw.p != [4]byte{0, 0, 0xff, 0xff} { - return errors.New("websocket: internal error, unexpected bytes at end of flate stream") - } - err2 := w.tw.w.Close() - if err1 != nil { - return err1 - } - return err2 -} - -type flateReadWrapper struct { - fr io.ReadCloser -} - -func (r *flateReadWrapper) Read(p []byte) (int, error) { - if r.fr == nil { - return 0, io.ErrClosedPipe - } - n, err := r.fr.Read(p) - if err == io.EOF { - // Preemptively place the reader back in the pool. This helps with - // scenarios where the application does not call NextReader() soon after - // this final read. - r.Close() - } - return n, err -} - -func (r *flateReadWrapper) Close() error { - if r.fr == nil { - return io.ErrClosedPipe - } - err := r.fr.Close() - flateReaderPool.Put(r.fr) - r.fr = nil - return err -} diff --git a/vendor/github.com/gorilla/websocket/conn.go b/vendor/github.com/gorilla/websocket/conn.go deleted file mode 100644 index ca46d2f79..000000000 --- a/vendor/github.com/gorilla/websocket/conn.go +++ /dev/null @@ -1,1201 +0,0 @@ -// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package websocket - -import ( - "bufio" - "encoding/binary" - "errors" - "io" - "io/ioutil" - "math/rand" - "net" - "strconv" - "sync" - "time" - "unicode/utf8" -) - -const ( - // Frame header byte 0 bits from Section 5.2 of RFC 6455 - finalBit = 1 << 7 - rsv1Bit = 1 << 6 - rsv2Bit = 1 << 5 - rsv3Bit = 1 << 4 - - // Frame header byte 1 bits from Section 5.2 of RFC 6455 - maskBit = 1 << 7 - - maxFrameHeaderSize = 2 + 8 + 4 // Fixed header + length + mask - maxControlFramePayloadSize = 125 - - writeWait = time.Second - - defaultReadBufferSize = 4096 - defaultWriteBufferSize = 4096 - - continuationFrame = 0 - noFrame = -1 -) - -// Close codes defined in RFC 6455, section 11.7. -const ( - CloseNormalClosure = 1000 - CloseGoingAway = 1001 - CloseProtocolError = 1002 - CloseUnsupportedData = 1003 - CloseNoStatusReceived = 1005 - CloseAbnormalClosure = 1006 - CloseInvalidFramePayloadData = 1007 - ClosePolicyViolation = 1008 - CloseMessageTooBig = 1009 - CloseMandatoryExtension = 1010 - CloseInternalServerErr = 1011 - CloseServiceRestart = 1012 - CloseTryAgainLater = 1013 - CloseTLSHandshake = 1015 -) - -// The message types are defined in RFC 6455, section 11.8. -const ( - // TextMessage denotes a text data message. The text message payload is - // interpreted as UTF-8 encoded text data. - TextMessage = 1 - - // BinaryMessage denotes a binary data message. - BinaryMessage = 2 - - // CloseMessage denotes a close control message. The optional message - // payload contains a numeric code and text. Use the FormatCloseMessage - // function to format a close message payload. - CloseMessage = 8 - - // PingMessage denotes a ping control message. The optional message payload - // is UTF-8 encoded text. - PingMessage = 9 - - // PongMessage denotes a pong control message. The optional message payload - // is UTF-8 encoded text. - PongMessage = 10 -) - -// ErrCloseSent is returned when the application writes a message to the -// connection after sending a close message. -var ErrCloseSent = errors.New("websocket: close sent") - -// ErrReadLimit is returned when reading a message that is larger than the -// read limit set for the connection. -var ErrReadLimit = errors.New("websocket: read limit exceeded") - -// netError satisfies the net Error interface. -type netError struct { - msg string - temporary bool - timeout bool -} - -func (e *netError) Error() string { return e.msg } -func (e *netError) Temporary() bool { return e.temporary } -func (e *netError) Timeout() bool { return e.timeout } - -// CloseError represents a close message. -type CloseError struct { - // Code is defined in RFC 6455, section 11.7. - Code int - - // Text is the optional text payload. - Text string -} - -func (e *CloseError) Error() string { - s := []byte("websocket: close ") - s = strconv.AppendInt(s, int64(e.Code), 10) - switch e.Code { - case CloseNormalClosure: - s = append(s, " (normal)"...) - case CloseGoingAway: - s = append(s, " (going away)"...) - case CloseProtocolError: - s = append(s, " (protocol error)"...) - case CloseUnsupportedData: - s = append(s, " (unsupported data)"...) - case CloseNoStatusReceived: - s = append(s, " (no status)"...) - case CloseAbnormalClosure: - s = append(s, " (abnormal closure)"...) - case CloseInvalidFramePayloadData: - s = append(s, " (invalid payload data)"...) - case ClosePolicyViolation: - s = append(s, " (policy violation)"...) - case CloseMessageTooBig: - s = append(s, " (message too big)"...) - case CloseMandatoryExtension: - s = append(s, " (mandatory extension missing)"...) - case CloseInternalServerErr: - s = append(s, " (internal server error)"...) - case CloseTLSHandshake: - s = append(s, " (TLS handshake error)"...) - } - if e.Text != "" { - s = append(s, ": "...) - s = append(s, e.Text...) - } - return string(s) -} - -// IsCloseError returns boolean indicating whether the error is a *CloseError -// with one of the specified codes. -func IsCloseError(err error, codes ...int) bool { - if e, ok := err.(*CloseError); ok { - for _, code := range codes { - if e.Code == code { - return true - } - } - } - return false -} - -// IsUnexpectedCloseError returns boolean indicating whether the error is a -// *CloseError with a code not in the list of expected codes. -func IsUnexpectedCloseError(err error, expectedCodes ...int) bool { - if e, ok := err.(*CloseError); ok { - for _, code := range expectedCodes { - if e.Code == code { - return false - } - } - return true - } - return false -} - -var ( - errWriteTimeout = &netError{msg: "websocket: write timeout", timeout: true, temporary: true} - errUnexpectedEOF = &CloseError{Code: CloseAbnormalClosure, Text: io.ErrUnexpectedEOF.Error()} - errBadWriteOpCode = errors.New("websocket: bad write message type") - errWriteClosed = errors.New("websocket: write closed") - errInvalidControlFrame = errors.New("websocket: invalid control frame") -) - -func newMaskKey() [4]byte { - n := rand.Uint32() - return [4]byte{byte(n), byte(n >> 8), byte(n >> 16), byte(n >> 24)} -} - -func hideTempErr(err error) error { - if e, ok := err.(net.Error); ok && e.Temporary() { - err = &netError{msg: e.Error(), timeout: e.Timeout()} - } - return err -} - -func isControl(frameType int) bool { - return frameType == CloseMessage || frameType == PingMessage || frameType == PongMessage -} - -func isData(frameType int) bool { - return frameType == TextMessage || frameType == BinaryMessage -} - -var validReceivedCloseCodes = map[int]bool{ - // see http://www.iana.org/assignments/websocket/websocket.xhtml#close-code-number - - CloseNormalClosure: true, - CloseGoingAway: true, - CloseProtocolError: true, - CloseUnsupportedData: true, - CloseNoStatusReceived: false, - CloseAbnormalClosure: false, - CloseInvalidFramePayloadData: true, - ClosePolicyViolation: true, - CloseMessageTooBig: true, - CloseMandatoryExtension: true, - CloseInternalServerErr: true, - CloseServiceRestart: true, - CloseTryAgainLater: true, - CloseTLSHandshake: false, -} - -func isValidReceivedCloseCode(code int) bool { - return validReceivedCloseCodes[code] || (code >= 3000 && code <= 4999) -} - -// BufferPool represents a pool of buffers. The *sync.Pool type satisfies this -// interface. The type of the value stored in a pool is not specified. -type BufferPool interface { - // Get gets a value from the pool or returns nil if the pool is empty. - Get() interface{} - // Put adds a value to the pool. - Put(interface{}) -} - -// writePoolData is the type added to the write buffer pool. This wrapper is -// used to prevent applications from peeking at and depending on the values -// added to the pool. -type writePoolData struct{ buf []byte } - -// The Conn type represents a WebSocket connection. -type Conn struct { - conn net.Conn - isServer bool - subprotocol string - - // Write fields - mu chan struct{} // used as mutex to protect write to conn - writeBuf []byte // frame is constructed in this buffer. - writePool BufferPool - writeBufSize int - writeDeadline time.Time - writer io.WriteCloser // the current writer returned to the application - isWriting bool // for best-effort concurrent write detection - - writeErrMu sync.Mutex - writeErr error - - enableWriteCompression bool - compressionLevel int - newCompressionWriter func(io.WriteCloser, int) io.WriteCloser - - // Read fields - reader io.ReadCloser // the current reader returned to the application - readErr error - br *bufio.Reader - // bytes remaining in current frame. - // set setReadRemaining to safely update this value and prevent overflow - readRemaining int64 - readFinal bool // true the current message has more frames. - readLength int64 // Message size. - readLimit int64 // Maximum message size. - readMaskPos int - readMaskKey [4]byte - handlePong func(string) error - handlePing func(string) error - handleClose func(int, string) error - readErrCount int - messageReader *messageReader // the current low-level reader - - readDecompress bool // whether last read frame had RSV1 set - newDecompressionReader func(io.Reader) io.ReadCloser -} - -func newConn(conn net.Conn, isServer bool, readBufferSize, writeBufferSize int, writeBufferPool BufferPool, br *bufio.Reader, writeBuf []byte) *Conn { - - if br == nil { - if readBufferSize == 0 { - readBufferSize = defaultReadBufferSize - } else if readBufferSize < maxControlFramePayloadSize { - // must be large enough for control frame - readBufferSize = maxControlFramePayloadSize - } - br = bufio.NewReaderSize(conn, readBufferSize) - } - - if writeBufferSize <= 0 { - writeBufferSize = defaultWriteBufferSize - } - writeBufferSize += maxFrameHeaderSize - - if writeBuf == nil && writeBufferPool == nil { - writeBuf = make([]byte, writeBufferSize) - } - - mu := make(chan struct{}, 1) - mu <- struct{}{} - c := &Conn{ - isServer: isServer, - br: br, - conn: conn, - mu: mu, - readFinal: true, - writeBuf: writeBuf, - writePool: writeBufferPool, - writeBufSize: writeBufferSize, - enableWriteCompression: true, - compressionLevel: defaultCompressionLevel, - } - c.SetCloseHandler(nil) - c.SetPingHandler(nil) - c.SetPongHandler(nil) - return c -} - -// setReadRemaining tracks the number of bytes remaining on the connection. If n -// overflows, an ErrReadLimit is returned. -func (c *Conn) setReadRemaining(n int64) error { - if n < 0 { - return ErrReadLimit - } - - c.readRemaining = n - return nil -} - -// Subprotocol returns the negotiated protocol for the connection. -func (c *Conn) Subprotocol() string { - return c.subprotocol -} - -// Close closes the underlying network connection without sending or waiting -// for a close message. -func (c *Conn) Close() error { - return c.conn.Close() -} - -// LocalAddr returns the local network address. -func (c *Conn) LocalAddr() net.Addr { - return c.conn.LocalAddr() -} - -// RemoteAddr returns the remote network address. -func (c *Conn) RemoteAddr() net.Addr { - return c.conn.RemoteAddr() -} - -// Write methods - -func (c *Conn) writeFatal(err error) error { - err = hideTempErr(err) - c.writeErrMu.Lock() - if c.writeErr == nil { - c.writeErr = err - } - c.writeErrMu.Unlock() - return err -} - -func (c *Conn) read(n int) ([]byte, error) { - p, err := c.br.Peek(n) - if err == io.EOF { - err = errUnexpectedEOF - } - c.br.Discard(len(p)) - return p, err -} - -func (c *Conn) write(frameType int, deadline time.Time, buf0, buf1 []byte) error { - <-c.mu - defer func() { c.mu <- struct{}{} }() - - c.writeErrMu.Lock() - err := c.writeErr - c.writeErrMu.Unlock() - if err != nil { - return err - } - - c.conn.SetWriteDeadline(deadline) - if len(buf1) == 0 { - _, err = c.conn.Write(buf0) - } else { - err = c.writeBufs(buf0, buf1) - } - if err != nil { - return c.writeFatal(err) - } - if frameType == CloseMessage { - c.writeFatal(ErrCloseSent) - } - return nil -} - -// WriteControl writes a control message with the given deadline. The allowed -// message types are CloseMessage, PingMessage and PongMessage. -func (c *Conn) WriteControl(messageType int, data []byte, deadline time.Time) error { - if !isControl(messageType) { - return errBadWriteOpCode - } - if len(data) > maxControlFramePayloadSize { - return errInvalidControlFrame - } - - b0 := byte(messageType) | finalBit - b1 := byte(len(data)) - if !c.isServer { - b1 |= maskBit - } - - buf := make([]byte, 0, maxFrameHeaderSize+maxControlFramePayloadSize) - buf = append(buf, b0, b1) - - if c.isServer { - buf = append(buf, data...) - } else { - key := newMaskKey() - buf = append(buf, key[:]...) - buf = append(buf, data...) - maskBytes(key, 0, buf[6:]) - } - - d := 1000 * time.Hour - if !deadline.IsZero() { - d = deadline.Sub(time.Now()) - if d < 0 { - return errWriteTimeout - } - } - - timer := time.NewTimer(d) - select { - case <-c.mu: - timer.Stop() - case <-timer.C: - return errWriteTimeout - } - defer func() { c.mu <- struct{}{} }() - - c.writeErrMu.Lock() - err := c.writeErr - c.writeErrMu.Unlock() - if err != nil { - return err - } - - c.conn.SetWriteDeadline(deadline) - _, err = c.conn.Write(buf) - if err != nil { - return c.writeFatal(err) - } - if messageType == CloseMessage { - c.writeFatal(ErrCloseSent) - } - return err -} - -// beginMessage prepares a connection and message writer for a new message. -func (c *Conn) beginMessage(mw *messageWriter, messageType int) error { - // Close previous writer if not already closed by the application. It's - // probably better to return an error in this situation, but we cannot - // change this without breaking existing applications. - if c.writer != nil { - c.writer.Close() - c.writer = nil - } - - if !isControl(messageType) && !isData(messageType) { - return errBadWriteOpCode - } - - c.writeErrMu.Lock() - err := c.writeErr - c.writeErrMu.Unlock() - if err != nil { - return err - } - - mw.c = c - mw.frameType = messageType - mw.pos = maxFrameHeaderSize - - if c.writeBuf == nil { - wpd, ok := c.writePool.Get().(writePoolData) - if ok { - c.writeBuf = wpd.buf - } else { - c.writeBuf = make([]byte, c.writeBufSize) - } - } - return nil -} - -// NextWriter returns a writer for the next message to send. The writer's Close -// method flushes the complete message to the network. -// -// There can be at most one open writer on a connection. NextWriter closes the -// previous writer if the application has not already done so. -// -// All message types (TextMessage, BinaryMessage, CloseMessage, PingMessage and -// PongMessage) are supported. -func (c *Conn) NextWriter(messageType int) (io.WriteCloser, error) { - var mw messageWriter - if err := c.beginMessage(&mw, messageType); err != nil { - return nil, err - } - c.writer = &mw - if c.newCompressionWriter != nil && c.enableWriteCompression && isData(messageType) { - w := c.newCompressionWriter(c.writer, c.compressionLevel) - mw.compress = true - c.writer = w - } - return c.writer, nil -} - -type messageWriter struct { - c *Conn - compress bool // whether next call to flushFrame should set RSV1 - pos int // end of data in writeBuf. - frameType int // type of the current frame. - err error -} - -func (w *messageWriter) endMessage(err error) error { - if w.err != nil { - return err - } - c := w.c - w.err = err - c.writer = nil - if c.writePool != nil { - c.writePool.Put(writePoolData{buf: c.writeBuf}) - c.writeBuf = nil - } - return err -} - -// flushFrame writes buffered data and extra as a frame to the network. The -// final argument indicates that this is the last frame in the message. -func (w *messageWriter) flushFrame(final bool, extra []byte) error { - c := w.c - length := w.pos - maxFrameHeaderSize + len(extra) - - // Check for invalid control frames. - if isControl(w.frameType) && - (!final || length > maxControlFramePayloadSize) { - return w.endMessage(errInvalidControlFrame) - } - - b0 := byte(w.frameType) - if final { - b0 |= finalBit - } - if w.compress { - b0 |= rsv1Bit - } - w.compress = false - - b1 := byte(0) - if !c.isServer { - b1 |= maskBit - } - - // Assume that the frame starts at beginning of c.writeBuf. - framePos := 0 - if c.isServer { - // Adjust up if mask not included in the header. - framePos = 4 - } - - switch { - case length >= 65536: - c.writeBuf[framePos] = b0 - c.writeBuf[framePos+1] = b1 | 127 - binary.BigEndian.PutUint64(c.writeBuf[framePos+2:], uint64(length)) - case length > 125: - framePos += 6 - c.writeBuf[framePos] = b0 - c.writeBuf[framePos+1] = b1 | 126 - binary.BigEndian.PutUint16(c.writeBuf[framePos+2:], uint16(length)) - default: - framePos += 8 - c.writeBuf[framePos] = b0 - c.writeBuf[framePos+1] = b1 | byte(length) - } - - if !c.isServer { - key := newMaskKey() - copy(c.writeBuf[maxFrameHeaderSize-4:], key[:]) - maskBytes(key, 0, c.writeBuf[maxFrameHeaderSize:w.pos]) - if len(extra) > 0 { - return w.endMessage(c.writeFatal(errors.New("websocket: internal error, extra used in client mode"))) - } - } - - // Write the buffers to the connection with best-effort detection of - // concurrent writes. See the concurrency section in the package - // documentation for more info. - - if c.isWriting { - panic("concurrent write to websocket connection") - } - c.isWriting = true - - err := c.write(w.frameType, c.writeDeadline, c.writeBuf[framePos:w.pos], extra) - - if !c.isWriting { - panic("concurrent write to websocket connection") - } - c.isWriting = false - - if err != nil { - return w.endMessage(err) - } - - if final { - w.endMessage(errWriteClosed) - return nil - } - - // Setup for next frame. - w.pos = maxFrameHeaderSize - w.frameType = continuationFrame - return nil -} - -func (w *messageWriter) ncopy(max int) (int, error) { - n := len(w.c.writeBuf) - w.pos - if n <= 0 { - if err := w.flushFrame(false, nil); err != nil { - return 0, err - } - n = len(w.c.writeBuf) - w.pos - } - if n > max { - n = max - } - return n, nil -} - -func (w *messageWriter) Write(p []byte) (int, error) { - if w.err != nil { - return 0, w.err - } - - if len(p) > 2*len(w.c.writeBuf) && w.c.isServer { - // Don't buffer large messages. - err := w.flushFrame(false, p) - if err != nil { - return 0, err - } - return len(p), nil - } - - nn := len(p) - for len(p) > 0 { - n, err := w.ncopy(len(p)) - if err != nil { - return 0, err - } - copy(w.c.writeBuf[w.pos:], p[:n]) - w.pos += n - p = p[n:] - } - return nn, nil -} - -func (w *messageWriter) WriteString(p string) (int, error) { - if w.err != nil { - return 0, w.err - } - - nn := len(p) - for len(p) > 0 { - n, err := w.ncopy(len(p)) - if err != nil { - return 0, err - } - copy(w.c.writeBuf[w.pos:], p[:n]) - w.pos += n - p = p[n:] - } - return nn, nil -} - -func (w *messageWriter) ReadFrom(r io.Reader) (nn int64, err error) { - if w.err != nil { - return 0, w.err - } - for { - if w.pos == len(w.c.writeBuf) { - err = w.flushFrame(false, nil) - if err != nil { - break - } - } - var n int - n, err = r.Read(w.c.writeBuf[w.pos:]) - w.pos += n - nn += int64(n) - if err != nil { - if err == io.EOF { - err = nil - } - break - } - } - return nn, err -} - -func (w *messageWriter) Close() error { - if w.err != nil { - return w.err - } - return w.flushFrame(true, nil) -} - -// WritePreparedMessage writes prepared message into connection. -func (c *Conn) WritePreparedMessage(pm *PreparedMessage) error { - frameType, frameData, err := pm.frame(prepareKey{ - isServer: c.isServer, - compress: c.newCompressionWriter != nil && c.enableWriteCompression && isData(pm.messageType), - compressionLevel: c.compressionLevel, - }) - if err != nil { - return err - } - if c.isWriting { - panic("concurrent write to websocket connection") - } - c.isWriting = true - err = c.write(frameType, c.writeDeadline, frameData, nil) - if !c.isWriting { - panic("concurrent write to websocket connection") - } - c.isWriting = false - return err -} - -// WriteMessage is a helper method for getting a writer using NextWriter, -// writing the message and closing the writer. -func (c *Conn) WriteMessage(messageType int, data []byte) error { - - if c.isServer && (c.newCompressionWriter == nil || !c.enableWriteCompression) { - // Fast path with no allocations and single frame. - - var mw messageWriter - if err := c.beginMessage(&mw, messageType); err != nil { - return err - } - n := copy(c.writeBuf[mw.pos:], data) - mw.pos += n - data = data[n:] - return mw.flushFrame(true, data) - } - - w, err := c.NextWriter(messageType) - if err != nil { - return err - } - if _, err = w.Write(data); err != nil { - return err - } - return w.Close() -} - -// SetWriteDeadline sets the write deadline on the underlying network -// connection. After a write has timed out, the websocket state is corrupt and -// all future writes will return an error. A zero value for t means writes will -// not time out. -func (c *Conn) SetWriteDeadline(t time.Time) error { - c.writeDeadline = t - return nil -} - -// Read methods - -func (c *Conn) advanceFrame() (int, error) { - // 1. Skip remainder of previous frame. - - if c.readRemaining > 0 { - if _, err := io.CopyN(ioutil.Discard, c.br, c.readRemaining); err != nil { - return noFrame, err - } - } - - // 2. Read and parse first two bytes of frame header. - - p, err := c.read(2) - if err != nil { - return noFrame, err - } - - final := p[0]&finalBit != 0 - frameType := int(p[0] & 0xf) - mask := p[1]&maskBit != 0 - c.setReadRemaining(int64(p[1] & 0x7f)) - - c.readDecompress = false - if c.newDecompressionReader != nil && (p[0]&rsv1Bit) != 0 { - c.readDecompress = true - p[0] &^= rsv1Bit - } - - if rsv := p[0] & (rsv1Bit | rsv2Bit | rsv3Bit); rsv != 0 { - return noFrame, c.handleProtocolError("unexpected reserved bits 0x" + strconv.FormatInt(int64(rsv), 16)) - } - - switch frameType { - case CloseMessage, PingMessage, PongMessage: - if c.readRemaining > maxControlFramePayloadSize { - return noFrame, c.handleProtocolError("control frame length > 125") - } - if !final { - return noFrame, c.handleProtocolError("control frame not final") - } - case TextMessage, BinaryMessage: - if !c.readFinal { - return noFrame, c.handleProtocolError("message start before final message frame") - } - c.readFinal = final - case continuationFrame: - if c.readFinal { - return noFrame, c.handleProtocolError("continuation after final message frame") - } - c.readFinal = final - default: - return noFrame, c.handleProtocolError("unknown opcode " + strconv.Itoa(frameType)) - } - - // 3. Read and parse frame length as per - // https://tools.ietf.org/html/rfc6455#section-5.2 - // - // The length of the "Payload data", in bytes: if 0-125, that is the payload - // length. - // - If 126, the following 2 bytes interpreted as a 16-bit unsigned - // integer are the payload length. - // - If 127, the following 8 bytes interpreted as - // a 64-bit unsigned integer (the most significant bit MUST be 0) are the - // payload length. Multibyte length quantities are expressed in network byte - // order. - - switch c.readRemaining { - case 126: - p, err := c.read(2) - if err != nil { - return noFrame, err - } - - if err := c.setReadRemaining(int64(binary.BigEndian.Uint16(p))); err != nil { - return noFrame, err - } - case 127: - p, err := c.read(8) - if err != nil { - return noFrame, err - } - - if err := c.setReadRemaining(int64(binary.BigEndian.Uint64(p))); err != nil { - return noFrame, err - } - } - - // 4. Handle frame masking. - - if mask != c.isServer { - return noFrame, c.handleProtocolError("incorrect mask flag") - } - - if mask { - c.readMaskPos = 0 - p, err := c.read(len(c.readMaskKey)) - if err != nil { - return noFrame, err - } - copy(c.readMaskKey[:], p) - } - - // 5. For text and binary messages, enforce read limit and return. - - if frameType == continuationFrame || frameType == TextMessage || frameType == BinaryMessage { - - c.readLength += c.readRemaining - // Don't allow readLength to overflow in the presence of a large readRemaining - // counter. - if c.readLength < 0 { - return noFrame, ErrReadLimit - } - - if c.readLimit > 0 && c.readLength > c.readLimit { - c.WriteControl(CloseMessage, FormatCloseMessage(CloseMessageTooBig, ""), time.Now().Add(writeWait)) - return noFrame, ErrReadLimit - } - - return frameType, nil - } - - // 6. Read control frame payload. - - var payload []byte - if c.readRemaining > 0 { - payload, err = c.read(int(c.readRemaining)) - c.setReadRemaining(0) - if err != nil { - return noFrame, err - } - if c.isServer { - maskBytes(c.readMaskKey, 0, payload) - } - } - - // 7. Process control frame payload. - - switch frameType { - case PongMessage: - if err := c.handlePong(string(payload)); err != nil { - return noFrame, err - } - case PingMessage: - if err := c.handlePing(string(payload)); err != nil { - return noFrame, err - } - case CloseMessage: - closeCode := CloseNoStatusReceived - closeText := "" - if len(payload) >= 2 { - closeCode = int(binary.BigEndian.Uint16(payload)) - if !isValidReceivedCloseCode(closeCode) { - return noFrame, c.handleProtocolError("invalid close code") - } - closeText = string(payload[2:]) - if !utf8.ValidString(closeText) { - return noFrame, c.handleProtocolError("invalid utf8 payload in close frame") - } - } - if err := c.handleClose(closeCode, closeText); err != nil { - return noFrame, err - } - return noFrame, &CloseError{Code: closeCode, Text: closeText} - } - - return frameType, nil -} - -func (c *Conn) handleProtocolError(message string) error { - c.WriteControl(CloseMessage, FormatCloseMessage(CloseProtocolError, message), time.Now().Add(writeWait)) - return errors.New("websocket: " + message) -} - -// NextReader returns the next data message received from the peer. The -// returned messageType is either TextMessage or BinaryMessage. -// -// There can be at most one open reader on a connection. NextReader discards -// the previous message if the application has not already consumed it. -// -// Applications must break out of the application's read loop when this method -// returns a non-nil error value. Errors returned from this method are -// permanent. Once this method returns a non-nil error, all subsequent calls to -// this method return the same error. -func (c *Conn) NextReader() (messageType int, r io.Reader, err error) { - // Close previous reader, only relevant for decompression. - if c.reader != nil { - c.reader.Close() - c.reader = nil - } - - c.messageReader = nil - c.readLength = 0 - - for c.readErr == nil { - frameType, err := c.advanceFrame() - if err != nil { - c.readErr = hideTempErr(err) - break - } - - if frameType == TextMessage || frameType == BinaryMessage { - c.messageReader = &messageReader{c} - c.reader = c.messageReader - if c.readDecompress { - c.reader = c.newDecompressionReader(c.reader) - } - return frameType, c.reader, nil - } - } - - // Applications that do handle the error returned from this method spin in - // tight loop on connection failure. To help application developers detect - // this error, panic on repeated reads to the failed connection. - c.readErrCount++ - if c.readErrCount >= 1000 { - panic("repeated read on failed websocket connection") - } - - return noFrame, nil, c.readErr -} - -type messageReader struct{ c *Conn } - -func (r *messageReader) Read(b []byte) (int, error) { - c := r.c - if c.messageReader != r { - return 0, io.EOF - } - - for c.readErr == nil { - - if c.readRemaining > 0 { - if int64(len(b)) > c.readRemaining { - b = b[:c.readRemaining] - } - n, err := c.br.Read(b) - c.readErr = hideTempErr(err) - if c.isServer { - c.readMaskPos = maskBytes(c.readMaskKey, c.readMaskPos, b[:n]) - } - rem := c.readRemaining - rem -= int64(n) - c.setReadRemaining(rem) - if c.readRemaining > 0 && c.readErr == io.EOF { - c.readErr = errUnexpectedEOF - } - return n, c.readErr - } - - if c.readFinal { - c.messageReader = nil - return 0, io.EOF - } - - frameType, err := c.advanceFrame() - switch { - case err != nil: - c.readErr = hideTempErr(err) - case frameType == TextMessage || frameType == BinaryMessage: - c.readErr = errors.New("websocket: internal error, unexpected text or binary in Reader") - } - } - - err := c.readErr - if err == io.EOF && c.messageReader == r { - err = errUnexpectedEOF - } - return 0, err -} - -func (r *messageReader) Close() error { - return nil -} - -// ReadMessage is a helper method for getting a reader using NextReader and -// reading from that reader to a buffer. -func (c *Conn) ReadMessage() (messageType int, p []byte, err error) { - var r io.Reader - messageType, r, err = c.NextReader() - if err != nil { - return messageType, nil, err - } - p, err = ioutil.ReadAll(r) - return messageType, p, err -} - -// SetReadDeadline sets the read deadline on the underlying network connection. -// After a read has timed out, the websocket connection state is corrupt and -// all future reads will return an error. A zero value for t means reads will -// not time out. -func (c *Conn) SetReadDeadline(t time.Time) error { - return c.conn.SetReadDeadline(t) -} - -// SetReadLimit sets the maximum size in bytes for a message read from the peer. If a -// message exceeds the limit, the connection sends a close message to the peer -// and returns ErrReadLimit to the application. -func (c *Conn) SetReadLimit(limit int64) { - c.readLimit = limit -} - -// CloseHandler returns the current close handler -func (c *Conn) CloseHandler() func(code int, text string) error { - return c.handleClose -} - -// SetCloseHandler sets the handler for close messages received from the peer. -// The code argument to h is the received close code or CloseNoStatusReceived -// if the close message is empty. The default close handler sends a close -// message back to the peer. -// -// The handler function is called from the NextReader, ReadMessage and message -// reader Read methods. The application must read the connection to process -// close messages as described in the section on Control Messages above. -// -// The connection read methods return a CloseError when a close message is -// received. Most applications should handle close messages as part of their -// normal error handling. Applications should only set a close handler when the -// application must perform some action before sending a close message back to -// the peer. -func (c *Conn) SetCloseHandler(h func(code int, text string) error) { - if h == nil { - h = func(code int, text string) error { - message := FormatCloseMessage(code, "") - c.WriteControl(CloseMessage, message, time.Now().Add(writeWait)) - return nil - } - } - c.handleClose = h -} - -// PingHandler returns the current ping handler -func (c *Conn) PingHandler() func(appData string) error { - return c.handlePing -} - -// SetPingHandler sets the handler for ping messages received from the peer. -// The appData argument to h is the PING message application data. The default -// ping handler sends a pong to the peer. -// -// The handler function is called from the NextReader, ReadMessage and message -// reader Read methods. The application must read the connection to process -// ping messages as described in the section on Control Messages above. -func (c *Conn) SetPingHandler(h func(appData string) error) { - if h == nil { - h = func(message string) error { - err := c.WriteControl(PongMessage, []byte(message), time.Now().Add(writeWait)) - if err == ErrCloseSent { - return nil - } else if e, ok := err.(net.Error); ok && e.Temporary() { - return nil - } - return err - } - } - c.handlePing = h -} - -// PongHandler returns the current pong handler -func (c *Conn) PongHandler() func(appData string) error { - return c.handlePong -} - -// SetPongHandler sets the handler for pong messages received from the peer. -// The appData argument to h is the PONG message application data. The default -// pong handler does nothing. -// -// The handler function is called from the NextReader, ReadMessage and message -// reader Read methods. The application must read the connection to process -// pong messages as described in the section on Control Messages above. -func (c *Conn) SetPongHandler(h func(appData string) error) { - if h == nil { - h = func(string) error { return nil } - } - c.handlePong = h -} - -// UnderlyingConn returns the internal net.Conn. This can be used to further -// modifications to connection specific flags. -func (c *Conn) UnderlyingConn() net.Conn { - return c.conn -} - -// EnableWriteCompression enables and disables write compression of -// subsequent text and binary messages. This function is a noop if -// compression was not negotiated with the peer. -func (c *Conn) EnableWriteCompression(enable bool) { - c.enableWriteCompression = enable -} - -// SetCompressionLevel sets the flate compression level for subsequent text and -// binary messages. This function is a noop if compression was not negotiated -// with the peer. See the compress/flate package for a description of -// compression levels. -func (c *Conn) SetCompressionLevel(level int) error { - if !isValidCompressionLevel(level) { - return errors.New("websocket: invalid compression level") - } - c.compressionLevel = level - return nil -} - -// FormatCloseMessage formats closeCode and text as a WebSocket close message. -// An empty message is returned for code CloseNoStatusReceived. -func FormatCloseMessage(closeCode int, text string) []byte { - if closeCode == CloseNoStatusReceived { - // Return empty message because it's illegal to send - // CloseNoStatusReceived. Return non-nil value in case application - // checks for nil. - return []byte{} - } - buf := make([]byte, 2+len(text)) - binary.BigEndian.PutUint16(buf, uint16(closeCode)) - copy(buf[2:], text) - return buf -} diff --git a/vendor/github.com/gorilla/websocket/conn_write.go b/vendor/github.com/gorilla/websocket/conn_write.go deleted file mode 100644 index a509a21f8..000000000 --- a/vendor/github.com/gorilla/websocket/conn_write.go +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright 2016 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// +build go1.8 - -package websocket - -import "net" - -func (c *Conn) writeBufs(bufs ...[]byte) error { - b := net.Buffers(bufs) - _, err := b.WriteTo(c.conn) - return err -} diff --git a/vendor/github.com/gorilla/websocket/conn_write_legacy.go b/vendor/github.com/gorilla/websocket/conn_write_legacy.go deleted file mode 100644 index 37edaff5a..000000000 --- a/vendor/github.com/gorilla/websocket/conn_write_legacy.go +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2016 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// +build !go1.8 - -package websocket - -func (c *Conn) writeBufs(bufs ...[]byte) error { - for _, buf := range bufs { - if len(buf) > 0 { - if _, err := c.conn.Write(buf); err != nil { - return err - } - } - } - return nil -} diff --git a/vendor/github.com/gorilla/websocket/doc.go b/vendor/github.com/gorilla/websocket/doc.go deleted file mode 100644 index 8db0cef95..000000000 --- a/vendor/github.com/gorilla/websocket/doc.go +++ /dev/null @@ -1,227 +0,0 @@ -// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// Package websocket implements the WebSocket protocol defined in RFC 6455. -// -// Overview -// -// The Conn type represents a WebSocket connection. A server application calls -// the Upgrader.Upgrade method from an HTTP request handler to get a *Conn: -// -// var upgrader = websocket.Upgrader{ -// ReadBufferSize: 1024, -// WriteBufferSize: 1024, -// } -// -// func handler(w http.ResponseWriter, r *http.Request) { -// conn, err := upgrader.Upgrade(w, r, nil) -// if err != nil { -// log.Println(err) -// return -// } -// ... Use conn to send and receive messages. -// } -// -// Call the connection's WriteMessage and ReadMessage methods to send and -// receive messages as a slice of bytes. This snippet of code shows how to echo -// messages using these methods: -// -// for { -// messageType, p, err := conn.ReadMessage() -// if err != nil { -// log.Println(err) -// return -// } -// if err := conn.WriteMessage(messageType, p); err != nil { -// log.Println(err) -// return -// } -// } -// -// In above snippet of code, p is a []byte and messageType is an int with value -// websocket.BinaryMessage or websocket.TextMessage. -// -// An application can also send and receive messages using the io.WriteCloser -// and io.Reader interfaces. To send a message, call the connection NextWriter -// method to get an io.WriteCloser, write the message to the writer and close -// the writer when done. To receive a message, call the connection NextReader -// method to get an io.Reader and read until io.EOF is returned. This snippet -// shows how to echo messages using the NextWriter and NextReader methods: -// -// for { -// messageType, r, err := conn.NextReader() -// if err != nil { -// return -// } -// w, err := conn.NextWriter(messageType) -// if err != nil { -// return err -// } -// if _, err := io.Copy(w, r); err != nil { -// return err -// } -// if err := w.Close(); err != nil { -// return err -// } -// } -// -// Data Messages -// -// The WebSocket protocol distinguishes between text and binary data messages. -// Text messages are interpreted as UTF-8 encoded text. The interpretation of -// binary messages is left to the application. -// -// This package uses the TextMessage and BinaryMessage integer constants to -// identify the two data message types. The ReadMessage and NextReader methods -// return the type of the received message. The messageType argument to the -// WriteMessage and NextWriter methods specifies the type of a sent message. -// -// It is the application's responsibility to ensure that text messages are -// valid UTF-8 encoded text. -// -// Control Messages -// -// The WebSocket protocol defines three types of control messages: close, ping -// and pong. Call the connection WriteControl, WriteMessage or NextWriter -// methods to send a control message to the peer. -// -// Connections handle received close messages by calling the handler function -// set with the SetCloseHandler method and by returning a *CloseError from the -// NextReader, ReadMessage or the message Read method. The default close -// handler sends a close message to the peer. -// -// Connections handle received ping messages by calling the handler function -// set with the SetPingHandler method. The default ping handler sends a pong -// message to the peer. -// -// Connections handle received pong messages by calling the handler function -// set with the SetPongHandler method. The default pong handler does nothing. -// If an application sends ping messages, then the application should set a -// pong handler to receive the corresponding pong. -// -// The control message handler functions are called from the NextReader, -// ReadMessage and message reader Read methods. The default close and ping -// handlers can block these methods for a short time when the handler writes to -// the connection. -// -// The application must read the connection to process close, ping and pong -// messages sent from the peer. If the application is not otherwise interested -// in messages from the peer, then the application should start a goroutine to -// read and discard messages from the peer. A simple example is: -// -// func readLoop(c *websocket.Conn) { -// for { -// if _, _, err := c.NextReader(); err != nil { -// c.Close() -// break -// } -// } -// } -// -// Concurrency -// -// Connections support one concurrent reader and one concurrent writer. -// -// Applications are responsible for ensuring that no more than one goroutine -// calls the write methods (NextWriter, SetWriteDeadline, WriteMessage, -// WriteJSON, EnableWriteCompression, SetCompressionLevel) concurrently and -// that no more than one goroutine calls the read methods (NextReader, -// SetReadDeadline, ReadMessage, ReadJSON, SetPongHandler, SetPingHandler) -// concurrently. -// -// The Close and WriteControl methods can be called concurrently with all other -// methods. -// -// Origin Considerations -// -// Web browsers allow Javascript applications to open a WebSocket connection to -// any host. It's up to the server to enforce an origin policy using the Origin -// request header sent by the browser. -// -// The Upgrader calls the function specified in the CheckOrigin field to check -// the origin. If the CheckOrigin function returns false, then the Upgrade -// method fails the WebSocket handshake with HTTP status 403. -// -// If the CheckOrigin field is nil, then the Upgrader uses a safe default: fail -// the handshake if the Origin request header is present and the Origin host is -// not equal to the Host request header. -// -// The deprecated package-level Upgrade function does not perform origin -// checking. The application is responsible for checking the Origin header -// before calling the Upgrade function. -// -// Buffers -// -// Connections buffer network input and output to reduce the number -// of system calls when reading or writing messages. -// -// Write buffers are also used for constructing WebSocket frames. See RFC 6455, -// Section 5 for a discussion of message framing. A WebSocket frame header is -// written to the network each time a write buffer is flushed to the network. -// Decreasing the size of the write buffer can increase the amount of framing -// overhead on the connection. -// -// The buffer sizes in bytes are specified by the ReadBufferSize and -// WriteBufferSize fields in the Dialer and Upgrader. The Dialer uses a default -// size of 4096 when a buffer size field is set to zero. The Upgrader reuses -// buffers created by the HTTP server when a buffer size field is set to zero. -// The HTTP server buffers have a size of 4096 at the time of this writing. -// -// The buffer sizes do not limit the size of a message that can be read or -// written by a connection. -// -// Buffers are held for the lifetime of the connection by default. If the -// Dialer or Upgrader WriteBufferPool field is set, then a connection holds the -// write buffer only when writing a message. -// -// Applications should tune the buffer sizes to balance memory use and -// performance. Increasing the buffer size uses more memory, but can reduce the -// number of system calls to read or write the network. In the case of writing, -// increasing the buffer size can reduce the number of frame headers written to -// the network. -// -// Some guidelines for setting buffer parameters are: -// -// Limit the buffer sizes to the maximum expected message size. Buffers larger -// than the largest message do not provide any benefit. -// -// Depending on the distribution of message sizes, setting the buffer size to -// a value less than the maximum expected message size can greatly reduce memory -// use with a small impact on performance. Here's an example: If 99% of the -// messages are smaller than 256 bytes and the maximum message size is 512 -// bytes, then a buffer size of 256 bytes will result in 1.01 more system calls -// than a buffer size of 512 bytes. The memory savings is 50%. -// -// A write buffer pool is useful when the application has a modest number -// writes over a large number of connections. when buffers are pooled, a larger -// buffer size has a reduced impact on total memory use and has the benefit of -// reducing system calls and frame overhead. -// -// Compression EXPERIMENTAL -// -// Per message compression extensions (RFC 7692) are experimentally supported -// by this package in a limited capacity. Setting the EnableCompression option -// to true in Dialer or Upgrader will attempt to negotiate per message deflate -// support. -// -// var upgrader = websocket.Upgrader{ -// EnableCompression: true, -// } -// -// If compression was successfully negotiated with the connection's peer, any -// message received in compressed form will be automatically decompressed. -// All Read methods will return uncompressed bytes. -// -// Per message compression of messages written to a connection can be enabled -// or disabled by calling the corresponding Conn method: -// -// conn.EnableWriteCompression(false) -// -// Currently this package does not support compression with "context takeover". -// This means that messages must be compressed and decompressed in isolation, -// without retaining sliding window or dictionary state across messages. For -// more details refer to RFC 7692. -// -// Use of compression is experimental and may result in decreased performance. -package websocket diff --git a/vendor/github.com/gorilla/websocket/go.mod b/vendor/github.com/gorilla/websocket/go.mod deleted file mode 100644 index 1a7afd502..000000000 --- a/vendor/github.com/gorilla/websocket/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/gorilla/websocket - -go 1.12 diff --git a/vendor/github.com/gorilla/websocket/go.sum b/vendor/github.com/gorilla/websocket/go.sum deleted file mode 100644 index e69de29bb..000000000 diff --git a/vendor/github.com/gorilla/websocket/join.go b/vendor/github.com/gorilla/websocket/join.go deleted file mode 100644 index c64f8c829..000000000 --- a/vendor/github.com/gorilla/websocket/join.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2019 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package websocket - -import ( - "io" - "strings" -) - -// JoinMessages concatenates received messages to create a single io.Reader. -// The string term is appended to each message. The returned reader does not -// support concurrent calls to the Read method. -func JoinMessages(c *Conn, term string) io.Reader { - return &joinReader{c: c, term: term} -} - -type joinReader struct { - c *Conn - term string - r io.Reader -} - -func (r *joinReader) Read(p []byte) (int, error) { - if r.r == nil { - var err error - _, r.r, err = r.c.NextReader() - if err != nil { - return 0, err - } - if r.term != "" { - r.r = io.MultiReader(r.r, strings.NewReader(r.term)) - } - } - n, err := r.r.Read(p) - if err == io.EOF { - err = nil - r.r = nil - } - return n, err -} diff --git a/vendor/github.com/gorilla/websocket/json.go b/vendor/github.com/gorilla/websocket/json.go deleted file mode 100644 index dc2c1f641..000000000 --- a/vendor/github.com/gorilla/websocket/json.go +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package websocket - -import ( - "encoding/json" - "io" -) - -// WriteJSON writes the JSON encoding of v as a message. -// -// Deprecated: Use c.WriteJSON instead. -func WriteJSON(c *Conn, v interface{}) error { - return c.WriteJSON(v) -} - -// WriteJSON writes the JSON encoding of v as a message. -// -// See the documentation for encoding/json Marshal for details about the -// conversion of Go values to JSON. -func (c *Conn) WriteJSON(v interface{}) error { - w, err := c.NextWriter(TextMessage) - if err != nil { - return err - } - err1 := json.NewEncoder(w).Encode(v) - err2 := w.Close() - if err1 != nil { - return err1 - } - return err2 -} - -// ReadJSON reads the next JSON-encoded message from the connection and stores -// it in the value pointed to by v. -// -// Deprecated: Use c.ReadJSON instead. -func ReadJSON(c *Conn, v interface{}) error { - return c.ReadJSON(v) -} - -// ReadJSON reads the next JSON-encoded message from the connection and stores -// it in the value pointed to by v. -// -// See the documentation for the encoding/json Unmarshal function for details -// about the conversion of JSON to a Go value. -func (c *Conn) ReadJSON(v interface{}) error { - _, r, err := c.NextReader() - if err != nil { - return err - } - err = json.NewDecoder(r).Decode(v) - if err == io.EOF { - // One value is expected in the message. - err = io.ErrUnexpectedEOF - } - return err -} diff --git a/vendor/github.com/gorilla/websocket/mask.go b/vendor/github.com/gorilla/websocket/mask.go deleted file mode 100644 index 577fce9ef..000000000 --- a/vendor/github.com/gorilla/websocket/mask.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2016 The Gorilla WebSocket Authors. All rights reserved. Use of -// this source code is governed by a BSD-style license that can be found in the -// LICENSE file. - -// +build !appengine - -package websocket - -import "unsafe" - -const wordSize = int(unsafe.Sizeof(uintptr(0))) - -func maskBytes(key [4]byte, pos int, b []byte) int { - // Mask one byte at a time for small buffers. - if len(b) < 2*wordSize { - for i := range b { - b[i] ^= key[pos&3] - pos++ - } - return pos & 3 - } - - // Mask one byte at a time to word boundary. - if n := int(uintptr(unsafe.Pointer(&b[0]))) % wordSize; n != 0 { - n = wordSize - n - for i := range b[:n] { - b[i] ^= key[pos&3] - pos++ - } - b = b[n:] - } - - // Create aligned word size key. - var k [wordSize]byte - for i := range k { - k[i] = key[(pos+i)&3] - } - kw := *(*uintptr)(unsafe.Pointer(&k)) - - // Mask one word at a time. - n := (len(b) / wordSize) * wordSize - for i := 0; i < n; i += wordSize { - *(*uintptr)(unsafe.Pointer(uintptr(unsafe.Pointer(&b[0])) + uintptr(i))) ^= kw - } - - // Mask one byte at a time for remaining bytes. - b = b[n:] - for i := range b { - b[i] ^= key[pos&3] - pos++ - } - - return pos & 3 -} diff --git a/vendor/github.com/gorilla/websocket/mask_safe.go b/vendor/github.com/gorilla/websocket/mask_safe.go deleted file mode 100644 index 2aac060e5..000000000 --- a/vendor/github.com/gorilla/websocket/mask_safe.go +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright 2016 The Gorilla WebSocket Authors. All rights reserved. Use of -// this source code is governed by a BSD-style license that can be found in the -// LICENSE file. - -// +build appengine - -package websocket - -func maskBytes(key [4]byte, pos int, b []byte) int { - for i := range b { - b[i] ^= key[pos&3] - pos++ - } - return pos & 3 -} diff --git a/vendor/github.com/gorilla/websocket/prepared.go b/vendor/github.com/gorilla/websocket/prepared.go deleted file mode 100644 index c854225e9..000000000 --- a/vendor/github.com/gorilla/websocket/prepared.go +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright 2017 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package websocket - -import ( - "bytes" - "net" - "sync" - "time" -) - -// PreparedMessage caches on the wire representations of a message payload. -// Use PreparedMessage to efficiently send a message payload to multiple -// connections. PreparedMessage is especially useful when compression is used -// because the CPU and memory expensive compression operation can be executed -// once for a given set of compression options. -type PreparedMessage struct { - messageType int - data []byte - mu sync.Mutex - frames map[prepareKey]*preparedFrame -} - -// prepareKey defines a unique set of options to cache prepared frames in PreparedMessage. -type prepareKey struct { - isServer bool - compress bool - compressionLevel int -} - -// preparedFrame contains data in wire representation. -type preparedFrame struct { - once sync.Once - data []byte -} - -// NewPreparedMessage returns an initialized PreparedMessage. You can then send -// it to connection using WritePreparedMessage method. Valid wire -// representation will be calculated lazily only once for a set of current -// connection options. -func NewPreparedMessage(messageType int, data []byte) (*PreparedMessage, error) { - pm := &PreparedMessage{ - messageType: messageType, - frames: make(map[prepareKey]*preparedFrame), - data: data, - } - - // Prepare a plain server frame. - _, frameData, err := pm.frame(prepareKey{isServer: true, compress: false}) - if err != nil { - return nil, err - } - - // To protect against caller modifying the data argument, remember the data - // copied to the plain server frame. - pm.data = frameData[len(frameData)-len(data):] - return pm, nil -} - -func (pm *PreparedMessage) frame(key prepareKey) (int, []byte, error) { - pm.mu.Lock() - frame, ok := pm.frames[key] - if !ok { - frame = &preparedFrame{} - pm.frames[key] = frame - } - pm.mu.Unlock() - - var err error - frame.once.Do(func() { - // Prepare a frame using a 'fake' connection. - // TODO: Refactor code in conn.go to allow more direct construction of - // the frame. - mu := make(chan struct{}, 1) - mu <- struct{}{} - var nc prepareConn - c := &Conn{ - conn: &nc, - mu: mu, - isServer: key.isServer, - compressionLevel: key.compressionLevel, - enableWriteCompression: true, - writeBuf: make([]byte, defaultWriteBufferSize+maxFrameHeaderSize), - } - if key.compress { - c.newCompressionWriter = compressNoContextTakeover - } - err = c.WriteMessage(pm.messageType, pm.data) - frame.data = nc.buf.Bytes() - }) - return pm.messageType, frame.data, err -} - -type prepareConn struct { - buf bytes.Buffer - net.Conn -} - -func (pc *prepareConn) Write(p []byte) (int, error) { return pc.buf.Write(p) } -func (pc *prepareConn) SetWriteDeadline(t time.Time) error { return nil } diff --git a/vendor/github.com/gorilla/websocket/proxy.go b/vendor/github.com/gorilla/websocket/proxy.go deleted file mode 100644 index e87a8c9f0..000000000 --- a/vendor/github.com/gorilla/websocket/proxy.go +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2017 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package websocket - -import ( - "bufio" - "encoding/base64" - "errors" - "net" - "net/http" - "net/url" - "strings" -) - -type netDialerFunc func(network, addr string) (net.Conn, error) - -func (fn netDialerFunc) Dial(network, addr string) (net.Conn, error) { - return fn(network, addr) -} - -func init() { - proxy_RegisterDialerType("http", func(proxyURL *url.URL, forwardDialer proxy_Dialer) (proxy_Dialer, error) { - return &httpProxyDialer{proxyURL: proxyURL, forwardDial: forwardDialer.Dial}, nil - }) -} - -type httpProxyDialer struct { - proxyURL *url.URL - forwardDial func(network, addr string) (net.Conn, error) -} - -func (hpd *httpProxyDialer) Dial(network string, addr string) (net.Conn, error) { - hostPort, _ := hostPortNoPort(hpd.proxyURL) - conn, err := hpd.forwardDial(network, hostPort) - if err != nil { - return nil, err - } - - connectHeader := make(http.Header) - if user := hpd.proxyURL.User; user != nil { - proxyUser := user.Username() - if proxyPassword, passwordSet := user.Password(); passwordSet { - credential := base64.StdEncoding.EncodeToString([]byte(proxyUser + ":" + proxyPassword)) - connectHeader.Set("Proxy-Authorization", "Basic "+credential) - } - } - - connectReq := &http.Request{ - Method: "CONNECT", - URL: &url.URL{Opaque: addr}, - Host: addr, - Header: connectHeader, - } - - if err := connectReq.Write(conn); err != nil { - conn.Close() - return nil, err - } - - // Read response. It's OK to use and discard buffered reader here becaue - // the remote server does not speak until spoken to. - br := bufio.NewReader(conn) - resp, err := http.ReadResponse(br, connectReq) - if err != nil { - conn.Close() - return nil, err - } - - if resp.StatusCode != 200 { - conn.Close() - f := strings.SplitN(resp.Status, " ", 2) - return nil, errors.New(f[1]) - } - return conn, nil -} diff --git a/vendor/github.com/gorilla/websocket/server.go b/vendor/github.com/gorilla/websocket/server.go deleted file mode 100644 index 887d55891..000000000 --- a/vendor/github.com/gorilla/websocket/server.go +++ /dev/null @@ -1,363 +0,0 @@ -// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package websocket - -import ( - "bufio" - "errors" - "io" - "net/http" - "net/url" - "strings" - "time" -) - -// HandshakeError describes an error with the handshake from the peer. -type HandshakeError struct { - message string -} - -func (e HandshakeError) Error() string { return e.message } - -// Upgrader specifies parameters for upgrading an HTTP connection to a -// WebSocket connection. -type Upgrader struct { - // HandshakeTimeout specifies the duration for the handshake to complete. - HandshakeTimeout time.Duration - - // ReadBufferSize and WriteBufferSize specify I/O buffer sizes in bytes. If a buffer - // size is zero, then buffers allocated by the HTTP server are used. The - // I/O buffer sizes do not limit the size of the messages that can be sent - // or received. - ReadBufferSize, WriteBufferSize int - - // WriteBufferPool is a pool of buffers for write operations. If the value - // is not set, then write buffers are allocated to the connection for the - // lifetime of the connection. - // - // A pool is most useful when the application has a modest volume of writes - // across a large number of connections. - // - // Applications should use a single pool for each unique value of - // WriteBufferSize. - WriteBufferPool BufferPool - - // Subprotocols specifies the server's supported protocols in order of - // preference. If this field is not nil, then the Upgrade method negotiates a - // subprotocol by selecting the first match in this list with a protocol - // requested by the client. If there's no match, then no protocol is - // negotiated (the Sec-Websocket-Protocol header is not included in the - // handshake response). - Subprotocols []string - - // Error specifies the function for generating HTTP error responses. If Error - // is nil, then http.Error is used to generate the HTTP response. - Error func(w http.ResponseWriter, r *http.Request, status int, reason error) - - // CheckOrigin returns true if the request Origin header is acceptable. If - // CheckOrigin is nil, then a safe default is used: return false if the - // Origin request header is present and the origin host is not equal to - // request Host header. - // - // A CheckOrigin function should carefully validate the request origin to - // prevent cross-site request forgery. - CheckOrigin func(r *http.Request) bool - - // EnableCompression specify if the server should attempt to negotiate per - // message compression (RFC 7692). Setting this value to true does not - // guarantee that compression will be supported. Currently only "no context - // takeover" modes are supported. - EnableCompression bool -} - -func (u *Upgrader) returnError(w http.ResponseWriter, r *http.Request, status int, reason string) (*Conn, error) { - err := HandshakeError{reason} - if u.Error != nil { - u.Error(w, r, status, err) - } else { - w.Header().Set("Sec-Websocket-Version", "13") - http.Error(w, http.StatusText(status), status) - } - return nil, err -} - -// checkSameOrigin returns true if the origin is not set or is equal to the request host. -func checkSameOrigin(r *http.Request) bool { - origin := r.Header["Origin"] - if len(origin) == 0 { - return true - } - u, err := url.Parse(origin[0]) - if err != nil { - return false - } - return equalASCIIFold(u.Host, r.Host) -} - -func (u *Upgrader) selectSubprotocol(r *http.Request, responseHeader http.Header) string { - if u.Subprotocols != nil { - clientProtocols := Subprotocols(r) - for _, serverProtocol := range u.Subprotocols { - for _, clientProtocol := range clientProtocols { - if clientProtocol == serverProtocol { - return clientProtocol - } - } - } - } else if responseHeader != nil { - return responseHeader.Get("Sec-Websocket-Protocol") - } - return "" -} - -// Upgrade upgrades the HTTP server connection to the WebSocket protocol. -// -// The responseHeader is included in the response to the client's upgrade -// request. Use the responseHeader to specify cookies (Set-Cookie) and the -// application negotiated subprotocol (Sec-WebSocket-Protocol). -// -// If the upgrade fails, then Upgrade replies to the client with an HTTP error -// response. -func (u *Upgrader) Upgrade(w http.ResponseWriter, r *http.Request, responseHeader http.Header) (*Conn, error) { - const badHandshake = "websocket: the client is not using the websocket protocol: " - - if !tokenListContainsValue(r.Header, "Connection", "upgrade") { - return u.returnError(w, r, http.StatusBadRequest, badHandshake+"'upgrade' token not found in 'Connection' header") - } - - if !tokenListContainsValue(r.Header, "Upgrade", "websocket") { - return u.returnError(w, r, http.StatusBadRequest, badHandshake+"'websocket' token not found in 'Upgrade' header") - } - - if r.Method != "GET" { - return u.returnError(w, r, http.StatusMethodNotAllowed, badHandshake+"request method is not GET") - } - - if !tokenListContainsValue(r.Header, "Sec-Websocket-Version", "13") { - return u.returnError(w, r, http.StatusBadRequest, "websocket: unsupported version: 13 not found in 'Sec-Websocket-Version' header") - } - - if _, ok := responseHeader["Sec-Websocket-Extensions"]; ok { - return u.returnError(w, r, http.StatusInternalServerError, "websocket: application specific 'Sec-WebSocket-Extensions' headers are unsupported") - } - - checkOrigin := u.CheckOrigin - if checkOrigin == nil { - checkOrigin = checkSameOrigin - } - if !checkOrigin(r) { - return u.returnError(w, r, http.StatusForbidden, "websocket: request origin not allowed by Upgrader.CheckOrigin") - } - - challengeKey := r.Header.Get("Sec-Websocket-Key") - if challengeKey == "" { - return u.returnError(w, r, http.StatusBadRequest, "websocket: not a websocket handshake: 'Sec-WebSocket-Key' header is missing or blank") - } - - subprotocol := u.selectSubprotocol(r, responseHeader) - - // Negotiate PMCE - var compress bool - if u.EnableCompression { - for _, ext := range parseExtensions(r.Header) { - if ext[""] != "permessage-deflate" { - continue - } - compress = true - break - } - } - - h, ok := w.(http.Hijacker) - if !ok { - return u.returnError(w, r, http.StatusInternalServerError, "websocket: response does not implement http.Hijacker") - } - var brw *bufio.ReadWriter - netConn, brw, err := h.Hijack() - if err != nil { - return u.returnError(w, r, http.StatusInternalServerError, err.Error()) - } - - if brw.Reader.Buffered() > 0 { - netConn.Close() - return nil, errors.New("websocket: client sent data before handshake is complete") - } - - var br *bufio.Reader - if u.ReadBufferSize == 0 && bufioReaderSize(netConn, brw.Reader) > 256 { - // Reuse hijacked buffered reader as connection reader. - br = brw.Reader - } - - buf := bufioWriterBuffer(netConn, brw.Writer) - - var writeBuf []byte - if u.WriteBufferPool == nil && u.WriteBufferSize == 0 && len(buf) >= maxFrameHeaderSize+256 { - // Reuse hijacked write buffer as connection buffer. - writeBuf = buf - } - - c := newConn(netConn, true, u.ReadBufferSize, u.WriteBufferSize, u.WriteBufferPool, br, writeBuf) - c.subprotocol = subprotocol - - if compress { - c.newCompressionWriter = compressNoContextTakeover - c.newDecompressionReader = decompressNoContextTakeover - } - - // Use larger of hijacked buffer and connection write buffer for header. - p := buf - if len(c.writeBuf) > len(p) { - p = c.writeBuf - } - p = p[:0] - - p = append(p, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: "...) - p = append(p, computeAcceptKey(challengeKey)...) - p = append(p, "\r\n"...) - if c.subprotocol != "" { - p = append(p, "Sec-WebSocket-Protocol: "...) - p = append(p, c.subprotocol...) - p = append(p, "\r\n"...) - } - if compress { - p = append(p, "Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover; client_no_context_takeover\r\n"...) - } - for k, vs := range responseHeader { - if k == "Sec-Websocket-Protocol" { - continue - } - for _, v := range vs { - p = append(p, k...) - p = append(p, ": "...) - for i := 0; i < len(v); i++ { - b := v[i] - if b <= 31 { - // prevent response splitting. - b = ' ' - } - p = append(p, b) - } - p = append(p, "\r\n"...) - } - } - p = append(p, "\r\n"...) - - // Clear deadlines set by HTTP server. - netConn.SetDeadline(time.Time{}) - - if u.HandshakeTimeout > 0 { - netConn.SetWriteDeadline(time.Now().Add(u.HandshakeTimeout)) - } - if _, err = netConn.Write(p); err != nil { - netConn.Close() - return nil, err - } - if u.HandshakeTimeout > 0 { - netConn.SetWriteDeadline(time.Time{}) - } - - return c, nil -} - -// Upgrade upgrades the HTTP server connection to the WebSocket protocol. -// -// Deprecated: Use websocket.Upgrader instead. -// -// Upgrade does not perform origin checking. The application is responsible for -// checking the Origin header before calling Upgrade. An example implementation -// of the same origin policy check is: -// -// if req.Header.Get("Origin") != "http://"+req.Host { -// http.Error(w, "Origin not allowed", http.StatusForbidden) -// return -// } -// -// If the endpoint supports subprotocols, then the application is responsible -// for negotiating the protocol used on the connection. Use the Subprotocols() -// function to get the subprotocols requested by the client. Use the -// Sec-Websocket-Protocol response header to specify the subprotocol selected -// by the application. -// -// The responseHeader is included in the response to the client's upgrade -// request. Use the responseHeader to specify cookies (Set-Cookie) and the -// negotiated subprotocol (Sec-Websocket-Protocol). -// -// The connection buffers IO to the underlying network connection. The -// readBufSize and writeBufSize parameters specify the size of the buffers to -// use. Messages can be larger than the buffers. -// -// If the request is not a valid WebSocket handshake, then Upgrade returns an -// error of type HandshakeError. Applications should handle this error by -// replying to the client with an HTTP error response. -func Upgrade(w http.ResponseWriter, r *http.Request, responseHeader http.Header, readBufSize, writeBufSize int) (*Conn, error) { - u := Upgrader{ReadBufferSize: readBufSize, WriteBufferSize: writeBufSize} - u.Error = func(w http.ResponseWriter, r *http.Request, status int, reason error) { - // don't return errors to maintain backwards compatibility - } - u.CheckOrigin = func(r *http.Request) bool { - // allow all connections by default - return true - } - return u.Upgrade(w, r, responseHeader) -} - -// Subprotocols returns the subprotocols requested by the client in the -// Sec-Websocket-Protocol header. -func Subprotocols(r *http.Request) []string { - h := strings.TrimSpace(r.Header.Get("Sec-Websocket-Protocol")) - if h == "" { - return nil - } - protocols := strings.Split(h, ",") - for i := range protocols { - protocols[i] = strings.TrimSpace(protocols[i]) - } - return protocols -} - -// IsWebSocketUpgrade returns true if the client requested upgrade to the -// WebSocket protocol. -func IsWebSocketUpgrade(r *http.Request) bool { - return tokenListContainsValue(r.Header, "Connection", "upgrade") && - tokenListContainsValue(r.Header, "Upgrade", "websocket") -} - -// bufioReaderSize size returns the size of a bufio.Reader. -func bufioReaderSize(originalReader io.Reader, br *bufio.Reader) int { - // This code assumes that peek on a reset reader returns - // bufio.Reader.buf[:0]. - // TODO: Use bufio.Reader.Size() after Go 1.10 - br.Reset(originalReader) - if p, err := br.Peek(0); err == nil { - return cap(p) - } - return 0 -} - -// writeHook is an io.Writer that records the last slice passed to it vio -// io.Writer.Write. -type writeHook struct { - p []byte -} - -func (wh *writeHook) Write(p []byte) (int, error) { - wh.p = p - return len(p), nil -} - -// bufioWriterBuffer grabs the buffer from a bufio.Writer. -func bufioWriterBuffer(originalWriter io.Writer, bw *bufio.Writer) []byte { - // This code assumes that bufio.Writer.buf[:1] is passed to the - // bufio.Writer's underlying writer. - var wh writeHook - bw.Reset(&wh) - bw.WriteByte(0) - bw.Flush() - - bw.Reset(originalWriter) - - return wh.p[:cap(wh.p)] -} diff --git a/vendor/github.com/gorilla/websocket/trace.go b/vendor/github.com/gorilla/websocket/trace.go deleted file mode 100644 index 834f122a0..000000000 --- a/vendor/github.com/gorilla/websocket/trace.go +++ /dev/null @@ -1,19 +0,0 @@ -// +build go1.8 - -package websocket - -import ( - "crypto/tls" - "net/http/httptrace" -) - -func doHandshakeWithTrace(trace *httptrace.ClientTrace, tlsConn *tls.Conn, cfg *tls.Config) error { - if trace.TLSHandshakeStart != nil { - trace.TLSHandshakeStart() - } - err := doHandshake(tlsConn, cfg) - if trace.TLSHandshakeDone != nil { - trace.TLSHandshakeDone(tlsConn.ConnectionState(), err) - } - return err -} diff --git a/vendor/github.com/gorilla/websocket/trace_17.go b/vendor/github.com/gorilla/websocket/trace_17.go deleted file mode 100644 index 77d05a0b5..000000000 --- a/vendor/github.com/gorilla/websocket/trace_17.go +++ /dev/null @@ -1,12 +0,0 @@ -// +build !go1.8 - -package websocket - -import ( - "crypto/tls" - "net/http/httptrace" -) - -func doHandshakeWithTrace(trace *httptrace.ClientTrace, tlsConn *tls.Conn, cfg *tls.Config) error { - return doHandshake(tlsConn, cfg) -} diff --git a/vendor/github.com/gorilla/websocket/util.go b/vendor/github.com/gorilla/websocket/util.go deleted file mode 100644 index 7bf2f66c6..000000000 --- a/vendor/github.com/gorilla/websocket/util.go +++ /dev/null @@ -1,283 +0,0 @@ -// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package websocket - -import ( - "crypto/rand" - "crypto/sha1" - "encoding/base64" - "io" - "net/http" - "strings" - "unicode/utf8" -) - -var keyGUID = []byte("258EAFA5-E914-47DA-95CA-C5AB0DC85B11") - -func computeAcceptKey(challengeKey string) string { - h := sha1.New() - h.Write([]byte(challengeKey)) - h.Write(keyGUID) - return base64.StdEncoding.EncodeToString(h.Sum(nil)) -} - -func generateChallengeKey() (string, error) { - p := make([]byte, 16) - if _, err := io.ReadFull(rand.Reader, p); err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(p), nil -} - -// Token octets per RFC 2616. -var isTokenOctet = [256]bool{ - '!': true, - '#': true, - '$': true, - '%': true, - '&': true, - '\'': true, - '*': true, - '+': true, - '-': true, - '.': true, - '0': true, - '1': true, - '2': true, - '3': true, - '4': true, - '5': true, - '6': true, - '7': true, - '8': true, - '9': true, - 'A': true, - 'B': true, - 'C': true, - 'D': true, - 'E': true, - 'F': true, - 'G': true, - 'H': true, - 'I': true, - 'J': true, - 'K': true, - 'L': true, - 'M': true, - 'N': true, - 'O': true, - 'P': true, - 'Q': true, - 'R': true, - 'S': true, - 'T': true, - 'U': true, - 'W': true, - 'V': true, - 'X': true, - 'Y': true, - 'Z': true, - '^': true, - '_': true, - '`': true, - 'a': true, - 'b': true, - 'c': true, - 'd': true, - 'e': true, - 'f': true, - 'g': true, - 'h': true, - 'i': true, - 'j': true, - 'k': true, - 'l': true, - 'm': true, - 'n': true, - 'o': true, - 'p': true, - 'q': true, - 'r': true, - 's': true, - 't': true, - 'u': true, - 'v': true, - 'w': true, - 'x': true, - 'y': true, - 'z': true, - '|': true, - '~': true, -} - -// skipSpace returns a slice of the string s with all leading RFC 2616 linear -// whitespace removed. -func skipSpace(s string) (rest string) { - i := 0 - for ; i < len(s); i++ { - if b := s[i]; b != ' ' && b != '\t' { - break - } - } - return s[i:] -} - -// nextToken returns the leading RFC 2616 token of s and the string following -// the token. -func nextToken(s string) (token, rest string) { - i := 0 - for ; i < len(s); i++ { - if !isTokenOctet[s[i]] { - break - } - } - return s[:i], s[i:] -} - -// nextTokenOrQuoted returns the leading token or quoted string per RFC 2616 -// and the string following the token or quoted string. -func nextTokenOrQuoted(s string) (value string, rest string) { - if !strings.HasPrefix(s, "\"") { - return nextToken(s) - } - s = s[1:] - for i := 0; i < len(s); i++ { - switch s[i] { - case '"': - return s[:i], s[i+1:] - case '\\': - p := make([]byte, len(s)-1) - j := copy(p, s[:i]) - escape := true - for i = i + 1; i < len(s); i++ { - b := s[i] - switch { - case escape: - escape = false - p[j] = b - j++ - case b == '\\': - escape = true - case b == '"': - return string(p[:j]), s[i+1:] - default: - p[j] = b - j++ - } - } - return "", "" - } - } - return "", "" -} - -// equalASCIIFold returns true if s is equal to t with ASCII case folding as -// defined in RFC 4790. -func equalASCIIFold(s, t string) bool { - for s != "" && t != "" { - sr, size := utf8.DecodeRuneInString(s) - s = s[size:] - tr, size := utf8.DecodeRuneInString(t) - t = t[size:] - if sr == tr { - continue - } - if 'A' <= sr && sr <= 'Z' { - sr = sr + 'a' - 'A' - } - if 'A' <= tr && tr <= 'Z' { - tr = tr + 'a' - 'A' - } - if sr != tr { - return false - } - } - return s == t -} - -// tokenListContainsValue returns true if the 1#token header with the given -// name contains a token equal to value with ASCII case folding. -func tokenListContainsValue(header http.Header, name string, value string) bool { -headers: - for _, s := range header[name] { - for { - var t string - t, s = nextToken(skipSpace(s)) - if t == "" { - continue headers - } - s = skipSpace(s) - if s != "" && s[0] != ',' { - continue headers - } - if equalASCIIFold(t, value) { - return true - } - if s == "" { - continue headers - } - s = s[1:] - } - } - return false -} - -// parseExtensions parses WebSocket extensions from a header. -func parseExtensions(header http.Header) []map[string]string { - // From RFC 6455: - // - // Sec-WebSocket-Extensions = extension-list - // extension-list = 1#extension - // extension = extension-token *( ";" extension-param ) - // extension-token = registered-token - // registered-token = token - // extension-param = token [ "=" (token | quoted-string) ] - // ;When using the quoted-string syntax variant, the value - // ;after quoted-string unescaping MUST conform to the - // ;'token' ABNF. - - var result []map[string]string -headers: - for _, s := range header["Sec-Websocket-Extensions"] { - for { - var t string - t, s = nextToken(skipSpace(s)) - if t == "" { - continue headers - } - ext := map[string]string{"": t} - for { - s = skipSpace(s) - if !strings.HasPrefix(s, ";") { - break - } - var k string - k, s = nextToken(skipSpace(s[1:])) - if k == "" { - continue headers - } - s = skipSpace(s) - var v string - if strings.HasPrefix(s, "=") { - v, s = nextTokenOrQuoted(skipSpace(s[1:])) - s = skipSpace(s) - } - if s != "" && s[0] != ',' && s[0] != ';' { - continue headers - } - ext[k] = v - } - if s != "" && s[0] != ',' { - continue headers - } - result = append(result, ext) - if s == "" { - continue headers - } - s = s[1:] - } - } - return result -} diff --git a/vendor/github.com/gorilla/websocket/x_net_proxy.go b/vendor/github.com/gorilla/websocket/x_net_proxy.go deleted file mode 100644 index 2e668f6b8..000000000 --- a/vendor/github.com/gorilla/websocket/x_net_proxy.go +++ /dev/null @@ -1,473 +0,0 @@ -// Code generated by golang.org/x/tools/cmd/bundle. DO NOT EDIT. -//go:generate bundle -o x_net_proxy.go golang.org/x/net/proxy - -// Package proxy provides support for a variety of protocols to proxy network -// data. -// - -package websocket - -import ( - "errors" - "io" - "net" - "net/url" - "os" - "strconv" - "strings" - "sync" -) - -type proxy_direct struct{} - -// Direct is a direct proxy: one that makes network connections directly. -var proxy_Direct = proxy_direct{} - -func (proxy_direct) Dial(network, addr string) (net.Conn, error) { - return net.Dial(network, addr) -} - -// A PerHost directs connections to a default Dialer unless the host name -// requested matches one of a number of exceptions. -type proxy_PerHost struct { - def, bypass proxy_Dialer - - bypassNetworks []*net.IPNet - bypassIPs []net.IP - bypassZones []string - bypassHosts []string -} - -// NewPerHost returns a PerHost Dialer that directs connections to either -// defaultDialer or bypass, depending on whether the connection matches one of -// the configured rules. -func proxy_NewPerHost(defaultDialer, bypass proxy_Dialer) *proxy_PerHost { - return &proxy_PerHost{ - def: defaultDialer, - bypass: bypass, - } -} - -// Dial connects to the address addr on the given network through either -// defaultDialer or bypass. -func (p *proxy_PerHost) Dial(network, addr string) (c net.Conn, err error) { - host, _, err := net.SplitHostPort(addr) - if err != nil { - return nil, err - } - - return p.dialerForRequest(host).Dial(network, addr) -} - -func (p *proxy_PerHost) dialerForRequest(host string) proxy_Dialer { - if ip := net.ParseIP(host); ip != nil { - for _, net := range p.bypassNetworks { - if net.Contains(ip) { - return p.bypass - } - } - for _, bypassIP := range p.bypassIPs { - if bypassIP.Equal(ip) { - return p.bypass - } - } - return p.def - } - - for _, zone := range p.bypassZones { - if strings.HasSuffix(host, zone) { - return p.bypass - } - if host == zone[1:] { - // For a zone ".example.com", we match "example.com" - // too. - return p.bypass - } - } - for _, bypassHost := range p.bypassHosts { - if bypassHost == host { - return p.bypass - } - } - return p.def -} - -// AddFromString parses a string that contains comma-separated values -// specifying hosts that should use the bypass proxy. Each value is either an -// IP address, a CIDR range, a zone (*.example.com) or a host name -// (localhost). A best effort is made to parse the string and errors are -// ignored. -func (p *proxy_PerHost) AddFromString(s string) { - hosts := strings.Split(s, ",") - for _, host := range hosts { - host = strings.TrimSpace(host) - if len(host) == 0 { - continue - } - if strings.Contains(host, "/") { - // We assume that it's a CIDR address like 127.0.0.0/8 - if _, net, err := net.ParseCIDR(host); err == nil { - p.AddNetwork(net) - } - continue - } - if ip := net.ParseIP(host); ip != nil { - p.AddIP(ip) - continue - } - if strings.HasPrefix(host, "*.") { - p.AddZone(host[1:]) - continue - } - p.AddHost(host) - } -} - -// AddIP specifies an IP address that will use the bypass proxy. Note that -// this will only take effect if a literal IP address is dialed. A connection -// to a named host will never match an IP. -func (p *proxy_PerHost) AddIP(ip net.IP) { - p.bypassIPs = append(p.bypassIPs, ip) -} - -// AddNetwork specifies an IP range that will use the bypass proxy. Note that -// this will only take effect if a literal IP address is dialed. A connection -// to a named host will never match. -func (p *proxy_PerHost) AddNetwork(net *net.IPNet) { - p.bypassNetworks = append(p.bypassNetworks, net) -} - -// AddZone specifies a DNS suffix that will use the bypass proxy. A zone of -// "example.com" matches "example.com" and all of its subdomains. -func (p *proxy_PerHost) AddZone(zone string) { - if strings.HasSuffix(zone, ".") { - zone = zone[:len(zone)-1] - } - if !strings.HasPrefix(zone, ".") { - zone = "." + zone - } - p.bypassZones = append(p.bypassZones, zone) -} - -// AddHost specifies a host name that will use the bypass proxy. -func (p *proxy_PerHost) AddHost(host string) { - if strings.HasSuffix(host, ".") { - host = host[:len(host)-1] - } - p.bypassHosts = append(p.bypassHosts, host) -} - -// A Dialer is a means to establish a connection. -type proxy_Dialer interface { - // Dial connects to the given address via the proxy. - Dial(network, addr string) (c net.Conn, err error) -} - -// Auth contains authentication parameters that specific Dialers may require. -type proxy_Auth struct { - User, Password string -} - -// FromEnvironment returns the dialer specified by the proxy related variables in -// the environment. -func proxy_FromEnvironment() proxy_Dialer { - allProxy := proxy_allProxyEnv.Get() - if len(allProxy) == 0 { - return proxy_Direct - } - - proxyURL, err := url.Parse(allProxy) - if err != nil { - return proxy_Direct - } - proxy, err := proxy_FromURL(proxyURL, proxy_Direct) - if err != nil { - return proxy_Direct - } - - noProxy := proxy_noProxyEnv.Get() - if len(noProxy) == 0 { - return proxy - } - - perHost := proxy_NewPerHost(proxy, proxy_Direct) - perHost.AddFromString(noProxy) - return perHost -} - -// proxySchemes is a map from URL schemes to a function that creates a Dialer -// from a URL with such a scheme. -var proxy_proxySchemes map[string]func(*url.URL, proxy_Dialer) (proxy_Dialer, error) - -// RegisterDialerType takes a URL scheme and a function to generate Dialers from -// a URL with that scheme and a forwarding Dialer. Registered schemes are used -// by FromURL. -func proxy_RegisterDialerType(scheme string, f func(*url.URL, proxy_Dialer) (proxy_Dialer, error)) { - if proxy_proxySchemes == nil { - proxy_proxySchemes = make(map[string]func(*url.URL, proxy_Dialer) (proxy_Dialer, error)) - } - proxy_proxySchemes[scheme] = f -} - -// FromURL returns a Dialer given a URL specification and an underlying -// Dialer for it to make network requests. -func proxy_FromURL(u *url.URL, forward proxy_Dialer) (proxy_Dialer, error) { - var auth *proxy_Auth - if u.User != nil { - auth = new(proxy_Auth) - auth.User = u.User.Username() - if p, ok := u.User.Password(); ok { - auth.Password = p - } - } - - switch u.Scheme { - case "socks5": - return proxy_SOCKS5("tcp", u.Host, auth, forward) - } - - // If the scheme doesn't match any of the built-in schemes, see if it - // was registered by another package. - if proxy_proxySchemes != nil { - if f, ok := proxy_proxySchemes[u.Scheme]; ok { - return f(u, forward) - } - } - - return nil, errors.New("proxy: unknown scheme: " + u.Scheme) -} - -var ( - proxy_allProxyEnv = &proxy_envOnce{ - names: []string{"ALL_PROXY", "all_proxy"}, - } - proxy_noProxyEnv = &proxy_envOnce{ - names: []string{"NO_PROXY", "no_proxy"}, - } -) - -// envOnce looks up an environment variable (optionally by multiple -// names) once. It mitigates expensive lookups on some platforms -// (e.g. Windows). -// (Borrowed from net/http/transport.go) -type proxy_envOnce struct { - names []string - once sync.Once - val string -} - -func (e *proxy_envOnce) Get() string { - e.once.Do(e.init) - return e.val -} - -func (e *proxy_envOnce) init() { - for _, n := range e.names { - e.val = os.Getenv(n) - if e.val != "" { - return - } - } -} - -// SOCKS5 returns a Dialer that makes SOCKSv5 connections to the given address -// with an optional username and password. See RFC 1928 and RFC 1929. -func proxy_SOCKS5(network, addr string, auth *proxy_Auth, forward proxy_Dialer) (proxy_Dialer, error) { - s := &proxy_socks5{ - network: network, - addr: addr, - forward: forward, - } - if auth != nil { - s.user = auth.User - s.password = auth.Password - } - - return s, nil -} - -type proxy_socks5 struct { - user, password string - network, addr string - forward proxy_Dialer -} - -const proxy_socks5Version = 5 - -const ( - proxy_socks5AuthNone = 0 - proxy_socks5AuthPassword = 2 -) - -const proxy_socks5Connect = 1 - -const ( - proxy_socks5IP4 = 1 - proxy_socks5Domain = 3 - proxy_socks5IP6 = 4 -) - -var proxy_socks5Errors = []string{ - "", - "general failure", - "connection forbidden", - "network unreachable", - "host unreachable", - "connection refused", - "TTL expired", - "command not supported", - "address type not supported", -} - -// Dial connects to the address addr on the given network via the SOCKS5 proxy. -func (s *proxy_socks5) Dial(network, addr string) (net.Conn, error) { - switch network { - case "tcp", "tcp6", "tcp4": - default: - return nil, errors.New("proxy: no support for SOCKS5 proxy connections of type " + network) - } - - conn, err := s.forward.Dial(s.network, s.addr) - if err != nil { - return nil, err - } - if err := s.connect(conn, addr); err != nil { - conn.Close() - return nil, err - } - return conn, nil -} - -// connect takes an existing connection to a socks5 proxy server, -// and commands the server to extend that connection to target, -// which must be a canonical address with a host and port. -func (s *proxy_socks5) connect(conn net.Conn, target string) error { - host, portStr, err := net.SplitHostPort(target) - if err != nil { - return err - } - - port, err := strconv.Atoi(portStr) - if err != nil { - return errors.New("proxy: failed to parse port number: " + portStr) - } - if port < 1 || port > 0xffff { - return errors.New("proxy: port number out of range: " + portStr) - } - - // the size here is just an estimate - buf := make([]byte, 0, 6+len(host)) - - buf = append(buf, proxy_socks5Version) - if len(s.user) > 0 && len(s.user) < 256 && len(s.password) < 256 { - buf = append(buf, 2 /* num auth methods */, proxy_socks5AuthNone, proxy_socks5AuthPassword) - } else { - buf = append(buf, 1 /* num auth methods */, proxy_socks5AuthNone) - } - - if _, err := conn.Write(buf); err != nil { - return errors.New("proxy: failed to write greeting to SOCKS5 proxy at " + s.addr + ": " + err.Error()) - } - - if _, err := io.ReadFull(conn, buf[:2]); err != nil { - return errors.New("proxy: failed to read greeting from SOCKS5 proxy at " + s.addr + ": " + err.Error()) - } - if buf[0] != 5 { - return errors.New("proxy: SOCKS5 proxy at " + s.addr + " has unexpected version " + strconv.Itoa(int(buf[0]))) - } - if buf[1] == 0xff { - return errors.New("proxy: SOCKS5 proxy at " + s.addr + " requires authentication") - } - - // See RFC 1929 - if buf[1] == proxy_socks5AuthPassword { - buf = buf[:0] - buf = append(buf, 1 /* password protocol version */) - buf = append(buf, uint8(len(s.user))) - buf = append(buf, s.user...) - buf = append(buf, uint8(len(s.password))) - buf = append(buf, s.password...) - - if _, err := conn.Write(buf); err != nil { - return errors.New("proxy: failed to write authentication request to SOCKS5 proxy at " + s.addr + ": " + err.Error()) - } - - if _, err := io.ReadFull(conn, buf[:2]); err != nil { - return errors.New("proxy: failed to read authentication reply from SOCKS5 proxy at " + s.addr + ": " + err.Error()) - } - - if buf[1] != 0 { - return errors.New("proxy: SOCKS5 proxy at " + s.addr + " rejected username/password") - } - } - - buf = buf[:0] - buf = append(buf, proxy_socks5Version, proxy_socks5Connect, 0 /* reserved */) - - if ip := net.ParseIP(host); ip != nil { - if ip4 := ip.To4(); ip4 != nil { - buf = append(buf, proxy_socks5IP4) - ip = ip4 - } else { - buf = append(buf, proxy_socks5IP6) - } - buf = append(buf, ip...) - } else { - if len(host) > 255 { - return errors.New("proxy: destination host name too long: " + host) - } - buf = append(buf, proxy_socks5Domain) - buf = append(buf, byte(len(host))) - buf = append(buf, host...) - } - buf = append(buf, byte(port>>8), byte(port)) - - if _, err := conn.Write(buf); err != nil { - return errors.New("proxy: failed to write connect request to SOCKS5 proxy at " + s.addr + ": " + err.Error()) - } - - if _, err := io.ReadFull(conn, buf[:4]); err != nil { - return errors.New("proxy: failed to read connect reply from SOCKS5 proxy at " + s.addr + ": " + err.Error()) - } - - failure := "unknown error" - if int(buf[1]) < len(proxy_socks5Errors) { - failure = proxy_socks5Errors[buf[1]] - } - - if len(failure) > 0 { - return errors.New("proxy: SOCKS5 proxy at " + s.addr + " failed to connect: " + failure) - } - - bytesToDiscard := 0 - switch buf[3] { - case proxy_socks5IP4: - bytesToDiscard = net.IPv4len - case proxy_socks5IP6: - bytesToDiscard = net.IPv6len - case proxy_socks5Domain: - _, err := io.ReadFull(conn, buf[:1]) - if err != nil { - return errors.New("proxy: failed to read domain length from SOCKS5 proxy at " + s.addr + ": " + err.Error()) - } - bytesToDiscard = int(buf[0]) - default: - return errors.New("proxy: got unknown address type " + strconv.Itoa(int(buf[3])) + " from SOCKS5 proxy at " + s.addr) - } - - if cap(buf) < bytesToDiscard { - buf = make([]byte, bytesToDiscard) - } else { - buf = buf[:bytesToDiscard] - } - if _, err := io.ReadFull(conn, buf); err != nil { - return errors.New("proxy: failed to read address from SOCKS5 proxy at " + s.addr + ": " + err.Error()) - } - - // Also need to discard the port number - if _, err := io.ReadFull(conn, buf[:2]); err != nil { - return errors.New("proxy: failed to read port from SOCKS5 proxy at " + s.addr + ": " + err.Error()) - } - - return nil -} diff --git a/vendor/github.com/pmezard/go-difflib/LICENSE b/vendor/github.com/pmezard/go-difflib/LICENSE deleted file mode 100644 index c67dad612..000000000 --- a/vendor/github.com/pmezard/go-difflib/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -Copyright (c) 2013, Patrick Mezard -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - Redistributions in binary form must reproduce the above copyright -notice, this list of conditions and the following disclaimer in the -documentation and/or other materials provided with the distribution. - The names of its contributors may not be used to endorse or promote -products derived from this software without specific prior written -permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS -IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED -TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A -PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED -TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/pmezard/go-difflib/difflib/difflib.go b/vendor/github.com/pmezard/go-difflib/difflib/difflib.go deleted file mode 100644 index 003e99fad..000000000 --- a/vendor/github.com/pmezard/go-difflib/difflib/difflib.go +++ /dev/null @@ -1,772 +0,0 @@ -// Package difflib is a partial port of Python difflib module. -// -// It provides tools to compare sequences of strings and generate textual diffs. -// -// The following class and functions have been ported: -// -// - SequenceMatcher -// -// - unified_diff -// -// - context_diff -// -// Getting unified diffs was the main goal of the port. Keep in mind this code -// is mostly suitable to output text differences in a human friendly way, there -// are no guarantees generated diffs are consumable by patch(1). -package difflib - -import ( - "bufio" - "bytes" - "fmt" - "io" - "strings" -) - -func min(a, b int) int { - if a < b { - return a - } - return b -} - -func max(a, b int) int { - if a > b { - return a - } - return b -} - -func calculateRatio(matches, length int) float64 { - if length > 0 { - return 2.0 * float64(matches) / float64(length) - } - return 1.0 -} - -type Match struct { - A int - B int - Size int -} - -type OpCode struct { - Tag byte - I1 int - I2 int - J1 int - J2 int -} - -// SequenceMatcher compares sequence of strings. The basic -// algorithm predates, and is a little fancier than, an algorithm -// published in the late 1980's by Ratcliff and Obershelp under the -// hyperbolic name "gestalt pattern matching". The basic idea is to find -// the longest contiguous matching subsequence that contains no "junk" -// elements (R-O doesn't address junk). The same idea is then applied -// recursively to the pieces of the sequences to the left and to the right -// of the matching subsequence. This does not yield minimal edit -// sequences, but does tend to yield matches that "look right" to people. -// -// SequenceMatcher tries to compute a "human-friendly diff" between two -// sequences. Unlike e.g. UNIX(tm) diff, the fundamental notion is the -// longest *contiguous* & junk-free matching subsequence. That's what -// catches peoples' eyes. The Windows(tm) windiff has another interesting -// notion, pairing up elements that appear uniquely in each sequence. -// That, and the method here, appear to yield more intuitive difference -// reports than does diff. This method appears to be the least vulnerable -// to synching up on blocks of "junk lines", though (like blank lines in -// ordinary text files, or maybe "

    " lines in HTML files). That may be -// because this is the only method of the 3 that has a *concept* of -// "junk" . -// -// Timing: Basic R-O is cubic time worst case and quadratic time expected -// case. SequenceMatcher is quadratic time for the worst case and has -// expected-case behavior dependent in a complicated way on how many -// elements the sequences have in common; best case time is linear. -type SequenceMatcher struct { - a []string - b []string - b2j map[string][]int - IsJunk func(string) bool - autoJunk bool - bJunk map[string]struct{} - matchingBlocks []Match - fullBCount map[string]int - bPopular map[string]struct{} - opCodes []OpCode -} - -func NewMatcher(a, b []string) *SequenceMatcher { - m := SequenceMatcher{autoJunk: true} - m.SetSeqs(a, b) - return &m -} - -func NewMatcherWithJunk(a, b []string, autoJunk bool, - isJunk func(string) bool) *SequenceMatcher { - - m := SequenceMatcher{IsJunk: isJunk, autoJunk: autoJunk} - m.SetSeqs(a, b) - return &m -} - -// Set two sequences to be compared. -func (m *SequenceMatcher) SetSeqs(a, b []string) { - m.SetSeq1(a) - m.SetSeq2(b) -} - -// Set the first sequence to be compared. The second sequence to be compared is -// not changed. -// -// SequenceMatcher computes and caches detailed information about the second -// sequence, so if you want to compare one sequence S against many sequences, -// use .SetSeq2(s) once and call .SetSeq1(x) repeatedly for each of the other -// sequences. -// -// See also SetSeqs() and SetSeq2(). -func (m *SequenceMatcher) SetSeq1(a []string) { - if &a == &m.a { - return - } - m.a = a - m.matchingBlocks = nil - m.opCodes = nil -} - -// Set the second sequence to be compared. The first sequence to be compared is -// not changed. -func (m *SequenceMatcher) SetSeq2(b []string) { - if &b == &m.b { - return - } - m.b = b - m.matchingBlocks = nil - m.opCodes = nil - m.fullBCount = nil - m.chainB() -} - -func (m *SequenceMatcher) chainB() { - // Populate line -> index mapping - b2j := map[string][]int{} - for i, s := range m.b { - indices := b2j[s] - indices = append(indices, i) - b2j[s] = indices - } - - // Purge junk elements - m.bJunk = map[string]struct{}{} - if m.IsJunk != nil { - junk := m.bJunk - for s, _ := range b2j { - if m.IsJunk(s) { - junk[s] = struct{}{} - } - } - for s, _ := range junk { - delete(b2j, s) - } - } - - // Purge remaining popular elements - popular := map[string]struct{}{} - n := len(m.b) - if m.autoJunk && n >= 200 { - ntest := n/100 + 1 - for s, indices := range b2j { - if len(indices) > ntest { - popular[s] = struct{}{} - } - } - for s, _ := range popular { - delete(b2j, s) - } - } - m.bPopular = popular - m.b2j = b2j -} - -func (m *SequenceMatcher) isBJunk(s string) bool { - _, ok := m.bJunk[s] - return ok -} - -// Find longest matching block in a[alo:ahi] and b[blo:bhi]. -// -// If IsJunk is not defined: -// -// Return (i,j,k) such that a[i:i+k] is equal to b[j:j+k], where -// alo <= i <= i+k <= ahi -// blo <= j <= j+k <= bhi -// and for all (i',j',k') meeting those conditions, -// k >= k' -// i <= i' -// and if i == i', j <= j' -// -// In other words, of all maximal matching blocks, return one that -// starts earliest in a, and of all those maximal matching blocks that -// start earliest in a, return the one that starts earliest in b. -// -// If IsJunk is defined, first the longest matching block is -// determined as above, but with the additional restriction that no -// junk element appears in the block. Then that block is extended as -// far as possible by matching (only) junk elements on both sides. So -// the resulting block never matches on junk except as identical junk -// happens to be adjacent to an "interesting" match. -// -// If no blocks match, return (alo, blo, 0). -func (m *SequenceMatcher) findLongestMatch(alo, ahi, blo, bhi int) Match { - // CAUTION: stripping common prefix or suffix would be incorrect. - // E.g., - // ab - // acab - // Longest matching block is "ab", but if common prefix is - // stripped, it's "a" (tied with "b"). UNIX(tm) diff does so - // strip, so ends up claiming that ab is changed to acab by - // inserting "ca" in the middle. That's minimal but unintuitive: - // "it's obvious" that someone inserted "ac" at the front. - // Windiff ends up at the same place as diff, but by pairing up - // the unique 'b's and then matching the first two 'a's. - besti, bestj, bestsize := alo, blo, 0 - - // find longest junk-free match - // during an iteration of the loop, j2len[j] = length of longest - // junk-free match ending with a[i-1] and b[j] - j2len := map[int]int{} - for i := alo; i != ahi; i++ { - // look at all instances of a[i] in b; note that because - // b2j has no junk keys, the loop is skipped if a[i] is junk - newj2len := map[int]int{} - for _, j := range m.b2j[m.a[i]] { - // a[i] matches b[j] - if j < blo { - continue - } - if j >= bhi { - break - } - k := j2len[j-1] + 1 - newj2len[j] = k - if k > bestsize { - besti, bestj, bestsize = i-k+1, j-k+1, k - } - } - j2len = newj2len - } - - // Extend the best by non-junk elements on each end. In particular, - // "popular" non-junk elements aren't in b2j, which greatly speeds - // the inner loop above, but also means "the best" match so far - // doesn't contain any junk *or* popular non-junk elements. - for besti > alo && bestj > blo && !m.isBJunk(m.b[bestj-1]) && - m.a[besti-1] == m.b[bestj-1] { - besti, bestj, bestsize = besti-1, bestj-1, bestsize+1 - } - for besti+bestsize < ahi && bestj+bestsize < bhi && - !m.isBJunk(m.b[bestj+bestsize]) && - m.a[besti+bestsize] == m.b[bestj+bestsize] { - bestsize += 1 - } - - // Now that we have a wholly interesting match (albeit possibly - // empty!), we may as well suck up the matching junk on each - // side of it too. Can't think of a good reason not to, and it - // saves post-processing the (possibly considerable) expense of - // figuring out what to do with it. In the case of an empty - // interesting match, this is clearly the right thing to do, - // because no other kind of match is possible in the regions. - for besti > alo && bestj > blo && m.isBJunk(m.b[bestj-1]) && - m.a[besti-1] == m.b[bestj-1] { - besti, bestj, bestsize = besti-1, bestj-1, bestsize+1 - } - for besti+bestsize < ahi && bestj+bestsize < bhi && - m.isBJunk(m.b[bestj+bestsize]) && - m.a[besti+bestsize] == m.b[bestj+bestsize] { - bestsize += 1 - } - - return Match{A: besti, B: bestj, Size: bestsize} -} - -// Return list of triples describing matching subsequences. -// -// Each triple is of the form (i, j, n), and means that -// a[i:i+n] == b[j:j+n]. The triples are monotonically increasing in -// i and in j. It's also guaranteed that if (i, j, n) and (i', j', n') are -// adjacent triples in the list, and the second is not the last triple in the -// list, then i+n != i' or j+n != j'. IOW, adjacent triples never describe -// adjacent equal blocks. -// -// The last triple is a dummy, (len(a), len(b), 0), and is the only -// triple with n==0. -func (m *SequenceMatcher) GetMatchingBlocks() []Match { - if m.matchingBlocks != nil { - return m.matchingBlocks - } - - var matchBlocks func(alo, ahi, blo, bhi int, matched []Match) []Match - matchBlocks = func(alo, ahi, blo, bhi int, matched []Match) []Match { - match := m.findLongestMatch(alo, ahi, blo, bhi) - i, j, k := match.A, match.B, match.Size - if match.Size > 0 { - if alo < i && blo < j { - matched = matchBlocks(alo, i, blo, j, matched) - } - matched = append(matched, match) - if i+k < ahi && j+k < bhi { - matched = matchBlocks(i+k, ahi, j+k, bhi, matched) - } - } - return matched - } - matched := matchBlocks(0, len(m.a), 0, len(m.b), nil) - - // It's possible that we have adjacent equal blocks in the - // matching_blocks list now. - nonAdjacent := []Match{} - i1, j1, k1 := 0, 0, 0 - for _, b := range matched { - // Is this block adjacent to i1, j1, k1? - i2, j2, k2 := b.A, b.B, b.Size - if i1+k1 == i2 && j1+k1 == j2 { - // Yes, so collapse them -- this just increases the length of - // the first block by the length of the second, and the first - // block so lengthened remains the block to compare against. - k1 += k2 - } else { - // Not adjacent. Remember the first block (k1==0 means it's - // the dummy we started with), and make the second block the - // new block to compare against. - if k1 > 0 { - nonAdjacent = append(nonAdjacent, Match{i1, j1, k1}) - } - i1, j1, k1 = i2, j2, k2 - } - } - if k1 > 0 { - nonAdjacent = append(nonAdjacent, Match{i1, j1, k1}) - } - - nonAdjacent = append(nonAdjacent, Match{len(m.a), len(m.b), 0}) - m.matchingBlocks = nonAdjacent - return m.matchingBlocks -} - -// Return list of 5-tuples describing how to turn a into b. -// -// Each tuple is of the form (tag, i1, i2, j1, j2). The first tuple -// has i1 == j1 == 0, and remaining tuples have i1 == the i2 from the -// tuple preceding it, and likewise for j1 == the previous j2. -// -// The tags are characters, with these meanings: -// -// 'r' (replace): a[i1:i2] should be replaced by b[j1:j2] -// -// 'd' (delete): a[i1:i2] should be deleted, j1==j2 in this case. -// -// 'i' (insert): b[j1:j2] should be inserted at a[i1:i1], i1==i2 in this case. -// -// 'e' (equal): a[i1:i2] == b[j1:j2] -func (m *SequenceMatcher) GetOpCodes() []OpCode { - if m.opCodes != nil { - return m.opCodes - } - i, j := 0, 0 - matching := m.GetMatchingBlocks() - opCodes := make([]OpCode, 0, len(matching)) - for _, m := range matching { - // invariant: we've pumped out correct diffs to change - // a[:i] into b[:j], and the next matching block is - // a[ai:ai+size] == b[bj:bj+size]. So we need to pump - // out a diff to change a[i:ai] into b[j:bj], pump out - // the matching block, and move (i,j) beyond the match - ai, bj, size := m.A, m.B, m.Size - tag := byte(0) - if i < ai && j < bj { - tag = 'r' - } else if i < ai { - tag = 'd' - } else if j < bj { - tag = 'i' - } - if tag > 0 { - opCodes = append(opCodes, OpCode{tag, i, ai, j, bj}) - } - i, j = ai+size, bj+size - // the list of matching blocks is terminated by a - // sentinel with size 0 - if size > 0 { - opCodes = append(opCodes, OpCode{'e', ai, i, bj, j}) - } - } - m.opCodes = opCodes - return m.opCodes -} - -// Isolate change clusters by eliminating ranges with no changes. -// -// Return a generator of groups with up to n lines of context. -// Each group is in the same format as returned by GetOpCodes(). -func (m *SequenceMatcher) GetGroupedOpCodes(n int) [][]OpCode { - if n < 0 { - n = 3 - } - codes := m.GetOpCodes() - if len(codes) == 0 { - codes = []OpCode{OpCode{'e', 0, 1, 0, 1}} - } - // Fixup leading and trailing groups if they show no changes. - if codes[0].Tag == 'e' { - c := codes[0] - i1, i2, j1, j2 := c.I1, c.I2, c.J1, c.J2 - codes[0] = OpCode{c.Tag, max(i1, i2-n), i2, max(j1, j2-n), j2} - } - if codes[len(codes)-1].Tag == 'e' { - c := codes[len(codes)-1] - i1, i2, j1, j2 := c.I1, c.I2, c.J1, c.J2 - codes[len(codes)-1] = OpCode{c.Tag, i1, min(i2, i1+n), j1, min(j2, j1+n)} - } - nn := n + n - groups := [][]OpCode{} - group := []OpCode{} - for _, c := range codes { - i1, i2, j1, j2 := c.I1, c.I2, c.J1, c.J2 - // End the current group and start a new one whenever - // there is a large range with no changes. - if c.Tag == 'e' && i2-i1 > nn { - group = append(group, OpCode{c.Tag, i1, min(i2, i1+n), - j1, min(j2, j1+n)}) - groups = append(groups, group) - group = []OpCode{} - i1, j1 = max(i1, i2-n), max(j1, j2-n) - } - group = append(group, OpCode{c.Tag, i1, i2, j1, j2}) - } - if len(group) > 0 && !(len(group) == 1 && group[0].Tag == 'e') { - groups = append(groups, group) - } - return groups -} - -// Return a measure of the sequences' similarity (float in [0,1]). -// -// Where T is the total number of elements in both sequences, and -// M is the number of matches, this is 2.0*M / T. -// Note that this is 1 if the sequences are identical, and 0 if -// they have nothing in common. -// -// .Ratio() is expensive to compute if you haven't already computed -// .GetMatchingBlocks() or .GetOpCodes(), in which case you may -// want to try .QuickRatio() or .RealQuickRation() first to get an -// upper bound. -func (m *SequenceMatcher) Ratio() float64 { - matches := 0 - for _, m := range m.GetMatchingBlocks() { - matches += m.Size - } - return calculateRatio(matches, len(m.a)+len(m.b)) -} - -// Return an upper bound on ratio() relatively quickly. -// -// This isn't defined beyond that it is an upper bound on .Ratio(), and -// is faster to compute. -func (m *SequenceMatcher) QuickRatio() float64 { - // viewing a and b as multisets, set matches to the cardinality - // of their intersection; this counts the number of matches - // without regard to order, so is clearly an upper bound - if m.fullBCount == nil { - m.fullBCount = map[string]int{} - for _, s := range m.b { - m.fullBCount[s] = m.fullBCount[s] + 1 - } - } - - // avail[x] is the number of times x appears in 'b' less the - // number of times we've seen it in 'a' so far ... kinda - avail := map[string]int{} - matches := 0 - for _, s := range m.a { - n, ok := avail[s] - if !ok { - n = m.fullBCount[s] - } - avail[s] = n - 1 - if n > 0 { - matches += 1 - } - } - return calculateRatio(matches, len(m.a)+len(m.b)) -} - -// Return an upper bound on ratio() very quickly. -// -// This isn't defined beyond that it is an upper bound on .Ratio(), and -// is faster to compute than either .Ratio() or .QuickRatio(). -func (m *SequenceMatcher) RealQuickRatio() float64 { - la, lb := len(m.a), len(m.b) - return calculateRatio(min(la, lb), la+lb) -} - -// Convert range to the "ed" format -func formatRangeUnified(start, stop int) string { - // Per the diff spec at http://www.unix.org/single_unix_specification/ - beginning := start + 1 // lines start numbering with one - length := stop - start - if length == 1 { - return fmt.Sprintf("%d", beginning) - } - if length == 0 { - beginning -= 1 // empty ranges begin at line just before the range - } - return fmt.Sprintf("%d,%d", beginning, length) -} - -// Unified diff parameters -type UnifiedDiff struct { - A []string // First sequence lines - FromFile string // First file name - FromDate string // First file time - B []string // Second sequence lines - ToFile string // Second file name - ToDate string // Second file time - Eol string // Headers end of line, defaults to LF - Context int // Number of context lines -} - -// Compare two sequences of lines; generate the delta as a unified diff. -// -// Unified diffs are a compact way of showing line changes and a few -// lines of context. The number of context lines is set by 'n' which -// defaults to three. -// -// By default, the diff control lines (those with ---, +++, or @@) are -// created with a trailing newline. This is helpful so that inputs -// created from file.readlines() result in diffs that are suitable for -// file.writelines() since both the inputs and outputs have trailing -// newlines. -// -// For inputs that do not have trailing newlines, set the lineterm -// argument to "" so that the output will be uniformly newline free. -// -// The unidiff format normally has a header for filenames and modification -// times. Any or all of these may be specified using strings for -// 'fromfile', 'tofile', 'fromfiledate', and 'tofiledate'. -// The modification times are normally expressed in the ISO 8601 format. -func WriteUnifiedDiff(writer io.Writer, diff UnifiedDiff) error { - buf := bufio.NewWriter(writer) - defer buf.Flush() - wf := func(format string, args ...interface{}) error { - _, err := buf.WriteString(fmt.Sprintf(format, args...)) - return err - } - ws := func(s string) error { - _, err := buf.WriteString(s) - return err - } - - if len(diff.Eol) == 0 { - diff.Eol = "\n" - } - - started := false - m := NewMatcher(diff.A, diff.B) - for _, g := range m.GetGroupedOpCodes(diff.Context) { - if !started { - started = true - fromDate := "" - if len(diff.FromDate) > 0 { - fromDate = "\t" + diff.FromDate - } - toDate := "" - if len(diff.ToDate) > 0 { - toDate = "\t" + diff.ToDate - } - if diff.FromFile != "" || diff.ToFile != "" { - err := wf("--- %s%s%s", diff.FromFile, fromDate, diff.Eol) - if err != nil { - return err - } - err = wf("+++ %s%s%s", diff.ToFile, toDate, diff.Eol) - if err != nil { - return err - } - } - } - first, last := g[0], g[len(g)-1] - range1 := formatRangeUnified(first.I1, last.I2) - range2 := formatRangeUnified(first.J1, last.J2) - if err := wf("@@ -%s +%s @@%s", range1, range2, diff.Eol); err != nil { - return err - } - for _, c := range g { - i1, i2, j1, j2 := c.I1, c.I2, c.J1, c.J2 - if c.Tag == 'e' { - for _, line := range diff.A[i1:i2] { - if err := ws(" " + line); err != nil { - return err - } - } - continue - } - if c.Tag == 'r' || c.Tag == 'd' { - for _, line := range diff.A[i1:i2] { - if err := ws("-" + line); err != nil { - return err - } - } - } - if c.Tag == 'r' || c.Tag == 'i' { - for _, line := range diff.B[j1:j2] { - if err := ws("+" + line); err != nil { - return err - } - } - } - } - } - return nil -} - -// Like WriteUnifiedDiff but returns the diff a string. -func GetUnifiedDiffString(diff UnifiedDiff) (string, error) { - w := &bytes.Buffer{} - err := WriteUnifiedDiff(w, diff) - return string(w.Bytes()), err -} - -// Convert range to the "ed" format. -func formatRangeContext(start, stop int) string { - // Per the diff spec at http://www.unix.org/single_unix_specification/ - beginning := start + 1 // lines start numbering with one - length := stop - start - if length == 0 { - beginning -= 1 // empty ranges begin at line just before the range - } - if length <= 1 { - return fmt.Sprintf("%d", beginning) - } - return fmt.Sprintf("%d,%d", beginning, beginning+length-1) -} - -type ContextDiff UnifiedDiff - -// Compare two sequences of lines; generate the delta as a context diff. -// -// Context diffs are a compact way of showing line changes and a few -// lines of context. The number of context lines is set by diff.Context -// which defaults to three. -// -// By default, the diff control lines (those with *** or ---) are -// created with a trailing newline. -// -// For inputs that do not have trailing newlines, set the diff.Eol -// argument to "" so that the output will be uniformly newline free. -// -// The context diff format normally has a header for filenames and -// modification times. Any or all of these may be specified using -// strings for diff.FromFile, diff.ToFile, diff.FromDate, diff.ToDate. -// The modification times are normally expressed in the ISO 8601 format. -// If not specified, the strings default to blanks. -func WriteContextDiff(writer io.Writer, diff ContextDiff) error { - buf := bufio.NewWriter(writer) - defer buf.Flush() - var diffErr error - wf := func(format string, args ...interface{}) { - _, err := buf.WriteString(fmt.Sprintf(format, args...)) - if diffErr == nil && err != nil { - diffErr = err - } - } - ws := func(s string) { - _, err := buf.WriteString(s) - if diffErr == nil && err != nil { - diffErr = err - } - } - - if len(diff.Eol) == 0 { - diff.Eol = "\n" - } - - prefix := map[byte]string{ - 'i': "+ ", - 'd': "- ", - 'r': "! ", - 'e': " ", - } - - started := false - m := NewMatcher(diff.A, diff.B) - for _, g := range m.GetGroupedOpCodes(diff.Context) { - if !started { - started = true - fromDate := "" - if len(diff.FromDate) > 0 { - fromDate = "\t" + diff.FromDate - } - toDate := "" - if len(diff.ToDate) > 0 { - toDate = "\t" + diff.ToDate - } - if diff.FromFile != "" || diff.ToFile != "" { - wf("*** %s%s%s", diff.FromFile, fromDate, diff.Eol) - wf("--- %s%s%s", diff.ToFile, toDate, diff.Eol) - } - } - - first, last := g[0], g[len(g)-1] - ws("***************" + diff.Eol) - - range1 := formatRangeContext(first.I1, last.I2) - wf("*** %s ****%s", range1, diff.Eol) - for _, c := range g { - if c.Tag == 'r' || c.Tag == 'd' { - for _, cc := range g { - if cc.Tag == 'i' { - continue - } - for _, line := range diff.A[cc.I1:cc.I2] { - ws(prefix[cc.Tag] + line) - } - } - break - } - } - - range2 := formatRangeContext(first.J1, last.J2) - wf("--- %s ----%s", range2, diff.Eol) - for _, c := range g { - if c.Tag == 'r' || c.Tag == 'i' { - for _, cc := range g { - if cc.Tag == 'd' { - continue - } - for _, line := range diff.B[cc.J1:cc.J2] { - ws(prefix[cc.Tag] + line) - } - } - break - } - } - } - return diffErr -} - -// Like WriteContextDiff but returns the diff a string. -func GetContextDiffString(diff ContextDiff) (string, error) { - w := &bytes.Buffer{} - err := WriteContextDiff(w, diff) - return string(w.Bytes()), err -} - -// Split a string on "\n" while preserving them. The output can be used -// as input for UnifiedDiff and ContextDiff structures. -func SplitLines(s string) []string { - lines := strings.SplitAfter(s, "\n") - lines[len(lines)-1] += "\n" - return lines -} diff --git a/vendor/github.com/stretchr/testify/LICENSE b/vendor/github.com/stretchr/testify/LICENSE deleted file mode 100644 index 473b670a7..000000000 --- a/vendor/github.com/stretchr/testify/LICENSE +++ /dev/null @@ -1,22 +0,0 @@ -Copyright (c) 2012 - 2013 Mat Ryer and Tyler Bunnell - -Please consider promoting this project if you find it useful. - -Permission is hereby granted, free of charge, to any person -obtaining a copy of this software and associated documentation -files (the "Software"), to deal in the Software without restriction, -including without limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of the Software, -and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included -in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT -OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE -OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/github.com/stretchr/testify/assert/assertion_format.go b/vendor/github.com/stretchr/testify/assert/assertion_format.go deleted file mode 100644 index aa1c2b95c..000000000 --- a/vendor/github.com/stretchr/testify/assert/assertion_format.go +++ /dev/null @@ -1,484 +0,0 @@ -/* -* CODE GENERATED AUTOMATICALLY WITH github.com/stretchr/testify/_codegen -* THIS FILE MUST NOT BE EDITED BY HAND - */ - -package assert - -import ( - http "net/http" - url "net/url" - time "time" -) - -// Conditionf uses a Comparison to assert a complex condition. -func Conditionf(t TestingT, comp Comparison, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Condition(t, comp, append([]interface{}{msg}, args...)...) -} - -// Containsf asserts that the specified string, list(array, slice...) or map contains the -// specified substring or element. -// -// assert.Containsf(t, "Hello World", "World", "error message %s", "formatted") -// assert.Containsf(t, ["Hello", "World"], "World", "error message %s", "formatted") -// assert.Containsf(t, {"Hello": "World"}, "Hello", "error message %s", "formatted") -func Containsf(t TestingT, s interface{}, contains interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Contains(t, s, contains, append([]interface{}{msg}, args...)...) -} - -// DirExistsf checks whether a directory exists in the given path. It also fails if the path is a file rather a directory or there is an error checking whether it exists. -func DirExistsf(t TestingT, path string, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return DirExists(t, path, append([]interface{}{msg}, args...)...) -} - -// ElementsMatchf asserts that the specified listA(array, slice...) is equal to specified -// listB(array, slice...) ignoring the order of the elements. If there are duplicate elements, -// the number of appearances of each of them in both lists should match. -// -// assert.ElementsMatchf(t, [1, 3, 2, 3], [1, 3, 3, 2], "error message %s", "formatted") -func ElementsMatchf(t TestingT, listA interface{}, listB interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return ElementsMatch(t, listA, listB, append([]interface{}{msg}, args...)...) -} - -// Emptyf asserts that the specified object is empty. I.e. nil, "", false, 0 or either -// a slice or a channel with len == 0. -// -// assert.Emptyf(t, obj, "error message %s", "formatted") -func Emptyf(t TestingT, object interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Empty(t, object, append([]interface{}{msg}, args...)...) -} - -// Equalf asserts that two objects are equal. -// -// assert.Equalf(t, 123, 123, "error message %s", "formatted") -// -// Pointer variable equality is determined based on the equality of the -// referenced values (as opposed to the memory addresses). Function equality -// cannot be determined and will always fail. -func Equalf(t TestingT, expected interface{}, actual interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Equal(t, expected, actual, append([]interface{}{msg}, args...)...) -} - -// EqualErrorf asserts that a function returned an error (i.e. not `nil`) -// and that it is equal to the provided error. -// -// actualObj, err := SomeFunction() -// assert.EqualErrorf(t, err, expectedErrorString, "error message %s", "formatted") -func EqualErrorf(t TestingT, theError error, errString string, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return EqualError(t, theError, errString, append([]interface{}{msg}, args...)...) -} - -// EqualValuesf asserts that two objects are equal or convertable to the same types -// and equal. -// -// assert.EqualValuesf(t, uint32(123, "error message %s", "formatted"), int32(123)) -func EqualValuesf(t TestingT, expected interface{}, actual interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return EqualValues(t, expected, actual, append([]interface{}{msg}, args...)...) -} - -// Errorf asserts that a function returned an error (i.e. not `nil`). -// -// actualObj, err := SomeFunction() -// if assert.Errorf(t, err, "error message %s", "formatted") { -// assert.Equal(t, expectedErrorf, err) -// } -func Errorf(t TestingT, err error, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Error(t, err, append([]interface{}{msg}, args...)...) -} - -// Exactlyf asserts that two objects are equal in value and type. -// -// assert.Exactlyf(t, int32(123, "error message %s", "formatted"), int64(123)) -func Exactlyf(t TestingT, expected interface{}, actual interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Exactly(t, expected, actual, append([]interface{}{msg}, args...)...) -} - -// Failf reports a failure through -func Failf(t TestingT, failureMessage string, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Fail(t, failureMessage, append([]interface{}{msg}, args...)...) -} - -// FailNowf fails test -func FailNowf(t TestingT, failureMessage string, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return FailNow(t, failureMessage, append([]interface{}{msg}, args...)...) -} - -// Falsef asserts that the specified value is false. -// -// assert.Falsef(t, myBool, "error message %s", "formatted") -func Falsef(t TestingT, value bool, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return False(t, value, append([]interface{}{msg}, args...)...) -} - -// FileExistsf checks whether a file exists in the given path. It also fails if the path points to a directory or there is an error when trying to check the file. -func FileExistsf(t TestingT, path string, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return FileExists(t, path, append([]interface{}{msg}, args...)...) -} - -// HTTPBodyContainsf asserts that a specified handler returns a -// body that contains a string. -// -// assert.HTTPBodyContainsf(t, myHandler, "GET", "www.google.com", nil, "I'm Feeling Lucky", "error message %s", "formatted") -// -// Returns whether the assertion was successful (true) or not (false). -func HTTPBodyContainsf(t TestingT, handler http.HandlerFunc, method string, url string, values url.Values, str interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return HTTPBodyContains(t, handler, method, url, values, str, append([]interface{}{msg}, args...)...) -} - -// HTTPBodyNotContainsf asserts that a specified handler returns a -// body that does not contain a string. -// -// assert.HTTPBodyNotContainsf(t, myHandler, "GET", "www.google.com", nil, "I'm Feeling Lucky", "error message %s", "formatted") -// -// Returns whether the assertion was successful (true) or not (false). -func HTTPBodyNotContainsf(t TestingT, handler http.HandlerFunc, method string, url string, values url.Values, str interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return HTTPBodyNotContains(t, handler, method, url, values, str, append([]interface{}{msg}, args...)...) -} - -// HTTPErrorf asserts that a specified handler returns an error status code. -// -// assert.HTTPErrorf(t, myHandler, "POST", "/a/b/c", url.Values{"a": []string{"b", "c"}} -// -// Returns whether the assertion was successful (true, "error message %s", "formatted") or not (false). -func HTTPErrorf(t TestingT, handler http.HandlerFunc, method string, url string, values url.Values, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return HTTPError(t, handler, method, url, values, append([]interface{}{msg}, args...)...) -} - -// HTTPRedirectf asserts that a specified handler returns a redirect status code. -// -// assert.HTTPRedirectf(t, myHandler, "GET", "/a/b/c", url.Values{"a": []string{"b", "c"}} -// -// Returns whether the assertion was successful (true, "error message %s", "formatted") or not (false). -func HTTPRedirectf(t TestingT, handler http.HandlerFunc, method string, url string, values url.Values, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return HTTPRedirect(t, handler, method, url, values, append([]interface{}{msg}, args...)...) -} - -// HTTPSuccessf asserts that a specified handler returns a success status code. -// -// assert.HTTPSuccessf(t, myHandler, "POST", "http://www.google.com", nil, "error message %s", "formatted") -// -// Returns whether the assertion was successful (true) or not (false). -func HTTPSuccessf(t TestingT, handler http.HandlerFunc, method string, url string, values url.Values, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return HTTPSuccess(t, handler, method, url, values, append([]interface{}{msg}, args...)...) -} - -// Implementsf asserts that an object is implemented by the specified interface. -// -// assert.Implementsf(t, (*MyInterface, "error message %s", "formatted")(nil), new(MyObject)) -func Implementsf(t TestingT, interfaceObject interface{}, object interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Implements(t, interfaceObject, object, append([]interface{}{msg}, args...)...) -} - -// InDeltaf asserts that the two numerals are within delta of each other. -// -// assert.InDeltaf(t, math.Pi, (22 / 7.0, "error message %s", "formatted"), 0.01) -func InDeltaf(t TestingT, expected interface{}, actual interface{}, delta float64, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return InDelta(t, expected, actual, delta, append([]interface{}{msg}, args...)...) -} - -// InDeltaMapValuesf is the same as InDelta, but it compares all values between two maps. Both maps must have exactly the same keys. -func InDeltaMapValuesf(t TestingT, expected interface{}, actual interface{}, delta float64, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return InDeltaMapValues(t, expected, actual, delta, append([]interface{}{msg}, args...)...) -} - -// InDeltaSlicef is the same as InDelta, except it compares two slices. -func InDeltaSlicef(t TestingT, expected interface{}, actual interface{}, delta float64, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return InDeltaSlice(t, expected, actual, delta, append([]interface{}{msg}, args...)...) -} - -// InEpsilonf asserts that expected and actual have a relative error less than epsilon -func InEpsilonf(t TestingT, expected interface{}, actual interface{}, epsilon float64, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return InEpsilon(t, expected, actual, epsilon, append([]interface{}{msg}, args...)...) -} - -// InEpsilonSlicef is the same as InEpsilon, except it compares each value from two slices. -func InEpsilonSlicef(t TestingT, expected interface{}, actual interface{}, epsilon float64, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return InEpsilonSlice(t, expected, actual, epsilon, append([]interface{}{msg}, args...)...) -} - -// IsTypef asserts that the specified objects are of the same type. -func IsTypef(t TestingT, expectedType interface{}, object interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return IsType(t, expectedType, object, append([]interface{}{msg}, args...)...) -} - -// JSONEqf asserts that two JSON strings are equivalent. -// -// assert.JSONEqf(t, `{"hello": "world", "foo": "bar"}`, `{"foo": "bar", "hello": "world"}`, "error message %s", "formatted") -func JSONEqf(t TestingT, expected string, actual string, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return JSONEq(t, expected, actual, append([]interface{}{msg}, args...)...) -} - -// Lenf asserts that the specified object has specific length. -// Lenf also fails if the object has a type that len() not accept. -// -// assert.Lenf(t, mySlice, 3, "error message %s", "formatted") -func Lenf(t TestingT, object interface{}, length int, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Len(t, object, length, append([]interface{}{msg}, args...)...) -} - -// Nilf asserts that the specified object is nil. -// -// assert.Nilf(t, err, "error message %s", "formatted") -func Nilf(t TestingT, object interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Nil(t, object, append([]interface{}{msg}, args...)...) -} - -// NoErrorf asserts that a function returned no error (i.e. `nil`). -// -// actualObj, err := SomeFunction() -// if assert.NoErrorf(t, err, "error message %s", "formatted") { -// assert.Equal(t, expectedObj, actualObj) -// } -func NoErrorf(t TestingT, err error, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return NoError(t, err, append([]interface{}{msg}, args...)...) -} - -// NotContainsf asserts that the specified string, list(array, slice...) or map does NOT contain the -// specified substring or element. -// -// assert.NotContainsf(t, "Hello World", "Earth", "error message %s", "formatted") -// assert.NotContainsf(t, ["Hello", "World"], "Earth", "error message %s", "formatted") -// assert.NotContainsf(t, {"Hello": "World"}, "Earth", "error message %s", "formatted") -func NotContainsf(t TestingT, s interface{}, contains interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return NotContains(t, s, contains, append([]interface{}{msg}, args...)...) -} - -// NotEmptyf asserts that the specified object is NOT empty. I.e. not nil, "", false, 0 or either -// a slice or a channel with len == 0. -// -// if assert.NotEmptyf(t, obj, "error message %s", "formatted") { -// assert.Equal(t, "two", obj[1]) -// } -func NotEmptyf(t TestingT, object interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return NotEmpty(t, object, append([]interface{}{msg}, args...)...) -} - -// NotEqualf asserts that the specified values are NOT equal. -// -// assert.NotEqualf(t, obj1, obj2, "error message %s", "formatted") -// -// Pointer variable equality is determined based on the equality of the -// referenced values (as opposed to the memory addresses). -func NotEqualf(t TestingT, expected interface{}, actual interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return NotEqual(t, expected, actual, append([]interface{}{msg}, args...)...) -} - -// NotNilf asserts that the specified object is not nil. -// -// assert.NotNilf(t, err, "error message %s", "formatted") -func NotNilf(t TestingT, object interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return NotNil(t, object, append([]interface{}{msg}, args...)...) -} - -// NotPanicsf asserts that the code inside the specified PanicTestFunc does NOT panic. -// -// assert.NotPanicsf(t, func(){ RemainCalm() }, "error message %s", "formatted") -func NotPanicsf(t TestingT, f PanicTestFunc, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return NotPanics(t, f, append([]interface{}{msg}, args...)...) -} - -// NotRegexpf asserts that a specified regexp does not match a string. -// -// assert.NotRegexpf(t, regexp.MustCompile("starts", "error message %s", "formatted"), "it's starting") -// assert.NotRegexpf(t, "^start", "it's not starting", "error message %s", "formatted") -func NotRegexpf(t TestingT, rx interface{}, str interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return NotRegexp(t, rx, str, append([]interface{}{msg}, args...)...) -} - -// NotSubsetf asserts that the specified list(array, slice...) contains not all -// elements given in the specified subset(array, slice...). -// -// assert.NotSubsetf(t, [1, 3, 4], [1, 2], "But [1, 3, 4] does not contain [1, 2]", "error message %s", "formatted") -func NotSubsetf(t TestingT, list interface{}, subset interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return NotSubset(t, list, subset, append([]interface{}{msg}, args...)...) -} - -// NotZerof asserts that i is not the zero value for its type. -func NotZerof(t TestingT, i interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return NotZero(t, i, append([]interface{}{msg}, args...)...) -} - -// Panicsf asserts that the code inside the specified PanicTestFunc panics. -// -// assert.Panicsf(t, func(){ GoCrazy() }, "error message %s", "formatted") -func Panicsf(t TestingT, f PanicTestFunc, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Panics(t, f, append([]interface{}{msg}, args...)...) -} - -// PanicsWithValuef asserts that the code inside the specified PanicTestFunc panics, and that -// the recovered panic value equals the expected panic value. -// -// assert.PanicsWithValuef(t, "crazy error", func(){ GoCrazy() }, "error message %s", "formatted") -func PanicsWithValuef(t TestingT, expected interface{}, f PanicTestFunc, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return PanicsWithValue(t, expected, f, append([]interface{}{msg}, args...)...) -} - -// Regexpf asserts that a specified regexp matches a string. -// -// assert.Regexpf(t, regexp.MustCompile("start", "error message %s", "formatted"), "it's starting") -// assert.Regexpf(t, "start...$", "it's not starting", "error message %s", "formatted") -func Regexpf(t TestingT, rx interface{}, str interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Regexp(t, rx, str, append([]interface{}{msg}, args...)...) -} - -// Subsetf asserts that the specified list(array, slice...) contains all -// elements given in the specified subset(array, slice...). -// -// assert.Subsetf(t, [1, 2, 3], [1, 2], "But [1, 2, 3] does contain [1, 2]", "error message %s", "formatted") -func Subsetf(t TestingT, list interface{}, subset interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Subset(t, list, subset, append([]interface{}{msg}, args...)...) -} - -// Truef asserts that the specified value is true. -// -// assert.Truef(t, myBool, "error message %s", "formatted") -func Truef(t TestingT, value bool, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return True(t, value, append([]interface{}{msg}, args...)...) -} - -// WithinDurationf asserts that the two times are within duration delta of each other. -// -// assert.WithinDurationf(t, time.Now(), time.Now(), 10*time.Second, "error message %s", "formatted") -func WithinDurationf(t TestingT, expected time.Time, actual time.Time, delta time.Duration, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return WithinDuration(t, expected, actual, delta, append([]interface{}{msg}, args...)...) -} - -// Zerof asserts that i is the zero value for its type. -func Zerof(t TestingT, i interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Zero(t, i, append([]interface{}{msg}, args...)...) -} diff --git a/vendor/github.com/stretchr/testify/assert/assertion_format.go.tmpl b/vendor/github.com/stretchr/testify/assert/assertion_format.go.tmpl deleted file mode 100644 index d2bb0b817..000000000 --- a/vendor/github.com/stretchr/testify/assert/assertion_format.go.tmpl +++ /dev/null @@ -1,5 +0,0 @@ -{{.CommentFormat}} -func {{.DocInfo.Name}}f(t TestingT, {{.ParamsFormat}}) bool { - if h, ok := t.(tHelper); ok { h.Helper() } - return {{.DocInfo.Name}}(t, {{.ForwardedParamsFormat}}) -} diff --git a/vendor/github.com/stretchr/testify/assert/assertion_forward.go b/vendor/github.com/stretchr/testify/assert/assertion_forward.go deleted file mode 100644 index de39f794e..000000000 --- a/vendor/github.com/stretchr/testify/assert/assertion_forward.go +++ /dev/null @@ -1,956 +0,0 @@ -/* -* CODE GENERATED AUTOMATICALLY WITH github.com/stretchr/testify/_codegen -* THIS FILE MUST NOT BE EDITED BY HAND - */ - -package assert - -import ( - http "net/http" - url "net/url" - time "time" -) - -// Condition uses a Comparison to assert a complex condition. -func (a *Assertions) Condition(comp Comparison, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Condition(a.t, comp, msgAndArgs...) -} - -// Conditionf uses a Comparison to assert a complex condition. -func (a *Assertions) Conditionf(comp Comparison, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Conditionf(a.t, comp, msg, args...) -} - -// Contains asserts that the specified string, list(array, slice...) or map contains the -// specified substring or element. -// -// a.Contains("Hello World", "World") -// a.Contains(["Hello", "World"], "World") -// a.Contains({"Hello": "World"}, "Hello") -func (a *Assertions) Contains(s interface{}, contains interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Contains(a.t, s, contains, msgAndArgs...) -} - -// Containsf asserts that the specified string, list(array, slice...) or map contains the -// specified substring or element. -// -// a.Containsf("Hello World", "World", "error message %s", "formatted") -// a.Containsf(["Hello", "World"], "World", "error message %s", "formatted") -// a.Containsf({"Hello": "World"}, "Hello", "error message %s", "formatted") -func (a *Assertions) Containsf(s interface{}, contains interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Containsf(a.t, s, contains, msg, args...) -} - -// DirExists checks whether a directory exists in the given path. It also fails if the path is a file rather a directory or there is an error checking whether it exists. -func (a *Assertions) DirExists(path string, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return DirExists(a.t, path, msgAndArgs...) -} - -// DirExistsf checks whether a directory exists in the given path. It also fails if the path is a file rather a directory or there is an error checking whether it exists. -func (a *Assertions) DirExistsf(path string, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return DirExistsf(a.t, path, msg, args...) -} - -// ElementsMatch asserts that the specified listA(array, slice...) is equal to specified -// listB(array, slice...) ignoring the order of the elements. If there are duplicate elements, -// the number of appearances of each of them in both lists should match. -// -// a.ElementsMatch([1, 3, 2, 3], [1, 3, 3, 2]) -func (a *Assertions) ElementsMatch(listA interface{}, listB interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return ElementsMatch(a.t, listA, listB, msgAndArgs...) -} - -// ElementsMatchf asserts that the specified listA(array, slice...) is equal to specified -// listB(array, slice...) ignoring the order of the elements. If there are duplicate elements, -// the number of appearances of each of them in both lists should match. -// -// a.ElementsMatchf([1, 3, 2, 3], [1, 3, 3, 2], "error message %s", "formatted") -func (a *Assertions) ElementsMatchf(listA interface{}, listB interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return ElementsMatchf(a.t, listA, listB, msg, args...) -} - -// Empty asserts that the specified object is empty. I.e. nil, "", false, 0 or either -// a slice or a channel with len == 0. -// -// a.Empty(obj) -func (a *Assertions) Empty(object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Empty(a.t, object, msgAndArgs...) -} - -// Emptyf asserts that the specified object is empty. I.e. nil, "", false, 0 or either -// a slice or a channel with len == 0. -// -// a.Emptyf(obj, "error message %s", "formatted") -func (a *Assertions) Emptyf(object interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Emptyf(a.t, object, msg, args...) -} - -// Equal asserts that two objects are equal. -// -// a.Equal(123, 123) -// -// Pointer variable equality is determined based on the equality of the -// referenced values (as opposed to the memory addresses). Function equality -// cannot be determined and will always fail. -func (a *Assertions) Equal(expected interface{}, actual interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Equal(a.t, expected, actual, msgAndArgs...) -} - -// EqualError asserts that a function returned an error (i.e. not `nil`) -// and that it is equal to the provided error. -// -// actualObj, err := SomeFunction() -// a.EqualError(err, expectedErrorString) -func (a *Assertions) EqualError(theError error, errString string, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return EqualError(a.t, theError, errString, msgAndArgs...) -} - -// EqualErrorf asserts that a function returned an error (i.e. not `nil`) -// and that it is equal to the provided error. -// -// actualObj, err := SomeFunction() -// a.EqualErrorf(err, expectedErrorString, "error message %s", "formatted") -func (a *Assertions) EqualErrorf(theError error, errString string, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return EqualErrorf(a.t, theError, errString, msg, args...) -} - -// EqualValues asserts that two objects are equal or convertable to the same types -// and equal. -// -// a.EqualValues(uint32(123), int32(123)) -func (a *Assertions) EqualValues(expected interface{}, actual interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return EqualValues(a.t, expected, actual, msgAndArgs...) -} - -// EqualValuesf asserts that two objects are equal or convertable to the same types -// and equal. -// -// a.EqualValuesf(uint32(123, "error message %s", "formatted"), int32(123)) -func (a *Assertions) EqualValuesf(expected interface{}, actual interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return EqualValuesf(a.t, expected, actual, msg, args...) -} - -// Equalf asserts that two objects are equal. -// -// a.Equalf(123, 123, "error message %s", "formatted") -// -// Pointer variable equality is determined based on the equality of the -// referenced values (as opposed to the memory addresses). Function equality -// cannot be determined and will always fail. -func (a *Assertions) Equalf(expected interface{}, actual interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Equalf(a.t, expected, actual, msg, args...) -} - -// Error asserts that a function returned an error (i.e. not `nil`). -// -// actualObj, err := SomeFunction() -// if a.Error(err) { -// assert.Equal(t, expectedError, err) -// } -func (a *Assertions) Error(err error, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Error(a.t, err, msgAndArgs...) -} - -// Errorf asserts that a function returned an error (i.e. not `nil`). -// -// actualObj, err := SomeFunction() -// if a.Errorf(err, "error message %s", "formatted") { -// assert.Equal(t, expectedErrorf, err) -// } -func (a *Assertions) Errorf(err error, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Errorf(a.t, err, msg, args...) -} - -// Exactly asserts that two objects are equal in value and type. -// -// a.Exactly(int32(123), int64(123)) -func (a *Assertions) Exactly(expected interface{}, actual interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Exactly(a.t, expected, actual, msgAndArgs...) -} - -// Exactlyf asserts that two objects are equal in value and type. -// -// a.Exactlyf(int32(123, "error message %s", "formatted"), int64(123)) -func (a *Assertions) Exactlyf(expected interface{}, actual interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Exactlyf(a.t, expected, actual, msg, args...) -} - -// Fail reports a failure through -func (a *Assertions) Fail(failureMessage string, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Fail(a.t, failureMessage, msgAndArgs...) -} - -// FailNow fails test -func (a *Assertions) FailNow(failureMessage string, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return FailNow(a.t, failureMessage, msgAndArgs...) -} - -// FailNowf fails test -func (a *Assertions) FailNowf(failureMessage string, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return FailNowf(a.t, failureMessage, msg, args...) -} - -// Failf reports a failure through -func (a *Assertions) Failf(failureMessage string, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Failf(a.t, failureMessage, msg, args...) -} - -// False asserts that the specified value is false. -// -// a.False(myBool) -func (a *Assertions) False(value bool, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return False(a.t, value, msgAndArgs...) -} - -// Falsef asserts that the specified value is false. -// -// a.Falsef(myBool, "error message %s", "formatted") -func (a *Assertions) Falsef(value bool, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Falsef(a.t, value, msg, args...) -} - -// FileExists checks whether a file exists in the given path. It also fails if the path points to a directory or there is an error when trying to check the file. -func (a *Assertions) FileExists(path string, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return FileExists(a.t, path, msgAndArgs...) -} - -// FileExistsf checks whether a file exists in the given path. It also fails if the path points to a directory or there is an error when trying to check the file. -func (a *Assertions) FileExistsf(path string, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return FileExistsf(a.t, path, msg, args...) -} - -// HTTPBodyContains asserts that a specified handler returns a -// body that contains a string. -// -// a.HTTPBodyContains(myHandler, "GET", "www.google.com", nil, "I'm Feeling Lucky") -// -// Returns whether the assertion was successful (true) or not (false). -func (a *Assertions) HTTPBodyContains(handler http.HandlerFunc, method string, url string, values url.Values, str interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return HTTPBodyContains(a.t, handler, method, url, values, str, msgAndArgs...) -} - -// HTTPBodyContainsf asserts that a specified handler returns a -// body that contains a string. -// -// a.HTTPBodyContainsf(myHandler, "GET", "www.google.com", nil, "I'm Feeling Lucky", "error message %s", "formatted") -// -// Returns whether the assertion was successful (true) or not (false). -func (a *Assertions) HTTPBodyContainsf(handler http.HandlerFunc, method string, url string, values url.Values, str interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return HTTPBodyContainsf(a.t, handler, method, url, values, str, msg, args...) -} - -// HTTPBodyNotContains asserts that a specified handler returns a -// body that does not contain a string. -// -// a.HTTPBodyNotContains(myHandler, "GET", "www.google.com", nil, "I'm Feeling Lucky") -// -// Returns whether the assertion was successful (true) or not (false). -func (a *Assertions) HTTPBodyNotContains(handler http.HandlerFunc, method string, url string, values url.Values, str interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return HTTPBodyNotContains(a.t, handler, method, url, values, str, msgAndArgs...) -} - -// HTTPBodyNotContainsf asserts that a specified handler returns a -// body that does not contain a string. -// -// a.HTTPBodyNotContainsf(myHandler, "GET", "www.google.com", nil, "I'm Feeling Lucky", "error message %s", "formatted") -// -// Returns whether the assertion was successful (true) or not (false). -func (a *Assertions) HTTPBodyNotContainsf(handler http.HandlerFunc, method string, url string, values url.Values, str interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return HTTPBodyNotContainsf(a.t, handler, method, url, values, str, msg, args...) -} - -// HTTPError asserts that a specified handler returns an error status code. -// -// a.HTTPError(myHandler, "POST", "/a/b/c", url.Values{"a": []string{"b", "c"}} -// -// Returns whether the assertion was successful (true) or not (false). -func (a *Assertions) HTTPError(handler http.HandlerFunc, method string, url string, values url.Values, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return HTTPError(a.t, handler, method, url, values, msgAndArgs...) -} - -// HTTPErrorf asserts that a specified handler returns an error status code. -// -// a.HTTPErrorf(myHandler, "POST", "/a/b/c", url.Values{"a": []string{"b", "c"}} -// -// Returns whether the assertion was successful (true, "error message %s", "formatted") or not (false). -func (a *Assertions) HTTPErrorf(handler http.HandlerFunc, method string, url string, values url.Values, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return HTTPErrorf(a.t, handler, method, url, values, msg, args...) -} - -// HTTPRedirect asserts that a specified handler returns a redirect status code. -// -// a.HTTPRedirect(myHandler, "GET", "/a/b/c", url.Values{"a": []string{"b", "c"}} -// -// Returns whether the assertion was successful (true) or not (false). -func (a *Assertions) HTTPRedirect(handler http.HandlerFunc, method string, url string, values url.Values, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return HTTPRedirect(a.t, handler, method, url, values, msgAndArgs...) -} - -// HTTPRedirectf asserts that a specified handler returns a redirect status code. -// -// a.HTTPRedirectf(myHandler, "GET", "/a/b/c", url.Values{"a": []string{"b", "c"}} -// -// Returns whether the assertion was successful (true, "error message %s", "formatted") or not (false). -func (a *Assertions) HTTPRedirectf(handler http.HandlerFunc, method string, url string, values url.Values, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return HTTPRedirectf(a.t, handler, method, url, values, msg, args...) -} - -// HTTPSuccess asserts that a specified handler returns a success status code. -// -// a.HTTPSuccess(myHandler, "POST", "http://www.google.com", nil) -// -// Returns whether the assertion was successful (true) or not (false). -func (a *Assertions) HTTPSuccess(handler http.HandlerFunc, method string, url string, values url.Values, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return HTTPSuccess(a.t, handler, method, url, values, msgAndArgs...) -} - -// HTTPSuccessf asserts that a specified handler returns a success status code. -// -// a.HTTPSuccessf(myHandler, "POST", "http://www.google.com", nil, "error message %s", "formatted") -// -// Returns whether the assertion was successful (true) or not (false). -func (a *Assertions) HTTPSuccessf(handler http.HandlerFunc, method string, url string, values url.Values, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return HTTPSuccessf(a.t, handler, method, url, values, msg, args...) -} - -// Implements asserts that an object is implemented by the specified interface. -// -// a.Implements((*MyInterface)(nil), new(MyObject)) -func (a *Assertions) Implements(interfaceObject interface{}, object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Implements(a.t, interfaceObject, object, msgAndArgs...) -} - -// Implementsf asserts that an object is implemented by the specified interface. -// -// a.Implementsf((*MyInterface, "error message %s", "formatted")(nil), new(MyObject)) -func (a *Assertions) Implementsf(interfaceObject interface{}, object interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Implementsf(a.t, interfaceObject, object, msg, args...) -} - -// InDelta asserts that the two numerals are within delta of each other. -// -// a.InDelta(math.Pi, (22 / 7.0), 0.01) -func (a *Assertions) InDelta(expected interface{}, actual interface{}, delta float64, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return InDelta(a.t, expected, actual, delta, msgAndArgs...) -} - -// InDeltaMapValues is the same as InDelta, but it compares all values between two maps. Both maps must have exactly the same keys. -func (a *Assertions) InDeltaMapValues(expected interface{}, actual interface{}, delta float64, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return InDeltaMapValues(a.t, expected, actual, delta, msgAndArgs...) -} - -// InDeltaMapValuesf is the same as InDelta, but it compares all values between two maps. Both maps must have exactly the same keys. -func (a *Assertions) InDeltaMapValuesf(expected interface{}, actual interface{}, delta float64, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return InDeltaMapValuesf(a.t, expected, actual, delta, msg, args...) -} - -// InDeltaSlice is the same as InDelta, except it compares two slices. -func (a *Assertions) InDeltaSlice(expected interface{}, actual interface{}, delta float64, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return InDeltaSlice(a.t, expected, actual, delta, msgAndArgs...) -} - -// InDeltaSlicef is the same as InDelta, except it compares two slices. -func (a *Assertions) InDeltaSlicef(expected interface{}, actual interface{}, delta float64, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return InDeltaSlicef(a.t, expected, actual, delta, msg, args...) -} - -// InDeltaf asserts that the two numerals are within delta of each other. -// -// a.InDeltaf(math.Pi, (22 / 7.0, "error message %s", "formatted"), 0.01) -func (a *Assertions) InDeltaf(expected interface{}, actual interface{}, delta float64, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return InDeltaf(a.t, expected, actual, delta, msg, args...) -} - -// InEpsilon asserts that expected and actual have a relative error less than epsilon -func (a *Assertions) InEpsilon(expected interface{}, actual interface{}, epsilon float64, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return InEpsilon(a.t, expected, actual, epsilon, msgAndArgs...) -} - -// InEpsilonSlice is the same as InEpsilon, except it compares each value from two slices. -func (a *Assertions) InEpsilonSlice(expected interface{}, actual interface{}, epsilon float64, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return InEpsilonSlice(a.t, expected, actual, epsilon, msgAndArgs...) -} - -// InEpsilonSlicef is the same as InEpsilon, except it compares each value from two slices. -func (a *Assertions) InEpsilonSlicef(expected interface{}, actual interface{}, epsilon float64, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return InEpsilonSlicef(a.t, expected, actual, epsilon, msg, args...) -} - -// InEpsilonf asserts that expected and actual have a relative error less than epsilon -func (a *Assertions) InEpsilonf(expected interface{}, actual interface{}, epsilon float64, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return InEpsilonf(a.t, expected, actual, epsilon, msg, args...) -} - -// IsType asserts that the specified objects are of the same type. -func (a *Assertions) IsType(expectedType interface{}, object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return IsType(a.t, expectedType, object, msgAndArgs...) -} - -// IsTypef asserts that the specified objects are of the same type. -func (a *Assertions) IsTypef(expectedType interface{}, object interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return IsTypef(a.t, expectedType, object, msg, args...) -} - -// JSONEq asserts that two JSON strings are equivalent. -// -// a.JSONEq(`{"hello": "world", "foo": "bar"}`, `{"foo": "bar", "hello": "world"}`) -func (a *Assertions) JSONEq(expected string, actual string, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return JSONEq(a.t, expected, actual, msgAndArgs...) -} - -// JSONEqf asserts that two JSON strings are equivalent. -// -// a.JSONEqf(`{"hello": "world", "foo": "bar"}`, `{"foo": "bar", "hello": "world"}`, "error message %s", "formatted") -func (a *Assertions) JSONEqf(expected string, actual string, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return JSONEqf(a.t, expected, actual, msg, args...) -} - -// Len asserts that the specified object has specific length. -// Len also fails if the object has a type that len() not accept. -// -// a.Len(mySlice, 3) -func (a *Assertions) Len(object interface{}, length int, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Len(a.t, object, length, msgAndArgs...) -} - -// Lenf asserts that the specified object has specific length. -// Lenf also fails if the object has a type that len() not accept. -// -// a.Lenf(mySlice, 3, "error message %s", "formatted") -func (a *Assertions) Lenf(object interface{}, length int, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Lenf(a.t, object, length, msg, args...) -} - -// Nil asserts that the specified object is nil. -// -// a.Nil(err) -func (a *Assertions) Nil(object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Nil(a.t, object, msgAndArgs...) -} - -// Nilf asserts that the specified object is nil. -// -// a.Nilf(err, "error message %s", "formatted") -func (a *Assertions) Nilf(object interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Nilf(a.t, object, msg, args...) -} - -// NoError asserts that a function returned no error (i.e. `nil`). -// -// actualObj, err := SomeFunction() -// if a.NoError(err) { -// assert.Equal(t, expectedObj, actualObj) -// } -func (a *Assertions) NoError(err error, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NoError(a.t, err, msgAndArgs...) -} - -// NoErrorf asserts that a function returned no error (i.e. `nil`). -// -// actualObj, err := SomeFunction() -// if a.NoErrorf(err, "error message %s", "formatted") { -// assert.Equal(t, expectedObj, actualObj) -// } -func (a *Assertions) NoErrorf(err error, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NoErrorf(a.t, err, msg, args...) -} - -// NotContains asserts that the specified string, list(array, slice...) or map does NOT contain the -// specified substring or element. -// -// a.NotContains("Hello World", "Earth") -// a.NotContains(["Hello", "World"], "Earth") -// a.NotContains({"Hello": "World"}, "Earth") -func (a *Assertions) NotContains(s interface{}, contains interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotContains(a.t, s, contains, msgAndArgs...) -} - -// NotContainsf asserts that the specified string, list(array, slice...) or map does NOT contain the -// specified substring or element. -// -// a.NotContainsf("Hello World", "Earth", "error message %s", "formatted") -// a.NotContainsf(["Hello", "World"], "Earth", "error message %s", "formatted") -// a.NotContainsf({"Hello": "World"}, "Earth", "error message %s", "formatted") -func (a *Assertions) NotContainsf(s interface{}, contains interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotContainsf(a.t, s, contains, msg, args...) -} - -// NotEmpty asserts that the specified object is NOT empty. I.e. not nil, "", false, 0 or either -// a slice or a channel with len == 0. -// -// if a.NotEmpty(obj) { -// assert.Equal(t, "two", obj[1]) -// } -func (a *Assertions) NotEmpty(object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotEmpty(a.t, object, msgAndArgs...) -} - -// NotEmptyf asserts that the specified object is NOT empty. I.e. not nil, "", false, 0 or either -// a slice or a channel with len == 0. -// -// if a.NotEmptyf(obj, "error message %s", "formatted") { -// assert.Equal(t, "two", obj[1]) -// } -func (a *Assertions) NotEmptyf(object interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotEmptyf(a.t, object, msg, args...) -} - -// NotEqual asserts that the specified values are NOT equal. -// -// a.NotEqual(obj1, obj2) -// -// Pointer variable equality is determined based on the equality of the -// referenced values (as opposed to the memory addresses). -func (a *Assertions) NotEqual(expected interface{}, actual interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotEqual(a.t, expected, actual, msgAndArgs...) -} - -// NotEqualf asserts that the specified values are NOT equal. -// -// a.NotEqualf(obj1, obj2, "error message %s", "formatted") -// -// Pointer variable equality is determined based on the equality of the -// referenced values (as opposed to the memory addresses). -func (a *Assertions) NotEqualf(expected interface{}, actual interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotEqualf(a.t, expected, actual, msg, args...) -} - -// NotNil asserts that the specified object is not nil. -// -// a.NotNil(err) -func (a *Assertions) NotNil(object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotNil(a.t, object, msgAndArgs...) -} - -// NotNilf asserts that the specified object is not nil. -// -// a.NotNilf(err, "error message %s", "formatted") -func (a *Assertions) NotNilf(object interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotNilf(a.t, object, msg, args...) -} - -// NotPanics asserts that the code inside the specified PanicTestFunc does NOT panic. -// -// a.NotPanics(func(){ RemainCalm() }) -func (a *Assertions) NotPanics(f PanicTestFunc, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotPanics(a.t, f, msgAndArgs...) -} - -// NotPanicsf asserts that the code inside the specified PanicTestFunc does NOT panic. -// -// a.NotPanicsf(func(){ RemainCalm() }, "error message %s", "formatted") -func (a *Assertions) NotPanicsf(f PanicTestFunc, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotPanicsf(a.t, f, msg, args...) -} - -// NotRegexp asserts that a specified regexp does not match a string. -// -// a.NotRegexp(regexp.MustCompile("starts"), "it's starting") -// a.NotRegexp("^start", "it's not starting") -func (a *Assertions) NotRegexp(rx interface{}, str interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotRegexp(a.t, rx, str, msgAndArgs...) -} - -// NotRegexpf asserts that a specified regexp does not match a string. -// -// a.NotRegexpf(regexp.MustCompile("starts", "error message %s", "formatted"), "it's starting") -// a.NotRegexpf("^start", "it's not starting", "error message %s", "formatted") -func (a *Assertions) NotRegexpf(rx interface{}, str interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotRegexpf(a.t, rx, str, msg, args...) -} - -// NotSubset asserts that the specified list(array, slice...) contains not all -// elements given in the specified subset(array, slice...). -// -// a.NotSubset([1, 3, 4], [1, 2], "But [1, 3, 4] does not contain [1, 2]") -func (a *Assertions) NotSubset(list interface{}, subset interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotSubset(a.t, list, subset, msgAndArgs...) -} - -// NotSubsetf asserts that the specified list(array, slice...) contains not all -// elements given in the specified subset(array, slice...). -// -// a.NotSubsetf([1, 3, 4], [1, 2], "But [1, 3, 4] does not contain [1, 2]", "error message %s", "formatted") -func (a *Assertions) NotSubsetf(list interface{}, subset interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotSubsetf(a.t, list, subset, msg, args...) -} - -// NotZero asserts that i is not the zero value for its type. -func (a *Assertions) NotZero(i interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotZero(a.t, i, msgAndArgs...) -} - -// NotZerof asserts that i is not the zero value for its type. -func (a *Assertions) NotZerof(i interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotZerof(a.t, i, msg, args...) -} - -// Panics asserts that the code inside the specified PanicTestFunc panics. -// -// a.Panics(func(){ GoCrazy() }) -func (a *Assertions) Panics(f PanicTestFunc, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Panics(a.t, f, msgAndArgs...) -} - -// PanicsWithValue asserts that the code inside the specified PanicTestFunc panics, and that -// the recovered panic value equals the expected panic value. -// -// a.PanicsWithValue("crazy error", func(){ GoCrazy() }) -func (a *Assertions) PanicsWithValue(expected interface{}, f PanicTestFunc, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return PanicsWithValue(a.t, expected, f, msgAndArgs...) -} - -// PanicsWithValuef asserts that the code inside the specified PanicTestFunc panics, and that -// the recovered panic value equals the expected panic value. -// -// a.PanicsWithValuef("crazy error", func(){ GoCrazy() }, "error message %s", "formatted") -func (a *Assertions) PanicsWithValuef(expected interface{}, f PanicTestFunc, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return PanicsWithValuef(a.t, expected, f, msg, args...) -} - -// Panicsf asserts that the code inside the specified PanicTestFunc panics. -// -// a.Panicsf(func(){ GoCrazy() }, "error message %s", "formatted") -func (a *Assertions) Panicsf(f PanicTestFunc, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Panicsf(a.t, f, msg, args...) -} - -// Regexp asserts that a specified regexp matches a string. -// -// a.Regexp(regexp.MustCompile("start"), "it's starting") -// a.Regexp("start...$", "it's not starting") -func (a *Assertions) Regexp(rx interface{}, str interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Regexp(a.t, rx, str, msgAndArgs...) -} - -// Regexpf asserts that a specified regexp matches a string. -// -// a.Regexpf(regexp.MustCompile("start", "error message %s", "formatted"), "it's starting") -// a.Regexpf("start...$", "it's not starting", "error message %s", "formatted") -func (a *Assertions) Regexpf(rx interface{}, str interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Regexpf(a.t, rx, str, msg, args...) -} - -// Subset asserts that the specified list(array, slice...) contains all -// elements given in the specified subset(array, slice...). -// -// a.Subset([1, 2, 3], [1, 2], "But [1, 2, 3] does contain [1, 2]") -func (a *Assertions) Subset(list interface{}, subset interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Subset(a.t, list, subset, msgAndArgs...) -} - -// Subsetf asserts that the specified list(array, slice...) contains all -// elements given in the specified subset(array, slice...). -// -// a.Subsetf([1, 2, 3], [1, 2], "But [1, 2, 3] does contain [1, 2]", "error message %s", "formatted") -func (a *Assertions) Subsetf(list interface{}, subset interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Subsetf(a.t, list, subset, msg, args...) -} - -// True asserts that the specified value is true. -// -// a.True(myBool) -func (a *Assertions) True(value bool, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return True(a.t, value, msgAndArgs...) -} - -// Truef asserts that the specified value is true. -// -// a.Truef(myBool, "error message %s", "formatted") -func (a *Assertions) Truef(value bool, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Truef(a.t, value, msg, args...) -} - -// WithinDuration asserts that the two times are within duration delta of each other. -// -// a.WithinDuration(time.Now(), time.Now(), 10*time.Second) -func (a *Assertions) WithinDuration(expected time.Time, actual time.Time, delta time.Duration, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return WithinDuration(a.t, expected, actual, delta, msgAndArgs...) -} - -// WithinDurationf asserts that the two times are within duration delta of each other. -// -// a.WithinDurationf(time.Now(), time.Now(), 10*time.Second, "error message %s", "formatted") -func (a *Assertions) WithinDurationf(expected time.Time, actual time.Time, delta time.Duration, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return WithinDurationf(a.t, expected, actual, delta, msg, args...) -} - -// Zero asserts that i is the zero value for its type. -func (a *Assertions) Zero(i interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Zero(a.t, i, msgAndArgs...) -} - -// Zerof asserts that i is the zero value for its type. -func (a *Assertions) Zerof(i interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Zerof(a.t, i, msg, args...) -} diff --git a/vendor/github.com/stretchr/testify/assert/assertion_forward.go.tmpl b/vendor/github.com/stretchr/testify/assert/assertion_forward.go.tmpl deleted file mode 100644 index 188bb9e17..000000000 --- a/vendor/github.com/stretchr/testify/assert/assertion_forward.go.tmpl +++ /dev/null @@ -1,5 +0,0 @@ -{{.CommentWithoutT "a"}} -func (a *Assertions) {{.DocInfo.Name}}({{.Params}}) bool { - if h, ok := a.t.(tHelper); ok { h.Helper() } - return {{.DocInfo.Name}}(a.t, {{.ForwardedParams}}) -} diff --git a/vendor/github.com/stretchr/testify/assert/assertions.go b/vendor/github.com/stretchr/testify/assert/assertions.go deleted file mode 100644 index 5bdec56cd..000000000 --- a/vendor/github.com/stretchr/testify/assert/assertions.go +++ /dev/null @@ -1,1394 +0,0 @@ -package assert - -import ( - "bufio" - "bytes" - "encoding/json" - "errors" - "fmt" - "math" - "os" - "reflect" - "regexp" - "runtime" - "strings" - "time" - "unicode" - "unicode/utf8" - - "github.com/davecgh/go-spew/spew" - "github.com/pmezard/go-difflib/difflib" -) - -//go:generate go run ../_codegen/main.go -output-package=assert -template=assertion_format.go.tmpl - -// TestingT is an interface wrapper around *testing.T -type TestingT interface { - Errorf(format string, args ...interface{}) -} - -// ComparisonAssertionFunc is a common function prototype when comparing two values. Can be useful -// for table driven tests. -type ComparisonAssertionFunc func(TestingT, interface{}, interface{}, ...interface{}) bool - -// ValueAssertionFunc is a common function prototype when validating a single value. Can be useful -// for table driven tests. -type ValueAssertionFunc func(TestingT, interface{}, ...interface{}) bool - -// BoolAssertionFunc is a common function prototype when validating a bool value. Can be useful -// for table driven tests. -type BoolAssertionFunc func(TestingT, bool, ...interface{}) bool - -// ValuesAssertionFunc is a common function prototype when validating an error value. Can be useful -// for table driven tests. -type ErrorAssertionFunc func(TestingT, error, ...interface{}) bool - -// Comparison a custom function that returns true on success and false on failure -type Comparison func() (success bool) - -/* - Helper functions -*/ - -// ObjectsAreEqual determines if two objects are considered equal. -// -// This function does no assertion of any kind. -func ObjectsAreEqual(expected, actual interface{}) bool { - if expected == nil || actual == nil { - return expected == actual - } - - exp, ok := expected.([]byte) - if !ok { - return reflect.DeepEqual(expected, actual) - } - - act, ok := actual.([]byte) - if !ok { - return false - } - if exp == nil || act == nil { - return exp == nil && act == nil - } - return bytes.Equal(exp, act) -} - -// ObjectsAreEqualValues gets whether two objects are equal, or if their -// values are equal. -func ObjectsAreEqualValues(expected, actual interface{}) bool { - if ObjectsAreEqual(expected, actual) { - return true - } - - actualType := reflect.TypeOf(actual) - if actualType == nil { - return false - } - expectedValue := reflect.ValueOf(expected) - if expectedValue.IsValid() && expectedValue.Type().ConvertibleTo(actualType) { - // Attempt comparison after type conversion - return reflect.DeepEqual(expectedValue.Convert(actualType).Interface(), actual) - } - - return false -} - -/* CallerInfo is necessary because the assert functions use the testing object -internally, causing it to print the file:line of the assert method, rather than where -the problem actually occurred in calling code.*/ - -// CallerInfo returns an array of strings containing the file and line number -// of each stack frame leading from the current test to the assert call that -// failed. -func CallerInfo() []string { - - pc := uintptr(0) - file := "" - line := 0 - ok := false - name := "" - - callers := []string{} - for i := 0; ; i++ { - pc, file, line, ok = runtime.Caller(i) - if !ok { - // The breaks below failed to terminate the loop, and we ran off the - // end of the call stack. - break - } - - // This is a huge edge case, but it will panic if this is the case, see #180 - if file == "" { - break - } - - f := runtime.FuncForPC(pc) - if f == nil { - break - } - name = f.Name() - - // testing.tRunner is the standard library function that calls - // tests. Subtests are called directly by tRunner, without going through - // the Test/Benchmark/Example function that contains the t.Run calls, so - // with subtests we should break when we hit tRunner, without adding it - // to the list of callers. - if name == "testing.tRunner" { - break - } - - parts := strings.Split(file, "/") - file = parts[len(parts)-1] - if len(parts) > 1 { - dir := parts[len(parts)-2] - if (dir != "assert" && dir != "mock" && dir != "require") || file == "mock_test.go" { - callers = append(callers, fmt.Sprintf("%s:%d", file, line)) - } - } - - // Drop the package - segments := strings.Split(name, ".") - name = segments[len(segments)-1] - if isTest(name, "Test") || - isTest(name, "Benchmark") || - isTest(name, "Example") { - break - } - } - - return callers -} - -// Stolen from the `go test` tool. -// isTest tells whether name looks like a test (or benchmark, according to prefix). -// It is a Test (say) if there is a character after Test that is not a lower-case letter. -// We don't want TesticularCancer. -func isTest(name, prefix string) bool { - if !strings.HasPrefix(name, prefix) { - return false - } - if len(name) == len(prefix) { // "Test" is ok - return true - } - rune, _ := utf8.DecodeRuneInString(name[len(prefix):]) - return !unicode.IsLower(rune) -} - -func messageFromMsgAndArgs(msgAndArgs ...interface{}) string { - if len(msgAndArgs) == 0 || msgAndArgs == nil { - return "" - } - if len(msgAndArgs) == 1 { - return msgAndArgs[0].(string) - } - if len(msgAndArgs) > 1 { - return fmt.Sprintf(msgAndArgs[0].(string), msgAndArgs[1:]...) - } - return "" -} - -// Aligns the provided message so that all lines after the first line start at the same location as the first line. -// Assumes that the first line starts at the correct location (after carriage return, tab, label, spacer and tab). -// The longestLabelLen parameter specifies the length of the longest label in the output (required becaues this is the -// basis on which the alignment occurs). -func indentMessageLines(message string, longestLabelLen int) string { - outBuf := new(bytes.Buffer) - - for i, scanner := 0, bufio.NewScanner(strings.NewReader(message)); scanner.Scan(); i++ { - // no need to align first line because it starts at the correct location (after the label) - if i != 0 { - // append alignLen+1 spaces to align with "{{longestLabel}}:" before adding tab - outBuf.WriteString("\n\t" + strings.Repeat(" ", longestLabelLen+1) + "\t") - } - outBuf.WriteString(scanner.Text()) - } - - return outBuf.String() -} - -type failNower interface { - FailNow() -} - -// FailNow fails test -func FailNow(t TestingT, failureMessage string, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - Fail(t, failureMessage, msgAndArgs...) - - // We cannot extend TestingT with FailNow() and - // maintain backwards compatibility, so we fallback - // to panicking when FailNow is not available in - // TestingT. - // See issue #263 - - if t, ok := t.(failNower); ok { - t.FailNow() - } else { - panic("test failed and t is missing `FailNow()`") - } - return false -} - -// Fail reports a failure through -func Fail(t TestingT, failureMessage string, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - content := []labeledContent{ - {"Error Trace", strings.Join(CallerInfo(), "\n\t\t\t")}, - {"Error", failureMessage}, - } - - // Add test name if the Go version supports it - if n, ok := t.(interface { - Name() string - }); ok { - content = append(content, labeledContent{"Test", n.Name()}) - } - - message := messageFromMsgAndArgs(msgAndArgs...) - if len(message) > 0 { - content = append(content, labeledContent{"Messages", message}) - } - - t.Errorf("\n%s", ""+labeledOutput(content...)) - - return false -} - -type labeledContent struct { - label string - content string -} - -// labeledOutput returns a string consisting of the provided labeledContent. Each labeled output is appended in the following manner: -// -// \t{{label}}:{{align_spaces}}\t{{content}}\n -// -// The initial carriage return is required to undo/erase any padding added by testing.T.Errorf. The "\t{{label}}:" is for the label. -// If a label is shorter than the longest label provided, padding spaces are added to make all the labels match in length. Once this -// alignment is achieved, "\t{{content}}\n" is added for the output. -// -// If the content of the labeledOutput contains line breaks, the subsequent lines are aligned so that they start at the same location as the first line. -func labeledOutput(content ...labeledContent) string { - longestLabel := 0 - for _, v := range content { - if len(v.label) > longestLabel { - longestLabel = len(v.label) - } - } - var output string - for _, v := range content { - output += "\t" + v.label + ":" + strings.Repeat(" ", longestLabel-len(v.label)) + "\t" + indentMessageLines(v.content, longestLabel) + "\n" - } - return output -} - -// Implements asserts that an object is implemented by the specified interface. -// -// assert.Implements(t, (*MyInterface)(nil), new(MyObject)) -func Implements(t TestingT, interfaceObject interface{}, object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - interfaceType := reflect.TypeOf(interfaceObject).Elem() - - if object == nil { - return Fail(t, fmt.Sprintf("Cannot check if nil implements %v", interfaceType), msgAndArgs...) - } - if !reflect.TypeOf(object).Implements(interfaceType) { - return Fail(t, fmt.Sprintf("%T must implement %v", object, interfaceType), msgAndArgs...) - } - - return true -} - -// IsType asserts that the specified objects are of the same type. -func IsType(t TestingT, expectedType interface{}, object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - if !ObjectsAreEqual(reflect.TypeOf(object), reflect.TypeOf(expectedType)) { - return Fail(t, fmt.Sprintf("Object expected to be of type %v, but was %v", reflect.TypeOf(expectedType), reflect.TypeOf(object)), msgAndArgs...) - } - - return true -} - -// Equal asserts that two objects are equal. -// -// assert.Equal(t, 123, 123) -// -// Pointer variable equality is determined based on the equality of the -// referenced values (as opposed to the memory addresses). Function equality -// cannot be determined and will always fail. -func Equal(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if err := validateEqualArgs(expected, actual); err != nil { - return Fail(t, fmt.Sprintf("Invalid operation: %#v == %#v (%s)", - expected, actual, err), msgAndArgs...) - } - - if !ObjectsAreEqual(expected, actual) { - diff := diff(expected, actual) - expected, actual = formatUnequalValues(expected, actual) - return Fail(t, fmt.Sprintf("Not equal: \n"+ - "expected: %s\n"+ - "actual : %s%s", expected, actual, diff), msgAndArgs...) - } - - return true - -} - -// formatUnequalValues takes two values of arbitrary types and returns string -// representations appropriate to be presented to the user. -// -// If the values are not of like type, the returned strings will be prefixed -// with the type name, and the value will be enclosed in parenthesis similar -// to a type conversion in the Go grammar. -func formatUnequalValues(expected, actual interface{}) (e string, a string) { - if reflect.TypeOf(expected) != reflect.TypeOf(actual) { - return fmt.Sprintf("%T(%#v)", expected, expected), - fmt.Sprintf("%T(%#v)", actual, actual) - } - - return fmt.Sprintf("%#v", expected), - fmt.Sprintf("%#v", actual) -} - -// EqualValues asserts that two objects are equal or convertable to the same types -// and equal. -// -// assert.EqualValues(t, uint32(123), int32(123)) -func EqualValues(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - if !ObjectsAreEqualValues(expected, actual) { - diff := diff(expected, actual) - expected, actual = formatUnequalValues(expected, actual) - return Fail(t, fmt.Sprintf("Not equal: \n"+ - "expected: %s\n"+ - "actual : %s%s", expected, actual, diff), msgAndArgs...) - } - - return true - -} - -// Exactly asserts that two objects are equal in value and type. -// -// assert.Exactly(t, int32(123), int64(123)) -func Exactly(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - aType := reflect.TypeOf(expected) - bType := reflect.TypeOf(actual) - - if aType != bType { - return Fail(t, fmt.Sprintf("Types expected to match exactly\n\t%v != %v", aType, bType), msgAndArgs...) - } - - return Equal(t, expected, actual, msgAndArgs...) - -} - -// NotNil asserts that the specified object is not nil. -// -// assert.NotNil(t, err) -func NotNil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if !isNil(object) { - return true - } - return Fail(t, "Expected value not to be nil.", msgAndArgs...) -} - -// isNil checks if a specified object is nil or not, without Failing. -func isNil(object interface{}) bool { - if object == nil { - return true - } - - value := reflect.ValueOf(object) - kind := value.Kind() - if kind >= reflect.Chan && kind <= reflect.Slice && value.IsNil() { - return true - } - - return false -} - -// Nil asserts that the specified object is nil. -// -// assert.Nil(t, err) -func Nil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if isNil(object) { - return true - } - return Fail(t, fmt.Sprintf("Expected nil, but got: %#v", object), msgAndArgs...) -} - -// isEmpty gets whether the specified object is considered empty or not. -func isEmpty(object interface{}) bool { - - // get nil case out of the way - if object == nil { - return true - } - - objValue := reflect.ValueOf(object) - - switch objValue.Kind() { - // collection types are empty when they have no element - case reflect.Array, reflect.Chan, reflect.Map, reflect.Slice: - return objValue.Len() == 0 - // pointers are empty if nil or if the value they point to is empty - case reflect.Ptr: - if objValue.IsNil() { - return true - } - deref := objValue.Elem().Interface() - return isEmpty(deref) - // for all other types, compare against the zero value - default: - zero := reflect.Zero(objValue.Type()) - return reflect.DeepEqual(object, zero.Interface()) - } -} - -// Empty asserts that the specified object is empty. I.e. nil, "", false, 0 or either -// a slice or a channel with len == 0. -// -// assert.Empty(t, obj) -func Empty(t TestingT, object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - pass := isEmpty(object) - if !pass { - Fail(t, fmt.Sprintf("Should be empty, but was %v", object), msgAndArgs...) - } - - return pass - -} - -// NotEmpty asserts that the specified object is NOT empty. I.e. not nil, "", false, 0 or either -// a slice or a channel with len == 0. -// -// if assert.NotEmpty(t, obj) { -// assert.Equal(t, "two", obj[1]) -// } -func NotEmpty(t TestingT, object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - pass := !isEmpty(object) - if !pass { - Fail(t, fmt.Sprintf("Should NOT be empty, but was %v", object), msgAndArgs...) - } - - return pass - -} - -// getLen try to get length of object. -// return (false, 0) if impossible. -func getLen(x interface{}) (ok bool, length int) { - v := reflect.ValueOf(x) - defer func() { - if e := recover(); e != nil { - ok = false - } - }() - return true, v.Len() -} - -// Len asserts that the specified object has specific length. -// Len also fails if the object has a type that len() not accept. -// -// assert.Len(t, mySlice, 3) -func Len(t TestingT, object interface{}, length int, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - ok, l := getLen(object) - if !ok { - return Fail(t, fmt.Sprintf("\"%s\" could not be applied builtin len()", object), msgAndArgs...) - } - - if l != length { - return Fail(t, fmt.Sprintf("\"%s\" should have %d item(s), but has %d", object, length, l), msgAndArgs...) - } - return true -} - -// True asserts that the specified value is true. -// -// assert.True(t, myBool) -func True(t TestingT, value bool, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if h, ok := t.(interface { - Helper() - }); ok { - h.Helper() - } - - if value != true { - return Fail(t, "Should be true", msgAndArgs...) - } - - return true - -} - -// False asserts that the specified value is false. -// -// assert.False(t, myBool) -func False(t TestingT, value bool, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - if value != false { - return Fail(t, "Should be false", msgAndArgs...) - } - - return true - -} - -// NotEqual asserts that the specified values are NOT equal. -// -// assert.NotEqual(t, obj1, obj2) -// -// Pointer variable equality is determined based on the equality of the -// referenced values (as opposed to the memory addresses). -func NotEqual(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if err := validateEqualArgs(expected, actual); err != nil { - return Fail(t, fmt.Sprintf("Invalid operation: %#v != %#v (%s)", - expected, actual, err), msgAndArgs...) - } - - if ObjectsAreEqual(expected, actual) { - return Fail(t, fmt.Sprintf("Should not be: %#v\n", actual), msgAndArgs...) - } - - return true - -} - -// containsElement try loop over the list check if the list includes the element. -// return (false, false) if impossible. -// return (true, false) if element was not found. -// return (true, true) if element was found. -func includeElement(list interface{}, element interface{}) (ok, found bool) { - - listValue := reflect.ValueOf(list) - elementValue := reflect.ValueOf(element) - defer func() { - if e := recover(); e != nil { - ok = false - found = false - } - }() - - if reflect.TypeOf(list).Kind() == reflect.String { - return true, strings.Contains(listValue.String(), elementValue.String()) - } - - if reflect.TypeOf(list).Kind() == reflect.Map { - mapKeys := listValue.MapKeys() - for i := 0; i < len(mapKeys); i++ { - if ObjectsAreEqual(mapKeys[i].Interface(), element) { - return true, true - } - } - return true, false - } - - for i := 0; i < listValue.Len(); i++ { - if ObjectsAreEqual(listValue.Index(i).Interface(), element) { - return true, true - } - } - return true, false - -} - -// Contains asserts that the specified string, list(array, slice...) or map contains the -// specified substring or element. -// -// assert.Contains(t, "Hello World", "World") -// assert.Contains(t, ["Hello", "World"], "World") -// assert.Contains(t, {"Hello": "World"}, "Hello") -func Contains(t TestingT, s, contains interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - ok, found := includeElement(s, contains) - if !ok { - return Fail(t, fmt.Sprintf("\"%s\" could not be applied builtin len()", s), msgAndArgs...) - } - if !found { - return Fail(t, fmt.Sprintf("\"%s\" does not contain \"%s\"", s, contains), msgAndArgs...) - } - - return true - -} - -// NotContains asserts that the specified string, list(array, slice...) or map does NOT contain the -// specified substring or element. -// -// assert.NotContains(t, "Hello World", "Earth") -// assert.NotContains(t, ["Hello", "World"], "Earth") -// assert.NotContains(t, {"Hello": "World"}, "Earth") -func NotContains(t TestingT, s, contains interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - ok, found := includeElement(s, contains) - if !ok { - return Fail(t, fmt.Sprintf("\"%s\" could not be applied builtin len()", s), msgAndArgs...) - } - if found { - return Fail(t, fmt.Sprintf("\"%s\" should not contain \"%s\"", s, contains), msgAndArgs...) - } - - return true - -} - -// Subset asserts that the specified list(array, slice...) contains all -// elements given in the specified subset(array, slice...). -// -// assert.Subset(t, [1, 2, 3], [1, 2], "But [1, 2, 3] does contain [1, 2]") -func Subset(t TestingT, list, subset interface{}, msgAndArgs ...interface{}) (ok bool) { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if subset == nil { - return true // we consider nil to be equal to the nil set - } - - subsetValue := reflect.ValueOf(subset) - defer func() { - if e := recover(); e != nil { - ok = false - } - }() - - listKind := reflect.TypeOf(list).Kind() - subsetKind := reflect.TypeOf(subset).Kind() - - if listKind != reflect.Array && listKind != reflect.Slice { - return Fail(t, fmt.Sprintf("%q has an unsupported type %s", list, listKind), msgAndArgs...) - } - - if subsetKind != reflect.Array && subsetKind != reflect.Slice { - return Fail(t, fmt.Sprintf("%q has an unsupported type %s", subset, subsetKind), msgAndArgs...) - } - - for i := 0; i < subsetValue.Len(); i++ { - element := subsetValue.Index(i).Interface() - ok, found := includeElement(list, element) - if !ok { - return Fail(t, fmt.Sprintf("\"%s\" could not be applied builtin len()", list), msgAndArgs...) - } - if !found { - return Fail(t, fmt.Sprintf("\"%s\" does not contain \"%s\"", list, element), msgAndArgs...) - } - } - - return true -} - -// NotSubset asserts that the specified list(array, slice...) contains not all -// elements given in the specified subset(array, slice...). -// -// assert.NotSubset(t, [1, 3, 4], [1, 2], "But [1, 3, 4] does not contain [1, 2]") -func NotSubset(t TestingT, list, subset interface{}, msgAndArgs ...interface{}) (ok bool) { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if subset == nil { - return Fail(t, fmt.Sprintf("nil is the empty set which is a subset of every set"), msgAndArgs...) - } - - subsetValue := reflect.ValueOf(subset) - defer func() { - if e := recover(); e != nil { - ok = false - } - }() - - listKind := reflect.TypeOf(list).Kind() - subsetKind := reflect.TypeOf(subset).Kind() - - if listKind != reflect.Array && listKind != reflect.Slice { - return Fail(t, fmt.Sprintf("%q has an unsupported type %s", list, listKind), msgAndArgs...) - } - - if subsetKind != reflect.Array && subsetKind != reflect.Slice { - return Fail(t, fmt.Sprintf("%q has an unsupported type %s", subset, subsetKind), msgAndArgs...) - } - - for i := 0; i < subsetValue.Len(); i++ { - element := subsetValue.Index(i).Interface() - ok, found := includeElement(list, element) - if !ok { - return Fail(t, fmt.Sprintf("\"%s\" could not be applied builtin len()", list), msgAndArgs...) - } - if !found { - return true - } - } - - return Fail(t, fmt.Sprintf("%q is a subset of %q", subset, list), msgAndArgs...) -} - -// ElementsMatch asserts that the specified listA(array, slice...) is equal to specified -// listB(array, slice...) ignoring the order of the elements. If there are duplicate elements, -// the number of appearances of each of them in both lists should match. -// -// assert.ElementsMatch(t, [1, 3, 2, 3], [1, 3, 3, 2]) -func ElementsMatch(t TestingT, listA, listB interface{}, msgAndArgs ...interface{}) (ok bool) { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if isEmpty(listA) && isEmpty(listB) { - return true - } - - aKind := reflect.TypeOf(listA).Kind() - bKind := reflect.TypeOf(listB).Kind() - - if aKind != reflect.Array && aKind != reflect.Slice { - return Fail(t, fmt.Sprintf("%q has an unsupported type %s", listA, aKind), msgAndArgs...) - } - - if bKind != reflect.Array && bKind != reflect.Slice { - return Fail(t, fmt.Sprintf("%q has an unsupported type %s", listB, bKind), msgAndArgs...) - } - - aValue := reflect.ValueOf(listA) - bValue := reflect.ValueOf(listB) - - aLen := aValue.Len() - bLen := bValue.Len() - - if aLen != bLen { - return Fail(t, fmt.Sprintf("lengths don't match: %d != %d", aLen, bLen), msgAndArgs...) - } - - // Mark indexes in bValue that we already used - visited := make([]bool, bLen) - for i := 0; i < aLen; i++ { - element := aValue.Index(i).Interface() - found := false - for j := 0; j < bLen; j++ { - if visited[j] { - continue - } - if ObjectsAreEqual(bValue.Index(j).Interface(), element) { - visited[j] = true - found = true - break - } - } - if !found { - return Fail(t, fmt.Sprintf("element %s appears more times in %s than in %s", element, aValue, bValue), msgAndArgs...) - } - } - - return true -} - -// Condition uses a Comparison to assert a complex condition. -func Condition(t TestingT, comp Comparison, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - result := comp() - if !result { - Fail(t, "Condition failed!", msgAndArgs...) - } - return result -} - -// PanicTestFunc defines a func that should be passed to the assert.Panics and assert.NotPanics -// methods, and represents a simple func that takes no arguments, and returns nothing. -type PanicTestFunc func() - -// didPanic returns true if the function passed to it panics. Otherwise, it returns false. -func didPanic(f PanicTestFunc) (bool, interface{}) { - - didPanic := false - var message interface{} - func() { - - defer func() { - if message = recover(); message != nil { - didPanic = true - } - }() - - // call the target function - f() - - }() - - return didPanic, message - -} - -// Panics asserts that the code inside the specified PanicTestFunc panics. -// -// assert.Panics(t, func(){ GoCrazy() }) -func Panics(t TestingT, f PanicTestFunc, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - if funcDidPanic, panicValue := didPanic(f); !funcDidPanic { - return Fail(t, fmt.Sprintf("func %#v should panic\n\tPanic value:\t%#v", f, panicValue), msgAndArgs...) - } - - return true -} - -// PanicsWithValue asserts that the code inside the specified PanicTestFunc panics, and that -// the recovered panic value equals the expected panic value. -// -// assert.PanicsWithValue(t, "crazy error", func(){ GoCrazy() }) -func PanicsWithValue(t TestingT, expected interface{}, f PanicTestFunc, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - funcDidPanic, panicValue := didPanic(f) - if !funcDidPanic { - return Fail(t, fmt.Sprintf("func %#v should panic\n\tPanic value:\t%#v", f, panicValue), msgAndArgs...) - } - if panicValue != expected { - return Fail(t, fmt.Sprintf("func %#v should panic with value:\t%#v\n\tPanic value:\t%#v", f, expected, panicValue), msgAndArgs...) - } - - return true -} - -// NotPanics asserts that the code inside the specified PanicTestFunc does NOT panic. -// -// assert.NotPanics(t, func(){ RemainCalm() }) -func NotPanics(t TestingT, f PanicTestFunc, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - if funcDidPanic, panicValue := didPanic(f); funcDidPanic { - return Fail(t, fmt.Sprintf("func %#v should not panic\n\tPanic value:\t%v", f, panicValue), msgAndArgs...) - } - - return true -} - -// WithinDuration asserts that the two times are within duration delta of each other. -// -// assert.WithinDuration(t, time.Now(), time.Now(), 10*time.Second) -func WithinDuration(t TestingT, expected, actual time.Time, delta time.Duration, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - dt := expected.Sub(actual) - if dt < -delta || dt > delta { - return Fail(t, fmt.Sprintf("Max difference between %v and %v allowed is %v, but difference was %v", expected, actual, delta, dt), msgAndArgs...) - } - - return true -} - -func toFloat(x interface{}) (float64, bool) { - var xf float64 - xok := true - - switch xn := x.(type) { - case uint8: - xf = float64(xn) - case uint16: - xf = float64(xn) - case uint32: - xf = float64(xn) - case uint64: - xf = float64(xn) - case int: - xf = float64(xn) - case int8: - xf = float64(xn) - case int16: - xf = float64(xn) - case int32: - xf = float64(xn) - case int64: - xf = float64(xn) - case float32: - xf = float64(xn) - case float64: - xf = float64(xn) - case time.Duration: - xf = float64(xn) - default: - xok = false - } - - return xf, xok -} - -// InDelta asserts that the two numerals are within delta of each other. -// -// assert.InDelta(t, math.Pi, (22 / 7.0), 0.01) -func InDelta(t TestingT, expected, actual interface{}, delta float64, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - af, aok := toFloat(expected) - bf, bok := toFloat(actual) - - if !aok || !bok { - return Fail(t, fmt.Sprintf("Parameters must be numerical"), msgAndArgs...) - } - - if math.IsNaN(af) { - return Fail(t, fmt.Sprintf("Expected must not be NaN"), msgAndArgs...) - } - - if math.IsNaN(bf) { - return Fail(t, fmt.Sprintf("Expected %v with delta %v, but was NaN", expected, delta), msgAndArgs...) - } - - dt := af - bf - if dt < -delta || dt > delta { - return Fail(t, fmt.Sprintf("Max difference between %v and %v allowed is %v, but difference was %v", expected, actual, delta, dt), msgAndArgs...) - } - - return true -} - -// InDeltaSlice is the same as InDelta, except it compares two slices. -func InDeltaSlice(t TestingT, expected, actual interface{}, delta float64, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if expected == nil || actual == nil || - reflect.TypeOf(actual).Kind() != reflect.Slice || - reflect.TypeOf(expected).Kind() != reflect.Slice { - return Fail(t, fmt.Sprintf("Parameters must be slice"), msgAndArgs...) - } - - actualSlice := reflect.ValueOf(actual) - expectedSlice := reflect.ValueOf(expected) - - for i := 0; i < actualSlice.Len(); i++ { - result := InDelta(t, actualSlice.Index(i).Interface(), expectedSlice.Index(i).Interface(), delta, msgAndArgs...) - if !result { - return result - } - } - - return true -} - -// InDeltaMapValues is the same as InDelta, but it compares all values between two maps. Both maps must have exactly the same keys. -func InDeltaMapValues(t TestingT, expected, actual interface{}, delta float64, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if expected == nil || actual == nil || - reflect.TypeOf(actual).Kind() != reflect.Map || - reflect.TypeOf(expected).Kind() != reflect.Map { - return Fail(t, "Arguments must be maps", msgAndArgs...) - } - - expectedMap := reflect.ValueOf(expected) - actualMap := reflect.ValueOf(actual) - - if expectedMap.Len() != actualMap.Len() { - return Fail(t, "Arguments must have the same number of keys", msgAndArgs...) - } - - for _, k := range expectedMap.MapKeys() { - ev := expectedMap.MapIndex(k) - av := actualMap.MapIndex(k) - - if !ev.IsValid() { - return Fail(t, fmt.Sprintf("missing key %q in expected map", k), msgAndArgs...) - } - - if !av.IsValid() { - return Fail(t, fmt.Sprintf("missing key %q in actual map", k), msgAndArgs...) - } - - if !InDelta( - t, - ev.Interface(), - av.Interface(), - delta, - msgAndArgs..., - ) { - return false - } - } - - return true -} - -func calcRelativeError(expected, actual interface{}) (float64, error) { - af, aok := toFloat(expected) - if !aok { - return 0, fmt.Errorf("expected value %q cannot be converted to float", expected) - } - if af == 0 { - return 0, fmt.Errorf("expected value must have a value other than zero to calculate the relative error") - } - bf, bok := toFloat(actual) - if !bok { - return 0, fmt.Errorf("actual value %q cannot be converted to float", actual) - } - - return math.Abs(af-bf) / math.Abs(af), nil -} - -// InEpsilon asserts that expected and actual have a relative error less than epsilon -func InEpsilon(t TestingT, expected, actual interface{}, epsilon float64, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - actualEpsilon, err := calcRelativeError(expected, actual) - if err != nil { - return Fail(t, err.Error(), msgAndArgs...) - } - if actualEpsilon > epsilon { - return Fail(t, fmt.Sprintf("Relative error is too high: %#v (expected)\n"+ - " < %#v (actual)", epsilon, actualEpsilon), msgAndArgs...) - } - - return true -} - -// InEpsilonSlice is the same as InEpsilon, except it compares each value from two slices. -func InEpsilonSlice(t TestingT, expected, actual interface{}, epsilon float64, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if expected == nil || actual == nil || - reflect.TypeOf(actual).Kind() != reflect.Slice || - reflect.TypeOf(expected).Kind() != reflect.Slice { - return Fail(t, fmt.Sprintf("Parameters must be slice"), msgAndArgs...) - } - - actualSlice := reflect.ValueOf(actual) - expectedSlice := reflect.ValueOf(expected) - - for i := 0; i < actualSlice.Len(); i++ { - result := InEpsilon(t, actualSlice.Index(i).Interface(), expectedSlice.Index(i).Interface(), epsilon) - if !result { - return result - } - } - - return true -} - -/* - Errors -*/ - -// NoError asserts that a function returned no error (i.e. `nil`). -// -// actualObj, err := SomeFunction() -// if assert.NoError(t, err) { -// assert.Equal(t, expectedObj, actualObj) -// } -func NoError(t TestingT, err error, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if err != nil { - return Fail(t, fmt.Sprintf("Received unexpected error:\n%+v", err), msgAndArgs...) - } - - return true -} - -// Error asserts that a function returned an error (i.e. not `nil`). -// -// actualObj, err := SomeFunction() -// if assert.Error(t, err) { -// assert.Equal(t, expectedError, err) -// } -func Error(t TestingT, err error, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - if err == nil { - return Fail(t, "An error is expected but got nil.", msgAndArgs...) - } - - return true -} - -// EqualError asserts that a function returned an error (i.e. not `nil`) -// and that it is equal to the provided error. -// -// actualObj, err := SomeFunction() -// assert.EqualError(t, err, expectedErrorString) -func EqualError(t TestingT, theError error, errString string, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if !Error(t, theError, msgAndArgs...) { - return false - } - expected := errString - actual := theError.Error() - // don't need to use deep equals here, we know they are both strings - if expected != actual { - return Fail(t, fmt.Sprintf("Error message not equal:\n"+ - "expected: %q\n"+ - "actual : %q", expected, actual), msgAndArgs...) - } - return true -} - -// matchRegexp return true if a specified regexp matches a string. -func matchRegexp(rx interface{}, str interface{}) bool { - - var r *regexp.Regexp - if rr, ok := rx.(*regexp.Regexp); ok { - r = rr - } else { - r = regexp.MustCompile(fmt.Sprint(rx)) - } - - return (r.FindStringIndex(fmt.Sprint(str)) != nil) - -} - -// Regexp asserts that a specified regexp matches a string. -// -// assert.Regexp(t, regexp.MustCompile("start"), "it's starting") -// assert.Regexp(t, "start...$", "it's not starting") -func Regexp(t TestingT, rx interface{}, str interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - match := matchRegexp(rx, str) - - if !match { - Fail(t, fmt.Sprintf("Expect \"%v\" to match \"%v\"", str, rx), msgAndArgs...) - } - - return match -} - -// NotRegexp asserts that a specified regexp does not match a string. -// -// assert.NotRegexp(t, regexp.MustCompile("starts"), "it's starting") -// assert.NotRegexp(t, "^start", "it's not starting") -func NotRegexp(t TestingT, rx interface{}, str interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - match := matchRegexp(rx, str) - - if match { - Fail(t, fmt.Sprintf("Expect \"%v\" to NOT match \"%v\"", str, rx), msgAndArgs...) - } - - return !match - -} - -// Zero asserts that i is the zero value for its type. -func Zero(t TestingT, i interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if i != nil && !reflect.DeepEqual(i, reflect.Zero(reflect.TypeOf(i)).Interface()) { - return Fail(t, fmt.Sprintf("Should be zero, but was %v", i), msgAndArgs...) - } - return true -} - -// NotZero asserts that i is not the zero value for its type. -func NotZero(t TestingT, i interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if i == nil || reflect.DeepEqual(i, reflect.Zero(reflect.TypeOf(i)).Interface()) { - return Fail(t, fmt.Sprintf("Should not be zero, but was %v", i), msgAndArgs...) - } - return true -} - -// FileExists checks whether a file exists in the given path. It also fails if the path points to a directory or there is an error when trying to check the file. -func FileExists(t TestingT, path string, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - info, err := os.Lstat(path) - if err != nil { - if os.IsNotExist(err) { - return Fail(t, fmt.Sprintf("unable to find file %q", path), msgAndArgs...) - } - return Fail(t, fmt.Sprintf("error when running os.Lstat(%q): %s", path, err), msgAndArgs...) - } - if info.IsDir() { - return Fail(t, fmt.Sprintf("%q is a directory", path), msgAndArgs...) - } - return true -} - -// DirExists checks whether a directory exists in the given path. It also fails if the path is a file rather a directory or there is an error checking whether it exists. -func DirExists(t TestingT, path string, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - info, err := os.Lstat(path) - if err != nil { - if os.IsNotExist(err) { - return Fail(t, fmt.Sprintf("unable to find file %q", path), msgAndArgs...) - } - return Fail(t, fmt.Sprintf("error when running os.Lstat(%q): %s", path, err), msgAndArgs...) - } - if !info.IsDir() { - return Fail(t, fmt.Sprintf("%q is a file", path), msgAndArgs...) - } - return true -} - -// JSONEq asserts that two JSON strings are equivalent. -// -// assert.JSONEq(t, `{"hello": "world", "foo": "bar"}`, `{"foo": "bar", "hello": "world"}`) -func JSONEq(t TestingT, expected string, actual string, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - var expectedJSONAsInterface, actualJSONAsInterface interface{} - - if err := json.Unmarshal([]byte(expected), &expectedJSONAsInterface); err != nil { - return Fail(t, fmt.Sprintf("Expected value ('%s') is not valid json.\nJSON parsing error: '%s'", expected, err.Error()), msgAndArgs...) - } - - if err := json.Unmarshal([]byte(actual), &actualJSONAsInterface); err != nil { - return Fail(t, fmt.Sprintf("Input ('%s') needs to be valid json.\nJSON parsing error: '%s'", actual, err.Error()), msgAndArgs...) - } - - return Equal(t, expectedJSONAsInterface, actualJSONAsInterface, msgAndArgs...) -} - -func typeAndKind(v interface{}) (reflect.Type, reflect.Kind) { - t := reflect.TypeOf(v) - k := t.Kind() - - if k == reflect.Ptr { - t = t.Elem() - k = t.Kind() - } - return t, k -} - -// diff returns a diff of both values as long as both are of the same type and -// are a struct, map, slice or array. Otherwise it returns an empty string. -func diff(expected interface{}, actual interface{}) string { - if expected == nil || actual == nil { - return "" - } - - et, ek := typeAndKind(expected) - at, _ := typeAndKind(actual) - - if et != at { - return "" - } - - if ek != reflect.Struct && ek != reflect.Map && ek != reflect.Slice && ek != reflect.Array && ek != reflect.String { - return "" - } - - var e, a string - if ek != reflect.String { - e = spewConfig.Sdump(expected) - a = spewConfig.Sdump(actual) - } else { - e = expected.(string) - a = actual.(string) - } - - diff, _ := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ - A: difflib.SplitLines(e), - B: difflib.SplitLines(a), - FromFile: "Expected", - FromDate: "", - ToFile: "Actual", - ToDate: "", - Context: 1, - }) - - return "\n\nDiff:\n" + diff -} - -// validateEqualArgs checks whether provided arguments can be safely used in the -// Equal/NotEqual functions. -func validateEqualArgs(expected, actual interface{}) error { - if isFunction(expected) || isFunction(actual) { - return errors.New("cannot take func type as argument") - } - return nil -} - -func isFunction(arg interface{}) bool { - if arg == nil { - return false - } - return reflect.TypeOf(arg).Kind() == reflect.Func -} - -var spewConfig = spew.ConfigState{ - Indent: " ", - DisablePointerAddresses: true, - DisableCapacities: true, - SortKeys: true, -} - -type tHelper interface { - Helper() -} diff --git a/vendor/github.com/stretchr/testify/assert/doc.go b/vendor/github.com/stretchr/testify/assert/doc.go deleted file mode 100644 index c9dccc4d6..000000000 --- a/vendor/github.com/stretchr/testify/assert/doc.go +++ /dev/null @@ -1,45 +0,0 @@ -// Package assert provides a set of comprehensive testing tools for use with the normal Go testing system. -// -// Example Usage -// -// The following is a complete example using assert in a standard test function: -// import ( -// "testing" -// "github.com/stretchr/testify/assert" -// ) -// -// func TestSomething(t *testing.T) { -// -// var a string = "Hello" -// var b string = "Hello" -// -// assert.Equal(t, a, b, "The two words should be the same.") -// -// } -// -// if you assert many times, use the format below: -// -// import ( -// "testing" -// "github.com/stretchr/testify/assert" -// ) -// -// func TestSomething(t *testing.T) { -// assert := assert.New(t) -// -// var a string = "Hello" -// var b string = "Hello" -// -// assert.Equal(a, b, "The two words should be the same.") -// } -// -// Assertions -// -// Assertions allow you to easily write test code, and are global funcs in the `assert` package. -// All assertion functions take, as the first argument, the `*testing.T` object provided by the -// testing framework. This allows the assertion funcs to write the failings and other details to -// the correct place. -// -// Every assertion function also takes an optional string message as the final argument, -// allowing custom error messages to be appended to the message the assertion method outputs. -package assert diff --git a/vendor/github.com/stretchr/testify/assert/errors.go b/vendor/github.com/stretchr/testify/assert/errors.go deleted file mode 100644 index ac9dc9d1d..000000000 --- a/vendor/github.com/stretchr/testify/assert/errors.go +++ /dev/null @@ -1,10 +0,0 @@ -package assert - -import ( - "errors" -) - -// AnError is an error instance useful for testing. If the code does not care -// about error specifics, and only needs to return the error for example, this -// error should be used to make the test code more readable. -var AnError = errors.New("assert.AnError general error for testing") diff --git a/vendor/github.com/stretchr/testify/assert/forward_assertions.go b/vendor/github.com/stretchr/testify/assert/forward_assertions.go deleted file mode 100644 index 9ad56851d..000000000 --- a/vendor/github.com/stretchr/testify/assert/forward_assertions.go +++ /dev/null @@ -1,16 +0,0 @@ -package assert - -// Assertions provides assertion methods around the -// TestingT interface. -type Assertions struct { - t TestingT -} - -// New makes a new Assertions object for the specified TestingT. -func New(t TestingT) *Assertions { - return &Assertions{ - t: t, - } -} - -//go:generate go run ../_codegen/main.go -output-package=assert -template=assertion_forward.go.tmpl -include-format-funcs diff --git a/vendor/github.com/stretchr/testify/assert/http_assertions.go b/vendor/github.com/stretchr/testify/assert/http_assertions.go deleted file mode 100644 index df46fa777..000000000 --- a/vendor/github.com/stretchr/testify/assert/http_assertions.go +++ /dev/null @@ -1,143 +0,0 @@ -package assert - -import ( - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "strings" -) - -// httpCode is a helper that returns HTTP code of the response. It returns -1 and -// an error if building a new request fails. -func httpCode(handler http.HandlerFunc, method, url string, values url.Values) (int, error) { - w := httptest.NewRecorder() - req, err := http.NewRequest(method, url, nil) - if err != nil { - return -1, err - } - req.URL.RawQuery = values.Encode() - handler(w, req) - return w.Code, nil -} - -// HTTPSuccess asserts that a specified handler returns a success status code. -// -// assert.HTTPSuccess(t, myHandler, "POST", "http://www.google.com", nil) -// -// Returns whether the assertion was successful (true) or not (false). -func HTTPSuccess(t TestingT, handler http.HandlerFunc, method, url string, values url.Values, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - code, err := httpCode(handler, method, url, values) - if err != nil { - Fail(t, fmt.Sprintf("Failed to build test request, got error: %s", err)) - return false - } - - isSuccessCode := code >= http.StatusOK && code <= http.StatusPartialContent - if !isSuccessCode { - Fail(t, fmt.Sprintf("Expected HTTP success status code for %q but received %d", url+"?"+values.Encode(), code)) - } - - return isSuccessCode -} - -// HTTPRedirect asserts that a specified handler returns a redirect status code. -// -// assert.HTTPRedirect(t, myHandler, "GET", "/a/b/c", url.Values{"a": []string{"b", "c"}} -// -// Returns whether the assertion was successful (true) or not (false). -func HTTPRedirect(t TestingT, handler http.HandlerFunc, method, url string, values url.Values, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - code, err := httpCode(handler, method, url, values) - if err != nil { - Fail(t, fmt.Sprintf("Failed to build test request, got error: %s", err)) - return false - } - - isRedirectCode := code >= http.StatusMultipleChoices && code <= http.StatusTemporaryRedirect - if !isRedirectCode { - Fail(t, fmt.Sprintf("Expected HTTP redirect status code for %q but received %d", url+"?"+values.Encode(), code)) - } - - return isRedirectCode -} - -// HTTPError asserts that a specified handler returns an error status code. -// -// assert.HTTPError(t, myHandler, "POST", "/a/b/c", url.Values{"a": []string{"b", "c"}} -// -// Returns whether the assertion was successful (true) or not (false). -func HTTPError(t TestingT, handler http.HandlerFunc, method, url string, values url.Values, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - code, err := httpCode(handler, method, url, values) - if err != nil { - Fail(t, fmt.Sprintf("Failed to build test request, got error: %s", err)) - return false - } - - isErrorCode := code >= http.StatusBadRequest - if !isErrorCode { - Fail(t, fmt.Sprintf("Expected HTTP error status code for %q but received %d", url+"?"+values.Encode(), code)) - } - - return isErrorCode -} - -// HTTPBody is a helper that returns HTTP body of the response. It returns -// empty string if building a new request fails. -func HTTPBody(handler http.HandlerFunc, method, url string, values url.Values) string { - w := httptest.NewRecorder() - req, err := http.NewRequest(method, url+"?"+values.Encode(), nil) - if err != nil { - return "" - } - handler(w, req) - return w.Body.String() -} - -// HTTPBodyContains asserts that a specified handler returns a -// body that contains a string. -// -// assert.HTTPBodyContains(t, myHandler, "GET", "www.google.com", nil, "I'm Feeling Lucky") -// -// Returns whether the assertion was successful (true) or not (false). -func HTTPBodyContains(t TestingT, handler http.HandlerFunc, method, url string, values url.Values, str interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - body := HTTPBody(handler, method, url, values) - - contains := strings.Contains(body, fmt.Sprint(str)) - if !contains { - Fail(t, fmt.Sprintf("Expected response body for \"%s\" to contain \"%s\" but found \"%s\"", url+"?"+values.Encode(), str, body)) - } - - return contains -} - -// HTTPBodyNotContains asserts that a specified handler returns a -// body that does not contain a string. -// -// assert.HTTPBodyNotContains(t, myHandler, "GET", "www.google.com", nil, "I'm Feeling Lucky") -// -// Returns whether the assertion was successful (true) or not (false). -func HTTPBodyNotContains(t TestingT, handler http.HandlerFunc, method, url string, values url.Values, str interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - body := HTTPBody(handler, method, url, values) - - contains := strings.Contains(body, fmt.Sprint(str)) - if contains { - Fail(t, fmt.Sprintf("Expected response body for \"%s\" to NOT contain \"%s\" but found \"%s\"", url+"?"+values.Encode(), str, body)) - } - - return !contains -} diff --git a/vendor/modules.txt b/vendor/modules.txt deleted file mode 100644 index cca25c04d..000000000 --- a/vendor/modules.txt +++ /dev/null @@ -1,22 +0,0 @@ -# github.com/davecgh/go-spew v1.1.1 -## explicit -github.com/davecgh/go-spew/spew -# github.com/go-test/deep v1.0.4 -## explicit -github.com/go-test/deep -# github.com/google/go-cmp v0.5.7 -## explicit -github.com/google/go-cmp/cmp -github.com/google/go-cmp/cmp/internal/diff -github.com/google/go-cmp/cmp/internal/flags -github.com/google/go-cmp/cmp/internal/function -github.com/google/go-cmp/cmp/internal/value -# github.com/gorilla/websocket v1.4.2 -## explicit -github.com/gorilla/websocket -# github.com/pmezard/go-difflib v1.0.0 -## explicit -github.com/pmezard/go-difflib/difflib -# github.com/stretchr/testify v1.2.2 -## explicit -github.com/stretchr/testify/assert From c03f7444c5e45a57525d08381134ef5b86ac3457 Mon Sep 17 00:00:00 2001 From: Daniel Levi-Minzi Date: Fri, 22 Apr 2022 09:06:02 -0400 Subject: [PATCH 061/606] remove ListRemoteFileParameters from return --- remotefiles.go | 8 ++++---- remotefiles_test.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/remotefiles.go b/remotefiles.go index a4f61d7da..370929884 100644 --- a/remotefiles.go +++ b/remotefiles.go @@ -130,12 +130,12 @@ func (api *Client) AddRemoteFileContext(ctx context.Context, params RemoteFilePa } // ListRemoteFiles retrieves all remote files according to the parameters given. Uses cursor based pagination. -func (api *Client) ListRemoteFiles(params ListRemoteFilesParameters) ([]RemoteFile, *ListRemoteFilesParameters, error) { +func (api *Client) ListRemoteFiles(params ListRemoteFilesParameters) ([]RemoteFile, error) { return api.ListRemoteFilesContext(context.Background(), params) } // ListRemoteFilesContext retrieves all remote files according to the parameters given with a custom context. Uses cursor based pagination. -func (api *Client) ListRemoteFilesContext(ctx context.Context, params ListRemoteFilesParameters) ([]RemoteFile, *ListRemoteFilesParameters, error) { +func (api *Client) ListRemoteFilesContext(ctx context.Context, params ListRemoteFilesParameters) ([]RemoteFile, error) { values := url.Values{ "token": {api.token}, } @@ -157,12 +157,12 @@ func (api *Client) ListRemoteFilesContext(ctx context.Context, params ListRemote response, err := api.remoteFileRequest(ctx, "files.remote.list", values) if err != nil { - return nil, nil, err + return nil, err } params.Cursor = response.SlackResponse.ResponseMetadata.Cursor - return response.Files, ¶ms, nil + return response.Files, nil } // GetRemoteFileInfo retrieves the complete remote file information. diff --git a/remotefiles_test.go b/remotefiles_test.go index 68e12a38d..4744b5cbe 100644 --- a/remotefiles_test.go +++ b/remotefiles_test.go @@ -52,7 +52,7 @@ func TestListRemoteFile(t *testing.T) { once.Do(startServer) api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) params := ListRemoteFilesParameters{} - if _, _, err := api.ListRemoteFiles(params); err != nil { + if _, err := api.ListRemoteFiles(params); err != nil { t.Errorf("Unexpected error: %s", err) } } From 253ffb4bb1e20f4ea96dd24885912cea916bf543 Mon Sep 17 00:00:00 2001 From: Justin Judd Date: Fri, 22 Apr 2022 16:39:29 -0700 Subject: [PATCH 062/606] Accepting suggestion to change Details field PrevValue -> PreviousValue Co-authored-by: Naoki Kanatani --- audit.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/audit.go b/audit.go index df4efb44f..f545298be 100644 --- a/audit.go +++ b/audit.go @@ -41,7 +41,7 @@ type AuditEntry struct { } `json:"context"` Details struct { NewValue interface{} `json:"new_value"` - PrevValue interface{} `json:"previous_value"` + PreviousValue interface{} `json:"previous_value"` PolicyName string `json:"policy_name"` AuthenticationMode string `json:"authentication_mode"` EntityIds string `json:"entity_ids"` From e2b4a3f2d4914222c5d46b323c777b990389f4a7 Mon Sep 17 00:00:00 2001 From: Justin Judd Date: Sat, 23 Apr 2022 00:03:13 +0000 Subject: [PATCH 063/606] Removing details fields that aren't in java and python sdk libs. --- audit.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/audit.go b/audit.go index f545298be..9153ad08d 100644 --- a/audit.go +++ b/audit.go @@ -42,14 +42,9 @@ type AuditEntry struct { Details struct { NewValue interface{} `json:"new_value"` PreviousValue interface{} `json:"previous_value"` - PolicyName string `json:"policy_name"` - AuthenticationMode string `json:"authentication_mode"` - EntityIds string `json:"entity_ids"` MobileOnly bool `json:"mobile_only"` WebOnly bool `json:"web_only"` NonSSOOnly bool `json:"non_sso_only"` - SucceededUsers string `json:"succeeded_users"` - FailedUsers string `json:"failed_users"` ExportType string `json:"export_type"` ExportStart string `json:"export_start_ts"` ExportEnd string `json:"export_end_ts"` From 4dbc82c6590a8c11bfd0b82fe428df39b539e44b Mon Sep 17 00:00:00 2001 From: Justin Judd Date: Sat, 23 Apr 2022 00:04:19 +0000 Subject: [PATCH 064/606] go fmt --- audit.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/audit.go b/audit.go index 9153ad08d..a3ea7ebdf 100644 --- a/audit.go +++ b/audit.go @@ -40,14 +40,14 @@ type AuditEntry struct { IPAddress string `json:"ip_address"` } `json:"context"` Details struct { - NewValue interface{} `json:"new_value"` - PreviousValue interface{} `json:"previous_value"` - MobileOnly bool `json:"mobile_only"` - WebOnly bool `json:"web_only"` - NonSSOOnly bool `json:"non_sso_only"` - ExportType string `json:"export_type"` - ExportStart string `json:"export_start_ts"` - ExportEnd string `json:"export_end_ts"` + NewValue interface{} `json:"new_value"` + PreviousValue interface{} `json:"previous_value"` + MobileOnly bool `json:"mobile_only"` + WebOnly bool `json:"web_only"` + NonSSOOnly bool `json:"non_sso_only"` + ExportType string `json:"export_type"` + ExportStart string `json:"export_start_ts"` + ExportEnd string `json:"export_end_ts"` } `json:"details"` } From 97e1ddeeeff5ae1542daa64aa31543bb7aa07390 Mon Sep 17 00:00:00 2001 From: Daniel Levi-Minzi Date: Sat, 23 Apr 2022 13:34:20 -0400 Subject: [PATCH 065/606] updated documentation --- remotefiles.go | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/remotefiles.go b/remotefiles.go index 370929884..8a908a8f3 100644 --- a/remotefiles.go +++ b/remotefiles.go @@ -17,7 +17,8 @@ const ( ) // RemoteFile contains all the information for a remote file -// More information can be found here: https://api.slack.com/messaging/files/remote +// For more details: +// https://api.slack.com/messaging/files/remote type RemoteFile struct { ID string `json:"id"` Created JSONTime `json:"created"` @@ -55,7 +56,8 @@ type RemoteFile struct { // ExternalID is a user defined GUID, ExternalURL is where the remote file can be accessed, // and Title is the name of the file. // -// More information can be found here: https://api.slack.com/methods/files.remote.add +// For more details: +// https://api.slack.com/methods/files.remote.add type RemoteFileParameters struct { ExternalID string // required ExternalURL string // required @@ -66,6 +68,9 @@ type RemoteFileParameters struct { PreviewImageReader io.Reader } +// ListRemoteFilesParameters contains arguments for the ListRemoteFiles method. +// For more details: +// https://api.slack.com/methods/files.remote.list type ListRemoteFilesParameters struct { Channel string Cursor string @@ -92,11 +97,14 @@ func (api *Client) remoteFileRequest(ctx context.Context, path string, values ur } // AddRemoteFile adds a remote file. Unlike regular files, remote files must be explicitly shared. +// For more details: +// https://api.slack.com/methods/files.remote.add func (api *Client) AddRemoteFile(params RemoteFileParameters) (*RemoteFile, error) { return api.AddRemoteFileContext(context.Background(), params) } // AddRemoteFileContext adds a remote file and setting a custom context +// For more details see the AddRemoteFile documentation. func (api *Client) AddRemoteFileContext(ctx context.Context, params RemoteFileParameters) (remotefile *RemoteFile, err error) { if params.ExternalID == "" || params.ExternalURL == "" || params.Title == "" { return nil, ErrParametersMissing @@ -130,11 +138,14 @@ func (api *Client) AddRemoteFileContext(ctx context.Context, params RemoteFilePa } // ListRemoteFiles retrieves all remote files according to the parameters given. Uses cursor based pagination. +// For more details: +// https://api.slack.com/methods/files.remote.list func (api *Client) ListRemoteFiles(params ListRemoteFilesParameters) ([]RemoteFile, error) { return api.ListRemoteFilesContext(context.Background(), params) } // ListRemoteFilesContext retrieves all remote files according to the parameters given with a custom context. Uses cursor based pagination. +// For more details see the ListRemoteFiles documentation. func (api *Client) ListRemoteFilesContext(ctx context.Context, params ListRemoteFilesParameters) ([]RemoteFile, error) { values := url.Values{ "token": {api.token}, @@ -166,11 +177,14 @@ func (api *Client) ListRemoteFilesContext(ctx context.Context, params ListRemote } // GetRemoteFileInfo retrieves the complete remote file information. +// For more details: +// https://api.slack.com/methods/files.remote.info func (api *Client) GetRemoteFileInfo(externalID, fileID string) (remotefile *RemoteFile, err error) { return api.GetRemoteFileInfoContext(context.Background(), externalID, fileID) } // GetRemoteFileInfoContext retrieves the complete remote file information given with a custom context. +// For more details see the GetRemoteFileInfo documentation. func (api *Client) GetRemoteFileInfoContext(ctx context.Context, externalID, fileID string) (remotefile *RemoteFile, err error) { if fileID == "" && externalID == "" { return nil, fmt.Errorf("either externalID or fileID is required") @@ -195,11 +209,14 @@ func (api *Client) GetRemoteFileInfoContext(ctx context.Context, externalID, fil } // ShareRemoteFile shares a remote file to channels +// For more details: +// https://api.slack.com/methods/files.remote.share func (api *Client) ShareRemoteFile(channels []string, externalID, fileID string) (file *RemoteFile, err error) { return api.ShareRemoteFileContext(context.Background(), channels, externalID, fileID) } -// ShareRemoteFileContext shares a remote file to channels with a custom context +// ShareRemoteFileContext shares a remote file to channels with a custom context. +// For more details see the ShareRemoteFile documentation. func (api *Client) ShareRemoteFileContext(ctx context.Context, channels []string, externalID, fileID string) (file *RemoteFile, err error) { if channels == nil || len(channels) == 0 { return nil, ErrParametersMissing @@ -225,11 +242,14 @@ func (api *Client) ShareRemoteFileContext(ctx context.Context, channels []string } // UpdateRemoteFile updates a remote file +// For more details: +// https://api.slack.com/methods/files.remote.update func (api *Client) UpdateRemoteFile(fileID string, params RemoteFileParameters) (remotefile *RemoteFile, err error) { return api.UpdateRemoteFileContext(context.Background(), fileID, params) } // UpdateRemoteFileContext updates a remote file with a custom context +// For more details see the UpdateRemoteFile documentation. func (api *Client) UpdateRemoteFileContext(ctx context.Context, fileID string, params RemoteFileParameters) (remotefile *RemoteFile, err error) { response := &remoteFileResponseFull{} values := url.Values{ @@ -266,12 +286,15 @@ func (api *Client) UpdateRemoteFileContext(ctx context.Context, fileID string, p return &response.RemoteFile, response.Err() } -// RemoveRemoteFile removes a remote file +// RemoveRemoteFile removes a remote file. +// For more details: +// https://api.slack.com/methods/files.remote.remove func (api *Client) RemoveRemoteFile(externalID, fileID string) (err error) { return api.RemoveRemoteFileContext(context.Background(), externalID, fileID) } // RemoveRemoteFileContext removes a remote file with a custom context +// For more information see the RemoveRemoteFiles documentation. func (api *Client) RemoveRemoteFileContext(ctx context.Context, externalID, fileID string) (err error) { if fileID == "" && externalID == "" { return fmt.Errorf("either externalID or fileID is required") From 48cd387cd1dad2bf81aca59a15467299a9b0cb6e Mon Sep 17 00:00:00 2001 From: Daniel Levi-Minzi Date: Sun, 24 Apr 2022 10:32:51 -0400 Subject: [PATCH 066/606] fixed broken example --- examples/remotefiles/remotefiles.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/remotefiles/remotefiles.go b/examples/remotefiles/remotefiles.go index b3d013f91..53569e19c 100644 --- a/examples/remotefiles/remotefiles.go +++ b/examples/remotefiles/remotefiles.go @@ -37,7 +37,7 @@ func main() { } fmt.Printf("share remote file %s successfully.\n", remotefile.Name) - remotefiles, _, err := api.ListRemoteFiles(slack.ListRemoteFilesParameters{ + remotefiles, err := api.ListRemoteFiles(slack.ListRemoteFilesParameters{ Channel: "YOUR_CHANNEL_HERE", }) if err != nil { From 439edc315288787a9532d9461d3fa112da8c2f76 Mon Sep 17 00:00:00 2001 From: Johan McGwire Date: Mon, 2 May 2022 19:30:27 -0400 Subject: [PATCH 067/606] Return errors upon missing parameters --- conversation.go | 8 +++++++- conversation_test.go | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/conversation.go b/conversation.go index 861d86aae..d3f94a6c0 100644 --- a/conversation.go +++ b/conversation.go @@ -5,6 +5,8 @@ import ( "net/url" "strconv" "strings" + + "github.com/pkg/errors" ) // Conversation is the foundation for IM and BaseGroupConversation @@ -372,7 +374,11 @@ func (api *Client) GetConversationInfo(input *GetConversationInfoInput) (*Channe // GetConversationInfoContext retrieves information about a conversation with a custom context func (api *Client) GetConversationInfoContext(ctx context.Context, input *GetConversationInfoInput) (*Channel, error) { if input == nil { - input = &GetConversationInfoInput{} + return nil, errors.New("GetConversationInfoInput must not be nil") + } + + if input.ChannelID == "" { + return nil, errors.New("ChannelID must be defined") } values := url.Values{ diff --git a/conversation_test.go b/conversation_test.go index fedba8f8b..990f72f68 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -413,6 +413,28 @@ func TestGetConversationInfo(t *testing.T) { } } +func TestGetConversationInfoNilError(t *testing.T) { + http.HandleFunc("/conversations.info", okChannelJsonHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + _, err := api.GetConversationInfo(nil) + if err == nil { + t.Errorf("Unexpected pass where there should have been nil input error") + return + } +} + +func TestGetConversationInfoMissingChannelError(t *testing.T) { + http.HandleFunc("/conversations.info", okChannelJsonHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + _, err := api.GetConversationInfo(&GetConversationInfoInput{}) + if err == nil { + t.Errorf("Unexpected pass where there should have been missing channel error") + return + } +} + func leaveConversationHandler(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "application/json") response, _ := json.Marshal(struct { From 6ee76f42bb1b37511fe07058730e3ffefd29499f Mon Sep 17 00:00:00 2001 From: Johan McGwire Date: Tue, 3 May 2022 10:37:18 -0400 Subject: [PATCH 068/606] Use built-in errors package --- conversation.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/conversation.go b/conversation.go index d3f94a6c0..c41be4ef7 100644 --- a/conversation.go +++ b/conversation.go @@ -2,11 +2,10 @@ package slack import ( "context" + "errors" "net/url" "strconv" "strings" - - "github.com/pkg/errors" ) // Conversation is the foundation for IM and BaseGroupConversation From ba92d3451f35a8ec4aa794920d0543612faa4c85 Mon Sep 17 00:00:00 2001 From: Johan McGwire Date: Sun, 8 May 2022 16:35:20 -0400 Subject: [PATCH 069/606] combine tests to handle http handler registration --- conversation_test.go | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/conversation_test.go b/conversation_test.go index 990f72f68..29f4b7742 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -411,24 +411,18 @@ func TestGetConversationInfo(t *testing.T) { t.Error("channel should not be nil") return } -} -func TestGetConversationInfoNilError(t *testing.T) { - http.HandleFunc("/conversations.info", okChannelJsonHandler) - once.Do(startServer) - api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - _, err := api.GetConversationInfo(nil) + // Nil Input Error + api = New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + _, err = api.GetConversationInfo(nil) if err == nil { t.Errorf("Unexpected pass where there should have been nil input error") return } -} -func TestGetConversationInfoMissingChannelError(t *testing.T) { - http.HandleFunc("/conversations.info", okChannelJsonHandler) - once.Do(startServer) - api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - _, err := api.GetConversationInfo(&GetConversationInfoInput{}) + // No Channel Error + api = New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + _, err = api.GetConversationInfo(&GetConversationInfoInput{}) if err == nil { t.Errorf("Unexpected pass where there should have been missing channel error") return From 4fe96f94b78cf0ddd170f894fa83794a40321a01 Mon Sep 17 00:00:00 2001 From: Giorgos Komninos Date: Mon, 9 May 2022 20:56:54 +0300 Subject: [PATCH 070/606] webhooks: discarding body after http requests in order to allow reusing the underlying connection --- webhooks.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/webhooks.go b/webhooks.go index 15097f03e..e7c12d348 100644 --- a/webhooks.go +++ b/webhooks.go @@ -5,6 +5,8 @@ import ( "context" "encoding/json" "fmt" + "io" + "io/ioutil" "net/http" ) @@ -51,7 +53,10 @@ func PostWebhookCustomHTTPContext(ctx context.Context, url string, httpClient *h if err != nil { return fmt.Errorf("failed to post webhook: %w", err) } - defer resp.Body.Close() + defer func() { + io.Copy(ioutil.Discard, resp.Body) + resp.Body.Close() + }() return checkStatusCode(resp, discard{}) } From f73cafc29f616e4ac725c83cfd22636064dbea65 Mon Sep 17 00:00:00 2001 From: youdofoo Date: Tue, 17 May 2022 10:44:30 +0900 Subject: [PATCH 071/606] Fix field types in SubteamMembersChangedEvent --- websocket_subteam.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/websocket_subteam.go b/websocket_subteam.go index a23b274cf..2f618733b 100644 --- a/websocket_subteam.go +++ b/websocket_subteam.go @@ -14,9 +14,9 @@ type SubteamMembersChangedEvent struct { DatePreviousUpdate JSONTime `json:"date_previous_update"` DateUpdate JSONTime `json:"date_update"` AddedUsers []string `json:"added_users"` - AddedUsersCount string `json:"added_users_count"` + AddedUsersCount int `json:"added_users_count"` RemovedUsers []string `json:"removed_users"` - RemovedUsersCount string `json:"removed_users_count"` + RemovedUsersCount int `json:"removed_users_count"` } // SubteamSelfAddedEvent represents an event of you have been added to a User Group From d77ddf50ddf95d3221ccd52e32b49d0e001521ec Mon Sep 17 00:00:00 2001 From: Antoan Date: Tue, 17 May 2022 23:12:41 +0100 Subject: [PATCH 072/606] Update README.md GetUserGroups expects a GetUserGroupsOption parameter, not a boolean one. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 39b04ce83..ce9216ef5 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ func main() { // If you set debugging, it will log all requests to the console // Useful when encountering issues // slack.New("YOUR_TOKEN_HERE", slack.OptionDebug(true)) - groups, err := api.GetUserGroups(false) + groups, err := api.GetUserGroups(slack.GetUserGroupsOptionIncludeUsers(false)) if err != nil { fmt.Printf("%s\n", err) return From 72873392757cbe9f1bf4499ca7e3e10b594e27d4 Mon Sep 17 00:00:00 2001 From: "whitesource-for-github-com[bot]" <50673670+whitesource-for-github-com[bot]@users.noreply.github.com> Date: Wed, 18 May 2022 14:54:38 +0000 Subject: [PATCH 073/606] Add .whitesource configuration file --- .whitesource | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .whitesource diff --git a/.whitesource b/.whitesource new file mode 100644 index 000000000..d5ea9ab85 --- /dev/null +++ b/.whitesource @@ -0,0 +1,3 @@ +{ + "settingsInheritedFrom": "improbable/whitesource-config@main" +} \ No newline at end of file From 487596387483c53b9ba9c08dd09d2300f6c23b1f Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Thu, 19 May 2022 17:15:31 +0200 Subject: [PATCH 074/606] Remove event details from unmarshalling error --- websocket_managed_conn.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/websocket_managed_conn.go b/websocket_managed_conn.go index 92536171f..5b2f895c9 100644 --- a/websocket_managed_conn.go +++ b/websocket_managed_conn.go @@ -475,7 +475,7 @@ func (rtm *RTM) handleEvent(typeStr string, event json.RawMessage) { v, exists := EventMapping[typeStr] if !exists { rtm.Debugf("RTM Error - received unmapped event %q: %s\n", typeStr, string(event)) - err := fmt.Errorf("RTM Error: Received unmapped event %q: %s", typeStr, string(event)) + err := fmt.Errorf("RTM Error: Received unmapped event %q", typeStr) rtm.IncomingEvents <- RTMEvent{"unmarshalling_error", &UnmarshallingErrorEvent{err}} return } @@ -484,7 +484,7 @@ func (rtm *RTM) handleEvent(typeStr string, event json.RawMessage) { err := json.Unmarshal(event, recvEvent) if err != nil { rtm.Debugf("RTM Error, could not unmarshall event %q: %s\n", typeStr, string(event)) - err := fmt.Errorf("RTM Error: Could not unmarshall event %q: %s", typeStr, string(event)) + err := fmt.Errorf("RTM Error: Could not unmarshall event %q", typeStr) rtm.IncomingEvents <- RTMEvent{"unmarshalling_error", &UnmarshallingErrorEvent{err}} return } From 86ba16d676d8f5573285a5c94893dbe63870fb91 Mon Sep 17 00:00:00 2001 From: John Reinstra Date: Thu, 26 May 2022 18:59:29 -0700 Subject: [PATCH 075/606] Add `Permalink` to Msg struct At least in the Slack list-stars method if not others, a permalink is included with each item returned. This permalink is not available via slack-go because it is not a field on the struct used to parse that response, so this change adds that missing field. --- messages.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/messages.go b/messages.go index 2cc31d5bf..1bcc2788a 100644 --- a/messages.go +++ b/messages.go @@ -131,6 +131,9 @@ type Msg struct { // Block type Message Blocks Blocks `json:"blocks,omitempty"` + + // permalink + Permalink string `json:"permalink,omitempty" } const ( From a6124c0673ce11f624847f34482b3514ab9a21c7 Mon Sep 17 00:00:00 2001 From: Jackie Xu Date: Thu, 19 May 2022 16:41:59 -0400 Subject: [PATCH 076/606] add ReplyBroadcast field to WebhookMessage --- webhooks.go | 1 + 1 file changed, 1 insertion(+) diff --git a/webhooks.go b/webhooks.go index e7c12d348..2f8fb47f6 100644 --- a/webhooks.go +++ b/webhooks.go @@ -23,6 +23,7 @@ type WebhookMessage struct { ResponseType string `json:"response_type,omitempty"` ReplaceOriginal bool `json:"replace_original,omitempty"` DeleteOriginal bool `json:"delete_original,omitempty"` + ReplyBroadcast bool `json:"reply_broadcast,omitempty"` } func PostWebhook(url string, msg *WebhookMessage) error { From ae04128037538113cae54fd2d6db3fea062236f6 Mon Sep 17 00:00:00 2001 From: Antoan Angelov Date: Thu, 2 Jun 2022 00:23:11 +0100 Subject: [PATCH 077/606] Expose StatusCodeError --- misc.go | 4 +--- misc_test.go | 6 ++---- socketmode/socket_mode_managed_conn.go | 3 +-- internal/misc/misc.go => status_code_error.go | 2 +- websocket_managed_conn.go | 3 +-- 5 files changed, 6 insertions(+), 12 deletions(-) rename internal/misc/misc.go => status_code_error.go (97%) diff --git a/misc.go b/misc.go index 804724d7e..bb99f2c42 100644 --- a/misc.go +++ b/misc.go @@ -18,8 +18,6 @@ import ( "strconv" "strings" "time" - - "github.com/slack-go/slack/internal/misc" ) // SlackResponse handles parsing out errors from the web api. @@ -299,7 +297,7 @@ func checkStatusCode(resp *http.Response, d Debug) error { // Slack seems to send an HTML body along with 5xx error codes. Don't parse it. if resp.StatusCode != http.StatusOK { logResponse(resp, d) - return misc.StatusCodeError{Code: resp.StatusCode, Status: resp.Status} + return StatusCodeError{Code: resp.StatusCode, Status: resp.Status} } return nil diff --git a/misc_test.go b/misc_test.go index 4dd2a6378..637cb77fb 100644 --- a/misc_test.go +++ b/misc_test.go @@ -8,8 +8,6 @@ import ( "sync" "testing" - "github.com/slack-go/slack/internal/misc" - "github.com/slack-go/slack/slackutilsx" ) @@ -94,8 +92,8 @@ func TestParseResponseInvalidToken(t *testing.T) { func TestRetryable(t *testing.T) { for _, e := range []error{ &RateLimitedError{}, - misc.StatusCodeError{Code: http.StatusInternalServerError}, - misc.StatusCodeError{Code: http.StatusTooManyRequests}, + StatusCodeError{Code: http.StatusInternalServerError}, + StatusCodeError{Code: http.StatusTooManyRequests}, } { r, ok := e.(slackutilsx.Retryable) if !ok { diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index 7caad5b4f..88937a571 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -15,7 +15,6 @@ import ( "github.com/slack-go/slack" "github.com/slack-go/slack/internal/backoff" - "github.com/slack-go/slack/internal/misc" "github.com/slack-go/slack/internal/timex" "github.com/slack-go/slack/slackevents" ) @@ -213,7 +212,7 @@ func (smc *Client) connect(ctx context.Context, connectionCount int, additionalP } switch actual := err.(type) { - case misc.StatusCodeError: + case StatusCodeError: if actual.Code == http.StatusNotFound { smc.Debugf("invalid auth when connecting with Socket Mode: %s", err) smc.Events <- newEvent(EventTypeInvalidAuth, &slack.InvalidAuthEvent{}) diff --git a/internal/misc/misc.go b/status_code_error.go similarity index 97% rename from internal/misc/misc.go rename to status_code_error.go index eab8cdd8c..7347137aa 100644 --- a/internal/misc/misc.go +++ b/status_code_error.go @@ -1,4 +1,4 @@ -package misc +package slack import ( "fmt" diff --git a/websocket_managed_conn.go b/websocket_managed_conn.go index 92536171f..1da5c91b1 100644 --- a/websocket_managed_conn.go +++ b/websocket_managed_conn.go @@ -13,7 +13,6 @@ import ( "github.com/slack-go/slack/internal/backoff" "github.com/slack-go/slack/internal/errorsx" - "github.com/slack-go/slack/internal/misc" "github.com/slack-go/slack/internal/timex" ) @@ -127,7 +126,7 @@ func (rtm *RTM) connect(connectionCount int, useRTMStart bool) (*Info, *websocke } switch actual := err.(type) { - case misc.StatusCodeError: + case StatusCodeError: if actual.Code == http.StatusNotFound { rtm.Debugf("invalid auth when connecting with RTM: %s", err) rtm.IncomingEvents <- RTMEvent{"invalid_auth", &InvalidAuthEvent{}} From d3e1f35559ea4f0858927132676f47c80a9b40d2 Mon Sep 17 00:00:00 2001 From: Antoan Angelov Date: Thu, 2 Jun 2022 00:44:27 +0100 Subject: [PATCH 078/606] Use slack package for import --- socketmode/socket_mode_managed_conn.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index 88937a571..428999732 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -212,7 +212,7 @@ func (smc *Client) connect(ctx context.Context, connectionCount int, additionalP } switch actual := err.(type) { - case StatusCodeError: + case slack.StatusCodeError: if actual.Code == http.StatusNotFound { smc.Debugf("invalid auth when connecting with Socket Mode: %s", err) smc.Events <- newEvent(EventTypeInvalidAuth, &slack.InvalidAuthEvent{}) From dedc97cac6014e5338ae333a2acd2b2232c8316f Mon Sep 17 00:00:00 2001 From: Naoki Kanatani Date: Fri, 3 Jun 2022 07:24:33 +0900 Subject: [PATCH 079/606] fix: broken example --- examples/modal_users/users.go | 3 ++- workflow_step_test.go | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/modal_users/users.go b/examples/modal_users/users.go index c250bd8e2..578ee51f6 100644 --- a/examples/modal_users/users.go +++ b/examples/modal_users/users.go @@ -38,12 +38,13 @@ func main() { // Section with users select - this input will not be included in the view_submission's view.state.values, // but instead be sent as a "block_actions" request additionalInviteeText := slack.NewTextBlockObject(slack.PlainTextType, "Invitee from complete list of users", false, false) + additionalInviteeHintText := slack.NewTextBlockObject(slack.PlainTextType, "", false, false) additionalInviteeOption := slack.NewOptionsSelectBlockElement(slack.OptTypeUser, additionalInviteeText, "user") additionalInviteeSection := slack.NewSectionBlock(additionalInviteeText, nil, slack.NewAccessory(additionalInviteeOption)) // Input with users select - this input will be included in the view_submission's view.state.values // It can be fetched as for example "payload.View.State.Values["user"]["user"].SelectedUser" - additionalInviteeBlock := slack.NewInputBlock("user", additionalInviteeText, additionalInviteeOption) + additionalInviteeBlock := slack.NewInputBlock("user", additionalInviteeText, additionalInviteeHintText, additionalInviteeOption) checkboxTxt := slack.NewTextBlockObject(slack.PlainTextType, "Checkbox", false, false) checkboxOptions := createOptionBlockObjects([]string{"option 1", "option 2", "option 3"}, false) diff --git a/workflow_step_test.go b/workflow_step_test.go index daa3b6da2..f2d2eb733 100644 --- a/workflow_step_test.go +++ b/workflow_step_test.go @@ -124,6 +124,7 @@ func configModalBlocks() Blocks { inputBlock := NewInputBlock( IDSelectOptionBlock, NewTextBlockObject("plain_text", "Select an option", false, false), + NewTextBlockObject("plain_text", "Hint", false, false), selection, ) From b67cada290a467f0063b6a2bbdfa9c3b8bcfbf68 Mon Sep 17 00:00:00 2001 From: Naoki Kanatani Date: Fri, 3 Jun 2022 07:31:51 +0900 Subject: [PATCH 080/606] fix incorrect style --- slackevents/inner_events.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index e340798e3..9eb2aefd7 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -502,7 +502,7 @@ const ( // EmojiChanged A custom emoji has been added or changed EmojiChanged = EventsAPIType("emoji_changed") // WorkflowStepExecute Happens, if a workflow step of your app is invoked - WorkflowStepExecute = EventsAPIType("workflow_step_execute") + WorkflowStepExecute = EventsAPIType("workflow_step_execute") ) // EventsAPIInnerEventMapping maps INNER Event API events to their corresponding struct From 97df436454dbdc75e0e578e767ba6ceb76601443 Mon Sep 17 00:00:00 2001 From: Alvin See Date: Sat, 4 Jun 2022 16:21:00 +1000 Subject: [PATCH 081/606] parse app_installed_team_id during view_submision and view_closed events --- interactions_test.go | 8 ++++++-- views.go | 37 +++++++++++++++++++------------------ 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/interactions_test.go b/interactions_test.go index 1e91f4b83..f492b1fd0 100644 --- a/interactions_test.go +++ b/interactions_test.go @@ -68,7 +68,8 @@ const ( "text": "Select a date" } } - }] + }], + "app_installed_team_id": "T1ABCD2E12" }, "api_app_id": "A123ABC", "is_cleared": false @@ -137,7 +138,8 @@ const ( } } } - } + }, + "app_installed_team_id": "T1ABCD2E12" }, "hash": "156663117.cd33ad1f", "response_urls": [ @@ -221,6 +223,7 @@ func TestViewClosedck(t *testing.T) { ), }, }, + AppInstalledTeamID: "T1ABCD2E12", }, APIAppID: "A123ABC", } @@ -297,6 +300,7 @@ func TestViewSubmissionCallback(t *testing.T) { }, }, }, + AppInstalledTeamID: "T1ABCD2E12", }, ViewSubmissionCallback: ViewSubmissionCallback{ Hash: "156663117.cd33ad1f", diff --git a/views.go b/views.go index a3a1bd056..e6a961788 100644 --- a/views.go +++ b/views.go @@ -18,24 +18,25 @@ type ViewState struct { type View struct { SlackResponse - ID string `json:"id"` - TeamID string `json:"team_id"` - Type ViewType `json:"type"` - Title *TextBlockObject `json:"title"` - Close *TextBlockObject `json:"close"` - Submit *TextBlockObject `json:"submit"` - Blocks Blocks `json:"blocks"` - PrivateMetadata string `json:"private_metadata"` - CallbackID string `json:"callback_id"` - State *ViewState `json:"state"` - Hash string `json:"hash"` - ClearOnClose bool `json:"clear_on_close"` - NotifyOnClose bool `json:"notify_on_close"` - RootViewID string `json:"root_view_id"` - PreviousViewID string `json:"previous_view_id"` - AppID string `json:"app_id"` - ExternalID string `json:"external_id"` - BotID string `json:"bot_id"` + ID string `json:"id"` + TeamID string `json:"team_id"` + Type ViewType `json:"type"` + Title *TextBlockObject `json:"title"` + Close *TextBlockObject `json:"close"` + Submit *TextBlockObject `json:"submit"` + Blocks Blocks `json:"blocks"` + PrivateMetadata string `json:"private_metadata"` + CallbackID string `json:"callback_id"` + State *ViewState `json:"state"` + Hash string `json:"hash"` + ClearOnClose bool `json:"clear_on_close"` + NotifyOnClose bool `json:"notify_on_close"` + RootViewID string `json:"root_view_id"` + PreviousViewID string `json:"previous_view_id"` + AppID string `json:"app_id"` + ExternalID string `json:"external_id"` + BotID string `json:"bot_id"` + AppInstalledTeamID string `json:"app_installed_team_id"` } type ViewSubmissionCallbackResponseURL struct { From ef6483ec7f47837d246c469e63166ea109dba6a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vilius=20Pu=C5=A1kunalis?= <47086537+puskunalis@users.noreply.github.com> Date: Sun, 5 Jun 2022 12:30:01 +0300 Subject: [PATCH 082/606] Add missing conversion to EventsAPIType --- slackevents/inner_events.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 9eb2aefd7..02767eb8f 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -464,7 +464,7 @@ const ( // ChannelRename is sent when a channel is rename. ChannelRename = EventsAPIType("channel_rename") // ChannelIDChanged is sent when a channel identifier is changed. - ChannelIDChanged = "channel_id_changed" + ChannelIDChanged = EventsAPIType("channel_id_changed") // GroupDeleted is sent when a group is deleted. GroupDeleted = EventsAPIType("group_deleted") // GroupArchive is sent when a group is archived. From 5a1b34cd6848247d4100d577e2bc6c490fe5178b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vilius=20Pu=C5=A1kunalis?= <47086537+puskunalis@users.noreply.github.com> Date: Sun, 5 Jun 2022 14:16:57 +0300 Subject: [PATCH 083/606] Add missing panic in socket mode handler example --- examples/socketmode_handler/socketmode_handler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/socketmode_handler/socketmode_handler.go b/examples/socketmode_handler/socketmode_handler.go index 9991e8c3c..221c3ea23 100644 --- a/examples/socketmode_handler/socketmode_handler.go +++ b/examples/socketmode_handler/socketmode_handler.go @@ -15,7 +15,7 @@ import ( func main() { appToken := os.Getenv("SLACK_APP_TOKEN") if appToken == "" { - + panic("SLACK_APP_TOKEN must be set.\n") } if !strings.HasPrefix(appToken, "xapp-") { From c473c5a08b8d4dc7a4324ff096f5a8e44cfda73c Mon Sep 17 00:00:00 2001 From: John Reinstra Date: Mon, 6 Jun 2022 13:06:07 -0700 Subject: [PATCH 084/606] Update messages.go Co-authored-by: Naoki Kanatani --- messages.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages.go b/messages.go index 1bcc2788a..f7a35fe0f 100644 --- a/messages.go +++ b/messages.go @@ -133,7 +133,7 @@ type Msg struct { Blocks Blocks `json:"blocks,omitempty"` // permalink - Permalink string `json:"permalink,omitempty" + Permalink string `json:"permalink,omitempty"` } const ( From a9160dd2a0127fb34e4256764420385ec7c9bdfe Mon Sep 17 00:00:00 2001 From: Antony Lucisano Date: Tue, 7 Jun 2022 15:54:19 +0100 Subject: [PATCH 085/606] added team ID as an additional option as this is required for Organization level API calls used in GRID --- users.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/users.go b/users.go index 873115690..62f2a7726 100644 --- a/users.go +++ b/users.go @@ -292,6 +292,13 @@ func GetUsersOptionPresence(n bool) GetUsersOption { } } +// GetUsersOptionTeamID include team_id +func GetUsersOptionTeamID(team_id string) GetUsersOption { + return func(p *UserPagination) { + p.team_id = team_id + } +} + func newUserPagination(c *Client, options ...GetUsersOption) (up UserPagination) { up = UserPagination{ c: c, @@ -310,6 +317,7 @@ type UserPagination struct { Users []User limit int presence bool + team_id string previousResp *ResponseMetadata c *Client } @@ -344,6 +352,7 @@ func (t UserPagination) Next(ctx context.Context) (_ UserPagination, err error) "presence": {strconv.FormatBool(t.presence)}, "token": {t.c.token}, "cursor": {t.previousResp.Cursor}, + "team_id": {t.team_id}, "include_locale": {strconv.FormatBool(true)}, } @@ -364,13 +373,13 @@ func (api *Client) GetUsersPaginated(options ...GetUsersOption) UserPagination { } // GetUsers returns the list of users (with their detailed information) -func (api *Client) GetUsers() ([]User, error) { - return api.GetUsersContext(context.Background()) +func (api *Client) GetUsers(options ...GetUsersOption) ([]User, error) { + return api.GetUsersContext(context.Background(), options...) } // GetUsersContext returns the list of users (with their detailed information) with a custom context -func (api *Client) GetUsersContext(ctx context.Context) (results []User, err error) { - p := api.GetUsersPaginated() +func (api *Client) GetUsersContext(ctx context.Context, options ...GetUsersOption) (results []User, err error) { + p := api.GetUsersPaginated(options...) for err == nil { p, err = p.Next(ctx) if err == nil { From 24179ef698a43828356e29de65a3424d0c6ca5da Mon Sep 17 00:00:00 2001 From: Antony Lucisano Date: Wed, 8 Jun 2022 11:15:23 +0100 Subject: [PATCH 086/606] added to git ignore --- .gitignore | 1 + .whitesource | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 .whitesource diff --git a/.gitignore b/.gitignore index ac6f3eeb3..dd0263f2a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.test *~ .idea/ +.whitesource diff --git a/.whitesource b/.whitesource deleted file mode 100644 index d5ea9ab85..000000000 --- a/.whitesource +++ /dev/null @@ -1,3 +0,0 @@ -{ - "settingsInheritedFrom": "improbable/whitesource-config@main" -} \ No newline at end of file From 9579c4354936ce4fd9aaa2e1f5db18175cf1a4cc Mon Sep 17 00:00:00 2001 From: "mend-for-github-com[bot]" <50673670+mend-for-github-com[bot]@users.noreply.github.com> Date: Wed, 8 Jun 2022 10:15:54 +0000 Subject: [PATCH 087/606] Add .whitesource configuration file --- .whitesource | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .whitesource diff --git a/.whitesource b/.whitesource new file mode 100644 index 000000000..d5ea9ab85 --- /dev/null +++ b/.whitesource @@ -0,0 +1,3 @@ +{ + "settingsInheritedFrom": "improbable/whitesource-config@main" +} \ No newline at end of file From 242318ef9892a7d3e24d901c6c5d26142c80015d Mon Sep 17 00:00:00 2001 From: Antony Lucisano Date: Wed, 8 Jun 2022 11:19:08 +0100 Subject: [PATCH 088/606] removed2 --- .whitesource | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 .whitesource diff --git a/.whitesource b/.whitesource deleted file mode 100644 index d5ea9ab85..000000000 --- a/.whitesource +++ /dev/null @@ -1,3 +0,0 @@ -{ - "settingsInheritedFrom": "improbable/whitesource-config@main" -} \ No newline at end of file From 46f2dffdd87c4f8b00c532c53e30704ca0fa90d4 Mon Sep 17 00:00:00 2001 From: Antony Lucisano Date: Wed, 8 Jun 2022 11:19:47 +0100 Subject: [PATCH 089/606] removed from gitignore --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index dd0263f2a..8ed91d521 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ *.test *~ -.idea/ -.whitesource +.idea/ \ No newline at end of file From 6ad7e3b228c7b651e9ba5007798dec828e684ce2 Mon Sep 17 00:00:00 2001 From: Antony Lucisano Date: Wed, 8 Jun 2022 11:20:04 +0100 Subject: [PATCH 090/606] removed from gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8ed91d521..ac6f3eeb3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ *.test *~ -.idea/ \ No newline at end of file +.idea/ From 9b406ddb615174c40bfb723fbcb37d7988c0cca5 Mon Sep 17 00:00:00 2001 From: Antony Lucisano Date: Mon, 13 Jun 2022 10:14:17 +0100 Subject: [PATCH 091/606] camel case --- users.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/users.go b/users.go index 62f2a7726..d22b44f8c 100644 --- a/users.go +++ b/users.go @@ -292,10 +292,10 @@ func GetUsersOptionPresence(n bool) GetUsersOption { } } -// GetUsersOptionTeamID include team_id -func GetUsersOptionTeamID(team_id string) GetUsersOption { +// GetUsersOptionTeamID include team Id +func GetUsersOptionTeamID(teamId string) GetUsersOption { return func(p *UserPagination) { - p.team_id = team_id + p.teamId = teamId } } @@ -317,7 +317,7 @@ type UserPagination struct { Users []User limit int presence bool - team_id string + teamId string previousResp *ResponseMetadata c *Client } @@ -352,7 +352,7 @@ func (t UserPagination) Next(ctx context.Context) (_ UserPagination, err error) "presence": {strconv.FormatBool(t.presence)}, "token": {t.c.token}, "cursor": {t.previousResp.Cursor}, - "team_id": {t.team_id}, + "team_id": {t.teamId}, "include_locale": {strconv.FormatBool(true)}, } From 5547165d166c93d2e81233e514a3d9876b437d26 Mon Sep 17 00:00:00 2001 From: tanaka Date: Mon, 2 May 2022 23:54:23 +0900 Subject: [PATCH 092/606] add: workflows.stepCompleted and workflwows.stepFailed --- workflow_step_execute.go | 74 +++++++++++++++++++++++++++++++++++ workflow_step_execute_test.go | 35 +++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 workflow_step_execute.go create mode 100644 workflow_step_execute_test.go diff --git a/workflow_step_execute.go b/workflow_step_execute.go new file mode 100644 index 000000000..de31b196f --- /dev/null +++ b/workflow_step_execute.go @@ -0,0 +1,74 @@ +package slack + +import ( + "context" + "encoding/json" +) + +type ( + WorkflowStepCompletedRequest struct { + WorkflowStepExecuteID string `json:"workflow_step_execute_id"` + Outputs *map[string]string `json:"outputs"` + } + + WorkflowStepFailedRequest struct { + WorkflowStepExecuteID string `json:"workflow_step_execute_id"` + Error struct { + Message string `json:"message"` + } `json:"error"` + } +) + +// WorkflowStepCompleted indicates step is completed +func (api *Client) WorkflowStepCompleted(workflowStepExecuteID string, outputs *map[string]string) error { + // More information: https://api.slack.com/methods/workflows.stepCompleted + r := WorkflowStepCompletedRequest{ + WorkflowStepExecuteID: workflowStepExecuteID, + } + if outputs != nil { + r.Outputs = outputs + } + + endpoint := api.endpoint + "workflows.stepCompleted" + jsonData, err := json.Marshal(r) + if err != nil { + return err + } + + response := &SlackResponse{} + if err := postJSON(context.Background(), api.httpclient, endpoint, api.token, jsonData, response, api); err != nil { + return err + } + + if !response.Ok { + return response.Err() + } + + return nil +} + +// WorkflowStepFailed indicates step is failed +func (api *Client) WorkflowStepFailed(workflowStepExecuteID string, errorMessage string) error { + // More information: https://api.slack.com/methods/workflows.stepFailed + r := WorkflowStepFailedRequest{ + WorkflowStepExecuteID: workflowStepExecuteID, + } + r.Error.Message = errorMessage + + endpoint := api.endpoint + "workflows.stepFailed" + jsonData, err := json.Marshal(r) + if err != nil { + return err + } + + response := &SlackResponse{} + if err := postJSON(context.Background(), api.httpclient, endpoint, api.token, jsonData, response, api); err != nil { + return err + } + + if !response.Ok { + return response.Err() + } + + return nil +} diff --git a/workflow_step_execute_test.go b/workflow_step_execute_test.go new file mode 100644 index 000000000..aae88f87e --- /dev/null +++ b/workflow_step_execute_test.go @@ -0,0 +1,35 @@ +package slack + +import ( + "encoding/json" + "net/http" + "testing" +) + +func workflowStepHandler(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(SlackResponse{ + Ok: true, + }) + rw.Write(response) +} + +func TestWorkflowStepCompleted(t *testing.T) { + http.HandleFunc("/workflows.stepCompleted", workflowStepHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + if err := api.WorkflowStepCompleted("executeID", nil); err != nil { + t.Errorf("Unexpected error: %s", err) + } +} + +func TestWorkflowStepFailed(t *testing.T) { + http.HandleFunc("/workflows.stepFailed", workflowStepHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + if err := api.WorkflowStepFailed("executeID", "error message"); err != nil { + t.Errorf("Unexpected error: %s", err) + } +} From 1035b22de5b578fd4a5c26b937b93a6e477ee6a7 Mon Sep 17 00:00:00 2001 From: John Reinstra Date: Mon, 13 Jun 2022 19:41:58 -0700 Subject: [PATCH 093/606] Update messages.go Co-authored-by: Naoki Kanatani --- messages.go | 1 - 1 file changed, 1 deletion(-) diff --git a/messages.go b/messages.go index f7a35fe0f..1a3110e44 100644 --- a/messages.go +++ b/messages.go @@ -131,7 +131,6 @@ type Msg struct { // Block type Message Blocks Blocks `json:"blocks,omitempty"` - // permalink Permalink string `json:"permalink,omitempty"` } From f55b7c92818a909c9f4ebc4c74bdbc84498eb0c5 Mon Sep 17 00:00:00 2001 From: tanaka Date: Mon, 13 Jun 2022 22:31:13 +0900 Subject: [PATCH 094/606] refactor: change WorkflowStepCompleted args --- workflow_step_execute.go | 17 ++++++++++++++--- workflow_step_execute_test.go | 2 +- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/workflow_step_execute.go b/workflow_step_execute.go index de31b196f..d1d77cbad 100644 --- a/workflow_step_execute.go +++ b/workflow_step_execute.go @@ -19,14 +19,25 @@ type ( } ) +type WorkflowStepCompletedRequestOption func(opt WorkflowStepCompletedRequest) error + +func WorkflowStepCompletedRequestOptionOutput(outputs map[string]string) WorkflowStepCompletedRequestOption { + return func(opt WorkflowStepCompletedRequest) error { + if len(outputs) > 0 { + opt.Outputs = &outputs + } + return nil + } +} + // WorkflowStepCompleted indicates step is completed -func (api *Client) WorkflowStepCompleted(workflowStepExecuteID string, outputs *map[string]string) error { +func (api *Client) WorkflowStepCompleted(workflowStepExecuteID string, options ...WorkflowStepCompletedRequestOption) error { // More information: https://api.slack.com/methods/workflows.stepCompleted r := WorkflowStepCompletedRequest{ WorkflowStepExecuteID: workflowStepExecuteID, } - if outputs != nil { - r.Outputs = outputs + for _, option := range options { + option(r) } endpoint := api.endpoint + "workflows.stepCompleted" diff --git a/workflow_step_execute_test.go b/workflow_step_execute_test.go index aae88f87e..60c21c8ec 100644 --- a/workflow_step_execute_test.go +++ b/workflow_step_execute_test.go @@ -19,7 +19,7 @@ func TestWorkflowStepCompleted(t *testing.T) { once.Do(startServer) api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - if err := api.WorkflowStepCompleted("executeID", nil); err != nil { + if err := api.WorkflowStepCompleted("executeID"); err != nil { t.Errorf("Unexpected error: %s", err) } } From 799e81a97795b070260817b35e2ab6f44a2591d9 Mon Sep 17 00:00:00 2001 From: h3rby7 Date: Wed, 22 Jun 2022 14:31:19 +0200 Subject: [PATCH 095/606] first steps to post metadata on chat.postMessage --- chat.go | 19 +++++++++++++++++++ messages.go | 3 +++ metadata.go | 7 +++++++ 3 files changed, 29 insertions(+) create mode 100644 metadata.go diff --git a/chat.go b/chat.go index 34848d151..1c498b2af 100644 --- a/chat.go +++ b/chat.go @@ -64,6 +64,9 @@ type PostMessageParameters struct { // chat.postEphemeral support Channel string `json:"channel"` User string `json:"user"` + + // chat metadata support + MetaData SlackMetadata `json:"metadata"` } // NewPostMessageParameters provides an instance of PostMessageParameters with all the sane default values set @@ -285,6 +288,7 @@ type sendConfig struct { endpoint string values url.Values attachments []Attachment + metadata SlackMetadata blocks Blocks responseType string replaceOriginal bool @@ -306,6 +310,7 @@ func (t sendConfig) BuildRequestContext(ctx context.Context, token, channelID st endpoint: t.endpoint, values: t.values, attachments: t.attachments, + metadata: t.metadata, blocks: t.blocks, responseType: t.responseType, replaceOriginal: t.replaceOriginal, @@ -336,6 +341,7 @@ type responseURLSender struct { endpoint string values url.Values attachments []Attachment + metadata SlackMetadata blocks Blocks responseType string replaceOriginal bool @@ -352,6 +358,7 @@ func (t responseURLSender) BuildRequestContext(ctx context.Context) (*http.Reque Timestamp: t.values.Get("ts"), Attachments: t.attachments, Blocks: t.blocks, + Metadata: t.metadata, ResponseType: t.responseType, ReplaceOriginal: t.replaceOriginal, DeleteOriginal: t.deleteOriginal, @@ -662,6 +669,18 @@ func MsgOptionIconEmoji(iconEmoji string) MsgOption { } } +// MsgOptionIconEmoji sets an icon emoji +func MsgOptionMetadata(metadata SlackMetadata) MsgOption { + return func(config *sendConfig) error { + config.metadata = metadata + meta, err := json.Marshal(metadata) + if err == nil { + config.values.Set("metadata", string(meta)) + } + return err + } +} + // UnsafeMsgOptionEndpoint deliver the message to the specified endpoint. // NOTE: USE AT YOUR OWN RISK: No issues relating to the use of this Option // will be supported by the library, it is subject to change without notice that diff --git a/messages.go b/messages.go index 1a3110e44..828fa6026 100644 --- a/messages.go +++ b/messages.go @@ -129,6 +129,9 @@ type Msg struct { ReplaceOriginal bool `json:"replace_original"` DeleteOriginal bool `json:"delete_original"` + // Block type Message + Metadata SlackMetadata `json:"metadata,omitempty"` + // Block type Message Blocks Blocks `json:"blocks,omitempty"` // permalink diff --git a/metadata.go b/metadata.go new file mode 100644 index 000000000..260471236 --- /dev/null +++ b/metadata.go @@ -0,0 +1,7 @@ +package slack + +// SlackMetadata https://api.slack.com/reference/metadata +type SlackMetadata struct { + EventType string `json:"event_type"` + EventPayload interface{} `json:"event_payload"` +} From 2a8832bf35614920fca9d22bb7bc4ed63119ec09 Mon Sep 17 00:00:00 2001 From: h3rby7 Date: Thu, 23 Jun 2022 14:53:36 +0200 Subject: [PATCH 096/606] add test for metadata sending, switch 'event_payload' from interface{} to map[string]interface{} to support proper JSON (un)marshalling --- chat.go | 2 +- chat_test.go | 18 +++++++++ metadata.go | 4 +- slackevents/inner_events.go | 73 ++++++++++++++++++++++--------------- 4 files changed, 65 insertions(+), 32 deletions(-) diff --git a/chat.go b/chat.go index 1c498b2af..4448e9f40 100644 --- a/chat.go +++ b/chat.go @@ -669,7 +669,7 @@ func MsgOptionIconEmoji(iconEmoji string) MsgOption { } } -// MsgOptionIconEmoji sets an icon emoji +// MsgOptionMetadata sets message metadata func MsgOptionMetadata(metadata SlackMetadata) MsgOption { return func(config *sendConfig) error { config.metadata = metadata diff --git a/chat_test.go b/chat_test.go index 40cea908b..524439520 100644 --- a/chat_test.go +++ b/chat_test.go @@ -109,6 +109,24 @@ func TestPostMessage(t *testing.T) { "token": []string{"testing-token"}, }, }, + "Metadata": { + endpoint: "/chat.postMessage", + opt: []MsgOption{ + MsgOptionMetadata( + SlackMetadata{ + EventType: "testing-event", + EventPayload: map[string]interface{}{ + "id": 13, + "name": "testing-name", + }, + }), + }, + expected: url.Values{ + "metadata": []string{`{"event_type":"testing-event","event_payload":{"id":13,"name":"testing-name"}}`}, + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + }, + }, "Unfurl": { endpoint: "/chat.unfurl", opt: []MsgOption{ diff --git a/metadata.go b/metadata.go index 260471236..a8c065046 100644 --- a/metadata.go +++ b/metadata.go @@ -2,6 +2,6 @@ package slack // SlackMetadata https://api.slack.com/reference/metadata type SlackMetadata struct { - EventType string `json:"event_type"` - EventPayload interface{} `json:"event_payload"` + EventType string `json:"event_type"` + EventPayload map[string]interface{} `json:"event_payload"` } diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 02767eb8f..f0118c5a3 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -234,6 +234,12 @@ type MessageEvent struct { Root *MessageEvent `json:"root"` } +type MessageMetadataEvent struct { + // TODO: not sure about the actual fields we get here! + Type string `json:"type"` + EventTimestamp string `json:"event_ts"` +} + // MemberJoinedChannelEvent A member joined a public or private channel type MemberJoinedChannelEvent struct { Type string `json:"type"` @@ -487,6 +493,12 @@ const ( MemberJoinedChannel = EventsAPIType("member_joined_channel") // Member Left Channel MemberLeftChannel = EventsAPIType("member_left_channel") + // Message metadata was deleted + MessageMetadataDeleted = EventsAPIType("message_metadata_deleted") + // Message metadata was posted + MessageMetadataPosted = EventsAPIType("message_metadata_posted") + // Message metadata was updated + MessageMetadataUpdated = EventsAPIType("message_metadata_updated") // PinAdded An item was pinned to a channel PinAdded = EventsAPIType("pin_added") // PinRemoved An item was unpinned from a channel @@ -509,33 +521,36 @@ const ( // implementations. The structs should be instances of the unmarshalling // target for the matching event type. var EventsAPIInnerEventMapping = map[EventsAPIType]interface{}{ - AppMention: AppMentionEvent{}, - AppHomeOpened: AppHomeOpenedEvent{}, - AppUninstalled: AppUninstalledEvent{}, - ChannelCreated: ChannelCreatedEvent{}, - ChannelDeleted: ChannelDeletedEvent{}, - ChannelArchive: ChannelArchiveEvent{}, - ChannelUnarchive: ChannelUnarchiveEvent{}, - ChannelLeft: ChannelLeftEvent{}, - ChannelRename: ChannelRenameEvent{}, - ChannelIDChanged: ChannelIDChangedEvent{}, - GroupDeleted: GroupDeletedEvent{}, - GroupArchive: GroupArchiveEvent{}, - GroupUnarchive: GroupUnarchiveEvent{}, - GroupLeft: GroupLeftEvent{}, - GroupRename: GroupRenameEvent{}, - GridMigrationFinished: GridMigrationFinishedEvent{}, - GridMigrationStarted: GridMigrationStartedEvent{}, - LinkShared: LinkSharedEvent{}, - Message: MessageEvent{}, - MemberJoinedChannel: MemberJoinedChannelEvent{}, - MemberLeftChannel: MemberLeftChannelEvent{}, - PinAdded: PinAddedEvent{}, - PinRemoved: PinRemovedEvent{}, - ReactionAdded: ReactionAddedEvent{}, - ReactionRemoved: ReactionRemovedEvent{}, - TeamJoin: TeamJoinEvent{}, - TokensRevoked: TokensRevokedEvent{}, - EmojiChanged: EmojiChangedEvent{}, - WorkflowStepExecute: WorkflowStepExecuteEvent{}, + AppMention: AppMentionEvent{}, + AppHomeOpened: AppHomeOpenedEvent{}, + AppUninstalled: AppUninstalledEvent{}, + ChannelCreated: ChannelCreatedEvent{}, + ChannelDeleted: ChannelDeletedEvent{}, + ChannelArchive: ChannelArchiveEvent{}, + ChannelUnarchive: ChannelUnarchiveEvent{}, + ChannelLeft: ChannelLeftEvent{}, + ChannelRename: ChannelRenameEvent{}, + ChannelIDChanged: ChannelIDChangedEvent{}, + GroupDeleted: GroupDeletedEvent{}, + GroupArchive: GroupArchiveEvent{}, + GroupUnarchive: GroupUnarchiveEvent{}, + GroupLeft: GroupLeftEvent{}, + GroupRename: GroupRenameEvent{}, + GridMigrationFinished: GridMigrationFinishedEvent{}, + GridMigrationStarted: GridMigrationStartedEvent{}, + LinkShared: LinkSharedEvent{}, + Message: MessageEvent{}, + MessageMetadataDeleted: MessageMetadataEvent{}, + MessageMetadataPosted: MessageMetadataEvent{}, + MessageMetadataUpdated: MessageMetadataEvent{}, + MemberJoinedChannel: MemberJoinedChannelEvent{}, + MemberLeftChannel: MemberLeftChannelEvent{}, + PinAdded: PinAddedEvent{}, + PinRemoved: PinRemovedEvent{}, + ReactionAdded: ReactionAddedEvent{}, + ReactionRemoved: ReactionRemovedEvent{}, + TeamJoin: TeamJoinEvent{}, + TokensRevoked: TokensRevokedEvent{}, + EmojiChanged: EmojiChangedEvent{}, + WorkflowStepExecute: WorkflowStepExecuteEvent{}, } From 33c3a9e70ae26e184c8da94dad0928b107589371 Mon Sep 17 00:00:00 2001 From: h3rby7 Date: Thu, 23 Jun 2022 14:56:18 +0200 Subject: [PATCH 097/606] RM 'metadata' events sections --- slackevents/inner_events.go | 73 +++++++++++++++---------------------- 1 file changed, 29 insertions(+), 44 deletions(-) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index f0118c5a3..02767eb8f 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -234,12 +234,6 @@ type MessageEvent struct { Root *MessageEvent `json:"root"` } -type MessageMetadataEvent struct { - // TODO: not sure about the actual fields we get here! - Type string `json:"type"` - EventTimestamp string `json:"event_ts"` -} - // MemberJoinedChannelEvent A member joined a public or private channel type MemberJoinedChannelEvent struct { Type string `json:"type"` @@ -493,12 +487,6 @@ const ( MemberJoinedChannel = EventsAPIType("member_joined_channel") // Member Left Channel MemberLeftChannel = EventsAPIType("member_left_channel") - // Message metadata was deleted - MessageMetadataDeleted = EventsAPIType("message_metadata_deleted") - // Message metadata was posted - MessageMetadataPosted = EventsAPIType("message_metadata_posted") - // Message metadata was updated - MessageMetadataUpdated = EventsAPIType("message_metadata_updated") // PinAdded An item was pinned to a channel PinAdded = EventsAPIType("pin_added") // PinRemoved An item was unpinned from a channel @@ -521,36 +509,33 @@ const ( // implementations. The structs should be instances of the unmarshalling // target for the matching event type. var EventsAPIInnerEventMapping = map[EventsAPIType]interface{}{ - AppMention: AppMentionEvent{}, - AppHomeOpened: AppHomeOpenedEvent{}, - AppUninstalled: AppUninstalledEvent{}, - ChannelCreated: ChannelCreatedEvent{}, - ChannelDeleted: ChannelDeletedEvent{}, - ChannelArchive: ChannelArchiveEvent{}, - ChannelUnarchive: ChannelUnarchiveEvent{}, - ChannelLeft: ChannelLeftEvent{}, - ChannelRename: ChannelRenameEvent{}, - ChannelIDChanged: ChannelIDChangedEvent{}, - GroupDeleted: GroupDeletedEvent{}, - GroupArchive: GroupArchiveEvent{}, - GroupUnarchive: GroupUnarchiveEvent{}, - GroupLeft: GroupLeftEvent{}, - GroupRename: GroupRenameEvent{}, - GridMigrationFinished: GridMigrationFinishedEvent{}, - GridMigrationStarted: GridMigrationStartedEvent{}, - LinkShared: LinkSharedEvent{}, - Message: MessageEvent{}, - MessageMetadataDeleted: MessageMetadataEvent{}, - MessageMetadataPosted: MessageMetadataEvent{}, - MessageMetadataUpdated: MessageMetadataEvent{}, - MemberJoinedChannel: MemberJoinedChannelEvent{}, - MemberLeftChannel: MemberLeftChannelEvent{}, - PinAdded: PinAddedEvent{}, - PinRemoved: PinRemovedEvent{}, - ReactionAdded: ReactionAddedEvent{}, - ReactionRemoved: ReactionRemovedEvent{}, - TeamJoin: TeamJoinEvent{}, - TokensRevoked: TokensRevokedEvent{}, - EmojiChanged: EmojiChangedEvent{}, - WorkflowStepExecute: WorkflowStepExecuteEvent{}, + AppMention: AppMentionEvent{}, + AppHomeOpened: AppHomeOpenedEvent{}, + AppUninstalled: AppUninstalledEvent{}, + ChannelCreated: ChannelCreatedEvent{}, + ChannelDeleted: ChannelDeletedEvent{}, + ChannelArchive: ChannelArchiveEvent{}, + ChannelUnarchive: ChannelUnarchiveEvent{}, + ChannelLeft: ChannelLeftEvent{}, + ChannelRename: ChannelRenameEvent{}, + ChannelIDChanged: ChannelIDChangedEvent{}, + GroupDeleted: GroupDeletedEvent{}, + GroupArchive: GroupArchiveEvent{}, + GroupUnarchive: GroupUnarchiveEvent{}, + GroupLeft: GroupLeftEvent{}, + GroupRename: GroupRenameEvent{}, + GridMigrationFinished: GridMigrationFinishedEvent{}, + GridMigrationStarted: GridMigrationStartedEvent{}, + LinkShared: LinkSharedEvent{}, + Message: MessageEvent{}, + MemberJoinedChannel: MemberJoinedChannelEvent{}, + MemberLeftChannel: MemberLeftChannelEvent{}, + PinAdded: PinAddedEvent{}, + PinRemoved: PinRemovedEvent{}, + ReactionAdded: ReactionAddedEvent{}, + ReactionRemoved: ReactionRemovedEvent{}, + TeamJoin: TeamJoinEvent{}, + TokensRevoked: TokensRevokedEvent{}, + EmojiChanged: EmojiChangedEvent{}, + WorkflowStepExecute: WorkflowStepExecuteEvent{}, } From c734cf8ca69f616162f7cbc69ee7791e54722e1b Mon Sep 17 00:00:00 2001 From: Martin Englund Date: Sat, 25 Jun 2022 13:28:39 -0700 Subject: [PATCH 098/606] sharedLinks struct should be exported --- slackevents/inner_events.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 02767eb8f..77b5e07a7 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -178,11 +178,11 @@ type LinkSharedEvent struct { // compose text area. MessageTimeStamp string `json:"message_ts"` ThreadTimeStamp string `json:"thread_ts"` - Links []sharedLinks `json:"links"` + Links []SharedLinks `json:"links"` EventTimestamp string `json:"event_ts"` } -type sharedLinks struct { +type SharedLinks struct { Domain string `json:"domain"` URL string `json:"url"` } From 672bb14130c17b721e53a511482f867fe4d55228 Mon Sep 17 00:00:00 2001 From: Martin Englund Date: Sat, 25 Jun 2022 13:46:06 -0700 Subject: [PATCH 099/606] add tests --- slackevents/inner_events_test.go | 69 +++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index 2a0ac56e8..cee2d211a 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -1,7 +1,8 @@ -package slackevents +package slackevents_test import ( "encoding/json" + "github.com/slack-go/slack/slackevents" "testing" ) @@ -20,7 +21,7 @@ func TestAppMention(t *testing.T) { "blah": "test" } `) - err := json.Unmarshal(rawE, &AppMentionEvent{}) + err := json.Unmarshal(rawE, &slackevents.AppMentionEvent{}) if err != nil { t.Error(err) } @@ -32,7 +33,7 @@ func TestAppUninstalled(t *testing.T) { "type": "app_uninstalled" } `) - err := json.Unmarshal(rawE, &AppUninstalledEvent{}) + err := json.Unmarshal(rawE, &slackevents.AppUninstalledEvent{}) if err != nil { t.Error(err) } @@ -45,7 +46,7 @@ func TestGridMigrationFinishedEvent(t *testing.T) { "enterprise_id": "EXXXXXXXX" } `) - err := json.Unmarshal(rawE, &GridMigrationFinishedEvent{}) + err := json.Unmarshal(rawE, &slackevents.GridMigrationFinishedEvent{}) if err != nil { t.Error(err) } @@ -66,7 +67,7 @@ func TestGridMigrationStartedEvent(t *testing.T) { "event_time": 1234567890 } `) - err := json.Unmarshal(rawE, &GridMigrationStartedEvent{}) + err := json.Unmarshal(rawE, &slackevents.GridMigrationStartedEvent{}) if err != nil { t.Error(err) } @@ -97,12 +98,40 @@ func TestLinkSharedEvent(t *testing.T) { ] } `) - err := json.Unmarshal(rawE, &LinkSharedEvent{}) + err := json.Unmarshal(rawE, &slackevents.LinkSharedEvent{}) if err != nil { t.Error(err) } } +func TestLinkSharedEvent_struct(t *testing.T) { + e := slackevents.LinkSharedEvent{ + Type: "link_shared", + User: "Uxxxxxxx", + TimeStamp: "123456789.9876", + Channel: "Cxxxxxx", + MessageTimeStamp: "123456789.9875", + ThreadTimeStamp: "123456789.9876", + Links: []slackevents.SharedLinks{ + {Domain: "example.com", URL: "https://example.com/12345"}, + {Domain: "example.com", URL: "https://example.com/67890"}, + {Domain: "another-example.com", URL: "https://yet.another-example.com/v/abcde"}, + }, + EventTimestamp: "123456789.9876", + } + rawE, err := json.Marshal(e) + if err != nil { + t.Error(err) + } + expected := `{"type":"link_shared","user":"Uxxxxxxx","ts":"123456789.9876","channel":"Cxxxxxx",` + + `"message_ts":"123456789.9875","thread_ts":"123456789.9876","links":[{"domain":"example.com",` + + `"url":"https://example.com/12345"},{"domain":"example.com","url":"https://example.com/67890"},` + + `{"domain":"another-example.com","url":"https://yet.another-example.com/v/abcde"}],"event_ts":"123456789.9876"}` + if string(rawE) != expected { + t.Errorf("expected %s, but got %s", expected, string(rawE)) + } +} + func TestLinkSharedComposerEvent(t *testing.T) { rawE := []byte(` { @@ -129,7 +158,7 @@ func TestLinkSharedComposerEvent(t *testing.T) { ] } `) - err := json.Unmarshal(rawE, &LinkSharedEvent{}) + err := json.Unmarshal(rawE, &slackevents.LinkSharedEvent{}) if err != nil { t.Error(err) } @@ -160,7 +189,7 @@ func TestMessageEvent(t *testing.T) { } } `) - err := json.Unmarshal(rawE, &MessageEvent{}) + err := json.Unmarshal(rawE, &slackevents.MessageEvent{}) if err != nil { t.Error(err) } @@ -178,7 +207,7 @@ func TestBotMessageEvent(t *testing.T) { "icons": {} } `) - err := json.Unmarshal(rawE, &MessageEvent{}) + err := json.Unmarshal(rawE, &slackevents.MessageEvent{}) if err != nil { t.Error(err) } @@ -214,7 +243,7 @@ func TestThreadBroadcastEvent(t *testing.T) { } `) - var me MessageEvent + var me slackevents.MessageEvent if err := json.Unmarshal(rawE, &me); err != nil { t.Error(err) } @@ -243,7 +272,7 @@ func TestMemberJoinedChannelEvent(t *testing.T) { "inviter": "U123456789" } `) - err := json.Unmarshal(rawE, &MemberJoinedChannelEvent{}) + err := json.Unmarshal(rawE, &slackevents.MemberJoinedChannelEvent{}) if err != nil { t.Error(err) } @@ -259,7 +288,7 @@ func TestMemberLeftChannelEvent(t *testing.T) { "team": "T024BE7LD" } `) - err := json.Unmarshal(rawE, &MemberLeftChannelEvent{}) + err := json.Unmarshal(rawE, &slackevents.MemberLeftChannelEvent{}) if err != nil { t.Error(err) } @@ -287,7 +316,7 @@ func TestPinAdded(t *testing.T) { "event_ts": "1515449522000016" } `) - err := json.Unmarshal(rawE, &PinAddedEvent{}) + err := json.Unmarshal(rawE, &slackevents.PinAddedEvent{}) if err != nil { t.Error(err) } @@ -315,7 +344,7 @@ func TestPinRemoved(t *testing.T) { "event_ts": "1515449522000016" } `) - err := json.Unmarshal(rawE, &PinRemovedEvent{}) + err := json.Unmarshal(rawE, &slackevents.PinRemovedEvent{}) if err != nil { t.Error(err) } @@ -335,7 +364,7 @@ func TestTokensRevoked(t *testing.T) { } } `) - tre := TokensRevokedEvent{} + tre := slackevents.TokensRevokedEvent{} err := json.Unmarshal(rawE, &tre) if err != nil { t.Error(err) @@ -356,7 +385,7 @@ func TestTokensRevoked(t *testing.T) { func TestEmojiChanged(t *testing.T) { var ( - ece EmojiChangedEvent + ece slackevents.EmojiChangedEvent err error ) @@ -370,7 +399,7 @@ func TestEmojiChanged(t *testing.T) { "event_ts" : "1361482916.000004" } `) - ece = EmojiChangedEvent{} + ece = slackevents.EmojiChangedEvent{} err = json.Unmarshal(rawAddE, &ece) if err != nil { t.Error(err) @@ -391,7 +420,7 @@ func TestEmojiChanged(t *testing.T) { "event_ts" : "1361482916.000004" } `) - ece = EmojiChangedEvent{} + ece = slackevents.EmojiChangedEvent{} err = json.Unmarshal(rawRemoveE, &ece) if err != nil { t.Error(err) @@ -417,7 +446,7 @@ func TestEmojiChanged(t *testing.T) { "event_ts" : "1361482916.000004" } `) - ece = EmojiChangedEvent{} + ece = slackevents.EmojiChangedEvent{} err = json.Unmarshal(rawRenameE, &ece) if err != nil { t.Error(err) @@ -457,7 +486,7 @@ func TestWorkflowStepExecute(t *testing.T) { } `) - wse := WorkflowStepExecuteEvent{} + wse := slackevents.WorkflowStepExecuteEvent{} err := json.Unmarshal(rawE, &wse) if err != nil { t.Error(err) From 7fc0e57490f30b47d51a2dadce771b6adbbaace8 Mon Sep 17 00:00:00 2001 From: Jumpei Matsuda Date: Wed, 29 Jun 2022 00:31:53 +0900 Subject: [PATCH 100/606] fix: Create UpdateUserGroup option and allow empty string/array as the values --- usergroups.go | 70 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/usergroups.go b/usergroups.go index 9417f8177..2990421e2 100644 --- a/usergroups.go +++ b/usergroups.go @@ -3,6 +3,7 @@ package slack import ( "context" "net/url" + "reflect" "strings" ) @@ -183,32 +184,77 @@ func (api *Client) GetUserGroupsContext(ctx context.Context, options ...GetUserG return response.UserGroups, nil } +// UpdateUserGroupsOption options for the UpdateUserGroup method call. +type UpdateUserGroupsOption func(*UpdateUserGroupsParams) + +// UpdateUserGroupsOptionName change the name of the User Group (default: empty, so it's no-op) +func UpdateUserGroupsOptionName(name string) UpdateUserGroupsOption { + return func(params *UpdateUserGroupsParams) { + params.Name = name + } +} + +// UpdateUserGroupsOptionHandle change the handle of the User Group (default: empty, so it's no-op) +func UpdateUserGroupsOptionHandle(handle string) UpdateUserGroupsOption { + return func(params *UpdateUserGroupsParams) { + params.Handle = handle + } +} + +// UpdateUserGroupsOptionDescription change the description of the User Group. (default: nil, so it's no-op) +func UpdateUserGroupsOptionDescription(description *string) UpdateUserGroupsOption { + return func(params *UpdateUserGroupsParams) { + params.Description = description + } +} + +// UpdateUserGroupsOptionChannels change the default channels of the User Group. (default: nil, so it's no-op) +func UpdateUserGroupsOptionChannels(channels *[]string) UpdateUserGroupsOption { + return func(params *UpdateUserGroupsParams) { + params.Channels = channels + } +} + +// UpdateUserGroupsParams contains arguments for UpdateUserGroup method call +type UpdateUserGroupsParams struct { + Name string + Handle string + Description *string + Channels *[]string +} + // UpdateUserGroup will update an existing user group -func (api *Client) UpdateUserGroup(userGroup UserGroup) (UserGroup, error) { - return api.UpdateUserGroupContext(context.Background(), userGroup) +func (api *Client) UpdateUserGroup(userGroupID string, options ...UpdateUserGroupsOption) (UserGroup, error) { + return api.UpdateUserGroupContext(context.Background(), userGroupID, options...) } // UpdateUserGroupContext will update an existing user group with a custom context -func (api *Client) UpdateUserGroupContext(ctx context.Context, userGroup UserGroup) (UserGroup, error) { +func (api *Client) UpdateUserGroupContext(ctx context.Context, userGroupID string, options ...UpdateUserGroupsOption) (UserGroup, error) { + params := UpdateUserGroupsParams{} + + for _, opt := range options { + opt(¶ms) + } + values := url.Values{ "token": {api.token}, - "usergroup": {userGroup.ID}, + "usergroup": {userGroupID}, } - if userGroup.Name != "" { - values["name"] = []string{userGroup.Name} + if params.Name != "" { + values["name"] = []string{params.Name} } - if userGroup.Handle != "" { - values["handle"] = []string{userGroup.Handle} + if params.Handle != "" { + values["handle"] = []string{params.Handle} } - if userGroup.Description != "" { - values["description"] = []string{userGroup.Description} + if !reflect.ValueOf(params.Description).IsNil() { + values["description"] = []string{*params.Description} } - if len(userGroup.Prefs.Channels) > 0 { - values["channels"] = []string{strings.Join(userGroup.Prefs.Channels, ",")} + if !reflect.ValueOf(params.Channels).IsNil() { + values["channels"] = []string{strings.Join(*params.Channels, ",")} } response, err := api.userGroupRequest(ctx, "usergroups.update", values) From 38367d1ba7a2afb4321629f6b52fe1653927f900 Mon Sep 17 00:00:00 2001 From: Jumpei Matsuda Date: Wed, 29 Jun 2022 01:14:30 +0900 Subject: [PATCH 101/606] test: make sure empty values are sent --- usergroups_test.go | 115 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 106 insertions(+), 9 deletions(-) diff --git a/usergroups_test.go b/usergroups_test.go index 5b92dcdf1..d98ca257e 100644 --- a/usergroups_test.go +++ b/usergroups_test.go @@ -45,16 +45,11 @@ func newUserGroupsHandler() *userGroupsHandler { } } -func (ugh *userGroupsHandler) accumulateFormValue(k string, r *http.Request) { - if v := r.FormValue(k); v != "" { - ugh.gotParams[k] = v - } -} - func (ugh *userGroupsHandler) handler(w http.ResponseWriter, r *http.Request) { - ugh.accumulateFormValue("name", r) - ugh.accumulateFormValue("description", r) - ugh.accumulateFormValue("handle", r) + r.ParseForm() + for k, v := range r.Form { + ugh.gotParams[k] = v[0] + } w.Header().Set("Content-Type", "application/json") w.Write([]byte(ugh.response)) } @@ -73,6 +68,7 @@ func TestCreateUserGroup(t *testing.T) { Description: "Marketing gurus, PR experts and product advocates.", Handle: "marketing-team"}, map[string]string{ + "token": "testing-token", "name": "Marketing Team", "description": "Marketing gurus, PR experts and product advocates.", "handle": "marketing-team", @@ -184,3 +180,104 @@ func TestGetUserGroups(t *testing.T) { t.Errorf("Got %#v, want %#v", userGroups[0], S0614TZR7) } } + +func updateUserGroupsHandler() *userGroupsHandler { + return &userGroupsHandler{ + gotParams: make(map[string]string), + response: `{ + "ok": true, + "usergroup": { + "id": "S0615G0KT", + "team_id": "T060RNRCH", + "is_usergroup": true, + "name": "Marketing Team", + "description": "Marketing gurus, PR experts and product advocates.", + "handle": "marketing-team", + "is_external": false, + "date_create": 1446746793, + "date_update": 1446746793, + "date_delete": 0, + "auto_type": null, + "created_by": "U060RNRCZ", + "updated_by": "U060RNRCZ", + "deleted_by": null, + "prefs": { + "channels": [ + "channel1", + "channel2" + ], + "groups": [ + + ] + }, + "user_count": 0 + } +}`, + } +} +func TestUpdateUserGroup(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + emptyDescription := "" + presenceDescription := "Marketing gurus, PR experts and product advocates." + + emptyChannels := []string{} + presenceChannels := []string{"channel1", "channel2"} + + tests := []struct { + options []UpdateUserGroupsOption + wantParams map[string]string + }{ + { + []UpdateUserGroupsOption{ + UpdateUserGroupsOptionName("Marketing Team"), + UpdateUserGroupsOptionHandle("marketing-team"), + }, + map[string]string{ + "token": "testing-token", + "usergroup": "S0615G0KT", + "name": "Marketing Team", + "handle": "marketing-team", + }, + }, + { + []UpdateUserGroupsOption{ + UpdateUserGroupsOptionDescription(&presenceDescription), + UpdateUserGroupsOptionChannels(&presenceChannels), + }, + map[string]string{ + "token": "testing-token", + "usergroup": "S0615G0KT", + "description": "Marketing gurus, PR experts and product advocates.", + "channels": "channel1,channel2", + }, + }, + { + []UpdateUserGroupsOption{ + UpdateUserGroupsOptionDescription(&emptyDescription), + UpdateUserGroupsOptionChannels(&emptyChannels), + }, + map[string]string{ + "token": "testing-token", + "usergroup": "S0615G0KT", + "description": "", + "channels": "", + }, + }, + } + + var rh *userGroupsHandler + http.HandleFunc("/usergroups.update", func(w http.ResponseWriter, r *http.Request) { rh.handler(w, r) }) + + for i, test := range tests { + rh = updateUserGroupsHandler() + _, err := api.UpdateUserGroup("S0615G0KT", test.options...) + if err != nil { + t.Fatalf("%d: Unexpected error: %s", i, err) + } + if !reflect.DeepEqual(rh.gotParams, test.wantParams) { + t.Errorf("%d: Got params %#v, want %#v", i, rh.gotParams, test.wantParams) + } + } +} From b31335c7f2c1a28556093363ec72240c365dd48c Mon Sep 17 00:00:00 2001 From: h3rby7 Date: Wed, 29 Jun 2022 14:36:15 +0200 Subject: [PATCH 102/606] add testcase for basic properties only in chat_go.test, add includeAllMetadata flag to conversationHistory --- chat_test.go | 8 ++++++++ conversation.go | 18 ++++++++++++------ messages.go | 2 +- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/chat_test.go b/chat_test.go index 524439520..7a549fa90 100644 --- a/chat_test.go +++ b/chat_test.go @@ -82,6 +82,14 @@ func TestPostMessage(t *testing.T) { blockStr := `[{"type":"context","block_id":"context","elements":[{"type":"plain_text","text":"hello"}]}]` tests := map[string]messageTest{ + "OnlyBasicProperties": { + endpoint: "/chat.postMessage", + opt: []MsgOption{}, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + }, + }, "Blocks": { endpoint: "/chat.postMessage", opt: []MsgOption{ diff --git a/conversation.go b/conversation.go index 299362601..e523716cc 100644 --- a/conversation.go +++ b/conversation.go @@ -571,12 +571,13 @@ func (api *Client) JoinConversationContext(ctx context.Context, channelID string } type GetConversationHistoryParameters struct { - ChannelID string - Cursor string - Inclusive bool - Latest string - Limit int - Oldest string + ChannelID string + Cursor string + Inclusive bool + Latest string + Limit int + Oldest string + IncludeAllMetadata bool } type GetConversationHistoryResponse struct { @@ -615,6 +616,11 @@ func (api *Client) GetConversationHistoryContext(ctx context.Context, params *Ge if params.Oldest != "" { values.Add("oldest", params.Oldest) } + if params.IncludeAllMetadata { + values.Add("include_all_metadata", "1") + } else { + values.Add("include_all_metadata", "0") + } response := GetConversationHistoryResponse{} diff --git a/messages.go b/messages.go index 828fa6026..333404563 100644 --- a/messages.go +++ b/messages.go @@ -129,7 +129,7 @@ type Msg struct { ReplaceOriginal bool `json:"replace_original"` DeleteOriginal bool `json:"delete_original"` - // Block type Message + // metadata Metadata SlackMetadata `json:"metadata,omitempty"` // Block type Message From cd6747e846146c38b7f0d07eae58a8fedc7fd273 Mon Sep 17 00:00:00 2001 From: Mateusz Szostok Date: Mon, 4 Jul 2022 17:49:21 +0200 Subject: [PATCH 103/606] Add option to detect unmapped error --- websocket_managed_conn.go | 34 ++++++++++++++++++- websocket_managed_conn_test.go | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/websocket_managed_conn.go b/websocket_managed_conn.go index 14dea2196..249450bb0 100644 --- a/websocket_managed_conn.go +++ b/websocket_managed_conn.go @@ -16,6 +16,38 @@ import ( "github.com/slack-go/slack/internal/timex" ) +// UnmappedError represents error occurred when there is no mapping between given event name +// and corresponding Go struct. +type UnmappedError struct { + eventType string + rawEvent json.RawMessage + ctxMsg string +} + +// NewUnmappedError returns new UnmappedError instance. +func NewUnmappedError(ctxMsg, eventType string, raw json.RawMessage) *UnmappedError { + return &UnmappedError{ + ctxMsg: ctxMsg, + eventType: eventType, + rawEvent: raw, + } +} + +// Error returns human-readable error message. +func (u UnmappedError) Error() string { + return fmt.Sprintf("%s: Received unmapped event %q", u.ctxMsg, u.eventType) +} + +// EventType returns event type name. +func (u UnmappedError) EventType() string { + return u.eventType +} + +// RawEvent returns raw event body. +func (u UnmappedError) RawEvent() json.RawMessage { + return u.rawEvent +} + // ManageConnection can be called on a Slack RTM instance returned by the // NewRTM method. It will connect to the slack RTM API and handle all incoming // and outgoing events. If a connection fails then it will attempt to reconnect @@ -474,7 +506,7 @@ func (rtm *RTM) handleEvent(typeStr string, event json.RawMessage) { v, exists := EventMapping[typeStr] if !exists { rtm.Debugf("RTM Error - received unmapped event %q: %s\n", typeStr, string(event)) - err := fmt.Errorf("RTM Error: Received unmapped event %q", typeStr) + err := NewUnmappedError("RTM Error", typeStr, event) rtm.IncomingEvents <- RTMEvent{"unmarshalling_error", &UnmarshallingErrorEvent{err}} return } diff --git a/websocket_managed_conn_test.go b/websocket_managed_conn_test.go index 429ce8d52..0e899aa44 100644 --- a/websocket_managed_conn_test.go +++ b/websocket_managed_conn_test.go @@ -1,6 +1,7 @@ package slack_test import ( + "encoding/json" "fmt" "log" "net/http" @@ -9,6 +10,7 @@ import ( websocket "github.com/gorilla/websocket" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/slack-go/slack" "github.com/slack-go/slack/slacktest" @@ -313,3 +315,61 @@ func TestRTMSingleConnect(t *testing.T) { assert.True(t, connectedReceived, "Should have received a connected event from the RTM instance.") assert.True(t, testMessageReceived, "Should have received a test message from the server.") } + +func TestRTMUnmappedError(t *testing.T) { + const unmappedEventName = "user_status_changed" + // Set up the test server. + testServer := slacktest.NewTestServer() + go testServer.Start() + + // Setup and start the RTM. + api := slack.New(testToken, slack.OptionAPIURL(testServer.GetAPIURL())) + rtm := api.NewRTM() + go rtm.ManageConnection() + + // Observe incoming messages. + done := make(chan struct{}) + var gotUnmarshallingError *slack.UnmarshallingErrorEvent + go func() { + for msg := range rtm.IncomingEvents { + switch ev := msg.Data.(type) { + case *slack.UnmarshallingErrorEvent: + gotUnmarshallingError = ev + rtm.Disconnect() + case *slack.DisconnectedEvent: + if ev.Intentional { + done <- struct{}{} + return + } + default: + t.Logf("Discarded event of type '%s' with content '%#v'", msg.Type, ev) + } + } + }() + + // Send a message and sleep for some time to make sure the message can be processed client-side. + testServer.SendToWebsocket(fixSlackMessage(t, unmappedEventName)) + <-done + testServer.Stop() + + // Verify that we got the expected error with details + unmappedErr, ok := gotUnmarshallingError.ErrorObj.(*slack.UnmappedError) + require.True(t, ok) + assert.Equal(t, unmappedEventName, unmappedErr.EventType()) +} + +func fixSlackMessage(t *testing.T, eType string) string { + t.Helper() + + m := slack.Message{ + Msg: slack.Msg{ + Type: eType, + Text: "Fixture Slack message", + Timestamp: fmt.Sprintf("%d", time.Now().Unix()), + }, + } + msg, err := json.Marshal(m) + require.NoError(t, err) + + return string(msg) +} From 62ab20d7429cbedcb64d5f8012b995843e8d0dbd Mon Sep 17 00:00:00 2001 From: Martin Englund Date: Wed, 6 Jul 2022 07:19:44 +0200 Subject: [PATCH 104/606] revert test to not use _test package --- slackevents/inner_events_test.go | 45 ++++++++++++++++---------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index cee2d211a..75344ba86 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -1,8 +1,7 @@ -package slackevents_test +package slackevents import ( "encoding/json" - "github.com/slack-go/slack/slackevents" "testing" ) @@ -21,7 +20,7 @@ func TestAppMention(t *testing.T) { "blah": "test" } `) - err := json.Unmarshal(rawE, &slackevents.AppMentionEvent{}) + err := json.Unmarshal(rawE, &AppMentionEvent{}) if err != nil { t.Error(err) } @@ -33,7 +32,7 @@ func TestAppUninstalled(t *testing.T) { "type": "app_uninstalled" } `) - err := json.Unmarshal(rawE, &slackevents.AppUninstalledEvent{}) + err := json.Unmarshal(rawE, &AppUninstalledEvent{}) if err != nil { t.Error(err) } @@ -46,7 +45,7 @@ func TestGridMigrationFinishedEvent(t *testing.T) { "enterprise_id": "EXXXXXXXX" } `) - err := json.Unmarshal(rawE, &slackevents.GridMigrationFinishedEvent{}) + err := json.Unmarshal(rawE, &GridMigrationFinishedEvent{}) if err != nil { t.Error(err) } @@ -67,7 +66,7 @@ func TestGridMigrationStartedEvent(t *testing.T) { "event_time": 1234567890 } `) - err := json.Unmarshal(rawE, &slackevents.GridMigrationStartedEvent{}) + err := json.Unmarshal(rawE, &GridMigrationStartedEvent{}) if err != nil { t.Error(err) } @@ -98,21 +97,21 @@ func TestLinkSharedEvent(t *testing.T) { ] } `) - err := json.Unmarshal(rawE, &slackevents.LinkSharedEvent{}) + err := json.Unmarshal(rawE, &LinkSharedEvent{}) if err != nil { t.Error(err) } } func TestLinkSharedEvent_struct(t *testing.T) { - e := slackevents.LinkSharedEvent{ + e := LinkSharedEvent{ Type: "link_shared", User: "Uxxxxxxx", TimeStamp: "123456789.9876", Channel: "Cxxxxxx", MessageTimeStamp: "123456789.9875", ThreadTimeStamp: "123456789.9876", - Links: []slackevents.SharedLinks{ + Links: []SharedLinks{ {Domain: "example.com", URL: "https://example.com/12345"}, {Domain: "example.com", URL: "https://example.com/67890"}, {Domain: "another-example.com", URL: "https://yet.another-example.com/v/abcde"}, @@ -158,7 +157,7 @@ func TestLinkSharedComposerEvent(t *testing.T) { ] } `) - err := json.Unmarshal(rawE, &slackevents.LinkSharedEvent{}) + err := json.Unmarshal(rawE, &LinkSharedEvent{}) if err != nil { t.Error(err) } @@ -189,7 +188,7 @@ func TestMessageEvent(t *testing.T) { } } `) - err := json.Unmarshal(rawE, &slackevents.MessageEvent{}) + err := json.Unmarshal(rawE, &MessageEvent{}) if err != nil { t.Error(err) } @@ -207,7 +206,7 @@ func TestBotMessageEvent(t *testing.T) { "icons": {} } `) - err := json.Unmarshal(rawE, &slackevents.MessageEvent{}) + err := json.Unmarshal(rawE, &MessageEvent{}) if err != nil { t.Error(err) } @@ -243,7 +242,7 @@ func TestThreadBroadcastEvent(t *testing.T) { } `) - var me slackevents.MessageEvent + var me MessageEvent if err := json.Unmarshal(rawE, &me); err != nil { t.Error(err) } @@ -272,7 +271,7 @@ func TestMemberJoinedChannelEvent(t *testing.T) { "inviter": "U123456789" } `) - err := json.Unmarshal(rawE, &slackevents.MemberJoinedChannelEvent{}) + err := json.Unmarshal(rawE, &MemberJoinedChannelEvent{}) if err != nil { t.Error(err) } @@ -288,7 +287,7 @@ func TestMemberLeftChannelEvent(t *testing.T) { "team": "T024BE7LD" } `) - err := json.Unmarshal(rawE, &slackevents.MemberLeftChannelEvent{}) + err := json.Unmarshal(rawE, &MemberLeftChannelEvent{}) if err != nil { t.Error(err) } @@ -316,7 +315,7 @@ func TestPinAdded(t *testing.T) { "event_ts": "1515449522000016" } `) - err := json.Unmarshal(rawE, &slackevents.PinAddedEvent{}) + err := json.Unmarshal(rawE, &PinAddedEvent{}) if err != nil { t.Error(err) } @@ -344,7 +343,7 @@ func TestPinRemoved(t *testing.T) { "event_ts": "1515449522000016" } `) - err := json.Unmarshal(rawE, &slackevents.PinRemovedEvent{}) + err := json.Unmarshal(rawE, &PinRemovedEvent{}) if err != nil { t.Error(err) } @@ -364,7 +363,7 @@ func TestTokensRevoked(t *testing.T) { } } `) - tre := slackevents.TokensRevokedEvent{} + tre := TokensRevokedEvent{} err := json.Unmarshal(rawE, &tre) if err != nil { t.Error(err) @@ -385,7 +384,7 @@ func TestTokensRevoked(t *testing.T) { func TestEmojiChanged(t *testing.T) { var ( - ece slackevents.EmojiChangedEvent + ece EmojiChangedEvent err error ) @@ -399,7 +398,7 @@ func TestEmojiChanged(t *testing.T) { "event_ts" : "1361482916.000004" } `) - ece = slackevents.EmojiChangedEvent{} + ece = EmojiChangedEvent{} err = json.Unmarshal(rawAddE, &ece) if err != nil { t.Error(err) @@ -420,7 +419,7 @@ func TestEmojiChanged(t *testing.T) { "event_ts" : "1361482916.000004" } `) - ece = slackevents.EmojiChangedEvent{} + ece = EmojiChangedEvent{} err = json.Unmarshal(rawRemoveE, &ece) if err != nil { t.Error(err) @@ -446,7 +445,7 @@ func TestEmojiChanged(t *testing.T) { "event_ts" : "1361482916.000004" } `) - ece = slackevents.EmojiChangedEvent{} + ece = EmojiChangedEvent{} err = json.Unmarshal(rawRenameE, &ece) if err != nil { t.Error(err) @@ -486,7 +485,7 @@ func TestWorkflowStepExecute(t *testing.T) { } `) - wse := slackevents.WorkflowStepExecuteEvent{} + wse := WorkflowStepExecuteEvent{} err := json.Unmarshal(rawE, &wse) if err != nil { t.Error(err) From 9e4de8bcdbb3ebe4789c18015ecb9b6bf99a66eb Mon Sep 17 00:00:00 2001 From: Mateusz Szostok Date: Thu, 7 Jul 2022 00:11:21 +0200 Subject: [PATCH 105/606] Apply suggestion after review --- websocket_managed_conn.go | 25 +++++++++---------------- websocket_managed_conn_test.go | 2 +- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/websocket_managed_conn.go b/websocket_managed_conn.go index 249450bb0..f107b2a47 100644 --- a/websocket_managed_conn.go +++ b/websocket_managed_conn.go @@ -19,33 +19,26 @@ import ( // UnmappedError represents error occurred when there is no mapping between given event name // and corresponding Go struct. type UnmappedError struct { - eventType string - rawEvent json.RawMessage - ctxMsg string + // EventType returns event type name. + EventType string + // RawEvent returns raw event body. + RawEvent json.RawMessage + + ctxMsg string } // NewUnmappedError returns new UnmappedError instance. func NewUnmappedError(ctxMsg, eventType string, raw json.RawMessage) *UnmappedError { return &UnmappedError{ ctxMsg: ctxMsg, - eventType: eventType, - rawEvent: raw, + EventType: eventType, + RawEvent: raw, } } // Error returns human-readable error message. func (u UnmappedError) Error() string { - return fmt.Sprintf("%s: Received unmapped event %q", u.ctxMsg, u.eventType) -} - -// EventType returns event type name. -func (u UnmappedError) EventType() string { - return u.eventType -} - -// RawEvent returns raw event body. -func (u UnmappedError) RawEvent() json.RawMessage { - return u.rawEvent + return fmt.Sprintf("%s: Received unmapped event %q", u.ctxMsg, u.EventType) } // ManageConnection can be called on a Slack RTM instance returned by the diff --git a/websocket_managed_conn_test.go b/websocket_managed_conn_test.go index 0e899aa44..c7a8b7034 100644 --- a/websocket_managed_conn_test.go +++ b/websocket_managed_conn_test.go @@ -355,7 +355,7 @@ func TestRTMUnmappedError(t *testing.T) { // Verify that we got the expected error with details unmappedErr, ok := gotUnmarshallingError.ErrorObj.(*slack.UnmappedError) require.True(t, ok) - assert.Equal(t, unmappedEventName, unmappedErr.EventType()) + assert.Equal(t, unmappedEventName, unmappedErr.EventType) } func fixSlackMessage(t *testing.T, eType string) string { From 9013791a078cb1d461d50e6646bacfe1978c9c81 Mon Sep 17 00:00:00 2001 From: tanaka Date: Fri, 22 Jul 2022 12:31:38 +0900 Subject: [PATCH 106/606] fix: change Outputs type *map[string]string to map[string]strring --- workflow_step_execute.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/workflow_step_execute.go b/workflow_step_execute.go index d1d77cbad..18516f23e 100644 --- a/workflow_step_execute.go +++ b/workflow_step_execute.go @@ -7,8 +7,8 @@ import ( type ( WorkflowStepCompletedRequest struct { - WorkflowStepExecuteID string `json:"workflow_step_execute_id"` - Outputs *map[string]string `json:"outputs"` + WorkflowStepExecuteID string `json:"workflow_step_execute_id"` + Outputs map[string]string `json:"outputs"` } WorkflowStepFailedRequest struct { @@ -24,7 +24,7 @@ type WorkflowStepCompletedRequestOption func(opt WorkflowStepCompletedRequest) e func WorkflowStepCompletedRequestOptionOutput(outputs map[string]string) WorkflowStepCompletedRequestOption { return func(opt WorkflowStepCompletedRequest) error { if len(outputs) > 0 { - opt.Outputs = &outputs + opt.Outputs = outputs } return nil } From c3c208411babf75cc5daad33c0168ee7c6c2bfda Mon Sep 17 00:00:00 2001 From: Jumpei Matsuda Date: Mon, 25 Jul 2022 13:37:54 +0900 Subject: [PATCH 107/606] fix: Don't use meaningless slice pointers and reflect package --- usergroups.go | 11 +++++------ usergroups_test.go | 7 ++----- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/usergroups.go b/usergroups.go index 2990421e2..c5d7a176b 100644 --- a/usergroups.go +++ b/usergroups.go @@ -3,7 +3,6 @@ package slack import ( "context" "net/url" - "reflect" "strings" ) @@ -208,10 +207,10 @@ func UpdateUserGroupsOptionDescription(description *string) UpdateUserGroupsOpti } } -// UpdateUserGroupsOptionChannels change the default channels of the User Group. (default: nil, so it's no-op) -func UpdateUserGroupsOptionChannels(channels *[]string) UpdateUserGroupsOption { +// UpdateUserGroupsOptionChannels change the default channels of the User Group. (default: unspecified, so it's no-op) +func UpdateUserGroupsOptionChannels(channels []string) UpdateUserGroupsOption { return func(params *UpdateUserGroupsParams) { - params.Channels = channels + params.Channels = &channels } } @@ -249,11 +248,11 @@ func (api *Client) UpdateUserGroupContext(ctx context.Context, userGroupID strin values["handle"] = []string{params.Handle} } - if !reflect.ValueOf(params.Description).IsNil() { + if params.Description != nil { values["description"] = []string{*params.Description} } - if !reflect.ValueOf(params.Channels).IsNil() { + if params.Channels != nil { values["channels"] = []string{strings.Join(*params.Channels, ",")} } diff --git a/usergroups_test.go b/usergroups_test.go index d98ca257e..9586cf167 100644 --- a/usergroups_test.go +++ b/usergroups_test.go @@ -222,9 +222,6 @@ func TestUpdateUserGroup(t *testing.T) { emptyDescription := "" presenceDescription := "Marketing gurus, PR experts and product advocates." - emptyChannels := []string{} - presenceChannels := []string{"channel1", "channel2"} - tests := []struct { options []UpdateUserGroupsOption wantParams map[string]string @@ -244,7 +241,7 @@ func TestUpdateUserGroup(t *testing.T) { { []UpdateUserGroupsOption{ UpdateUserGroupsOptionDescription(&presenceDescription), - UpdateUserGroupsOptionChannels(&presenceChannels), + UpdateUserGroupsOptionChannels([]string{"channel1", "channel2"}), }, map[string]string{ "token": "testing-token", @@ -256,7 +253,7 @@ func TestUpdateUserGroup(t *testing.T) { { []UpdateUserGroupsOption{ UpdateUserGroupsOptionDescription(&emptyDescription), - UpdateUserGroupsOptionChannels(&emptyChannels), + UpdateUserGroupsOptionChannels([]string{}), }, map[string]string{ "token": "testing-token", From b69d0eaa83790dc93a33568c49f909d9c6a54d1e Mon Sep 17 00:00:00 2001 From: David Parsley Date: Thu, 28 Jul 2022 11:53:18 -0400 Subject: [PATCH 108/606] Fail on context cancelled, retry on timeout --- socketmode/socket_mode_managed_conn.go | 36 +++++++------------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index 428999732..9c6d0b681 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -92,14 +92,11 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { // We're now connected so we can set up listeners var ( - wg sync.WaitGroup firstErr error firstErrOnce sync.Once ) - wg.Add(1) go func() { - defer wg.Done() defer cancel() // The response sender sends Socket Mode responses over the WebSocket conn @@ -110,9 +107,7 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { } }() - wg.Add(1) go func() { - defer wg.Done() defer cancel() // The handler reads Socket Mode requests, and enqueues responses for sending by the response sender @@ -123,9 +118,7 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { } }() - wg.Add(1) go func() { - defer wg.Done() defer cancel() // The receiver reads WebSocket messages, and enqueues parsed Socket Mode requests to be handled by @@ -137,26 +130,17 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { } }() - wg.Add(1) - go func() { - defer wg.Done() - - select { - case <-ctx.Done(): - // Detect when the connection is dead and try close connection. - if err = conn.Close(); err != nil { - smc.Debugf("Failed to close connection: %v", err) - } - case <-deadmanTimer.Elapsed(): - firstErrOnce.Do(func() { - firstErr = errors.New("ping timeout: Slack did not send us WebSocket PING for more than Client.maxInterval") - }) - - cancel() + select { + case <-ctx.Done(): + // Detect when the connection is dead and try close connection. + if err = conn.Close(); err != nil { + smc.Debugf("Failed to close connection: %v", err) } - }() - - wg.Wait() + case <-deadmanTimer.Elapsed(): + firstErrOnce.Do(func() { + firstErr = errors.New("ping timeout: Slack did not send us WebSocket PING for more than Client.maxInterval") + }) + } if firstErr == context.Canceled { return firstErr From d96792e25e981fd3c78ccf46f248ba4b70d91e70 Mon Sep 17 00:00:00 2001 From: David Parsley Date: Thu, 28 Jul 2022 12:06:15 -0400 Subject: [PATCH 109/606] Update comment, remove reference to timeout --- socketmode/socket_mode_managed_conn.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index 9c6d0b681..e08d77642 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -146,7 +146,7 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { return firstErr } - // wg.Wait() finishes only after any of the above go routines finishes. + // select unblocks on first cancel or timeout. // Also, we can expect firstErr to be not nil, as goroutines can finish only on error. smc.Debugf("Reconnecting due to %v", firstErr) From 9521295006fdd196d4828486438600184d6adf20 Mon Sep 17 00:00:00 2001 From: David Parsley Date: Fri, 29 Jul 2022 08:39:13 -0400 Subject: [PATCH 110/606] Add back the waitgroup, just don't wait on ReadJSON --- socketmode/socket_mode_managed_conn.go | 39 ++++++++++++++++++-------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index e08d77642..b259fd624 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -92,11 +92,14 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { // We're now connected so we can set up listeners var ( + wg sync.WaitGroup firstErr error firstErrOnce sync.Once ) + wg.Add(1) go func() { + defer wg.Done() defer cancel() // The response sender sends Socket Mode responses over the WebSocket conn @@ -107,7 +110,9 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { } }() + wg.Add(1) go func() { + defer wg.Done() defer cancel() // The handler reads Socket Mode requests, and enqueues responses for sending by the response sender @@ -118,6 +123,8 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { } }() + // We don't wait on runMessageReceiver because it doesn't block on a select with the context, + // so we'd have to wait for the ReadJSON to time out, which can take a while. go func() { defer cancel() @@ -130,23 +137,33 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { } }() - select { - case <-ctx.Done(): - // Detect when the connection is dead and try close connection. - if err = conn.Close(); err != nil { - smc.Debugf("Failed to close connection: %v", err) + wg.Add(1) + go func() { + defer wg.Done() + + select { + case <-ctx.Done(): + // Detect when the connection is dead and try close connection. + if err = conn.Close(); err != nil { + smc.Debugf("Failed to close connection: %v", err) + } + case <-deadmanTimer.Elapsed(): + firstErrOnce.Do(func() { + firstErr = errors.New("ping timeout: Slack did not send us WebSocket PING for more than Client.maxInterval") + }) + + cancel() } - case <-deadmanTimer.Elapsed(): - firstErrOnce.Do(func() { - firstErr = errors.New("ping timeout: Slack did not send us WebSocket PING for more than Client.maxInterval") - }) - } + }() + + wg.Wait() if firstErr == context.Canceled { return firstErr } - // select unblocks on first cancel or timeout. + // wg.Wait() finishes only after any of the above go routines finishes and cancels the + // context, allowing the other threads to shut down gracefully. // Also, we can expect firstErr to be not nil, as goroutines can finish only on error. smc.Debugf("Reconnecting due to %v", firstErr) From 5e834ef4b99a449653b48bdf504ef6344fcd4cb4 Mon Sep 17 00:00:00 2001 From: Gary Kramlich Date: Wed, 10 Aug 2022 14:58:11 -0500 Subject: [PATCH 111/606] Expose reactionItem and reactionEvent --- websocket_reactions.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/websocket_reactions.go b/websocket_reactions.go index e49738783..6098a6cac 100644 --- a/websocket_reactions.go +++ b/websocket_reactions.go @@ -1,7 +1,7 @@ package slack -// reactionItem is a lighter-weight item than is returned by the reactions list. -type reactionItem struct { +// ReactionItem is a lighter-weight item than is returned by the reactions list. +type ReactionItem struct { Type string `json:"type"` Channel string `json:"channel,omitempty"` File string `json:"file,omitempty"` @@ -9,17 +9,17 @@ type reactionItem struct { Timestamp string `json:"ts,omitempty"` } -type reactionEvent struct { +type ReactionEvent struct { Type string `json:"type"` User string `json:"user"` ItemUser string `json:"item_user"` - Item reactionItem `json:"item"` + Item ReactionItem `json:"item"` Reaction string `json:"reaction"` EventTimestamp string `json:"event_ts"` } // ReactionAddedEvent represents the Reaction added event -type ReactionAddedEvent reactionEvent +type ReactionAddedEvent ReactionEvent // ReactionRemovedEvent represents the Reaction removed event -type ReactionRemovedEvent reactionEvent +type ReactionRemovedEvent ReactionEvent From 5eec5a2e406e3c6661e2ae5b6e07fd956a33be58 Mon Sep 17 00:00:00 2001 From: yuriskov Date: Sat, 13 Aug 2022 23:11:38 +0900 Subject: [PATCH 112/606] Feat: Add structs for metadata events --- slackevents/inner_events.go | 108 ++++++++++++++++++++++++++---------- 1 file changed, 79 insertions(+), 29 deletions(-) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 02767eb8f..5af3b225f 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -332,6 +332,47 @@ type WorkflowStepExecuteEvent struct { EventTimestamp string `json:"event_ts"` } +// MessageMetadataPostedEvent is sent, if a message with metadata is posted +type MessageMetadataPostedEvent struct { + Type string `json:"type"` + AppId string `json:"app_id"` + BotId string `json:"bot_id"` + UserId string `json:"user_id"` + TeamId string `json:"team_id"` + ChannelId string `json:"channel_id"` + Metadata *slack.SlackMetadata `json:"metadata"` + MessageTimestamp string `json:"message_ts"` + EventTimestamp string `json:"event_ts"` +} + +// MessageMetadataUpdatedEvent is sent, if a message with metadata is deleted +type MessageMetadataUpdatedEvent struct { + Type string `json:"type"` + ChannelId string `json:"channel_id"` + EventTimestamp string `json:"event_ts"` + PreviousMetadata *slack.SlackMetadata `json:"previous_metadata"` + AppId string `json:"app_id"` + BotId string `json:"bot_id"` + UserId string `json:"user_id"` + TeamId string `json:"team_id"` + MessageTimestamp string `json:"message_ts"` + Metadata *slack.SlackMetadata `json:"metadata"` +} + +// MessageMetadataDeletedEvent is sent, if a message with metadata is deleted +type MessageMetadataDeletedEvent struct { + Type string `json:"type"` + ChannelId string `json:"channel_id"` + EventTimestamp string `json:"event_ts"` + PreviousMetadata *slack.SlackMetadata `json:"previous_metadata"` + AppId string `json:"app_id"` + BotId string `json:"bot_id"` + UserId string `json:"user_id"` + TeamId string `json:"team_id"` + MessageTimestamp string `json:"message_ts"` + DeletedTimestamp string `json:"deleted_ts"` +} + type EventWorkflowStep struct { WorkflowStepExecuteID string `json:"workflow_step_execute_id"` WorkflowID string `json:"workflow_id"` @@ -503,39 +544,48 @@ const ( EmojiChanged = EventsAPIType("emoji_changed") // WorkflowStepExecute Happens, if a workflow step of your app is invoked WorkflowStepExecute = EventsAPIType("workflow_step_execute") + // MessageMetadataPosted A message with metadata was posted + MessageMetadataPosted = EventsAPIType("message_metadata_posted") + // MessageMetadataPosted A message with metadata was updated + MessageMetadataUpdated = EventsAPIType("message_metadata_updated") + // MessageMetadataPosted A message with metadata was deleted + MessageMetadataDeleted = EventsAPIType("message_metadata_deleted") ) // EventsAPIInnerEventMapping maps INNER Event API events to their corresponding struct // implementations. The structs should be instances of the unmarshalling // target for the matching event type. var EventsAPIInnerEventMapping = map[EventsAPIType]interface{}{ - AppMention: AppMentionEvent{}, - AppHomeOpened: AppHomeOpenedEvent{}, - AppUninstalled: AppUninstalledEvent{}, - ChannelCreated: ChannelCreatedEvent{}, - ChannelDeleted: ChannelDeletedEvent{}, - ChannelArchive: ChannelArchiveEvent{}, - ChannelUnarchive: ChannelUnarchiveEvent{}, - ChannelLeft: ChannelLeftEvent{}, - ChannelRename: ChannelRenameEvent{}, - ChannelIDChanged: ChannelIDChangedEvent{}, - GroupDeleted: GroupDeletedEvent{}, - GroupArchive: GroupArchiveEvent{}, - GroupUnarchive: GroupUnarchiveEvent{}, - GroupLeft: GroupLeftEvent{}, - GroupRename: GroupRenameEvent{}, - GridMigrationFinished: GridMigrationFinishedEvent{}, - GridMigrationStarted: GridMigrationStartedEvent{}, - LinkShared: LinkSharedEvent{}, - Message: MessageEvent{}, - MemberJoinedChannel: MemberJoinedChannelEvent{}, - MemberLeftChannel: MemberLeftChannelEvent{}, - PinAdded: PinAddedEvent{}, - PinRemoved: PinRemovedEvent{}, - ReactionAdded: ReactionAddedEvent{}, - ReactionRemoved: ReactionRemovedEvent{}, - TeamJoin: TeamJoinEvent{}, - TokensRevoked: TokensRevokedEvent{}, - EmojiChanged: EmojiChangedEvent{}, - WorkflowStepExecute: WorkflowStepExecuteEvent{}, + AppMention: AppMentionEvent{}, + AppHomeOpened: AppHomeOpenedEvent{}, + AppUninstalled: AppUninstalledEvent{}, + ChannelCreated: ChannelCreatedEvent{}, + ChannelDeleted: ChannelDeletedEvent{}, + ChannelArchive: ChannelArchiveEvent{}, + ChannelUnarchive: ChannelUnarchiveEvent{}, + ChannelLeft: ChannelLeftEvent{}, + ChannelRename: ChannelRenameEvent{}, + ChannelIDChanged: ChannelIDChangedEvent{}, + GroupDeleted: GroupDeletedEvent{}, + GroupArchive: GroupArchiveEvent{}, + GroupUnarchive: GroupUnarchiveEvent{}, + GroupLeft: GroupLeftEvent{}, + GroupRename: GroupRenameEvent{}, + GridMigrationFinished: GridMigrationFinishedEvent{}, + GridMigrationStarted: GridMigrationStartedEvent{}, + LinkShared: LinkSharedEvent{}, + Message: MessageEvent{}, + MemberJoinedChannel: MemberJoinedChannelEvent{}, + MemberLeftChannel: MemberLeftChannelEvent{}, + PinAdded: PinAddedEvent{}, + PinRemoved: PinRemovedEvent{}, + ReactionAdded: ReactionAddedEvent{}, + ReactionRemoved: ReactionRemovedEvent{}, + TeamJoin: TeamJoinEvent{}, + TokensRevoked: TokensRevokedEvent{}, + EmojiChanged: EmojiChangedEvent{}, + WorkflowStepExecute: WorkflowStepExecuteEvent{}, + MessageMetadataPosted: MessageMetadataPostedEvent{}, + MessageMetadataUpdated: MessageMetadataUpdatedEvent{}, + MessageMetadataDeleted: MessageMetadataDeletedEvent{}, } From 95d03413f06eace8707dfc984d06606254db13e7 Mon Sep 17 00:00:00 2001 From: yuriskov Date: Sat, 13 Aug 2022 23:11:50 +0900 Subject: [PATCH 113/606] Feat: Add tests for metadata related events struct --- slackevents/inner_events_test.go | 186 +++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index 2a0ac56e8..c8906e67b 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -493,3 +493,189 @@ func TestWorkflowStepExecute(t *testing.T) { } } } + +func TestMessageMetadataPosted(t *testing.T) { + rawE := []byte(` + { + "type":"message_metadata_posted", + "app_id":"APPXXX", + "bot_id":"BOTXXX", + "user_id":"USERXXX", + "team_id":"TEAMXXX", + "channel_id":"CHANNELXXX", + "metadata":{ + "event_type":"type", + "event_payload":{"key": "value"} + }, + "message_ts":"1660398079.756349", + "event_ts":"1660398079.756349" + } + `) + + mmp := MessageMetadataPostedEvent{} + err := json.Unmarshal(rawE, &mmp) + if err != nil { + t.Error(err) + } + + if mmp.Type != "message_metadata_posted" { + t.Fail() + } + if mmp.AppId != "APPXXX" { + t.Fail() + } + if mmp.BotId != "BOTXXX" { + t.Fail() + } + if mmp.UserId != "USERXXX" { + t.Fail() + } + if mmp.TeamId != "TEAMXXX" { + t.Fail() + } + if mmp.ChannelId != "CHANNELXXX" { + t.Fail() + } + if mmp.Metadata.EventType != "type" { + t.Fail() + } + payload := mmp.Metadata.EventPayload + if len(payload) <= 0 { + t.Fail() + } + if mmp.EventTimestamp != "1660398079.756349" { + t.Fail() + } + if mmp.MessageTimestamp != "1660398079.756349" { + t.Fail() + } +} + +func TestMessageMetadataUpdated(t *testing.T) { + rawE := []byte(` + { + "type":"message_metadata_updated", + "channel_id":"CHANNELXXX", + "event_ts":"1660398079.756349", + "previous_metadata":{ + "event_type":"type1", + "event_payload":{"key1": "value1"} + }, + "app_id":"APPXXX", + "bot_id":"BOTXXX", + "user_id":"USERXXX", + "team_id":"TEAMXXX", + "message_ts":"1660398079.756349", + "metadata":{ + "event_type":"type2", + "event_payload":{"key2": "value2"} + } + } + `) + + mmp := MessageMetadataUpdatedEvent{} + err := json.Unmarshal(rawE, &mmp) + if err != nil { + t.Error(err) + } + + if mmp.Type != "message_metadata_updated" { + t.Fail() + } + if mmp.ChannelId != "CHANNELXXX" { + t.Fail() + } + if mmp.EventTimestamp != "1660398079.756349" { + t.Fail() + } + if mmp.PreviousMetadata.EventType != "type1" { + t.Fail() + } + payload := mmp.PreviousMetadata.EventPayload + if len(payload) <= 0 { + t.Fail() + } + if mmp.AppId != "APPXXX" { + t.Fail() + } + if mmp.BotId != "BOTXXX" { + t.Fail() + } + if mmp.UserId != "USERXXX" { + t.Fail() + } + if mmp.TeamId != "TEAMXXX" { + t.Fail() + } + if mmp.MessageTimestamp != "1660398079.756349" { + t.Fail() + } + if mmp.Metadata.EventType != "type2" { + t.Fail() + } + payload = mmp.Metadata.EventPayload + if len(payload) <= 0 { + t.Fail() + } +} + +func TestMessageMetadataDeleted(t *testing.T) { + rawE := []byte(` + { + "type":"message_metadata_deleted", + "channel_id":"CHANNELXXX", + "event_ts":"1660398079.756349", + "previous_metadata":{ + "event_type":"type", + "event_payload":{"key": "value"} + }, + "app_id":"APPXXX", + "bot_id":"BOTXXX", + "user_id":"USERXXX", + "team_id":"TEAMXXX", + "message_ts":"1660398079.756349", + "deleted_ts":"1660398079.756349" + } + `) + + mmp := MessageMetadataDeletedEvent{} + err := json.Unmarshal(rawE, &mmp) + if err != nil { + t.Error(err) + } + + if mmp.Type != "message_metadata_deleted" { + t.Fail() + } + if mmp.ChannelId != "CHANNELXXX" { + t.Fail() + } + if mmp.EventTimestamp != "1660398079.756349" { + t.Fail() + } + if mmp.PreviousMetadata.EventType != "type" { + t.Fail() + } + payload := mmp.PreviousMetadata.EventPayload + if len(payload) <= 0 { + t.Fail() + } + if mmp.AppId != "APPXXX" { + t.Fail() + } + if mmp.BotId != "BOTXXX" { + t.Fail() + } + if mmp.UserId != "USERXXX" { + t.Fail() + } + if mmp.TeamId != "TEAMXXX" { + t.Fail() + } + if mmp.MessageTimestamp != "1660398079.756349" { + t.Fail() + } + if mmp.DeletedTimestamp != "1660398079.756349" { + t.Fail() + } +} From d0f246b8f9372c036a896d944740d6eb27bfb2fb Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Mon, 15 Aug 2022 09:25:09 -0400 Subject: [PATCH 114/606] Added functionality to pull user custom fields from Team information. Added Functionality to update User Custom Fields on their profile --- team.go | 48 +++++++++++++++++++++++++++++++++++ team_test.go | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++ users.go | 49 ++++++++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+) diff --git a/team.go b/team.go index 029e2b5bc..cc1d700cd 100644 --- a/team.go +++ b/team.go @@ -24,6 +24,26 @@ type TeamInfo struct { Icon map[string]interface{} `json:"icon"` } +type TeamProfileResponse struct { + Profile TeamProfile `json:"profile"` + SlackResponse +} + +type TeamProfile struct { + Fields []TeamProfileField `json:"fields"` +} + +type TeamProfileField struct { + ID string `json:"id"` + Ordering int `json:"ordering"` + Label string `json:"label"` + Hint string `json:"hint"` + Type string `json:"type"` + PossibleValues []string `json:"possible_values"` + IsHidden bool `json:"is_hidden"` + Options map[string]bool `json:"options"` +} + type LoginResponse struct { Logins []Login `json:"logins"` Paging `json:"paging"` @@ -95,6 +115,15 @@ func (api *Client) accessLogsRequest(ctx context.Context, path string, values ur return response, response.Err() } +func (api *Client) teamProfileRequest(ctx context.Context, client httpClient, path string, values url.Values) (*TeamProfileResponse, error) { + response := &TeamProfileResponse{} + err := api.postMethod(ctx, path, values, response) + if err != nil { + return nil, err + } + return response, response.Err() +} + // GetTeamInfo gets the Team Information of the user func (api *Client) GetTeamInfo() (*TeamInfo, error) { return api.GetTeamInfoContext(context.Background()) @@ -113,6 +142,25 @@ func (api *Client) GetTeamInfoContext(ctx context.Context) (*TeamInfo, error) { return &response.Team, nil } +// GetTeamProfile gets the Team Profile settings of the user +func (api *Client) GetTeamProfile() (*TeamProfile, error) { + return api.GetTeamProfileContext(context.Background()) +} + +// GetTeamProfileContext gets the Team Profile settings of the user with a custom context +func (api *Client) GetTeamProfileContext(ctx context.Context) (*TeamProfile, error) { + values := url.Values{ + "token": {api.token}, + } + + response, err := api.teamProfileRequest(ctx, api.httpclient, "team.profile.get", values) + if err != nil { + return nil, err + } + return &response.Profile, nil + +} + // GetAccessLogs retrieves a page of logins according to the parameters given func (api *Client) GetAccessLogs(params AccessLogParameters) ([]Login, *Paging, error) { return api.GetAccessLogsContext(context.Background(), params) diff --git a/team_test.go b/team_test.go index ecc3769e2..d0055d177 100644 --- a/team_test.go +++ b/team_test.go @@ -54,6 +54,76 @@ func TestGetTeamInfo(t *testing.T) { } } +func getTeamProfile(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response := []byte(`{ + "ok":true, + "profile":{ + "fields":[ + { + "id":"XXXD7KN555", + "ordering":2, + "field_name":"", + "label":"Skype", + "hint":"This will be displayed on your profile.", + "type":"text", + "possible_values":null, + "options":null, + "is_hidden":true + }, + { + "id":"XXXGGE5AAN7", + "ordering":4, + "field_name":"title", + "label":"Title", + "hint":"", + "type":"text", + "possible_values":null, + "options":{ + "is_protected":true, + "is_scim":true + }, + "is_hidden":false + } + ] + } + }`) + + rw.Write(response) +} + +func TestGetTeamProfile(t *testing.T) { + http.HandleFunc("/team.profile.get", getTeamProfile) + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + teamProfile, err := api.GetTeamProfile() + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + + // t.Fatal refers to -> t.Errorf & return + if teamProfile.Fields[0].ID != "XXXD7KN555" { + t.Fatal(ErrIncorrectResponse) + } + if teamProfile.Fields[0].Label != "Skype" { + t.Fatal(ErrIncorrectResponse) + } + + if teamProfile.Fields[1].ID != "XXXGGE5AAN7" { + t.Fatal(ErrIncorrectResponse) + } + if teamProfile.Fields[1].Label != "Title" { + t.Fatal(ErrIncorrectResponse) + } + if !teamProfile.Fields[1].Options["is_protected"] { + t.Fatal(ErrIncorrectResponse) + } + +} + func getTeamAccessLogs(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "application/json") response := []byte(`{"ok": true, "logins": [{ diff --git a/users.go b/users.go index d22b44f8c..f11ca6831 100644 --- a/users.go +++ b/users.go @@ -556,6 +556,55 @@ func (api *Client) SetUserRealNameContextWithUser(ctx context.Context, user, rea return response.Err() } +// SetUserCustomFields sets Custom Profile fields on the provided users account. Due to the non-repeating elements +// within the request, a map fields is required. The key in the map signifies the field that will be updated. +// +// Note: You may need to change the way the custom field is populated within the Profile section of the Admin Console from +// SCIM or User Entered to API. +// +// See GetTeamProfile for information to retrieve possible fields for your account. +func (api *Client) SetUserCustomFields(userID string, customFields map[string]UserProfileCustomField) error { + return api.SetUserCustomFieldsContext(context.Background(), userID, customFields) +} + +// SetUserCustomFieldsContext will set a users custom profile field with context. +// +// For more information see SetUserCustomFields +func (api *Client) SetUserCustomFieldsContext(ctx context.Context, userID string, customFields map[string]UserProfileCustomField) error { + + // Convert data to data type with custom marshall / unmarshall + // For more information, see UserProfileCustomFields definition. + updateFields := UserProfileCustomFields{} + updateFields.SetMap(customFields) + + // This anonymous struct is needed to set the fields level of the request data. The base struct for + // UserProfileCustomFields has an unexported variable named fields that does not contain a struct tag, + // which has resulted in this configuration. + profile, err := json.Marshal(&struct { + Fields UserProfileCustomFields `json:"fields"` + }{ + Fields: updateFields, + }) + + if err != nil { + return err + } + + values := url.Values{ + "token": {api.token}, + "user": {userID}, + "profile": {string(profile)}, + } + + response := &userResponseFull{} + if err := postForm(ctx, api.httpclient, APIURL+"users.profile.set", values, response, api); err != nil { + return err + } + + return response.Err() + +} + // SetUserCustomStatus will set a custom status and emoji for the currently // authenticated user. If statusEmoji is "" and statusText is not, the Slack API // will automatically set it to ":speech_balloon:". Otherwise, if both are "" From fc79e2784a768a0642cae9cab5dc671d0eaba70c Mon Sep 17 00:00:00 2001 From: Adam Leskis Date: Thu, 18 Aug 2022 21:02:05 +0100 Subject: [PATCH 115/606] fix some simple typos --- misc.go | 2 +- slackevents/outer_events.go | 2 +- slackevents/parsers.go | 4 ++-- slackevents/parsers_test.go | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/misc.go b/misc.go index bb99f2c42..9180116af 100644 --- a/misc.go +++ b/misc.go @@ -50,7 +50,7 @@ type SlackErrorResponse struct { func (r SlackErrorResponse) Error() string { return r.Err } -// RateLimitedError represents the rate limit respond from slack +// RateLimitedError represents the rate limit response from slack type RateLimitedError struct { RetryAfter time.Duration } diff --git a/slackevents/outer_events.go b/slackevents/outer_events.go index 8d682cc32..6277248dc 100644 --- a/slackevents/outer_events.go +++ b/slackevents/outer_events.go @@ -61,7 +61,7 @@ const ( AppRateLimited = "app_rate_limited" ) -// EventsAPIEventMap maps OUTTER Event API events to their corresponding struct +// EventsAPIEventMap maps OUTER Event API events to their corresponding struct // implementations. The structs should be instances of the unmarshalling // target for the matching event type. var EventsAPIEventMap = map[string]interface{}{ diff --git a/slackevents/parsers.go b/slackevents/parsers.go index 114dba867..23ba2b85b 100644 --- a/slackevents/parsers.go +++ b/slackevents/parsers.go @@ -12,7 +12,7 @@ import ( // eventsMap checks both slack.EventsMapping and // and slackevents.EventsAPIInnerEventMapping. If the event -// exists, returns the the unmarshalled struct instance of +// exists, returns the unmarshalled struct instance of // target for the matching event type. // TODO: Consider moving all events into its own package? func eventsMap(t string) (interface{}, bool) { @@ -176,7 +176,7 @@ func (c TokenComparator) Verify(t string) bool { return subtle.ConstantTimeCompare([]byte(c.VerificationToken), []byte(t)) == 1 } -// ParseEvent parses the outter and inner events (if applicable) of an events +// ParseEvent parses the outer and inner events (if applicable) of an events // api event returning a EventsAPIEvent type. If the event is a url_verification event, // the inner event is empty. func ParseEvent(rawEvent json.RawMessage, opts ...Option) (EventsAPIEvent, error) { diff --git a/slackevents/parsers_test.go b/slackevents/parsers_test.go index 8dcdedda0..821d50f70 100644 --- a/slackevents/parsers_test.go +++ b/slackevents/parsers_test.go @@ -95,7 +95,7 @@ func TestThatOuterCallbackEventHasInnerEvent(t *testing.T) { fmt.Println(e) t.Fail() } - switch outterEvent := msg.Data.(type) { + switch outerEvent := msg.Data.(type) { case *EventsAPICallbackEvent: { switch innerEvent := msg.InnerEvent.Data.(type) { @@ -109,7 +109,7 @@ func TestThatOuterCallbackEventHasInnerEvent(t *testing.T) { } default: { - fmt.Println(outterEvent) + fmt.Println(outerEvent) t.Fail() } } From 563f8e66401cbe5e98f505500bbce8011541e27a Mon Sep 17 00:00:00 2001 From: Naoki Kanatani Date: Tue, 23 Aug 2022 06:11:07 +0900 Subject: [PATCH 116/606] Fix GoDoc grammers --- dialog_text.go | 2 +- internal/backoff/backoff.go | 2 +- socketmode/request.go | 2 +- stars.go | 19 ++++++++++--------- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/dialog_text.go b/dialog_text.go index da06bd6de..25fa1b693 100644 --- a/dialog_text.go +++ b/dialog_text.go @@ -18,7 +18,7 @@ const ( ) // TextInputElement subtype of DialogInput -// https://api.slack.com/dialogs#option_element_attributes#text_element_attributes +// https://api.slack.com/dialogs#option_element_attributes#text_element_attributes type TextInputElement struct { DialogInput MaxLength int `json:"max_length,omitempty"` diff --git a/internal/backoff/backoff.go b/internal/backoff/backoff.go index df210f80d..833e9f2b6 100644 --- a/internal/backoff/backoff.go +++ b/internal/backoff/backoff.go @@ -51,7 +51,7 @@ func (b *Backoff) Duration() (dur time.Duration) { return dur } -//Resets the current value of the counter back to Min +// Resets the current value of the counter back to Min func (b *Backoff) Reset() { b.attempts = 0 } diff --git a/socketmode/request.go b/socketmode/request.go index 078003a0a..254c8c49e 100644 --- a/socketmode/request.go +++ b/socketmode/request.go @@ -6,7 +6,7 @@ import "encoding/json" // // We call this a "request" rather than e.g. a WebSocket message or an Socket Mode "event" following python-slack-sdk: // -// https://github.com/slackapi/python-slack-sdk/blob/3f1c4c6e27bf7ee8af57699b2543e6eb7848bcf9/slack_sdk/socket_mode/request.py#L6 +// https://github.com/slackapi/python-slack-sdk/blob/3f1c4c6e27bf7ee8af57699b2543e6eb7848bcf9/slack_sdk/socket_mode/request.py#L6 // // We know that node-slack-sdk calls it an "event", that makes it hard for us to distinguish our client's own event // that wraps both internal events and Socket Mode "events", vs node-slack-sdk's is for the latter only. diff --git a/stars.go b/stars.go index 529676048..6e0ebbe32 100644 --- a/stars.go +++ b/stars.go @@ -130,17 +130,18 @@ func (api *Client) ListStarsContext(ctx context.Context, params StarsParameters) // GetStarred returns a list of StarredItem items. // // The user then has to iterate over them and figure out what they should -// be looking at according to what is in the Type. -// for _, item := range items { -// switch c.Type { -// case "file_comment": -// log.Println(c.Comment) -// case "file": -// ... +// be looking at according to what is in the Type: +// +// for _, item := range items { +// switch c.Type { +// case "file_comment": +// log.Println(c.Comment) +// case "file": +// ... +// } // -// } // This function still exists to maintain backwards compatibility. -// I exposed it as returning []StarredItem, so it shall stay as StarredItem +// I exposed it as returning []StarredItem, so it shall stay as StarredItem. func (api *Client) GetStarred(params StarsParameters) ([]StarredItem, *Paging, error) { return api.GetStarredContext(context.Background(), params) } From 83bcc585db0ba7c4acdc486ab1a09751395722f7 Mon Sep 17 00:00:00 2001 From: Naoki Kanatani Date: Tue, 23 Aug 2022 06:38:15 +0900 Subject: [PATCH 117/606] Update workflow for testing * Support Go 1.19 (Remove Go1.16) * Fix golangci-lint version to 1.48.0(this is latest now) --- .github/workflows/test.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 97dacfc66..805b3bd1c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,13 +8,13 @@ on: jobs: test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: go: - - '1.16' - '1.17' - '1.18' + - '1.19' name: test go-${{ matrix.go }} steps: - uses: actions/checkout@v3 @@ -26,11 +26,11 @@ jobs: env: GO111MODULE: on lint: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 name: lint steps: - uses: actions/checkout@v3 - name: golangci-lint - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@537aa1903e5d359d0b27dbc19ddd22c5087f3fbc # v3.2.0 with: - version: latest + version: v1.48.0 From 0b2c5fadabaa03e34322e310eb30b54d5b14b2eb Mon Sep 17 00:00:00 2001 From: Rustam <16064414+rusq@users.noreply.github.com> Date: Wed, 31 Aug 2022 17:39:43 +1000 Subject: [PATCH 118/606] Handle null JSONTime Unmarshal Handle the NULL value, when API returns "timestamp": null --- info.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/info.go b/info.go index fde2bc98e..b06dffdbe 100644 --- a/info.go +++ b/info.go @@ -409,6 +409,11 @@ func (t JSONTime) Time() time.Time { func (t *JSONTime) UnmarshalJSON(buf []byte) error { s := bytes.Trim(buf, `"`) + if bytes.EqualFold(s, []byte("null")) { + *t = JSONTime(0) + return nil + } + v, err := strconv.Atoi(string(s)) if err != nil { return err From e47ef98b88297a61307ae049e534ebd088efb6cb Mon Sep 17 00:00:00 2001 From: Rustam Gilyazov <16064414+rusq@users.noreply.github.com> Date: Wed, 31 Aug 2022 17:46:57 +1000 Subject: [PATCH 119/606] add JSONTime test --- info_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 info_test.go diff --git a/info_test.go b/info_test.go new file mode 100644 index 000000000..baf91d0b9 --- /dev/null +++ b/info_test.go @@ -0,0 +1,44 @@ +package slack + +import ( + "testing" +) + +func TestJSONTime_UnmarshalJSON(t *testing.T) { + type args struct { + buf []byte + } + tests := []struct { + name string + args args + wantTr JSONTime + wantErr bool + }{ + { + "acceptable int64 timestamp", + args{[]byte(`1643435556`)}, + JSONTime(1643435556), + false, + }, + { + "acceptable string timestamp", + args{[]byte(`"1643435556"`)}, + JSONTime(1643435556), + false, + }, + { + "null", + args{[]byte(`null`)}, + JSONTime(0), + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var tr JSONTime + if err := tr.UnmarshalJSON(tt.args.buf); (err != nil) != tt.wantErr { + t.Errorf("JSONTime.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} From 6e76f47f828f2de1480a8680368d2dd136cc91d7 Mon Sep 17 00:00:00 2001 From: Kevin Tseng Date: Fri, 2 Sep 2022 17:14:00 -0700 Subject: [PATCH 120/606] fix: UploadFileContext not calling api.AuthTestContext --- files.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/files.go b/files.go index e7e71c495..e7cfe1fee 100644 --- a/files.go +++ b/files.go @@ -299,7 +299,7 @@ func (api *Client) UploadFile(params FileUploadParameters) (file *File, err erro func (api *Client) UploadFileContext(ctx context.Context, params FileUploadParameters) (file *File, err error) { // Test if user token is valid. This helps because client.Do doesn't like this for some reason. XXX: More // investigation needed, but for now this will do. - _, err = api.AuthTest() + _, err = api.AuthTestContext(ctx) if err != nil { return nil, err } From 413153e68f8c7eee6918195075a9fea8d49b05aa Mon Sep 17 00:00:00 2001 From: Andy Haskell Date: Tue, 10 Aug 2021 16:09:09 -0400 Subject: [PATCH 121/606] add deserializations for file change, shared, deleted events --- slackevents/inner_events.go | 42 +++++++++++++++ slackevents/inner_events_test.go | 88 ++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index a92df6a5c..c78c4062b 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -155,6 +155,39 @@ type GroupRenameInfo struct { Created int `json:"created"` } +// FileChangeEvent represents the information associated with the File change +// event. +type FileChangeEvent struct { + Type string `json:"type"` + FileID string `json:"file_id"` + File FileEventFile `json:"file"` +} + +// FileDeletedEvent represents the information associated with the File deleted +// event. +type FileDeletedEvent struct { + Type string `json:"type"` + FileID string `json:"file_id"` + EventTimestamp string `json:"event_ts"` +} + +// FileSharedEvent represents the information associated with the File shared +// event. +type FileSharedEvent struct { + Type string `json:"type"` + ChannelID string `json:"channel_id"` + FileID string `json:"file_id"` + UserID string `json:"user_id"` + File FileEventFile `json:"file"` + EventTimestamp string `json:"event_ts"` +} + +// FileEventFile represents information on the specific file being shared in a +// file-related Slack event. +type FileEventFile struct { + ID string `json:"id"` +} + // GridMigrationFinishedEvent An enterprise grid migration has finished on this workspace. type GridMigrationFinishedEvent struct { Type string `json:"type"` @@ -516,6 +549,12 @@ const ( GroupLeft = EventsAPIType("group_left") // GroupRename is sent when a group is renamed. GroupRename = EventsAPIType("group_rename") + // FileChange is sent when a file is changed. + FileChange = EventsAPIType("file_change") + // FileDeleted is sent when a file is deleted. + FileDeleted = EventsAPIType("file_deleted") + // FileShared is sent when a file is shared. + FileShared = EventsAPIType("file_shared") // GridMigrationFinished An enterprise grid migration has finished on this workspace. GridMigrationFinished = EventsAPIType("grid_migration_finished") // GridMigrationStarted An enterprise grid migration has started on this workspace. @@ -566,6 +605,9 @@ var EventsAPIInnerEventMapping = map[EventsAPIType]interface{}{ ChannelLeft: ChannelLeftEvent{}, ChannelRename: ChannelRenameEvent{}, ChannelIDChanged: ChannelIDChangedEvent{}, + FileChange: FileChangeEvent{}, + FileDeleted: FileDeletedEvent{}, + FileShared: FileSharedEvent{}, GroupDeleted: GroupDeletedEvent{}, GroupArchive: GroupArchiveEvent{}, GroupUnarchive: GroupUnarchiveEvent{}, diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index 2b9af99bf..694ac47d9 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -38,6 +38,94 @@ func TestAppUninstalled(t *testing.T) { } } +func TestFileChangeEvent(t *testing.T) { + rawE := []byte(` + { + "type": "file_change", + "file_id": "F1234567890", + "file": { + "id": "F1234567890" + } + } + `) + + var e FileChangeEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "file_change" { + t.Errorf("type should be file_change, was %s", e.Type) + } + if e.FileID != "F1234567890" { + t.Errorf("file ID should be F1234567890, was %s", e.FileID) + } + if e.File.ID != "F1234567890" { + t.Errorf("file.id should be F1234567890, was %s", e.File.ID) + } +} + +func TestFileDeletedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "file_deleted", + "file_id": "F1234567890", + "event_ts": "1234567890.123456" + } + `) + + var e FileDeletedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "file_deleted" { + t.Errorf("type should be file_deleted, was %s", e.Type) + } + if e.FileID != "F1234567890" { + t.Errorf("file ID should be F1234567890, was %s", e.FileID) + } + if e.EventTimestamp != "1234567890.123456" { + t.Errorf("event timestamp should be 1234567890.123456, was %s", e.EventTimestamp) + } +} + +func TestFileSharedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "file_shared", + "channel_id": "C1234567890", + "file_id": "F1234567890", + "user_id": "U11235813", + "file": { + "id": "F1234567890" + }, + "event_ts": "1234567890.123456" + } + `) + + var e FileSharedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "file_shared" { + t.Errorf("type should be file_shared, was %s", e.Type) + } + if e.ChannelID != "C1234567890" { + t.Errorf("channel ID should be C1234567890, was %s", e.ChannelID) + } + if e.FileID != "F1234567890" { + t.Errorf("file ID should be F1234567890, was %s", e.FileID) + } + if e.UserID != "U11235813" { + t.Errorf("user ID should be U11235813, was %s", e.UserID) + } + if e.File.ID != "F1234567890" { + t.Errorf("file.id should be F1234567890, was %s", e.File.ID) + } + if e.EventTimestamp != "1234567890.123456" { + t.Errorf("event timestamp should be 1234567890.123456, was %s", e.EventTimestamp) + } +} + func TestGridMigrationFinishedEvent(t *testing.T) { rawE := []byte(` { From 8bc6dae800e7228d5af5f2dceecfe61d2df28870 Mon Sep 17 00:00:00 2001 From: Andy Haskell Date: Thu, 2 Sep 2021 09:21:53 -0400 Subject: [PATCH 122/606] add deserialization for file unshared event --- slackevents/inner_events.go | 11 +++++++++++ slackevents/inner_events_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index c78c4062b..eb1e8df65 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -182,6 +182,14 @@ type FileSharedEvent struct { EventTimestamp string `json:"event_ts"` } +// FileUnsharedEvent represents the information associated with the File +// unshared event. +type FileUnsharedEvent struct { + Type string `json:"type"` + FileID string `json:"file_id"` + File FileEventFile `json:"file"` +} + // FileEventFile represents information on the specific file being shared in a // file-related Slack event. type FileEventFile struct { @@ -555,6 +563,8 @@ const ( FileDeleted = EventsAPIType("file_deleted") // FileShared is sent when a file is shared. FileShared = EventsAPIType("file_shared") + // FileUnshared is sent when a file is unshared. + FileUnshared = EventsAPIType("file_unshared") // GridMigrationFinished An enterprise grid migration has finished on this workspace. GridMigrationFinished = EventsAPIType("grid_migration_finished") // GridMigrationStarted An enterprise grid migration has started on this workspace. @@ -608,6 +618,7 @@ var EventsAPIInnerEventMapping = map[EventsAPIType]interface{}{ FileChange: FileChangeEvent{}, FileDeleted: FileDeletedEvent{}, FileShared: FileSharedEvent{}, + FileUnshared: FileUnsharedEvent{}, GroupDeleted: GroupDeletedEvent{}, GroupArchive: GroupArchiveEvent{}, GroupUnarchive: GroupUnarchiveEvent{}, diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index 694ac47d9..ae3ee179c 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -126,6 +126,32 @@ func TestFileSharedEvent(t *testing.T) { } } +func TestFileUnsharedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "file_unshared", + "file_id": "F1234567890", + "file": { + "id": "F1234567890" + } + } + `) + + var e FileUnsharedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "file_unshared" { + t.Errorf("type should be file_shared, was %s", e.Type) + } + if e.FileID != "F1234567890" { + t.Errorf("file ID should be F1234567890, was %s", e.FileID) + } + if e.File.ID != "F1234567890" { + t.Errorf("file.id should be F1234567890, was %s", e.File.ID) + } +} + func TestGridMigrationFinishedEvent(t *testing.T) { rawE := []byte(` { From 1fdc136cc04c6902a17ae5641e1daad9a7d3088a Mon Sep 17 00:00:00 2001 From: Johan Cos Date: Tue, 25 Oct 2022 08:31:26 -0400 Subject: [PATCH 123/606] add GetOtherTeam method --- team.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/team.go b/team.go index 029e2b5bc..18f35517e 100644 --- a/team.go +++ b/team.go @@ -100,6 +100,22 @@ func (api *Client) GetTeamInfo() (*TeamInfo, error) { return api.GetTeamInfoContext(context.Background()) } +// GetOtherTeamInfo gets Team information for any team +func (api *Client) GetOtherTeamInfo(team string) (*TeamInfo, error) { + if team == "" { + return api.GetTeamInfo() + } + values := url.Values{ + "token": {api.token}, + } + values.Add("team", team) + response, err := api.teamRequest(context.Background(), "team.info", values) + if err != nil { + return nil, err + } + return &response.Team, nil +} + // GetTeamInfoContext gets the Team Information of the user with a custom context func (api *Client) GetTeamInfoContext(ctx context.Context) (*TeamInfo, error) { values := url.Values{ From 048adff3fa23cd26c15c315dcf68bf9e480cfc87 Mon Sep 17 00:00:00 2001 From: Paul Tyng Date: Mon, 31 Oct 2022 12:58:40 -0400 Subject: [PATCH 124/606] Add support for status_emoji_display_info --- users.go | 56 +++++++++++++++++++++++++++++---------------------- users_test.go | 40 +++++++++++++++++++++++------------- 2 files changed, 58 insertions(+), 38 deletions(-) diff --git a/users.go b/users.go index d22b44f8c..7257cb72e 100644 --- a/users.go +++ b/users.go @@ -17,30 +17,38 @@ const ( // UserProfile contains all the information details of a given user type UserProfile struct { - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - RealName string `json:"real_name"` - RealNameNormalized string `json:"real_name_normalized"` - DisplayName string `json:"display_name"` - DisplayNameNormalized string `json:"display_name_normalized"` - Email string `json:"email"` - Skype string `json:"skype"` - Phone string `json:"phone"` - Image24 string `json:"image_24"` - Image32 string `json:"image_32"` - Image48 string `json:"image_48"` - Image72 string `json:"image_72"` - Image192 string `json:"image_192"` - Image512 string `json:"image_512"` - ImageOriginal string `json:"image_original"` - Title string `json:"title"` - BotID string `json:"bot_id,omitempty"` - ApiAppID string `json:"api_app_id,omitempty"` - StatusText string `json:"status_text,omitempty"` - StatusEmoji string `json:"status_emoji,omitempty"` - StatusExpiration int `json:"status_expiration"` - Team string `json:"team"` - Fields UserProfileCustomFields `json:"fields"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + RealName string `json:"real_name"` + RealNameNormalized string `json:"real_name_normalized"` + DisplayName string `json:"display_name"` + DisplayNameNormalized string `json:"display_name_normalized"` + Email string `json:"email"` + Skype string `json:"skype"` + Phone string `json:"phone"` + Image24 string `json:"image_24"` + Image32 string `json:"image_32"` + Image48 string `json:"image_48"` + Image72 string `json:"image_72"` + Image192 string `json:"image_192"` + Image512 string `json:"image_512"` + ImageOriginal string `json:"image_original"` + Title string `json:"title"` + BotID string `json:"bot_id,omitempty"` + ApiAppID string `json:"api_app_id,omitempty"` + StatusText string `json:"status_text,omitempty"` + StatusEmoji string `json:"status_emoji,omitempty"` + StatusEmojiDisplayInfo []UserProfileStatusEmojiDisplayInfo `json:"status_emoji_display_info,omitempty"` + StatusExpiration int `json:"status_expiration"` + Team string `json:"team"` + Fields UserProfileCustomFields `json:"fields"` +} + +type UserProfileStatusEmojiDisplayInfo struct { + EmojiName string `json:"emoji_name"` + DisplayAlias string `json:"display_alias,omitempty"` + DisplayURL string `json:"display_url,omitempty"` + Unicode string `json:"unicode,omitempty"` } // UserProfileCustomFields represents user profile's custom fields. diff --git a/users_test.go b/users_test.go index 58b3788b5..5ba915995 100644 --- a/users_test.go +++ b/users_test.go @@ -33,22 +33,31 @@ func getTestUserProfileCustomFields() UserProfileCustomFields { }} } +func getTestUserProfileStatusEmojiDisplayInfo() []UserProfileStatusEmojiDisplayInfo { + return []UserProfileStatusEmojiDisplayInfo{{ + EmojiName: "construction", + Unicode: "1f6a7", + DisplayURL: "https://a.slack-edge.com/production-standard-emoji-assets/14.0/apple-large/1f6a7.png", + }} +} + func getTestUserProfile() UserProfile { return UserProfile{ - StatusText: "testStatus", - StatusEmoji: ":construction:", - RealName: "Test Real Name", - RealNameNormalized: "Test Real Name Normalized", - DisplayName: "Test Display Name", - DisplayNameNormalized: "Test Display Name Normalized", - Email: "test@test.com", - Image24: "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_24.jpg", - Image32: "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_32.jpg", - Image48: "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_48.jpg", - Image72: "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_72.jpg", - Image192: "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_192.jpg", - Image512: "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_512.jpg", - Fields: getTestUserProfileCustomFields(), + StatusText: "testStatus", + StatusEmoji: ":construction:", + StatusEmojiDisplayInfo: getTestUserProfileStatusEmojiDisplayInfo(), + RealName: "Test Real Name", + RealNameNormalized: "Test Real Name Normalized", + DisplayName: "Test Display Name", + DisplayNameNormalized: "Test Display Name Normalized", + Email: "test@test.com", + Image24: "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_24.jpg", + Image32: "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_32.jpg", + Image48: "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_48.jpg", + Image72: "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_72.jpg", + Image192: "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_192.jpg", + Image512: "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_512.jpg", + Fields: getTestUserProfileCustomFields(), } } @@ -608,6 +617,9 @@ func TestGetUserProfile(t *testing.T) { if profile.DisplayName != exp.DisplayName { t.Fatalf(`profile.DisplayName = "%s", wanted "%s"`, profile.DisplayName, exp.DisplayName) } + if len(profile.StatusEmojiDisplayInfo) != 1 { + t.Fatalf(`expected 1 emoji, got %d`, len(profile.StatusEmojiDisplayInfo)) + } } func TestSetFieldsMap(t *testing.T) { From e745a45f9f6888494556d664084f78e6a95b99be Mon Sep 17 00:00:00 2001 From: yokishava Date: Thu, 3 Nov 2022 15:15:20 +0900 Subject: [PATCH 125/606] fix: remove omitempty from replace_original and delete_orginal of WebhookMessage --- webhooks.go | 4 ++-- webhooks_test.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/webhooks.go b/webhooks.go index 2f8fb47f6..e3233536a 100644 --- a/webhooks.go +++ b/webhooks.go @@ -21,8 +21,8 @@ type WebhookMessage struct { Parse string `json:"parse,omitempty"` Blocks *Blocks `json:"blocks,omitempty"` ResponseType string `json:"response_type,omitempty"` - ReplaceOriginal bool `json:"replace_original,omitempty"` - DeleteOriginal bool `json:"delete_original,omitempty"` + ReplaceOriginal bool `json:"replace_original"` + DeleteOriginal bool `json:"delete_original"` ReplyBroadcast bool `json:"reply_broadcast,omitempty"` } diff --git a/webhooks_test.go b/webhooks_test.go index 6068802db..90b7e285d 100644 --- a/webhooks_test.go +++ b/webhooks_test.go @@ -77,15 +77,15 @@ func TestWebhookMessage_WithBlocks(t *testing.T) { assert.Equal(t, 1, len(msgSingleBlock.Blocks.BlockSet)) msgJsonSingleBlock, _ := json.Marshal(msgSingleBlock) - assert.Equal(t, `{"blocks":[{"type":"section","text":{"type":"plain_text","text":"text"}}]}`, string(msgJsonSingleBlock)) + assert.Equal(t, `{"blocks":[{"type":"section","text":{"type":"plain_text","text":"text"}}],"replace_original":false,"delete_original":false}`, string(msgJsonSingleBlock)) msgTwoBlocks := WebhookMessage{Blocks: twoBlocks} assert.Equal(t, 2, len(msgTwoBlocks.Blocks.BlockSet)) msgJsonTwoBlocks, _ := json.Marshal(msgTwoBlocks) - assert.Equal(t, `{"blocks":[{"type":"section","text":{"type":"plain_text","text":"text"}},{"type":"section","text":{"type":"plain_text","text":"text"}}]}`, string(msgJsonTwoBlocks)) + assert.Equal(t, `{"blocks":[{"type":"section","text":{"type":"plain_text","text":"text"}},{"type":"section","text":{"type":"plain_text","text":"text"}}],"replace_original":false,"delete_original":false}`, string(msgJsonTwoBlocks)) msgNoBlocks := WebhookMessage{Text: "foo"} msgJsonNoBlocks, _ := json.Marshal(msgNoBlocks) - assert.Equal(t, `{"text":"foo"}`, string(msgJsonNoBlocks)) + assert.Equal(t, `{"text":"foo","replace_original":false,"delete_original":false}`, string(msgJsonNoBlocks)) } From 3d6bc0061351e7de91763c0057f48b62ebe522e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Finance?= Date: Fri, 18 Nov 2022 01:44:15 +0100 Subject: [PATCH 126/606] Fix WorkflowStepCompletedRequest options handling --- workflow_step_execute.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/workflow_step_execute.go b/workflow_step_execute.go index 18516f23e..32db9ba0b 100644 --- a/workflow_step_execute.go +++ b/workflow_step_execute.go @@ -19,10 +19,10 @@ type ( } ) -type WorkflowStepCompletedRequestOption func(opt WorkflowStepCompletedRequest) error +type WorkflowStepCompletedRequestOption func(opt *WorkflowStepCompletedRequest) error func WorkflowStepCompletedRequestOptionOutput(outputs map[string]string) WorkflowStepCompletedRequestOption { - return func(opt WorkflowStepCompletedRequest) error { + return func(opt *WorkflowStepCompletedRequest) error { if len(outputs) > 0 { opt.Outputs = outputs } @@ -33,7 +33,7 @@ func WorkflowStepCompletedRequestOptionOutput(outputs map[string]string) Workflo // WorkflowStepCompleted indicates step is completed func (api *Client) WorkflowStepCompleted(workflowStepExecuteID string, options ...WorkflowStepCompletedRequestOption) error { // More information: https://api.slack.com/methods/workflows.stepCompleted - r := WorkflowStepCompletedRequest{ + r := &WorkflowStepCompletedRequest{ WorkflowStepExecuteID: workflowStepExecuteID, } for _, option := range options { From 8563f13262fed664b29d3fda8f6a26e2e7d70085 Mon Sep 17 00:00:00 2001 From: Sarthak Kothari Date: Mon, 21 Nov 2022 00:13:27 -0800 Subject: [PATCH 127/606] Added new FileUploadV2 function to avoid server side file timeouts --- files.go | 145 ++++++++++++++++++++++++++++++++++++++++++++++++++ files_test.go | 62 +++++++++++++++++++++ misc.go | 3 ++ 3 files changed, 210 insertions(+) diff --git a/files.go b/files.go index e7cfe1fee..5c2957f53 100644 --- a/files.go +++ b/files.go @@ -2,6 +2,7 @@ package slack import ( "context" + "encoding/json" "fmt" "io" "net/url" @@ -145,6 +146,44 @@ type ListFilesParameters struct { Cursor string } +type FileUploadV2Parameters struct { + File string + FileSize int + Content string + Reader io.Reader + Filename string + Title string + InitialComment string + Channel string + ThreadTimestamp string + AltTxt string + SnippetText string +} + +type getUploadURLExternalResponse struct { + UploadURL string `json:"upload_url"` + FileID string `json:"file_id"` + SlackResponse +} + +type uploadToExternalParams struct { + UploadURL string + Reader io.Reader + File string + Content string + Filename string +} + +type FileSummary struct { + ID string `json:"id"` + Title string `json:"title"` +} + +type completeUploadExternalResponse struct { + SlackResponse + Files []FileSummary `json:"files"` +} + type fileResponseFull struct { File `json:"file"` Paging `json:"paging"` @@ -416,3 +455,109 @@ func (api *Client) ShareFilePublicURLContext(ctx context.Context, fileID string) } return &response.File, response.Comments, &response.Paging, nil } + +func (api *Client) getUploadURLExternal(ctx context.Context, fileSize int, fileName, altText, snippetText string) (*getUploadURLExternalResponse, error) { + values := url.Values{ + "token": {api.token}, + } + values.Add("filename", fileName) + values.Add("length", strconv.Itoa(fileSize)) + if altText != "" { + values.Add("initial_comment", altText) + } + if snippetText != "" { + values.Add("thread_ts", snippetText) + } + response := &getUploadURLExternalResponse{} + err := api.postMethod(ctx, "files.getUploadURLExternal", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +func (api *Client) uploadToURL(ctx context.Context, params uploadToExternalParams) (err error) { + values := url.Values{} + if params.Content != "" { + values.Add("content", params.Content) + values.Add("token", api.token) + err = postForm(ctx, api.httpclient, params.UploadURL, values, nil, api) + } else if params.File != "" { + err = postLocalWithMultipartResponse(ctx, api.httpclient, params.UploadURL, params.File, "file", api.token, values, nil, api) + } else if params.Reader != nil { + err = postWithMultipartResponse(ctx, api.httpclient, params.UploadURL, params.Filename, "file", api.token, values, params.Reader, nil, api) + } + _ = 1 + return err +} + +func (api *Client) completeUploadExternal(ctx context.Context, fileID string, params FileUploadV2Parameters) (file *FileSummary, err error) { + values := url.Values{ + "token": {api.token}, + } + + request := []FileSummary{{ID: fileID, Title: params.Title}} + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, err + } + values.Add("files", string(requestBytes)) + values.Add("channel_id", params.Channel) + + if params.InitialComment != "" { + values.Add("initial_comment", params.InitialComment) + } + if params.ThreadTimestamp != "" { + values.Add("thread_ts", params.ThreadTimestamp) + } + response := &completeUploadExternalResponse{} + err = api.postMethod(ctx, "files.completeUploadExternal", values, response) + if err != nil { + return nil, err + } + if response.Err() != nil { + return nil, response.Err() + } + if len(response.Files) != 1 { + return nil, fmt.Errorf("files.completeUploadExternal: something went wrong; recieved %d files instead of 1", len(response.Files)) + } + return &response.Files[0], nil +} + +func (api *Client) UploadFileV2(params FileUploadV2Parameters) (*FileSummary, error) { + return api.UploadFileV2Context(context.Background(), params) +} + +func (api *Client) UploadFileV2Context(ctx context.Context, params FileUploadV2Parameters) (file *FileSummary, err error) { + if params.Filename == "" { + return nil, fmt.Errorf("file.upload.v2: filename cannot be empty") + } + if params.FileSize == 0 { + return nil, fmt.Errorf("file.upload.v2: file size cannot be 0") + } + if params.Channel == "" { + return nil, fmt.Errorf("file.upload.v2: channel cannot be empty") + } + u, err := api.getUploadURLExternal(ctx, params.FileSize, params.Filename, params.AltTxt, params.SnippetText) + if err != nil { + return nil, err + } + + err = api.uploadToURL(ctx, uploadToExternalParams{ + UploadURL: u.UploadURL, + Reader: params.Reader, + File: params.File, + Content: params.Content, + Filename: params.Filename, + }) + if err != nil { + return nil, err + } + + file, err = api.completeUploadExternal(ctx, u.FileID, params) + if err != nil { + return nil, err + } + return file, nil +} diff --git a/files_test.go b/files_test.go index 5361c06d3..3a962fe39 100644 --- a/files_test.go +++ b/files_test.go @@ -211,3 +211,65 @@ func TestUploadFileWithoutFilename(t *testing.T) { t.Errorf("Error message should mention empty FileUploadParameters.Filename") } } + +func uploadURLHandler(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(getUploadURLExternalResponse{ + FileID: "RandomID", + UploadURL: "http://" + serverAddr + "/abc", + SlackResponse: SlackResponse{Ok: true}}) + rw.Write(response) +} + +func urlFileUploadHandler(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "text") + rw.Write([]byte("Ok: 200, file uploaded")) +} + +func completeURLUpload(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(completeUploadExternalResponse{ + Files: []FileSummary{ + { + ID: "RandomID", + Title: "", + }, + }, + SlackResponse: SlackResponse{Ok: true}}) + rw.Write(response) +} + +func TestUploadFileV2(t *testing.T) { + http.HandleFunc("/files.getUploadURLExternal", uploadURLHandler) + http.HandleFunc("/abc", urlFileUploadHandler) + http.HandleFunc("/files.completeUploadExternal", completeURLUpload) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := FileUploadV2Parameters{ + Filename: "test.txt", Content: "test content", FileSize: 10, + Channel: "CXXXXXXXX", + } + if _, err := api.UploadFileV2(params); err != nil { + t.Errorf("Unexpected error: %s", err) + } + + reader := bytes.NewBufferString("test reader") + params = FileUploadV2Parameters{ + Filename: "test.txt", + Reader: reader, + FileSize: 10, + Channel: "CXXXXXXXX"} + if _, err := api.UploadFileV2(params); err != nil { + t.Errorf("Unexpected error: %s", err) + } + + largeByt := make([]byte, 107374200) + reader = bytes.NewBuffer(largeByt) + params = FileUploadV2Parameters{ + Filename: "test.txt", Reader: reader, FileSize: len(largeByt), + Channel: "CXXXXXXXX"} + if _, err := api.UploadFileV2(params); err != nil { + t.Errorf("Unexpected error: %s", err) + } +} diff --git a/misc.go b/misc.go index 9180116af..7e5a8d54a 100644 --- a/misc.go +++ b/misc.go @@ -307,6 +307,9 @@ type responseParser func(*http.Response) error func newJSONParser(dst interface{}) responseParser { return func(resp *http.Response) error { + if dst == nil { + return nil + } return json.NewDecoder(resp.Body).Decode(dst) } } From d85f3251b69119b075da29d2c863ea0452ddd136 Mon Sep 17 00:00:00 2001 From: Sarthak Kothari Date: Mon, 21 Nov 2022 08:30:00 -0800 Subject: [PATCH 128/606] Adding comments and cleanup --- files.go | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/files.go b/files.go index 5c2957f53..5c750d14e 100644 --- a/files.go +++ b/files.go @@ -456,6 +456,7 @@ func (api *Client) ShareFilePublicURLContext(ctx context.Context, fileID string) return &response.File, response.Comments, &response.Paging, nil } +// getUploadURLExternal gets a URL and fileID from slack which can later be used to upload a file func (api *Client) getUploadURLExternal(ctx context.Context, fileSize int, fileName, altText, snippetText string) (*getUploadURLExternalResponse, error) { values := url.Values{ "token": {api.token}, @@ -477,6 +478,7 @@ func (api *Client) getUploadURLExternal(ctx context.Context, fileSize int, fileN return response, response.Err() } +// uploadToURL uploads the file to the provided URL using post method func (api *Client) uploadToURL(ctx context.Context, params uploadToExternalParams) (err error) { values := url.Values{} if params.Content != "" { @@ -488,11 +490,11 @@ func (api *Client) uploadToURL(ctx context.Context, params uploadToExternalParam } else if params.Reader != nil { err = postWithMultipartResponse(ctx, api.httpclient, params.UploadURL, params.Filename, "file", api.token, values, params.Reader, nil, api) } - _ = 1 return err } -func (api *Client) completeUploadExternal(ctx context.Context, fileID string, params FileUploadV2Parameters) (file *FileSummary, err error) { +// completeUploadExternal once files are uploaded, this completes the upload and shares it to the specified channel +func (api *Client) completeUploadExternal(ctx context.Context, fileID string, params FileUploadV2Parameters) (file *completeUploadExternalResponse, err error) { values := url.Values{ "token": {api.token}, } @@ -519,16 +521,21 @@ func (api *Client) completeUploadExternal(ctx context.Context, fileID string, pa if response.Err() != nil { return nil, response.Err() } - if len(response.Files) != 1 { - return nil, fmt.Errorf("files.completeUploadExternal: something went wrong; recieved %d files instead of 1", len(response.Files)) - } - return &response.Files[0], nil + return response, nil } +// UploadFileV2 uploads file to a given slack channel using 3 steps - +// 1. Get an upload URL using files.getUploadURLExternal API +// 2. Send the file as a post to the URL provided by slack +// 3. Complete the upload and share it to the specified channel using files.completeUploadExternal func (api *Client) UploadFileV2(params FileUploadV2Parameters) (*FileSummary, error) { return api.UploadFileV2Context(context.Background(), params) } +// UploadFileV2 uploads file to a given slack channel using 3 steps with a custom context - +// 1. Get an upload URL using files.getUploadURLExternal API +// 2. Send the file as a post to the URL provided by slack +// 3. Complete the upload and share it to the specified channel using files.completeUploadExternal func (api *Client) UploadFileV2Context(ctx context.Context, params FileUploadV2Parameters) (file *FileSummary, err error) { if params.Filename == "" { return nil, fmt.Errorf("file.upload.v2: filename cannot be empty") @@ -555,9 +562,13 @@ func (api *Client) UploadFileV2Context(ctx context.Context, params FileUploadV2P return nil, err } - file, err = api.completeUploadExternal(ctx, u.FileID, params) + c, err := api.completeUploadExternal(ctx, u.FileID, params) if err != nil { return nil, err } - return file, nil + if len(c.Files) != 1 { + return nil, fmt.Errorf("file.upload.v2: something went wrong; recieved %d files instead of 1", len(c.Files)) + } + + return &c.Files[0], nil } From 0040eadde5daa78554fa11e28822332a445929e7 Mon Sep 17 00:00:00 2001 From: yokishava Date: Tue, 22 Nov 2022 13:07:41 +0900 Subject: [PATCH 129/606] fix: change type of timestamp --- block_rich_text.go | 6 +++--- block_rich_text_test.go | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/block_rich_text.go b/block_rich_text.go index 281db213a..555a61989 100644 --- a/block_rich_text.go +++ b/block_rich_text.go @@ -327,17 +327,17 @@ func NewRichTextSectionUserGroupElement(usergroupID string) *RichTextSectionUser type RichTextSectionDateElement struct { Type RichTextSectionElementType `json:"type"` - Timestamp string `json:"timestamp"` + Timestamp JSONTime `json:"timestamp"` } func (r RichTextSectionDateElement) RichTextSectionElementType() RichTextSectionElementType { return r.Type } -func NewRichTextSectionDateElement(timestamp string) *RichTextSectionDateElement { +func NewRichTextSectionDateElement(timestamp int64) *RichTextSectionDateElement { return &RichTextSectionDateElement{ Type: RTSEDate, - Timestamp: timestamp, + Timestamp: JSONTime(timestamp), } } diff --git a/block_rich_text_test.go b/block_rich_text_test.go index 4c889eba5..270dde94c 100644 --- a/block_rich_text_test.go +++ b/block_rich_text_test.go @@ -80,12 +80,13 @@ func TestRichTextSection_UnmarshalJSON(t *testing.T) { err error }{ { - []byte(`{"elements":[{"type":"unknown","value":10},{"type":"text","text":"hi"}]}`), + []byte(`{"elements":[{"type":"unknown","value":10},{"type":"text","text":"hi"},{"type":"date","timestamp":1636961629}]}`), RichTextSection{ Type: RTESection, Elements: []RichTextSectionElement{ &RichTextSectionUnknownElement{Type: RTSEUnknown, Raw: `{"type":"unknown","value":10}`}, &RichTextSectionTextElement{Type: RTSEText, Text: "hi"}, + &RichTextSectionDateElement{Type: RTSEDate, Timestamp: JSONTime(1636961629)}, }, }, nil, From 2f1cae1672c5b42e6ef205666d9a757a465d3270 Mon Sep 17 00:00:00 2001 From: Sehyun Park Date: Wed, 23 Nov 2022 02:39:03 +0900 Subject: [PATCH 130/606] feat: add number input block element --- block_element.go | 32 ++++++++++++++++++++++++++++++++ block_element_test.go | 10 ++++++++++ 2 files changed, 42 insertions(+) diff --git a/block_element.go b/block_element.go index 643529ff0..71086740d 100644 --- a/block_element.go +++ b/block_element.go @@ -11,6 +11,7 @@ const ( METTimepicker MessageElementType = "timepicker" METPlainTextInput MessageElementType = "plain_text_input" METRadioButtons MessageElementType = "radio_buttons" + METNumber MessageElementType = "number_input" MixedElementImage MixedElementType = "mixed_image" MixedElementText MixedElementType = "mixed_text" @@ -475,3 +476,34 @@ func NewRadioButtonsBlockElement(actionID string, options ...*OptionBlockObject) Options: options, } } + +// NumberInputBlockElement creates a field where a user can enter number +// data. +// Number input elements are currently only available in modals. +// +// More Information: https://api.slack.com/reference/block-kit/block-elements#number +type NumberInputBlockElement struct { + Type MessageElementType `json:"type"` + IsDecimalAllowed bool `json:"is_decimal_allowed"` + ActionID string `json:"action_id,omitempty"` + Placeholder *TextBlockObject `json:"placeholder,omitempty"` + InitialValue string `json:"initial_value,omitempty"` + MinValue string `json:"min_value,omitempty"` + MaxValue string `json:"max_value,omitempty"` + DispatchActionConfig *DispatchActionConfig `json:"dispatch_action_config,omitempty"` +} + +// ElementType returns the type of the Element +func (s NumberInputBlockElement) ElementType() MessageElementType { + return s.Type +} + +// NewNumberInputBlockElement returns an instance of a number input element +func NewNumberInputBlockElement(placeholder *TextBlockObject, actionID string, isDecimalAllowed bool) *NumberInputBlockElement { + return &NumberInputBlockElement{ + Type: METNumber, + ActionID: actionID, + Placeholder: placeholder, + IsDecimalAllowed: isDecimalAllowed, + } +} diff --git a/block_element_test.go b/block_element_test.go index 7da816eab..fad1ad2a1 100644 --- a/block_element_test.go +++ b/block_element_test.go @@ -185,3 +185,13 @@ func TestNewRadioButtonsBlockElement(t *testing.T) { assert.Equal(t, len(radioButtonsElement.Options), 3) } + +func TestNewNumberInputBlockElement(t *testing.T) { + + numberInputElement := NewNumberInputBlockElement(nil, "test", true) + + assert.Equal(t, string(numberInputElement.Type), "number_input") + assert.Equal(t, numberInputElement.ActionID, "test") + assert.Equal(t, numberInputElement.IsDecimalAllowed, true) + +} From 163befd77fe58695fbf875e4b2ad1ef2d9e78689 Mon Sep 17 00:00:00 2001 From: Caleb Tennis Date: Thu, 24 Nov 2022 10:19:18 -0500 Subject: [PATCH 131/606] Add team_id as a parameter to the users.conversations API method --- conversation.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/conversation.go b/conversation.go index e523716cc..d1edcedbb 100644 --- a/conversation.go +++ b/conversation.go @@ -71,6 +71,7 @@ type GetConversationsForUserParameters struct { Types []string Limit int ExcludeArchived bool + TeamID string } type responseMetaData struct { @@ -137,6 +138,10 @@ func (api *Client) GetConversationsForUserContext(ctx context.Context, params *G if params.ExcludeArchived { values.Add("exclude_archived", "true") } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } + response := struct { Channels []Channel `json:"channels"` ResponseMetaData responseMetaData `json:"response_metadata"` From 5da22aaea2dce1aa9afed41553ff6b0aea9f107b Mon Sep 17 00:00:00 2001 From: Sehyun Park Date: Fri, 25 Nov 2022 16:47:10 +0900 Subject: [PATCH 132/606] update UnmarshalJSON for NumberInputBlockElement --- block_conv.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/block_conv.go b/block_conv.go index 1a2c57e9f..76d7eebcd 100644 --- a/block_conv.go +++ b/block_conv.go @@ -122,6 +122,8 @@ func (b *InputBlock) UnmarshalJSON(data []byte) error { e = &OverflowBlockElement{} case "radio_buttons": e = &RadioButtonsBlockElement{} + case "number_input": + e = &NumberInputBlockElement{} default: return errors.New("unsupported block element type") } @@ -192,6 +194,8 @@ func (b *BlockElements) UnmarshalJSON(data []byte) error { blockElement = &RadioButtonsBlockElement{} case "static_select", "external_select", "users_select", "conversations_select", "channels_select": blockElement = &SelectBlockElement{} + case "number_input": + blockElement = &NumberInputBlockElement{} default: return fmt.Errorf("unsupported block element type %v", blockElementType) } From f524e2c449e638d68ee2b76af64ea0055a18432f Mon Sep 17 00:00:00 2001 From: Amelia Gapin Date: Tue, 29 Nov 2022 17:16:19 -0500 Subject: [PATCH 133/606] add block elements for email_text_input and url_text_input --- block_conv.go | 8 ++++++ block_element.go | 58 +++++++++++++++++++++++++++++++++++++++++++ block_element_test.go | 14 +++++++++++ 3 files changed, 80 insertions(+) diff --git a/block_conv.go b/block_conv.go index 1a2c57e9f..d01849cad 100644 --- a/block_conv.go +++ b/block_conv.go @@ -112,6 +112,10 @@ func (b *InputBlock) UnmarshalJSON(data []byte) error { e = &TimePickerBlockElement{} case "plain_text_input": e = &PlainTextInputBlockElement{} + case "email_text_input": + e = &EmailTextInputBlockElement{} + case "url_text_input": + e = &URLTextInputBlockElement{} case "static_select", "external_select", "users_select", "conversations_select", "channels_select": e = &SelectBlockElement{} case "multi_static_select", "multi_external_select", "multi_users_select", "multi_conversations_select", "multi_channels_select": @@ -186,6 +190,10 @@ func (b *BlockElements) UnmarshalJSON(data []byte) error { blockElement = &TimePickerBlockElement{} case "plain_text_input": blockElement = &PlainTextInputBlockElement{} + case "email_text_input": + blockElement = &EmailTextInputBlockElement{} + case "url_text_input": + blockElement = &URLTextInputBlockElement{} case "checkboxes": blockElement = &CheckboxGroupsBlockElement{} case "radio_buttons": diff --git a/block_element.go b/block_element.go index 643529ff0..019433e0e 100644 --- a/block_element.go +++ b/block_element.go @@ -11,6 +11,8 @@ const ( METTimepicker MessageElementType = "timepicker" METPlainTextInput MessageElementType = "plain_text_input" METRadioButtons MessageElementType = "radio_buttons" + METEmailTextInput MessageElementType = "email_text_input" + METURLTextInput MessageElementType = "url_text_input" MixedElementImage MixedElementType = "mixed_image" MixedElementText MixedElementType = "mixed_text" @@ -389,6 +391,62 @@ func NewTimePickerBlockElement(actionID string) *TimePickerBlockElement { } } +// EmailTextInputBlockElement creates a field where a user can enter email +// data. +// email-text-input elements are currently only available in modals. +// +// More Information: https://api.slack.com/reference/block-kit/block-elements#email +type EmailTextInputBlockElement struct { + Type MessageElementType `json:"type"` + ActionID string `json:"action_id,omitempty"` + Placeholder *TextBlockObject `json:"placeholder,omitempty"` + InitialValue string `json:"initial_value,omitempty"` + DispatchActionConfig *DispatchActionConfig `json:"dispatch_action_config,omitempty"` +} + +// ElementType returns the type of the Element +func (s EmailTextInputBlockElement) ElementType() MessageElementType { + return s.Type +} + +// NewEmailTextInputBlockElement returns an instance of a plain-text input +// element +func NewEmailTextInputBlockElement(placeholder *TextBlockObject, actionID string) *EmailTextInputBlockElement { + return &EmailTextInputBlockElement{ + Type: METEmailTextInput, + ActionID: actionID, + Placeholder: placeholder, + } +} + +// URLTextInputBlockElement creates a field where a user can enter url data. +// +// url-text-input elements are currently only available in modals. +// +// More Information: https://api.slack.com/reference/block-kit/block-elements#url +type URLTextInputBlockElement struct { + Type MessageElementType `json:"type"` + ActionID string `json:"action_id,omitempty"` + Placeholder *TextBlockObject `json:"placeholder,omitempty"` + InitialValue string `json:"initial_value,omitempty"` + DispatchActionConfig *DispatchActionConfig `json:"dispatch_action_config,omitempty"` +} + +// ElementType returns the type of the Element +func (s URLTextInputBlockElement) ElementType() MessageElementType { + return s.Type +} + +// NewURLTextInputBlockElement returns an instance of a plain-text input +// element +func NewURLTextInputBlockElement(placeholder *TextBlockObject, actionID string) *URLTextInputBlockElement { + return &URLTextInputBlockElement{ + Type: METURLTextInput, + ActionID: actionID, + Placeholder: placeholder, + } +} + // PlainTextInputBlockElement creates a field where a user can enter freeform // data. // Plain-text input elements are currently only available in modals. diff --git a/block_element_test.go b/block_element_test.go index 7da816eab..45e84b9f5 100644 --- a/block_element_test.go +++ b/block_element_test.go @@ -142,6 +142,20 @@ func TestNewPlainTextInputBlockElement(t *testing.T) { } +func TestNewEmailTextInputBlockElement(t *testing.T) { + emailTextInputElement := NewEmailTextInputBlockElement(nil, "test@test.com") + + assert.Equal(t, string(emailTextInputElement.Type), "email_text_input") + assert.Equal(t, emailTextInputElement.ActionID, "test@test.com") +} + +func TestNewURLTextInputBlockElement(t *testing.T) { + urlTextInputElement := NewURLTextInputBlockElement(nil, "www.test.com") + + assert.Equal(t, string(urlTextInputElement.Type), "url_text_input") + assert.Equal(t, urlTextInputElement.ActionID, "www.test.com") +} + func TestNewCheckboxGroupsBlockElement(t *testing.T) { // Build Text Objects associated with each option checkBoxOptionTextOne := NewTextBlockObject("plain_text", "Check One", false, false) From 521977375b1e3bf6883e59354e01794d9257393e Mon Sep 17 00:00:00 2001 From: ebkn Date: Wed, 30 Nov 2022 13:52:19 +0900 Subject: [PATCH 134/606] feat: add include_all_metadata param to conversations.replies api --- conversation.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/conversation.go b/conversation.go index e523716cc..a5eed4ff8 100644 --- a/conversation.go +++ b/conversation.go @@ -398,13 +398,14 @@ func (api *Client) LeaveConversationContext(ctx context.Context, channelID strin } type GetConversationRepliesParameters struct { - ChannelID string - Timestamp string - Cursor string - Inclusive bool - Latest string - Limit int - Oldest string + ChannelID string + Timestamp string + Cursor string + Inclusive bool + Latest string + Limit int + Oldest string + IncludeAllMetadata bool } // GetConversationReplies retrieves a thread of messages posted to a conversation @@ -436,6 +437,11 @@ func (api *Client) GetConversationRepliesContext(ctx context.Context, params *Ge } else { values.Add("inclusive", "0") } + if params.IncludeAllMetadata { + values.Add("include_all_metadata", "1") + } else { + values.Add("include_all_metadata", "0") + } response := struct { SlackResponse HasMore bool `json:"has_more"` From 5b488b4a52c6295c9e128894ed48d149ca5164b2 Mon Sep 17 00:00:00 2001 From: Sarthak Kothari Date: Fri, 2 Dec 2022 17:56:25 -0800 Subject: [PATCH 135/606] fix lint --- files.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/files.go b/files.go index 5c750d14e..50bf04e6f 100644 --- a/files.go +++ b/files.go @@ -524,7 +524,7 @@ func (api *Client) completeUploadExternal(ctx context.Context, fileID string, pa return response, nil } -// UploadFileV2 uploads file to a given slack channel using 3 steps - +// UploadFileV2 uploads file to a given slack channel using 3 steps - // 1. Get an upload URL using files.getUploadURLExternal API // 2. Send the file as a post to the URL provided by slack // 3. Complete the upload and share it to the specified channel using files.completeUploadExternal @@ -532,7 +532,7 @@ func (api *Client) UploadFileV2(params FileUploadV2Parameters) (*FileSummary, er return api.UploadFileV2Context(context.Background(), params) } -// UploadFileV2 uploads file to a given slack channel using 3 steps with a custom context - +// UploadFileV2 uploads file to a given slack channel using 3 steps with a custom context - // 1. Get an upload URL using files.getUploadURLExternal API // 2. Send the file as a post to the URL provided by slack // 3. Complete the upload and share it to the specified channel using files.completeUploadExternal @@ -567,7 +567,7 @@ func (api *Client) UploadFileV2Context(ctx context.Context, params FileUploadV2P return nil, err } if len(c.Files) != 1 { - return nil, fmt.Errorf("file.upload.v2: something went wrong; recieved %d files instead of 1", len(c.Files)) + return nil, fmt.Errorf("file.upload.v2: something went wrong; received %d files instead of 1", len(c.Files)) } return &c.Files[0], nil From 7c39b554599a5cac924386bed1355b5c1ff730d9 Mon Sep 17 00:00:00 2001 From: Sarthak Kothari Date: Fri, 2 Dec 2022 18:48:55 -0800 Subject: [PATCH 136/606] More improvements --- files.go | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/files.go b/files.go index 50bf04e6f..d32001047 100644 --- a/files.go +++ b/files.go @@ -459,10 +459,10 @@ func (api *Client) ShareFilePublicURLContext(ctx context.Context, fileID string) // getUploadURLExternal gets a URL and fileID from slack which can later be used to upload a file func (api *Client) getUploadURLExternal(ctx context.Context, fileSize int, fileName, altText, snippetText string) (*getUploadURLExternalResponse, error) { values := url.Values{ - "token": {api.token}, + "token": {api.token}, + "filename": {fileName}, + "length": {strconv.Itoa(fileSize)}, } - values.Add("filename", fileName) - values.Add("length", strconv.Itoa(fileSize)) if altText != "" { values.Add("initial_comment", altText) } @@ -495,17 +495,16 @@ func (api *Client) uploadToURL(ctx context.Context, params uploadToExternalParam // completeUploadExternal once files are uploaded, this completes the upload and shares it to the specified channel func (api *Client) completeUploadExternal(ctx context.Context, fileID string, params FileUploadV2Parameters) (file *completeUploadExternalResponse, err error) { - values := url.Values{ - "token": {api.token}, - } - request := []FileSummary{{ID: fileID, Title: params.Title}} requestBytes, err := json.Marshal(request) if err != nil { return nil, err } - values.Add("files", string(requestBytes)) - values.Add("channel_id", params.Channel) + values := url.Values{ + "token": {api.token}, + "files": {string(requestBytes)}, + "channel_id": {params.Channel}, + } if params.InitialComment != "" { values.Add("initial_comment", params.InitialComment) From 7a7c3e271c3488f1793446c48a503de5c3518507 Mon Sep 17 00:00:00 2001 From: Stephen Wan Date: Wed, 26 Oct 2022 09:43:25 -0500 Subject: [PATCH 137/606] events: add TeamAccessGranted and TeamAccessRevoked --- slackevents/inner_events.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index eb1e8df65..e88b6cefe 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -524,6 +524,18 @@ func (e MessageEvent) IsEdited() bool { e.Message.Edited != nil } +// TeamAccessGrantedEvent is sent if access to teams was granted for your org-wide app. +type TeamAccessGrantedEvent struct { + Type string `json:"type"` + TeamIDs []string `json:"team_ids"` +} + +// TeamAccessRevokedEvent is sent if access to teams was revoked for your org-wide app. +type TeamAccessRevokedEvent struct { + Type string `json:"type"` + TeamIDs []string `json:"team_ids"` +} + type EventsAPIType string const ( @@ -599,6 +611,10 @@ const ( MessageMetadataUpdated = EventsAPIType("message_metadata_updated") // MessageMetadataPosted A message with metadata was deleted MessageMetadataDeleted = EventsAPIType("message_metadata_deleted") + // TeamAccessGranted is sent if access to teams was granted for your org-wide app. + TeamAccessGranted = EventsAPIType("team_access_granted") + // TeamAccessrevoked is sent if access to teams was revoked for your org-wide app. + TeamAccessrevoked = EventsAPIType("team_access_revoked") ) // EventsAPIInnerEventMapping maps INNER Event API events to their corresponding struct @@ -641,4 +657,6 @@ var EventsAPIInnerEventMapping = map[EventsAPIType]interface{}{ MessageMetadataPosted: MessageMetadataPostedEvent{}, MessageMetadataUpdated: MessageMetadataUpdatedEvent{}, MessageMetadataDeleted: MessageMetadataDeletedEvent{}, + TeamAccessGranted: TeamAccessGrantedEvent{}, + TeamAccessrevoked: TeamAccessRevokedEvent{}, } From 26ae199df4a511d07e2cd963d6add6d115e7c47e Mon Sep 17 00:00:00 2001 From: Stephen Wan Date: Wed, 26 Oct 2022 09:43:42 -0500 Subject: [PATCH 138/606] api/auth: add ListTeams for auth.teams.list --- auth.go | 34 ++++++++++++++++++++++++++++++++++ auth_test.go | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 auth_test.go diff --git a/auth.go b/auth.go index f4f7f003a..bf6e80d36 100644 --- a/auth.go +++ b/auth.go @@ -38,3 +38,37 @@ func (api *Client) SendAuthRevokeContext(ctx context.Context, token string) (*Au return api.authRequest(ctx, "auth.revoke", values) } + +type listTeamsResponse struct { + Teams []Team `json:"teams"` + SlackResponse +} + +type ListTeamsParameters struct { + Limit int + Cursor string +} + +// ListTeams returns all workspaces a token can access. +// More info: https://api.slack.com/methods/admin.teams.list +func (api *Client) ListTeams(params ListTeamsParameters) ([]Team, string, error) { + return api.ListTeamsContext(context.Background(), params) +} + +// ListTeams returns all workspaces a token can access with a custom context. +func (api *Client) ListTeamsContext(ctx context.Context, params ListTeamsParameters) ([]Team, string, error) { + values := url.Values{ + "token": {api.token}, + } + if params.Cursor != "" { + values.Add("cursor", params.Cursor) + } + + response := &listTeamsResponse{} + err := api.postMethod(ctx, "auth.teams.list", values, response) + if err != nil { + return nil, "", err + } + + return response.Teams, response.ResponseMetadata.Cursor, response.Err() +} diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 000000000..5fe9900eb --- /dev/null +++ b/auth_test.go @@ -0,0 +1,51 @@ +package slack + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func getTeamList(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response := []byte(`{ + "ok": true, + "teams": [ + { + "name": "Shinichi's workspace", + "id": "T12345678" + }, + { + "name": "Migi's workspace", + "id": "T12345679" + } + ], + "response_metadata": { + "next_cursor": "dXNlcl9pZDo5MTQyOTI5Mzkz" + } +}`) + rw.Write(response) +} + +func TestListTeams(t *testing.T) { + http.HandleFunc("/auth.teams.list", getTeamList) + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + teams, cursor, err := api.ListTeams(ListTeamsParameters{}) + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + + assert.Len(t, teams, 2) + assert.Equal(t, "T12345678", teams[0].ID) + assert.Equal(t, "Shinichi's workspace", teams[0].Name) + + assert.Equal(t, "T12345679", teams[1].ID) + assert.Equal(t, "Migi's workspace", teams[1].Name) + + assert.Equal(t, "dXNlcl9pZDo5MTQyOTI5Mzkz", cursor) +} From 86690c762bd0ac470c13d07c1afc90bd991e7c0c Mon Sep 17 00:00:00 2001 From: Stephen Wan Date: Wed, 26 Oct 2022 12:07:12 -0500 Subject: [PATCH 139/606] slackevents: fix bug where enterprise id is not filled on inner events Before this change, innerEvent.EnterpriseID was always empty string. Now, it is filled when available from the outer event. --- slackevents/outer_events.go | 1 + slackevents/parsers.go | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/slackevents/outer_events.go b/slackevents/outer_events.go index 6277248dc..bcc85f331 100644 --- a/slackevents/outer_events.go +++ b/slackevents/outer_events.go @@ -35,6 +35,7 @@ type EventsAPICallbackEvent struct { Token string `json:"token"` TeamID string `json:"team_id"` APIAppID string `json:"api_app_id"` + EnterpriseID string `json:"enterprise_id"` InnerEvent *json.RawMessage `json:"event"` AuthedUsers []string `json:"authed_users"` AuthedTeams []string `json:"authed_teams"` diff --git a/slackevents/parsers.go b/slackevents/parsers.go index 23ba2b85b..96ba5681b 100644 --- a/slackevents/parsers.go +++ b/slackevents/parsers.go @@ -102,7 +102,7 @@ func parseInnerEvent(e *EventsAPICallbackEvent) (EventsAPIEvent, error) { e.TeamID, "unmarshalling_error", e.APIAppID, - "", + e.EnterpriseID, &slack.UnmarshallingErrorEvent{ErrorObj: err}, EventsAPIInnerEvent{}, }, err @@ -114,7 +114,7 @@ func parseInnerEvent(e *EventsAPICallbackEvent) (EventsAPIEvent, error) { e.TeamID, iE.Type, e.APIAppID, - "", + e.EnterpriseID, nil, EventsAPIInnerEvent{}, }, fmt.Errorf("Inner Event does not exist! %s", iE.Type) @@ -128,7 +128,7 @@ func parseInnerEvent(e *EventsAPICallbackEvent) (EventsAPIEvent, error) { e.TeamID, "unmarshalling_error", e.APIAppID, - "", + e.EnterpriseID, &slack.UnmarshallingErrorEvent{ErrorObj: err}, EventsAPIInnerEvent{}, }, err @@ -138,7 +138,7 @@ func parseInnerEvent(e *EventsAPICallbackEvent) (EventsAPIEvent, error) { e.TeamID, e.Type, e.APIAppID, - "", + e.EnterpriseID, e, EventsAPIInnerEvent{iE.Type, recvEvent}, }, nil From 2d81614fa683a937e459b8b49482ceabcb3c40e2 Mon Sep 17 00:00:00 2001 From: Stephen Wan Date: Thu, 27 Oct 2022 10:22:05 -0500 Subject: [PATCH 140/606] conversation: support passing team id in CreateConversation This is a breaking change that hopefully future proofs this codepath from future breaking changes, since adding additional params to the struct should be backwards compatible. --- conversation.go | 19 ++++++++++++++----- conversation_test.go | 2 +- examples/pins/pins.go | 2 +- slacktest/handlers_test.go | 2 +- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/conversation.go b/conversation.go index 07864d573..225608677 100644 --- a/conversation.go +++ b/conversation.go @@ -342,17 +342,26 @@ func (api *Client) CloseConversationContext(ctx context.Context, channelID strin return response.NoOp, response.AlreadyClosed, response.Err() } +type CreateConversationParams struct { + ChannelName string + IsPrivate bool + TeamID string +} + // CreateConversation initiates a public or private channel-based conversation -func (api *Client) CreateConversation(channelName string, isPrivate bool) (*Channel, error) { - return api.CreateConversationContext(context.Background(), channelName, isPrivate) +func (api *Client) CreateConversation(params CreateConversationParams) (*Channel, error) { + return api.CreateConversationContext(context.Background(), params) } // CreateConversationContext initiates a public or private channel-based conversation with a custom context -func (api *Client) CreateConversationContext(ctx context.Context, channelName string, isPrivate bool) (*Channel, error) { +func (api *Client) CreateConversationContext(ctx context.Context, params CreateConversationParams) (*Channel, error) { values := url.Values{ "token": {api.token}, - "name": {channelName}, - "is_private": {strconv.FormatBool(isPrivate)}, + "name": {params.ChannelName}, + "is_private": {strconv.FormatBool(params.IsPrivate)}, + } + if params.TeamID != "" { + values.Set("team_id", params.TeamID) } response, err := api.channelRequest(ctx, "conversations.create", values) if err != nil { diff --git a/conversation_test.go b/conversation_test.go index 61a4e92b1..adbad7c28 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -385,7 +385,7 @@ func TestCreateConversation(t *testing.T) { http.HandleFunc("/conversations.create", okChannelJsonHandler) once.Do(startServer) api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - channel, err := api.CreateConversation("CXXXXXXXX", false) + channel, err := api.CreateConversation(CreateConversationParams{ChannelName: "CXXXXXXXX"}) if err != nil { t.Errorf("Unexpected error: %s", err) return diff --git a/examples/pins/pins.go b/examples/pins/pins.go index d13d2d2c8..5c4f67c22 100644 --- a/examples/pins/pins.go +++ b/examples/pins/pins.go @@ -43,7 +43,7 @@ func main() { postAsUserID = authTest.UserID // Create a temporary channel - channel, err := api.CreateConversation(channelName, false) + channel, err := api.CreateConversation(slack.CreateConversationParams{ChannelName: channelName}) if err != nil { // If the channel exists, that means we just need to unarchive it diff --git a/slacktest/handlers_test.go b/slacktest/handlers_test.go index 6678e7684..dec4c99be 100644 --- a/slacktest/handlers_test.go +++ b/slacktest/handlers_test.go @@ -37,7 +37,7 @@ func TestServerCreateConversationHandler(t *testing.T) { go s.Start() client := slack.New("ABCDEFG", slack.OptionAPIURL(s.GetAPIURL())) - conversation, err := client.CreateConversation("test", false) + conversation, err := client.CreateConversation(slack.CreateConversationParams{ChannelName: "test"}) assert.NoError(t, err) assert.Equal(t, "C0EAQDV4Z", conversation.ID) assert.Equal(t, "U023BECGF", conversation.Creator) From 23204b5d470e489e0f7b0b9dbb8290077c49c895 Mon Sep 17 00:00:00 2001 From: Amelia Gapin Date: Fri, 9 Dec 2022 11:33:51 -0500 Subject: [PATCH 141/606] add focus_on_load option for email/url text inputs --- block_element.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/block_element.go b/block_element.go index 019433e0e..ee42a5645 100644 --- a/block_element.go +++ b/block_element.go @@ -402,6 +402,7 @@ type EmailTextInputBlockElement struct { Placeholder *TextBlockObject `json:"placeholder,omitempty"` InitialValue string `json:"initial_value,omitempty"` DispatchActionConfig *DispatchActionConfig `json:"dispatch_action_config,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` } // ElementType returns the type of the Element @@ -430,6 +431,7 @@ type URLTextInputBlockElement struct { Placeholder *TextBlockObject `json:"placeholder,omitempty"` InitialValue string `json:"initial_value,omitempty"` DispatchActionConfig *DispatchActionConfig `json:"dispatch_action_config,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` } // ElementType returns the type of the Element From 6958897d79b5a31025a008c3e2a98acd672bc730 Mon Sep 17 00:00:00 2001 From: Amelia Gapin Date: Fri, 9 Dec 2022 11:34:12 -0500 Subject: [PATCH 142/606] use example.com in tests for email/url text inputs --- block_element_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/block_element_test.go b/block_element_test.go index 45e84b9f5..94ae574c5 100644 --- a/block_element_test.go +++ b/block_element_test.go @@ -143,17 +143,17 @@ func TestNewPlainTextInputBlockElement(t *testing.T) { } func TestNewEmailTextInputBlockElement(t *testing.T) { - emailTextInputElement := NewEmailTextInputBlockElement(nil, "test@test.com") + emailTextInputElement := NewEmailTextInputBlockElement(nil, "example@example.com") assert.Equal(t, string(emailTextInputElement.Type), "email_text_input") - assert.Equal(t, emailTextInputElement.ActionID, "test@test.com") + assert.Equal(t, emailTextInputElement.ActionID, "example@example.com") } func TestNewURLTextInputBlockElement(t *testing.T) { - urlTextInputElement := NewURLTextInputBlockElement(nil, "www.test.com") + urlTextInputElement := NewURLTextInputBlockElement(nil, "www.example.com") assert.Equal(t, string(urlTextInputElement.Type), "url_text_input") - assert.Equal(t, urlTextInputElement.ActionID, "www.test.com") + assert.Equal(t, urlTextInputElement.ActionID, "www.example.com") } func TestNewCheckboxGroupsBlockElement(t *testing.T) { From b54bd857f4350d0b241379233582a5c407766e97 Mon Sep 17 00:00:00 2001 From: kouwakai Date: Tue, 13 Dec 2022 09:42:09 +0900 Subject: [PATCH 143/606] Add example of updating modal --- examples/modal/modal.go | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/examples/modal/modal.go b/examples/modal/modal.go index e628e853f..60466ae5d 100644 --- a/examples/modal/modal.go +++ b/examples/modal/modal.go @@ -60,6 +60,30 @@ func generateModalRequest() slack.ModalViewRequest { return modalRequest } +func updateModal() slack.ModalViewRequest { + // Create a ModalViewRequest with a header and two inputs + titleText := slack.NewTextBlockObject("plain_text", "My App", false, false) + closeText := slack.NewTextBlockObject("plain_text", "Close", false, false) + submitText := slack.NewTextBlockObject("plain_text", "Submit", false, false) + + headerText := slack.NewTextBlockObject("mrkdwn", "Modal updated!", false, false) + headerSection := slack.NewSectionBlock(headerText, nil, nil) + + blocks := slack.Blocks{ + BlockSet: []slack.Block{ + headerSection, + }, + } + + var modalRequest slack.ModalViewRequest + modalRequest.Type = slack.ViewType("modal") + modalRequest.Title = titleText + modalRequest.Close = closeText + modalRequest.Submit = submitText + modalRequest.Blocks = blocks + return modalRequest +} + // This was taken from the slash example // https://github.com/slack-go/slack/blob/master/examples/slash/slash.go func verifySigningSecret(r *http.Request) error { @@ -149,6 +173,22 @@ func handleModal(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) return } + + // update modal sample + switch i.Type { + //update when interaction type is view_submission + case slack.InteractionTypeViewSubmission: + api := slack.New("YOUR_TOKEN_HERE") + //you can use any modal you want to show to users just like creating modal. + updateModal := updateModal() + //you can use hash and ViewID from payload and externalID if exists + _, err := api.UpdateView(updateModal, "", i.View.Hash, i.View.ID) + if err != nil { + fmt.Printf("Error updating view: %s", err) + w.WriteHeader(http.StatusUnauthorized) + return + } + } } func main() { From d778e9eeb5dfe91db3009415832421bc473ba4fe Mon Sep 17 00:00:00 2001 From: "Johan M. Cos" Date: Tue, 13 Dec 2022 10:11:50 -0500 Subject: [PATCH 144/606] add GetOtherTeamInfoContext method --- team.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/team.go b/team.go index 62d242d44..d21a1b642 100644 --- a/team.go +++ b/team.go @@ -129,22 +129,27 @@ func (api *Client) GetTeamInfo() (*TeamInfo, error) { return api.GetTeamInfoContext(context.Background()) } -// GetOtherTeamInfo gets Team information for any team -func (api *Client) GetOtherTeamInfo(team string) (*TeamInfo, error) { +// GetOtherTeamInfoContext gets Team information for any team with a custom context +func (api *Client) GetOtherTeamInfoContext(ctx context.Context, team string) (*TeamInfo, error) { if team == "" { - return api.GetTeamInfo() + return api.GetTeamInfoContext(ctx) } values := url.Values{ "token": {api.token}, } values.Add("team", team) - response, err := api.teamRequest(context.Background(), "team.info", values) + response, err := api.teamRequest(ctx, "team.info", values) if err != nil { return nil, err } return &response.Team, nil } +// GetOtherTeamInfo gets Team information for any team +func (api *Client) GetOtherTeamInfo(team string) (*TeamInfo, error) { + return api.GetOtherTeamInfoContext(context.Background(), team) +} + // GetTeamInfoContext gets the Team Information of the user with a custom context func (api *Client) GetTeamInfoContext(ctx context.Context) (*TeamInfo, error) { values := url.Values{ From 914e9aaaffa7198d0be9294ccc0b34c0b6d6254c Mon Sep 17 00:00:00 2001 From: Hussachai Puripunpinyo Date: Mon, 19 Dec 2022 11:37:41 -0800 Subject: [PATCH 145/606] add datetimepicker component --- block.go | 1 + block_conv.go | 5 +++++ block_element.go | 25 +++++++++++++++++++++++++ block_element_test.go | 6 ++++++ 4 files changed, 37 insertions(+) diff --git a/block.go b/block.go index 240f55279..18222bd2b 100644 --- a/block.go +++ b/block.go @@ -50,6 +50,7 @@ type BlockAction struct { SelectedConversations []string `json:"selected_conversations"` SelectedDate string `json:"selected_date"` SelectedTime string `json:"selected_time"` + SelectedDateTime int64 `json:"selected_date_time"` InitialOption OptionBlockObject `json:"initial_option"` InitialUser string `json:"initial_user"` InitialChannel string `json:"initial_channel"` diff --git a/block_conv.go b/block_conv.go index 555e48117..4ab58de3f 100644 --- a/block_conv.go +++ b/block_conv.go @@ -110,6 +110,8 @@ func (b *InputBlock) UnmarshalJSON(data []byte) error { e = &DatePickerBlockElement{} case "timepicker": e = &TimePickerBlockElement{} + case "datetimepicker": + e = &DateTimePickerBlockElement{} case "plain_text_input": e = &PlainTextInputBlockElement{} case "email_text_input": @@ -190,6 +192,8 @@ func (b *BlockElements) UnmarshalJSON(data []byte) error { blockElement = &DatePickerBlockElement{} case "timepicker": blockElement = &TimePickerBlockElement{} + case "datetimepicker": + blockElement = &DateTimePickerBlockElement{} case "plain_text_input": blockElement = &PlainTextInputBlockElement{} case "email_text_input": @@ -233,6 +237,7 @@ func (a *Accessory) MarshalJSON() ([]byte, error) { // UnmarshalJSON implements the Unmarshaller interface for Accessory, so that any JSON // unmarshalling is delegated and proper type determination can be made before unmarshal +// Note: datetimepicker is not supported in Accessory func (a *Accessory) UnmarshalJSON(data []byte) error { var r json.RawMessage diff --git a/block_element.go b/block_element.go index aba29c6ba..bee65879d 100644 --- a/block_element.go +++ b/block_element.go @@ -9,6 +9,7 @@ const ( METOverflow MessageElementType = "overflow" METDatepicker MessageElementType = "datepicker" METTimepicker MessageElementType = "timepicker" + METDatetimepicker MessageElementType = "datetimepicker" METPlainTextInput MessageElementType = "plain_text_input" METRadioButtons MessageElementType = "radio_buttons" METEmailTextInput MessageElementType = "email_text_input" @@ -392,6 +393,30 @@ func NewTimePickerBlockElement(actionID string) *TimePickerBlockElement { } } +// DateTimePickerBlockElement defines an element that allows the selection of both +// a date and a time of day formatted as a UNIX timestamp. +// More Information: https://api.slack.com/reference/messaging/block-elements#datetimepicker +type DateTimePickerBlockElement struct { + Type MessageElementType `json:"type"` + ActionID string `json:"action_id,omitempty"` + Placeholder *TextBlockObject `json:"placeholder,omitempty"` + InitialDateTime int64 `json:"initial_date_time,omitempty"` + Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` +} + +// ElementType returns the type of the Element +func (s DateTimePickerBlockElement) ElementType() MessageElementType { + return s.Type +} + +// NewDatePickerBlockElement returns an instance of a datetime picker element +func NewDateTimePickerBlockElement(actionID string) *DateTimePickerBlockElement { + return &DateTimePickerBlockElement{ + Type: METDatetimepicker, + ActionID: actionID, + } +} + // EmailTextInputBlockElement creates a field where a user can enter email // data. // email-text-input elements are currently only available in modals. diff --git a/block_element_test.go b/block_element_test.go index 164f8e2f7..0c47affc3 100644 --- a/block_element_test.go +++ b/block_element_test.go @@ -133,6 +133,12 @@ func TestNewTimePickerBlockElement(t *testing.T) { assert.Equal(t, timepickerElement.ActionID, "test") } +func TestNewDateTimePickerBlockElement(t *testing.T) { + datetimepickerElement := NewDateTimePickerBlockElement("test") + assert.Equal(t, string(datetimepickerElement.Type), "datetimepicker") + assert.Equal(t, datetimepickerElement.ActionID, "test") +} + func TestNewPlainTextInputBlockElement(t *testing.T) { plainTextInputElement := NewPlainTextInputBlockElement(nil, "test") From 31e7361606d41376e8c3299911e3f551eddebb51 Mon Sep 17 00:00:00 2001 From: yokishava Date: Tue, 20 Dec 2022 13:46:19 +0900 Subject: [PATCH 146/606] fix: adding a style to RichTextSectionElements with no style definition --- block_rich_text.go | 22 +++++++++++++++++----- block_rich_text_test.go | 3 ++- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/block_rich_text.go b/block_rich_text.go index 555a61989..ce0e81fbc 100644 --- a/block_rich_text.go +++ b/block_rich_text.go @@ -294,7 +294,7 @@ func NewRichTextSectionLinkElement(url, text string, style *RichTextSectionTextS type RichTextSectionTeamElement struct { Type RichTextSectionElementType `json:"type"` TeamID string `json:"team_id"` - Style *RichTextSectionTextStyle `json:"style.omitempty"` + Style *RichTextSectionTextStyle `json:"style,omitempty"` } func (r RichTextSectionTeamElement) RichTextSectionElementType() RichTextSectionElementType { @@ -312,64 +312,76 @@ func NewRichTextSectionTeamElement(teamID string, style *RichTextSectionTextStyl type RichTextSectionUserGroupElement struct { Type RichTextSectionElementType `json:"type"` UsergroupID string `json:"usergroup_id"` + Style *RichTextSectionTextStyle `json:"style,omitempty"` } func (r RichTextSectionUserGroupElement) RichTextSectionElementType() RichTextSectionElementType { return r.Type } -func NewRichTextSectionUserGroupElement(usergroupID string) *RichTextSectionUserGroupElement { +func NewRichTextSectionUserGroupElement(usergroupID string, style *RichTextSectionTextStyle) *RichTextSectionUserGroupElement { return &RichTextSectionUserGroupElement{ Type: RTSEUserGroup, UsergroupID: usergroupID, + Style: style, } } type RichTextSectionDateElement struct { Type RichTextSectionElementType `json:"type"` Timestamp JSONTime `json:"timestamp"` + Format string `json:"format"` + Fallback string `json:"fallback,omitempty"` + Style *RichTextSectionTextStyle `json:"style,omitempty"` } func (r RichTextSectionDateElement) RichTextSectionElementType() RichTextSectionElementType { return r.Type } -func NewRichTextSectionDateElement(timestamp int64) *RichTextSectionDateElement { +func NewRichTextSectionDateElement(timestamp int64, format, fallback string, style *RichTextSectionTextStyle) *RichTextSectionDateElement { return &RichTextSectionDateElement{ Type: RTSEDate, Timestamp: JSONTime(timestamp), + Format: format, + Fallback: fallback, + Style: style, } } type RichTextSectionBroadcastElement struct { Type RichTextSectionElementType `json:"type"` Range string `json:"range"` + Style *RichTextSectionTextStyle `json:"style,omitempty"` } func (r RichTextSectionBroadcastElement) RichTextSectionElementType() RichTextSectionElementType { return r.Type } -func NewRichTextSectionBroadcastElement(rangeStr string) *RichTextSectionBroadcastElement { +func NewRichTextSectionBroadcastElement(rangeStr string, style *RichTextSectionTextStyle) *RichTextSectionBroadcastElement { return &RichTextSectionBroadcastElement{ Type: RTSEBroadcast, Range: rangeStr, + Style: style, } } type RichTextSectionColorElement struct { Type RichTextSectionElementType `json:"type"` Value string `json:"value"` + Style *RichTextSectionTextStyle `json:"style,omitempty"` } func (r RichTextSectionColorElement) RichTextSectionElementType() RichTextSectionElementType { return r.Type } -func NewRichTextSectionColorElement(value string) *RichTextSectionColorElement { +func NewRichTextSectionColorElement(value string, style *RichTextSectionTextStyle) *RichTextSectionColorElement { return &RichTextSectionColorElement{ Type: RTSEColor, Value: value, + Style: style, } } diff --git a/block_rich_text_test.go b/block_rich_text_test.go index 270dde94c..f92fb9890 100644 --- a/block_rich_text_test.go +++ b/block_rich_text_test.go @@ -80,13 +80,14 @@ func TestRichTextSection_UnmarshalJSON(t *testing.T) { err error }{ { - []byte(`{"elements":[{"type":"unknown","value":10},{"type":"text","text":"hi"},{"type":"date","timestamp":1636961629}]}`), + []byte(`{"elements":[{"type":"unknown","value":10},{"type":"text","text":"hi"},{"type":"date","timestamp":1636961629},{"type":"date","timestamp":1636961629,"format":"{date} at {time}","fallback":"not available"}]}`), RichTextSection{ Type: RTESection, Elements: []RichTextSectionElement{ &RichTextSectionUnknownElement{Type: RTSEUnknown, Raw: `{"type":"unknown","value":10}`}, &RichTextSectionTextElement{Type: RTSEText, Text: "hi"}, &RichTextSectionDateElement{Type: RTSEDate, Timestamp: JSONTime(1636961629)}, + &RichTextSectionDateElement{Type: RTSEDate, Timestamp: JSONTime(1636961629), Format: "{date} at {time}", Fallback: "not available"}, }, }, nil, From bddec0ac2cd0142ba49f994453522141e885a9d2 Mon Sep 17 00:00:00 2001 From: Naoki Kanatani Date: Tue, 20 Dec 2022 23:42:47 +0900 Subject: [PATCH 147/606] refactor FileUploadV2 implementation --- files.go | 72 ++++++++++++++++++++++++++++++++++----------------- files_test.go | 6 ++--- 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/files.go b/files.go index d32001047..356284420 100644 --- a/files.go +++ b/files.go @@ -146,7 +146,7 @@ type ListFilesParameters struct { Cursor string } -type FileUploadV2Parameters struct { +type UploadFileV2Parameters struct { File string FileSize int Content string @@ -160,13 +160,20 @@ type FileUploadV2Parameters struct { SnippetText string } +type getUploadURLExternalParameters struct { + altText string + fileSize int + fileName string + snippetText string +} + type getUploadURLExternalResponse struct { UploadURL string `json:"upload_url"` FileID string `json:"file_id"` SlackResponse } -type uploadToExternalParams struct { +type uploadToURLParameters struct { UploadURL string Reader io.Reader File string @@ -179,6 +186,13 @@ type FileSummary struct { Title string `json:"title"` } +type completeUploadExternalParameters struct { + title string + channel string + initialComment string + threadTimestamp string +} + type completeUploadExternalResponse struct { SlackResponse Files []FileSummary `json:"files"` @@ -457,17 +471,17 @@ func (api *Client) ShareFilePublicURLContext(ctx context.Context, fileID string) } // getUploadURLExternal gets a URL and fileID from slack which can later be used to upload a file -func (api *Client) getUploadURLExternal(ctx context.Context, fileSize int, fileName, altText, snippetText string) (*getUploadURLExternalResponse, error) { +func (api *Client) getUploadURLExternal(ctx context.Context, params getUploadURLExternalParameters) (*getUploadURLExternalResponse, error) { values := url.Values{ "token": {api.token}, - "filename": {fileName}, - "length": {strconv.Itoa(fileSize)}, + "filename": {params.fileName}, + "length": {strconv.Itoa(params.fileSize)}, } - if altText != "" { - values.Add("initial_comment", altText) + if params.altText != "" { + values.Add("initial_comment", params.altText) } - if snippetText != "" { - values.Add("thread_ts", snippetText) + if params.snippetText != "" { + values.Add("thread_ts", params.snippetText) } response := &getUploadURLExternalResponse{} err := api.postMethod(ctx, "files.getUploadURLExternal", values, response) @@ -479,7 +493,7 @@ func (api *Client) getUploadURLExternal(ctx context.Context, fileSize int, fileN } // uploadToURL uploads the file to the provided URL using post method -func (api *Client) uploadToURL(ctx context.Context, params uploadToExternalParams) (err error) { +func (api *Client) uploadToURL(ctx context.Context, params uploadToURLParameters) (err error) { values := url.Values{} if params.Content != "" { values.Add("content", params.Content) @@ -494,8 +508,8 @@ func (api *Client) uploadToURL(ctx context.Context, params uploadToExternalParam } // completeUploadExternal once files are uploaded, this completes the upload and shares it to the specified channel -func (api *Client) completeUploadExternal(ctx context.Context, fileID string, params FileUploadV2Parameters) (file *completeUploadExternalResponse, err error) { - request := []FileSummary{{ID: fileID, Title: params.Title}} +func (api *Client) completeUploadExternal(ctx context.Context, fileID string, params completeUploadExternalParameters) (file *completeUploadExternalResponse, err error) { + request := []FileSummary{{ID: fileID, Title: params.title}} requestBytes, err := json.Marshal(request) if err != nil { return nil, err @@ -503,14 +517,14 @@ func (api *Client) completeUploadExternal(ctx context.Context, fileID string, pa values := url.Values{ "token": {api.token}, "files": {string(requestBytes)}, - "channel_id": {params.Channel}, + "channel_id": {params.channel}, } - if params.InitialComment != "" { - values.Add("initial_comment", params.InitialComment) + if params.initialComment != "" { + values.Add("initial_comment", params.initialComment) } - if params.ThreadTimestamp != "" { - values.Add("thread_ts", params.ThreadTimestamp) + if params.threadTimestamp != "" { + values.Add("thread_ts", params.threadTimestamp) } response := &completeUploadExternalResponse{} err = api.postMethod(ctx, "files.completeUploadExternal", values, response) @@ -524,18 +538,18 @@ func (api *Client) completeUploadExternal(ctx context.Context, fileID string, pa } // UploadFileV2 uploads file to a given slack channel using 3 steps - -// 1. Get an upload URL using files.getUploadURLExternal API +// 1. Get an upload URL using files.getUploadURLExternal API // 2. Send the file as a post to the URL provided by slack // 3. Complete the upload and share it to the specified channel using files.completeUploadExternal -func (api *Client) UploadFileV2(params FileUploadV2Parameters) (*FileSummary, error) { +func (api *Client) UploadFileV2(params UploadFileV2Parameters) (*FileSummary, error) { return api.UploadFileV2Context(context.Background(), params) } // UploadFileV2 uploads file to a given slack channel using 3 steps with a custom context - -// 1. Get an upload URL using files.getUploadURLExternal API +// 1. Get an upload URL using files.getUploadURLExternal API // 2. Send the file as a post to the URL provided by slack // 3. Complete the upload and share it to the specified channel using files.completeUploadExternal -func (api *Client) UploadFileV2Context(ctx context.Context, params FileUploadV2Parameters) (file *FileSummary, err error) { +func (api *Client) UploadFileV2Context(ctx context.Context, params UploadFileV2Parameters) (file *FileSummary, err error) { if params.Filename == "" { return nil, fmt.Errorf("file.upload.v2: filename cannot be empty") } @@ -545,12 +559,17 @@ func (api *Client) UploadFileV2Context(ctx context.Context, params FileUploadV2P if params.Channel == "" { return nil, fmt.Errorf("file.upload.v2: channel cannot be empty") } - u, err := api.getUploadURLExternal(ctx, params.FileSize, params.Filename, params.AltTxt, params.SnippetText) + u, err := api.getUploadURLExternal(ctx, getUploadURLExternalParameters{ + altText: params.AltTxt, + fileName: params.Filename, + fileSize: params.FileSize, + snippetText: params.SnippetText, + }) if err != nil { return nil, err } - err = api.uploadToURL(ctx, uploadToExternalParams{ + err = api.uploadToURL(ctx, uploadToURLParameters{ UploadURL: u.UploadURL, Reader: params.Reader, File: params.File, @@ -561,7 +580,12 @@ func (api *Client) UploadFileV2Context(ctx context.Context, params FileUploadV2P return nil, err } - c, err := api.completeUploadExternal(ctx, u.FileID, params) + c, err := api.completeUploadExternal(ctx, u.FileID, completeUploadExternalParameters{ + title: params.Title, + channel: params.Channel, + initialComment: params.InitialComment, + threadTimestamp: params.ThreadTimestamp, + }) if err != nil { return nil, err } diff --git a/files_test.go b/files_test.go index 3a962fe39..f304a4e95 100644 --- a/files_test.go +++ b/files_test.go @@ -246,7 +246,7 @@ func TestUploadFileV2(t *testing.T) { once.Do(startServer) api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - params := FileUploadV2Parameters{ + params := UploadFileV2Parameters{ Filename: "test.txt", Content: "test content", FileSize: 10, Channel: "CXXXXXXXX", } @@ -255,7 +255,7 @@ func TestUploadFileV2(t *testing.T) { } reader := bytes.NewBufferString("test reader") - params = FileUploadV2Parameters{ + params = UploadFileV2Parameters{ Filename: "test.txt", Reader: reader, FileSize: 10, @@ -266,7 +266,7 @@ func TestUploadFileV2(t *testing.T) { largeByt := make([]byte, 107374200) reader = bytes.NewBuffer(largeByt) - params = FileUploadV2Parameters{ + params = UploadFileV2Parameters{ Filename: "test.txt", Reader: reader, FileSize: len(largeByt), Channel: "CXXXXXXXX"} if _, err := api.UploadFileV2(params); err != nil { From 32bb00de734342e91b18c8991387e710f029e5d5 Mon Sep 17 00:00:00 2001 From: Ian Burton Date: Tue, 20 Dec 2022 14:48:01 -0600 Subject: [PATCH 148/606] socketmode.Client.run: Fix #1125 by allowing producer of channel to close when it is finished, and consumer to see the close --- socketmode/socket_mode_managed_conn.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index b259fd624..c13063235 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -54,9 +54,7 @@ func (smc *Client) RunContext(ctx context.Context) error { } func (smc *Client) run(ctx context.Context, connectionCount int) error { - messages := make(chan json.RawMessage) - defer close(messages) - + messages := make(chan json.RawMessage, 1) deadmanTimer := newDeadmanTimer(smc.maxPingInterval) pingHandler := func(_ string) error { @@ -127,6 +125,8 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { // so we'd have to wait for the ReadJSON to time out, which can take a while. go func() { defer cancel() + // We close messages here as it is the producer for the channel. + defer close(messages) // The receiver reads WebSocket messages, and enqueues parsed Socket Mode requests to be handled by // the request handler @@ -332,7 +332,13 @@ func (smc *Client) runRequestHandler(ctx context.Context, websocket chan json.Ra select { case <-ctx.Done(): return ctx.Err() - case message := <-websocket: + case message, ok := <-websocket: + if !ok { + // The producer closed the channel because it encountered an error (or panic), + // we need only return. + return nil + } + smc.Debugf("Received WebSocket message: %s", message) // listen for incoming messages that need to be parsed From c6ed812b7efd18430333116fb10d610a2c934d64 Mon Sep 17 00:00:00 2001 From: Ian Burton Date: Tue, 20 Dec 2022 14:57:34 -0600 Subject: [PATCH 149/606] socketmode.Client.run: Fix small race potential on 'firstErr' as the outer goroutine did not wait on all inner goroutines that had a chance to set it, also make sure to check error for context.Canceled appropriately --- socketmode/socket_mode_managed_conn.go | 38 ++++++++++++-------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index c13063235..18dae4639 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -89,11 +89,15 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { // We're now connected so we can set up listeners - var ( - wg sync.WaitGroup - firstErr error - firstErrOnce sync.Once - ) + wg := new(sync.WaitGroup) + // sendErr relies on the buffer of 1 here + errc := make(chan error, 1) + sendErr := func(err error) { + select { + case errc <- err: + default: + } + } wg.Add(1) go func() { @@ -102,9 +106,7 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { // The response sender sends Socket Mode responses over the WebSocket conn if err := smc.runResponseSender(ctx, conn); err != nil { - firstErrOnce.Do(func() { - firstErr = err - }) + sendErr(err) } }() @@ -115,9 +117,7 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { // The handler reads Socket Mode requests, and enqueues responses for sending by the response sender if err := smc.runRequestHandler(ctx, messages); err != nil { - firstErrOnce.Do(func() { - firstErr = err - }) + sendErr(err) } }() @@ -131,9 +131,7 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { // The receiver reads WebSocket messages, and enqueues parsed Socket Mode requests to be handled by // the request handler if err := smc.runMessageReceiver(ctx, conn, messages); err != nil { - firstErrOnce.Do(func() { - firstErr = err - }) + sendErr(err) } }() @@ -148,9 +146,7 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { smc.Debugf("Failed to close connection: %v", err) } case <-deadmanTimer.Elapsed(): - firstErrOnce.Do(func() { - firstErr = errors.New("ping timeout: Slack did not send us WebSocket PING for more than Client.maxInterval") - }) + sendErr(err) cancel() } @@ -158,14 +154,14 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { wg.Wait() - if firstErr == context.Canceled { - return firstErr + if err = <-errc; errors.Is(err, context.Canceled) { + return err } // wg.Wait() finishes only after any of the above go routines finishes and cancels the // context, allowing the other threads to shut down gracefully. - // Also, we can expect firstErr to be not nil, as goroutines can finish only on error. - smc.Debugf("Reconnecting due to %v", firstErr) + // Also, we can expect our (first)err to be not nil, as goroutines can finish only on error. + smc.Debugf("Reconnecting due to %v", err) return nil } From 0254b8fb8c2c555d38e62095411740a09c262e77 Mon Sep 17 00:00:00 2001 From: Ian Burton Date: Tue, 20 Dec 2022 15:48:47 -0600 Subject: [PATCH 150/606] socketmode.Client.Events: Change all events produced to Events channel via a method that has an escape hatch; unable to change public Events field without breaking api, though --- socketmode/socket_mode_managed_conn.go | 28 +++++++++++++------------- socketmode/socketmode.go | 12 +++++++++++ 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index 18dae4639..47ec63a5e 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -80,10 +80,10 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { ctx, cancel := context.WithCancel(ctx) defer cancel() - smc.Events <- newEvent(EventTypeConnected, &ConnectedEvent{ + smc.sendEvent(ctx, newEvent(EventTypeConnected, &ConnectedEvent{ ConnectionCount: connectionCount, Info: info, - }) + })) smc.Debugf("WebSocket connection succeeded on try %d", connectionCount) @@ -189,10 +189,10 @@ func (smc *Client) connect(ctx context.Context, connectionCount int, additionalP ) // send connecting event - smc.Events <- newEvent(EventTypeConnecting, &slack.ConnectingEvent{ + smc.sendEvent(ctx, newEvent(EventTypeConnecting, &slack.ConnectingEvent{ Attempt: boff.Attempts() + 1, ConnectionCount: connectionCount, - }) + })) // attempt to start the connection info, conn, err := smc.openAndDial(ctx, additionalPingHandler) @@ -212,7 +212,7 @@ func (smc *Client) connect(ctx context.Context, connectionCount int, additionalP case slack.StatusCodeError: if actual.Code == http.StatusNotFound { smc.Debugf("invalid auth when connecting with Socket Mode: %s", err) - smc.Events <- newEvent(EventTypeInvalidAuth, &slack.InvalidAuthEvent{}) + smc.sendEvent(ctx, newEvent(EventTypeInvalidAuth, &slack.InvalidAuthEvent{})) return nil, nil, err } case *slack.RateLimitedError: @@ -223,11 +223,11 @@ func (smc *Client) connect(ctx context.Context, connectionCount int, additionalP backoff = timex.Max(backoff, boff.Duration()) // any other errors are treated as recoverable and we try again after // sending the event along the Events channel - smc.Events <- newEvent(EventTypeConnectionError, &slack.ConnectionErrorEvent{ + smc.sendEvent(ctx, newEvent(EventTypeConnectionError, &slack.ConnectionErrorEvent{ Attempt: boff.Attempts(), Backoff: backoff, ErrorObj: err, - }) + })) // get time we should wait before attempting to connect again smc.Debugf("reconnection %d failed: %s reconnecting in %v\n", boff.Attempts(), err, backoff) @@ -308,10 +308,10 @@ func (smc *Client) runResponseSender(ctx context.Context, conn *websocket.Conn) smc.Debugf("Sending Socket Mode response with envelope ID %q: %v", res.EnvelopeID, res) if err := unsafeWriteSocketModeResponse(conn, res); err != nil { - smc.Events <- newEvent(EventTypeErrorWriteFailed, &ErrorWriteFailed{ + smc.sendEvent(ctx, newEvent(EventTypeErrorWriteFailed, &ErrorWriteFailed{ Cause: err, Response: res, - }) + })) } smc.Debugf("Finished sending Socket Mode response with envelope ID %q", res.EnvelopeID) @@ -340,10 +340,10 @@ func (smc *Client) runRequestHandler(ctx context.Context, websocket chan json.Ra // listen for incoming messages that need to be parsed evt, err := smc.parseEvent(message) if err != nil { - smc.Events <- newEvent(EventTypeErrorBadMessage, &ErrorBadMessage{ + smc.sendEvent(ctx, newEvent(EventTypeErrorBadMessage, &ErrorBadMessage{ Cause: err, Message: message, - }) + })) } else if evt != nil { if evt.Type == EventTypeDisconnect { // We treat the `disconnect` request from Slack as an error internally, @@ -351,7 +351,7 @@ func (smc *Client) runRequestHandler(ctx context.Context, websocket chan json.Ra return errorRequestedDisconnect{} } - smc.Events <- *evt + smc.sendEvent(ctx, *evt) } } } @@ -463,9 +463,9 @@ func (smc *Client) receiveMessagesInto(ctx context.Context, conn *websocket.Conn case err != nil: // All other errors from ReadJSON come from NextReader, and should // kill the read loop and force a reconnect. - smc.Events <- newEvent(EventTypeIncomingError, &slack.IncomingEventError{ + smc.sendEvent(ctx, newEvent(EventTypeIncomingError, &slack.IncomingEventError{ ErrorObj: err, - }) + })) return err case len(event) == 0: diff --git a/socketmode/socketmode.go b/socketmode/socketmode.go index 1871e6763..6ca8f487c 100644 --- a/socketmode/socketmode.go +++ b/socketmode/socketmode.go @@ -119,3 +119,15 @@ func New(api *slack.Client, options ...Option) *Client { return result } + +// sendEvent safely sends an event into the Clients Events channel +// and blocks until buffer space is had, or the context is canceled. +// This prevents deadlocking in the event that Events buffer is full, +// other goroutines are waiting, and/or timing allows receivers to exit +// before all senders are finished. +func (smc *Client) sendEvent(ctx context.Context, event Event) { + select { + case smc.Events <- event: + case <-ctx.Done(): + } +} From 25f2f6e12759d96a1bb49df416536a9f23ef7f3b Mon Sep 17 00:00:00 2001 From: Ian Burton Date: Tue, 20 Dec 2022 15:58:08 -0600 Subject: [PATCH 151/606] socketmode.Client.run: Cover edge case where all goroutines exit nil, when getting buffered (first) error --- socketmode/socket_mode_managed_conn.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index 47ec63a5e..8dcabc55c 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -142,11 +142,11 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { select { case <-ctx.Done(): // Detect when the connection is dead and try close connection. - if err = conn.Close(); err != nil { + if err := conn.Close(); err != nil { smc.Debugf("Failed to close connection: %v", err) } case <-deadmanTimer.Elapsed(): - sendErr(err) + sendErr(errors.New("ping timeout: Slack did not send us WebSocket PING for more than Client.maxInterval")) cancel() } @@ -154,7 +154,14 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { wg.Wait() - if err = <-errc; errors.Is(err, context.Canceled) { + select { + case err = <-errc: + // Get buffered error + default: + // Or nothing if they all exited nil + } + + if errors.Is(err, context.Canceled) { return err } From bb282370afaec97ec02bf1ce163308d56ff8b08b Mon Sep 17 00:00:00 2001 From: Ian Burton Date: Tue, 20 Dec 2022 16:41:38 -0600 Subject: [PATCH 152/606] socketmode: Slight refactor of Send/Ack on Client to allow passing contexts for channel ops, though they are very similar now --- socketmode/socket_mode_managed_conn.go | 47 ++++++++++++++++++++------ 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index 8dcabc55c..b356734cc 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -416,29 +416,54 @@ func newEvent(tpe EventType, data interface{}, req ...*Request) Event { // This tells Slack that the we have received the request denoted by the envelope ID, // by sending back the envelope ID over the WebSocket connection. func (smc *Client) Ack(req Request, payload ...interface{}) { - res := Response{ - EnvelopeID: req.EnvelopeID, - } - + var pld interface{} if len(payload) > 0 { - res.Payload = payload[0] + pld = payload[0] } - smc.Send(res) + smc.AckCtx(context.TODO(), req.EnvelopeID, pld) +} + +// AckCtx acknowledges the Socket Mode request envelope ID with the payload. +// +// This tells Slack that the we have received the request denoted by the request (envelope) ID, +// by sending back the ID over the WebSocket connection. +func (smc *Client) AckCtx(ctx context.Context, reqID string, payload interface{}) error { + return smc.SendCtx(ctx, Response{ + EnvelopeID: reqID, + Payload: payload, + }) } // Send sends the Socket Mode response over a WebSocket connection. // This is usually used for acknowledging requests, but if you need more control over Client.Ack(). // It's normally recommended to use Client.Ack() instead of this. func (smc *Client) Send(res Response) { - js, err := json.Marshal(res) - if err != nil { - panic(err) + smc.SendCtx(context.TODO(), res) +} + +// SendCtx sends the Socket Mode response over a WebSocket connection. +// This is usually used for acknowledging requests, but if you need more control +// it's normally recommended to use Client.AckCtx() instead of this. +func (smc *Client) SendCtx(ctx context.Context, res Response) error { + if smc.debug { + js, err := json.Marshal(res) + + // Log the error so users of `Send` don't see it entirely disappear as that method + // does not return an error and used to panic on failure (with or without debug) + smc.Debugf("Scheduling Socket Mode response (error: %v) for envelope ID %s: %s", err, res.EnvelopeID, js) + if err != nil { + return err + } } - smc.Debugf("Scheduling Socket Mode response for envelope ID %s: %s", res.EnvelopeID, js) + select { + case <-ctx.Done(): + return ctx.Err() + case smc.socketModeResponses <- &res: + } - smc.socketModeResponses <- &res + return nil } // receiveMessagesInto attempts to receive an event from the WebSocket connection for Socket Mode. From 377ffe4586a62eea6fa82d413a7a40cf064f728f Mon Sep 17 00:00:00 2001 From: Ian Burton Date: Tue, 20 Dec 2022 17:30:52 -0600 Subject: [PATCH 153/606] socketmode: Shore up error handling via errors.Is/As/(Un)Wrapping, other small changes --- socketmode/socket_mode_managed_conn.go | 111 +++++++++++++------------ 1 file changed, 59 insertions(+), 52 deletions(-) diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index b356734cc..53bf1294a 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -215,18 +215,24 @@ func (smc *Client) connect(ctx context.Context, connectionCount int, additionalP default: } - switch actual := err.(type) { - case slack.StatusCodeError: - if actual.Code == http.StatusNotFound { - smc.Debugf("invalid auth when connecting with Socket Mode: %s", err) - smc.sendEvent(ctx, newEvent(EventTypeInvalidAuth, &slack.InvalidAuthEvent{})) - return nil, nil, err - } - case *slack.RateLimitedError: - backoff = actual.RetryAfter - default: + var ( + actual slack.StatusCodeError + rlError *slack.RateLimitedError + ) + + if errors.As(err, &actual) && actual.Code == http.StatusNotFound { + smc.Debugf("invalid auth when connecting with Socket Mode: %s", err) + smc.sendEvent(ctx, newEvent(EventTypeInvalidAuth, &slack.InvalidAuthEvent{})) + + return nil, nil, err + } else if errors.As(err, &rlError) { + backoff = rlError.RetryAfter } + // If we check for errors.Is(err, context.Canceled) here and + // return early then we don't send the Event below that some users + // may already rely on; ie a behavior change. + backoff = timex.Max(backoff, boff.Duration()) // any other errors are treated as recoverable and we try again after // sending the event along the Events channel @@ -242,9 +248,11 @@ func (smc *Client) connect(ctx context.Context, connectionCount int, additionalP // wait for one of the following to occur, // backoff duration has elapsed, disconnectCh is signalled, or // the smc finishes disconnecting. + timer := time.NewTimer(backoff) select { - case <-time.After(backoff): // retry after the backoff. + case <-timer.C: // retry after the backoff. case <-ctx.Done(): + timer.Stop() return nil, nil, ctx.Err() } } @@ -394,11 +402,7 @@ func unsafeWriteSocketModeResponse(conn *websocket.Conn, res *Response) error { // Remove write deadline regardless of WriteJSON succeeds or not defer conn.SetWriteDeadline(time.Time{}) - if err := conn.WriteJSON(res); err != nil { - return err - } - - return nil + return conn.WriteJSON(res) } func newEvent(tpe EventType, data interface{}, req ...*Request) Event { @@ -475,53 +479,56 @@ func (smc *Client) receiveMessagesInto(ctx context.Context, conn *websocket.Conn event := json.RawMessage{} err := conn.ReadJSON(&event) + if err != nil { + // check if the connection was closed. + // This version of the gorilla/websocket package also does a type assertion + // on the error, rather than unwrapping it, so we'll do the unwrapping then pass + // the unwrapped error + var wsErr *websocket.CloseError + if errors.As(err, &wsErr) && websocket.IsUnexpectedCloseError(wsErr) { + return err + } - // check if the connection was closed. - if websocket.IsUnexpectedCloseError(err) { - return err - } + if errors.Is(err, io.ErrUnexpectedEOF) { + // EOF's don't seem to signify a failed connection so instead we ignore + // them here and detect a failed connection upon attempting to send a + // 'PING' message - switch { - case err == io.ErrUnexpectedEOF: - // EOF's don't seem to signify a failed connection so instead we ignore - // them here and detect a failed connection upon attempting to send a - // 'PING' message + // Unlike RTM, we don't ping from the our end as there seem to have no client ping. + // We just continue to the next loop so that we `smc.disconnected` should be received if + // this EOF error was actually due to disconnection. - // Unlike RTM, we don't ping from the our end as there seem to have no client ping. - // We just continue to the next loop so that we `smc.disconnected` should be received if - // this EOF error was actually due to disconnection. + return nil + } - return nil - case err != nil: // All other errors from ReadJSON come from NextReader, and should // kill the read loop and force a reconnect. + // TODO: Unless it's a JSON unmarshal-type error in which case maybe reconnecting isn't needed... smc.sendEvent(ctx, newEvent(EventTypeIncomingError, &slack.IncomingEventError{ ErrorObj: err, })) return err - case len(event) == 0: - smc.Debugln("Received empty event") - default: - if smc.debug { - buf := &bytes.Buffer{} - d := json.NewEncoder(buf) - d.SetIndent("", " ") - if err := d.Encode(event); err != nil { - smc.Debugln("Failed encoding decoded json:", err) - } - reencoded := buf.String() + } - smc.Debugln("Incoming WebSocket message:", reencoded) + if smc.debug { + buf := &bytes.Buffer{} + d := json.NewEncoder(buf) + d.SetIndent("", " ") + if err := d.Encode(event); err != nil { + smc.Debugln("Failed encoding decoded json:", err) } + reencoded := buf.String() - select { - case sink <- event: - case <-ctx.Done(): - smc.Debugln("cancelled while attempting to send raw event") + smc.Debugln("Incoming WebSocket message:", reencoded) + } - return ctx.Err() - } + select { + case sink <- event: + case <-ctx.Done(): + smc.Debugln("cancelled while attempting to send raw event") + + return ctx.Err() } return nil @@ -534,7 +541,7 @@ func (smc *Client) parseEvent(wsMsg json.RawMessage) (*Event, error) { req := &Request{} err := json.Unmarshal(wsMsg, req) if err != nil { - return nil, fmt.Errorf("unmarshalling WebSocket message: %v", err) + return nil, fmt.Errorf("unmarshalling WebSocket message: %w", err) } var evt Event @@ -550,7 +557,7 @@ func (smc *Client) parseEvent(wsMsg json.RawMessage) (*Event, error) { eventsAPIEvent, err := slackevents.ParseEvent(payloadEvent, slackevents.OptionNoVerifyToken()) if err != nil { - return nil, fmt.Errorf("parsing Events API event: %v", err) + return nil, fmt.Errorf("parsing Events API event: %w", err) } evt = newEvent(EventTypeEventsAPI, eventsAPIEvent, req) @@ -563,7 +570,7 @@ func (smc *Client) parseEvent(wsMsg json.RawMessage) (*Event, error) { var cmd slack.SlashCommand if err := json.Unmarshal(req.Payload, &cmd); err != nil { - return nil, fmt.Errorf("parsing slash command: %v", err) + return nil, fmt.Errorf("parsing slash command: %w", err) } evt = newEvent(EventTypeSlashCommand, cmd, req) @@ -577,7 +584,7 @@ func (smc *Client) parseEvent(wsMsg json.RawMessage) (*Event, error) { var callback slack.InteractionCallback if err := json.Unmarshal(req.Payload, &callback); err != nil { - return nil, fmt.Errorf("parsing interaction callback: %v", err) + return nil, fmt.Errorf("parsing interaction callback: %w", err) } evt = newEvent(EventTypeInteractive, callback, req) From 80ebd29df0c745f75f7b5314122fe17e02fb4a8e Mon Sep 17 00:00:00 2001 From: yokishava Date: Wed, 21 Dec 2022 18:06:49 +0900 Subject: [PATCH 154/606] fix: removing newly added fields --- block_rich_text.go | 20 ++++---------------- block_rich_text_test.go | 3 +-- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/block_rich_text.go b/block_rich_text.go index ce0e81fbc..01e5cdb96 100644 --- a/block_rich_text.go +++ b/block_rich_text.go @@ -312,76 +312,64 @@ func NewRichTextSectionTeamElement(teamID string, style *RichTextSectionTextStyl type RichTextSectionUserGroupElement struct { Type RichTextSectionElementType `json:"type"` UsergroupID string `json:"usergroup_id"` - Style *RichTextSectionTextStyle `json:"style,omitempty"` } func (r RichTextSectionUserGroupElement) RichTextSectionElementType() RichTextSectionElementType { return r.Type } -func NewRichTextSectionUserGroupElement(usergroupID string, style *RichTextSectionTextStyle) *RichTextSectionUserGroupElement { +func NewRichTextSectionUserGroupElement(usergroupID string) *RichTextSectionUserGroupElement { return &RichTextSectionUserGroupElement{ Type: RTSEUserGroup, UsergroupID: usergroupID, - Style: style, } } type RichTextSectionDateElement struct { Type RichTextSectionElementType `json:"type"` Timestamp JSONTime `json:"timestamp"` - Format string `json:"format"` - Fallback string `json:"fallback,omitempty"` - Style *RichTextSectionTextStyle `json:"style,omitempty"` } func (r RichTextSectionDateElement) RichTextSectionElementType() RichTextSectionElementType { return r.Type } -func NewRichTextSectionDateElement(timestamp int64, format, fallback string, style *RichTextSectionTextStyle) *RichTextSectionDateElement { +func NewRichTextSectionDateElement(timestamp int64) *RichTextSectionDateElement { return &RichTextSectionDateElement{ Type: RTSEDate, Timestamp: JSONTime(timestamp), - Format: format, - Fallback: fallback, - Style: style, } } type RichTextSectionBroadcastElement struct { Type RichTextSectionElementType `json:"type"` Range string `json:"range"` - Style *RichTextSectionTextStyle `json:"style,omitempty"` } func (r RichTextSectionBroadcastElement) RichTextSectionElementType() RichTextSectionElementType { return r.Type } -func NewRichTextSectionBroadcastElement(rangeStr string, style *RichTextSectionTextStyle) *RichTextSectionBroadcastElement { +func NewRichTextSectionBroadcastElement(rangeStr string) *RichTextSectionBroadcastElement { return &RichTextSectionBroadcastElement{ Type: RTSEBroadcast, Range: rangeStr, - Style: style, } } type RichTextSectionColorElement struct { Type RichTextSectionElementType `json:"type"` Value string `json:"value"` - Style *RichTextSectionTextStyle `json:"style,omitempty"` } func (r RichTextSectionColorElement) RichTextSectionElementType() RichTextSectionElementType { return r.Type } -func NewRichTextSectionColorElement(value string, style *RichTextSectionTextStyle) *RichTextSectionColorElement { +func NewRichTextSectionColorElement(value string) *RichTextSectionColorElement { return &RichTextSectionColorElement{ Type: RTSEColor, Value: value, - Style: style, } } diff --git a/block_rich_text_test.go b/block_rich_text_test.go index f92fb9890..270dde94c 100644 --- a/block_rich_text_test.go +++ b/block_rich_text_test.go @@ -80,14 +80,13 @@ func TestRichTextSection_UnmarshalJSON(t *testing.T) { err error }{ { - []byte(`{"elements":[{"type":"unknown","value":10},{"type":"text","text":"hi"},{"type":"date","timestamp":1636961629},{"type":"date","timestamp":1636961629,"format":"{date} at {time}","fallback":"not available"}]}`), + []byte(`{"elements":[{"type":"unknown","value":10},{"type":"text","text":"hi"},{"type":"date","timestamp":1636961629}]}`), RichTextSection{ Type: RTESection, Elements: []RichTextSectionElement{ &RichTextSectionUnknownElement{Type: RTSEUnknown, Raw: `{"type":"unknown","value":10}`}, &RichTextSectionTextElement{Type: RTSEText, Text: "hi"}, &RichTextSectionDateElement{Type: RTSEDate, Timestamp: JSONTime(1636961629)}, - &RichTextSectionDateElement{Type: RTSEDate, Timestamp: JSONTime(1636961629), Format: "{date} at {time}", Fallback: "not available"}, }, }, nil, From 68bc30c07ba0fb3f5ae4dc71ecee60fd1b858db9 Mon Sep 17 00:00:00 2001 From: Hussachai Puripunpinyo Date: Wed, 21 Dec 2022 10:14:38 -0800 Subject: [PATCH 155/606] remove unsupported field `Placeholder` from Datetimepicker --- block_element.go | 1 - 1 file changed, 1 deletion(-) diff --git a/block_element.go b/block_element.go index bee65879d..a70d8f2de 100644 --- a/block_element.go +++ b/block_element.go @@ -399,7 +399,6 @@ func NewTimePickerBlockElement(actionID string) *TimePickerBlockElement { type DateTimePickerBlockElement struct { Type MessageElementType `json:"type"` ActionID string `json:"action_id,omitempty"` - Placeholder *TextBlockObject `json:"placeholder,omitempty"` InitialDateTime int64 `json:"initial_date_time,omitempty"` Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` } From 7589264eeb0012925b52b577c58f40ef6a8c3747 Mon Sep 17 00:00:00 2001 From: KouWakai Date: Sun, 25 Dec 2022 13:24:44 +0900 Subject: [PATCH 156/606] delete dupulicated code at L181 --- examples/modal/modal.go | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/modal/modal.go b/examples/modal/modal.go index 60466ae5d..b5a9001d1 100644 --- a/examples/modal/modal.go +++ b/examples/modal/modal.go @@ -178,7 +178,6 @@ func handleModal(w http.ResponseWriter, r *http.Request) { switch i.Type { //update when interaction type is view_submission case slack.InteractionTypeViewSubmission: - api := slack.New("YOUR_TOKEN_HERE") //you can use any modal you want to show to users just like creating modal. updateModal := updateModal() //you can use hash and ViewID from payload and externalID if exists From c4f3ebe4586df3cf1f61114dac0527e41b17b8ea Mon Sep 17 00:00:00 2001 From: KouWakai <35868597+KouWakai@users.noreply.github.com> Date: Sun, 25 Dec 2022 13:26:25 +0900 Subject: [PATCH 157/606] Change comments at L183 to suggestion Co-authored-by: Naoki Kanatani --- examples/modal/modal.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/modal/modal.go b/examples/modal/modal.go index b5a9001d1..dd337542d 100644 --- a/examples/modal/modal.go +++ b/examples/modal/modal.go @@ -180,7 +180,8 @@ func handleModal(w http.ResponseWriter, r *http.Request) { case slack.InteractionTypeViewSubmission: //you can use any modal you want to show to users just like creating modal. updateModal := updateModal() - //you can use hash and ViewID from payload and externalID if exists + // You must set one of external_id or view_id and you can use hash for avoiding race condition. + // More details: https://api.slack.com/surfaces/modals/using#updating_apis _, err := api.UpdateView(updateModal, "", i.View.Hash, i.View.ID) if err != nil { fmt.Printf("Error updating view: %s", err) From 824342ea812a3cd131d53c6063d7b7f166325908 Mon Sep 17 00:00:00 2001 From: KouWakai <35868597+KouWakai@users.noreply.github.com> Date: Sun, 25 Dec 2022 13:28:00 +0900 Subject: [PATCH 158/606] Change http response StatusUnauthorized at L188 to StatusInternalServerError Co-authored-by: Naoki Kanatani --- examples/modal/modal.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/modal/modal.go b/examples/modal/modal.go index dd337542d..92bfc8b00 100644 --- a/examples/modal/modal.go +++ b/examples/modal/modal.go @@ -185,7 +185,7 @@ func handleModal(w http.ResponseWriter, r *http.Request) { _, err := api.UpdateView(updateModal, "", i.View.Hash, i.View.ID) if err != nil { fmt.Printf("Error updating view: %s", err) - w.WriteHeader(http.StatusUnauthorized) + w.WriteHeader(http.StatusInternalServerError) return } } From ff7c50286635c24fff5361c14bf5135fb4f4a935 Mon Sep 17 00:00:00 2001 From: kittydoor Date: Sun, 5 Feb 2023 01:25:04 +0100 Subject: [PATCH 159/606] socketmode example: use embedded api client in socketmode client --- examples/socketmode/socketmode.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/socketmode/socketmode.go b/examples/socketmode/socketmode.go index 6f1e65b48..1490a932b 100644 --- a/examples/socketmode/socketmode.go +++ b/examples/socketmode/socketmode.go @@ -72,7 +72,7 @@ func main() { innerEvent := eventsAPIEvent.InnerEvent switch ev := innerEvent.Data.(type) { case *slackevents.AppMentionEvent: - _, _, err := api.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) + _, _, err := client.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) if err != nil { fmt.Printf("failed posting message: %v", err) } @@ -137,7 +137,8 @@ func main() { ), ), ), - }} + }, + } client.Ack(*evt.Request, payload) default: From 2c070189eab94db3c3314fa3d57505950a8db3e9 Mon Sep 17 00:00:00 2001 From: kittydoor Date: Sun, 5 Feb 2023 02:55:22 +0100 Subject: [PATCH 160/606] socketmode remove duplicate debug logging --- socketmode/socket_mode_managed_conn.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index b259fd624..684535ff5 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -469,18 +469,6 @@ func (smc *Client) receiveMessagesInto(ctx context.Context, conn *websocket.Conn case len(event) == 0: smc.Debugln("Received empty event") default: - if smc.debug { - buf := &bytes.Buffer{} - d := json.NewEncoder(buf) - d.SetIndent("", " ") - if err := d.Encode(event); err != nil { - smc.Debugln("Failed encoding decoded json:", err) - } - reencoded := buf.String() - - smc.Debugln("Incoming WebSocket message:", reencoded) - } - select { case sink <- event: case <-ctx.Done(): From d66f160abdc69cbb07af6265d0fbbddc356e5996 Mon Sep 17 00:00:00 2001 From: kittydoor Date: Sun, 5 Feb 2023 02:58:22 +0100 Subject: [PATCH 161/606] socketmode bytes no longer used --- socketmode/socket_mode_managed_conn.go | 1 - 1 file changed, 1 deletion(-) diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index 684535ff5..1b1d62d32 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -1,7 +1,6 @@ package socketmode import ( - "bytes" "context" "encoding/json" "errors" From 3d08e57438ea78153d98e5a85c79ce078e8886f5 Mon Sep 17 00:00:00 2001 From: lololozhkin Date: Wed, 15 Feb 2023 13:36:12 +0500 Subject: [PATCH 162/606] socketmodehandler: implemented RunEventLoopContext method. now socketmodehandler will react on context cancellation --- socketmode/socketmode_handler.go | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/socketmode/socketmode_handler.go b/socketmode/socketmode_handler.go index 636feefa5..a26a7b1f2 100644 --- a/socketmode/socketmode_handler.go +++ b/socketmode/socketmode_handler.go @@ -1,6 +1,7 @@ package socketmode import ( + "context" "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" ) @@ -105,15 +106,31 @@ func (r *SocketmodeHandler) HandleDefault(f SocketmodeHandlerFunc) { // RunSlackEventLoop receives the event via the socket func (r *SocketmodeHandler) RunEventLoop() error { - go r.runEventLoop() + go r.runEventLoop(context.Background()) return r.Client.Run() } +func (r *SocketmodeHandler) RunEventLoopContext(ctx context.Context) error { + go r.runEventLoop(ctx) + + return r.Client.RunContext(ctx) +} + // Call the dispatcher for each incomming event -func (r *SocketmodeHandler) runEventLoop() { - for evt := range r.Client.Events { - r.dispatcher(evt) +func (r *SocketmodeHandler) runEventLoop(ctx context.Context) { + for { + select { + case evt, closed := <-r.Client.Events: + if closed { + return + } + + r.dispatcher(evt) + + case <-ctx.Done(): + return + } } } From dc96c9588265f4785cf507ba2532e004566c069d Mon Sep 17 00:00:00 2001 From: lololozhkin Date: Wed, 15 Feb 2023 20:52:03 +0500 Subject: [PATCH 163/606] socketmode: awaiting of message receiver goroutine in run method to avoid panics --- socketmode/socket_mode_managed_conn.go | 39 ++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index b259fd624..216ee307e 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -123,9 +123,10 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { } }() - // We don't wait on runMessageReceiver because it doesn't block on a select with the context, - // so we'd have to wait for the ReadJSON to time out, which can take a while. + // Need to wait for runMessageReceiver to avoid panic described in https://github.com/slack-go/slack/issues/1125 + wg.Add(1) go func() { + defer wg.Done() defer cancel() // The receiver reads WebSocket messages, and enqueues parsed Socket Mode requests to be handled by @@ -439,8 +440,40 @@ func (smc *Client) receiveMessagesInto(ctx context.Context, conn *websocket.Conn smc.Debugf("Starting to receive message") defer smc.Debugf("Finished to receive message") + var err error + event := json.RawMessage{} - err := conn.ReadJSON(&event) + + readJsonErr := make(chan error) + go func() { + select { + case readJsonErr <- conn.ReadJSON(&event): + // if conn.ReadJSON method returns after ctx.Done(), no one will read the unbuffered channel + // so, to avoid goroutines leak we need to check for ctx.Done() here too. + // need to say here, that conn.ReadJSON will really return after ctx.Done(), because in that case + // conn is closed + break + case <-ctx.Done(): + // just need to listen ctx.Done, nothing has to be done here + break + } + }() + + select { + case err = <-readJsonErr: + // we have awaited response from conn.ReadJSON, so, we can handle error now + break + case <-ctx.Done(): + // context cancellation signal got, closing connection and returning + cerr := conn.Close() + if cerr != nil { + smc.Debugf("Failed to close connection: %v", cerr) + } + + return ctx.Err() + } + + // after select above, the err will be set to result from conn.ReadJSON, handling // check if the connection was closed. if websocket.IsUnexpectedCloseError(err) { From 39c076e520d3c7a23781571c7715301c199249c4 Mon Sep 17 00:00:00 2001 From: lololozhkin Date: Fri, 17 Feb 2023 17:30:09 +0500 Subject: [PATCH 164/606] bugfix: silly mistake in runLoop method, renamed closed to ok (recv chan) --- socketmode/socketmode_handler.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/socketmode/socketmode_handler.go b/socketmode/socketmode_handler.go index a26a7b1f2..0cfe7555d 100644 --- a/socketmode/socketmode_handler.go +++ b/socketmode/socketmode_handler.go @@ -121,8 +121,8 @@ func (r *SocketmodeHandler) RunEventLoopContext(ctx context.Context) error { func (r *SocketmodeHandler) runEventLoop(ctx context.Context) { for { select { - case evt, closed := <-r.Client.Events: - if closed { + case evt, ok := <-r.Client.Events: + if !ok { return } From c13750f30f8183cf65f53870052a5a3b4245504f Mon Sep 17 00:00:00 2001 From: Kevin Paulisse Date: Mon, 27 Feb 2023 16:53:03 -0600 Subject: [PATCH 165/606] Export the Binder type from slacktest --- slacktest/server.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/slacktest/server.go b/slacktest/server.go index 1b18923a7..647031015 100644 --- a/slacktest/server.go +++ b/slacktest/server.go @@ -26,10 +26,10 @@ type Customize interface { Handle(pattern string, handler http.HandlerFunc) } -type binder func(Customize) +type Binder func(Customize) // NewTestServer returns a slacktest.Server ready to be started -func NewTestServer(custom ...binder) *Server { +func NewTestServer(custom ...Binder) *Server { serverChans := newMessageChannels() channels := &serverChannels{} From 63294cb7d6e64c19dc2c8cc825b40569d399720c Mon Sep 17 00:00:00 2001 From: Aleksa Arsic <85497919+aleksa11010@users.noreply.github.com> Date: Mon, 6 Mar 2023 17:29:16 +0100 Subject: [PATCH 166/606] Add ReplyUsers to Msg Struct This is something that Slack returns by default on API response. --- messages.go | 1 + 1 file changed, 1 insertion(+) diff --git a/messages.go b/messages.go index 333404563..e0c45ab25 100644 --- a/messages.go +++ b/messages.go @@ -101,6 +101,7 @@ type Msg struct { // channels.replies, groups.replies, im.replies, mpim.replies ReplyCount int `json:"reply_count,omitempty"` + ReplyUsers []string `json:"reply_users,omitempty"` Replies []Reply `json:"replies,omitempty"` ParentUserId string `json:"parent_user_id,omitempty"` LatestReply string `json:"latest_reply,omitempty"` From b870f294657f8b645a2d0b27d8e633168d968d9a Mon Sep 17 00:00:00 2001 From: Jeffrey Dorrycott Date: Thu, 16 Mar 2023 11:44:41 -0500 Subject: [PATCH 167/606] add support for user_profile_changed callback event --- slackevents/inner_events.go | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index e88b6cefe..fb1ab132a 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -536,6 +536,14 @@ type TeamAccessRevokedEvent struct { TeamIDs []string `json:"team_ids"` } +// UserProfileChangedEvent is sent if access to teams was revoked for your org-wide app. +type UserProfileChangedEvent struct { + User *slack.User `json:"user"` + CacheTs int `json:"cache_ts"` + Type string `json:"type"` + EventTs string `json:"event_ts"` +} + type EventsAPIType string const ( @@ -585,9 +593,9 @@ const ( LinkShared = EventsAPIType("link_shared") // Message A message was posted to a channel, private channel (group), im, or mim Message = EventsAPIType("message") - // Member Joined Channel + // MemberJoinedChannel is sent if a member joined a channel. MemberJoinedChannel = EventsAPIType("member_joined_channel") - // Member Left Channel + // MemberLeftChannel is sent if a member left a channel. MemberLeftChannel = EventsAPIType("member_left_channel") // PinAdded An item was pinned to a channel PinAdded = EventsAPIType("pin_added") @@ -607,14 +615,16 @@ const ( WorkflowStepExecute = EventsAPIType("workflow_step_execute") // MessageMetadataPosted A message with metadata was posted MessageMetadataPosted = EventsAPIType("message_metadata_posted") - // MessageMetadataPosted A message with metadata was updated + // MessageMetadataUpdated A message with metadata was updated MessageMetadataUpdated = EventsAPIType("message_metadata_updated") - // MessageMetadataPosted A message with metadata was deleted + // MessageMetadataDeleted A message with metadata was deleted MessageMetadataDeleted = EventsAPIType("message_metadata_deleted") // TeamAccessGranted is sent if access to teams was granted for your org-wide app. TeamAccessGranted = EventsAPIType("team_access_granted") - // TeamAccessrevoked is sent if access to teams was revoked for your org-wide app. - TeamAccessrevoked = EventsAPIType("team_access_revoked") + // TeamAccessRevoked is sent if access to teams was revoked for your org-wide app. + TeamAccessRevoked = EventsAPIType("team_access_revoked") + // UserProfileChanged is sent if a user's profile information has changed. + UserProfileChanged = EventsAPIType("user_profile_changed") ) // EventsAPIInnerEventMapping maps INNER Event API events to their corresponding struct @@ -658,5 +668,6 @@ var EventsAPIInnerEventMapping = map[EventsAPIType]interface{}{ MessageMetadataUpdated: MessageMetadataUpdatedEvent{}, MessageMetadataDeleted: MessageMetadataDeletedEvent{}, TeamAccessGranted: TeamAccessGrantedEvent{}, - TeamAccessrevoked: TeamAccessRevokedEvent{}, + TeamAccessRevoked: TeamAccessRevokedEvent{}, + UserProfileChanged: UserProfileChangedEvent{}, } From f63770d8a5f50de8483dafc2dceadd61967d3ba8 Mon Sep 17 00:00:00 2001 From: Jeffrey Dorrycott Date: Thu, 16 Mar 2023 11:53:09 -0500 Subject: [PATCH 168/606] add test --- slackevents/inner_events_test.go | 93 ++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index ae3ee179c..f21f66beb 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -821,3 +821,96 @@ func TestMessageMetadataDeleted(t *testing.T) { t.Fail() } } + +func TestUserProfileChanged(t *testing.T) { + rawE := []byte(` + { + "token": "whatever", + "team_id": "whatever", + "api_app_id": "whatever", + "event": { + "user": { + "id": "whatever", + "team_id": "whatever", + "name": "whatever", + "deleted": true, + "profile": { + "title": "", + "phone": "", + "skype": "", + "real_name": "whatever", + "real_name_normalized": "whatever", + "display_name": "", + "display_name_normalized": "", + "fields": {}, + "status_text": "", + "status_emoji": "", + "status_emoji_display_info": [], + "status_expiration": 0, + "avatar_hash": "whatever", + "api_app_id": "whatever", + "always_active": true, + "bot_id": "whatever", + "first_name": "whatever", + "last_name": "", + "image_24": "https://secure.gravatar.com/avatar/whatever.jpg", + "image_32": "https://secure.gravatar.com/avatar/whatever.jpg", + "image_48": "https://secure.gravatar.com/avatar/whatever.jpg", + "image_72": "https://secure.gravatar.com/avatar/whatever.jpg", + "image_192": "https://secure.gravatar.com/avatar/whatever.jpg", + "image_512": "https://secure.gravatar.com/avatar/whatever.jpg", + "status_text_canonical": "", + "team": "whatever" + }, + "is_bot": true, + "is_app_user": false, + "updated": 1678984254 + }, + "cache_ts": 1678984254, + "type": "user_profile_changed", + "event_ts": "1678984255.006500" + }, + "type": "event_callback", + "event_id": "whatever", + "event_time": 1678984255, + "authorizations": [ + { + "enterprise_id": null, + "team_id": "whatever", + "user_id": "whatever", + "is_bot": false, + "is_enterprise_install": false + } + ], + "is_ext_shared_channel": false + } + `) + + evt := &EventsAPICallbackEvent{} + err := json.Unmarshal(rawE, &evt) + if err != nil { + t.Error(err) + } + + if evt.Type != "event_callback" { + t.Fail() + } + + parsedEvent, err := parseInnerEvent(evt) + if err != nil { + t.Error(err) + } + + if parsedEvent.InnerEvent.Type != "user_profile_changed" { + t.Fail() + } + + actual, ok := parsedEvent.InnerEvent.Data.(*UserProfileChangedEvent) + if !ok { + t.Fail() + } + + if actual.User.Name != "whatever" { + t.Fail() + } +} From 5380d7dd00a5b716eff02e74aceeeb3c041f1272 Mon Sep 17 00:00:00 2001 From: Ivan Milchev Date: Fri, 24 Mar 2023 13:05:48 +0100 Subject: [PATCH 169/606] add 2FA type to slack user Signed-off-by: Ivan Milchev --- users.go | 1 + 1 file changed, 1 insertion(+) diff --git a/users.go b/users.go index 55f42118f..74be41f35 100644 --- a/users.go +++ b/users.go @@ -130,6 +130,7 @@ type User struct { IsAppUser bool `json:"is_app_user"` IsInvitedUser bool `json:"is_invited_user"` Has2FA bool `json:"has_2fa"` + TwoFactorType *string `json:"two_factor_type,omitempty"` HasFiles bool `json:"has_files"` Presence string `json:"presence"` Locale string `json:"locale"` From a22a95b521da7c032791e47c5679f4ecd39a63dd Mon Sep 17 00:00:00 2001 From: Naoki Kanatani Date: Wed, 29 Mar 2023 06:44:06 +0900 Subject: [PATCH 170/606] Remove omitempty --- users.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/users.go b/users.go index 74be41f35..a3b4c0664 100644 --- a/users.go +++ b/users.go @@ -130,7 +130,7 @@ type User struct { IsAppUser bool `json:"is_app_user"` IsInvitedUser bool `json:"is_invited_user"` Has2FA bool `json:"has_2fa"` - TwoFactorType *string `json:"two_factor_type,omitempty"` + TwoFactorType *string `json:"two_factor_type"` HasFiles bool `json:"has_files"` Presence string `json:"presence"` Locale string `json:"locale"` From 22489e7c0a53f685f9f6f383f0107520d137a9d2 Mon Sep 17 00:00:00 2001 From: Naoki Kanatani Date: Wed, 29 Mar 2023 06:48:02 +0900 Subject: [PATCH 171/606] Test with Go 1.18~1.20 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 805b3bd1c..0248d613d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,9 +12,9 @@ jobs: strategy: matrix: go: - - '1.17' - '1.18' - '1.19' + - '1.20' name: test go-${{ matrix.go }} steps: - uses: actions/checkout@v3 From c0414146ad06faa60f74b2d0a4b6cc8d84d6956e Mon Sep 17 00:00:00 2001 From: Naoki Kanatani Date: Wed, 29 Mar 2023 06:56:54 +0900 Subject: [PATCH 172/606] Update README for slacktest --- slacktest/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/slacktest/README.md b/slacktest/README.md index 3897c062a..9a09dad62 100644 --- a/slacktest/README.md +++ b/slacktest/README.md @@ -1,7 +1,9 @@ # slacktest -This package was copied from https://github.com/lusis/slack-test for historical reasons. -This package's license is the following. +This package was originally copied from https://github.com/lusis/slack-test for historical reasons. +It is currently in use with some modifications. + +The license of this package is as follows. --- From 92a834bcd01e827059bae74a96a7b8ac3919f026 Mon Sep 17 00:00:00 2001 From: Naoki Kanatani Date: Thu, 6 Apr 2023 07:43:32 +0900 Subject: [PATCH 173/606] Add actions/stale action --- .github/workflows/stale.yaml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/stale.yaml diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml new file mode 100644 index 000000000..2bf6f702d --- /dev/null +++ b/.github/workflows/stale.yaml @@ -0,0 +1,24 @@ +name: 'Close stale issues and PRs' +on: + schedule: + - cron: '0 0 * * 1' # every Monday 0:00 UTC + + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0 + with: + any-of-labels: 'feedback given' + days-before-stale: 45 + days-before-pr-close: 10 + stale-issue-message: 'This issue is stale because it has been open 45 days with no activity. Remove stale label or comment or this will be closed in 10 days.' + stale-pr-message: 'This PR is stale because it has been open 45 days with no activity. Remove stale label or comment or this will be closed in 10 days.' + close-issue-message: 'This issue was closed because it has been stalled for 10 days with no activity.' + close-pr-message: 'This PR was closed because it has been stalled for 10 days with no activity.' From b146c45ef479f192e38d2419999544f3c82daf48 Mon Sep 17 00:00:00 2001 From: Naoki Kanatani Date: Thu, 6 Apr 2023 08:07:47 +0900 Subject: [PATCH 174/606] Increase operations-per-run --- .github/workflows/stale.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml index 2bf6f702d..e99b5e620 100644 --- a/.github/workflows/stale.yaml +++ b/.github/workflows/stale.yaml @@ -22,3 +22,4 @@ jobs: stale-pr-message: 'This PR is stale because it has been open 45 days with no activity. Remove stale label or comment or this will be closed in 10 days.' close-issue-message: 'This issue was closed because it has been stalled for 10 days with no activity.' close-pr-message: 'This PR was closed because it has been stalled for 10 days with no activity.' + operations-per-run: 120 From d5e621e78fcbc15fcc21d1c1e89c61793fc6904c Mon Sep 17 00:00:00 2001 From: Naoki Kanatani Date: Thu, 13 Apr 2023 13:35:17 +0900 Subject: [PATCH 175/606] Bump up golangci-lint and its action to latest --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0248d613d..4cc1d2e22 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,6 +31,6 @@ jobs: steps: - uses: actions/checkout@v3 - name: golangci-lint - uses: golangci/golangci-lint-action@537aa1903e5d359d0b27dbc19ddd22c5087f3fbc # v3.2.0 + uses: golangci/golangci-lint-action@08e2f20817b15149a52b5b3ebe7de50aff2ba8c5 # v3.4.0 with: - version: v1.48.0 + version: v1.52.2 From 39c7b2ab3be5a3325f806e29fb945b6a72cbc3ef Mon Sep 17 00:00:00 2001 From: Naoki Kanatani Date: Thu, 13 Apr 2023 13:40:01 +0900 Subject: [PATCH 176/606] Add git repository's checkout option --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4cc1d2e22..abd682e3f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,8 @@ jobs: name: lint steps: - uses: actions/checkout@v3 + with: + fetch-depth: 0 - name: golangci-lint uses: golangci/golangci-lint-action@08e2f20817b15149a52b5b3ebe7de50aff2ba8c5 # v3.4.0 with: From 54fa87ea9090571088099274cc1df787c192f396 Mon Sep 17 00:00:00 2001 From: Naoki Kanatani Date: Thu, 13 Apr 2023 13:42:52 +0900 Subject: [PATCH 177/606] Add setup-go step --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index abd682e3f..7906afbdc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,6 +29,10 @@ jobs: runs-on: ubuntu-22.04 name: lint steps: + - uses: actions/setup-go@v4 + with: + go-version: '1.20' + cache: false - uses: actions/checkout@v3 with: fetch-depth: 0 From 2b2e893679ddbc7874e45e5a6bff88d999cf6180 Mon Sep 17 00:00:00 2001 From: Naoki Kanatani Date: Thu, 13 Apr 2023 14:44:03 +0900 Subject: [PATCH 178/606] gofmt --- examples/pins/pins.go | 4 +--- messages.go | 8 ++++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/examples/pins/pins.go b/examples/pins/pins.go index 5c4f67c22..55158cb33 100644 --- a/examples/pins/pins.go +++ b/examples/pins/pins.go @@ -7,9 +7,7 @@ import ( "github.com/slack-go/slack" ) -/* - WARNING: This example is destructive in the sense that it create a channel called testpinning -*/ +// WARNING: This example is destructive in the sense that it create a channel called testpinning func main() { var ( apiToken string diff --git a/messages.go b/messages.go index e0c45ab25..13a1ede98 100644 --- a/messages.go +++ b/messages.go @@ -100,11 +100,11 @@ type Msg struct { Members []string `json:"members,omitempty"` // channels.replies, groups.replies, im.replies, mpim.replies - ReplyCount int `json:"reply_count,omitempty"` + ReplyCount int `json:"reply_count,omitempty"` ReplyUsers []string `json:"reply_users,omitempty"` - Replies []Reply `json:"replies,omitempty"` - ParentUserId string `json:"parent_user_id,omitempty"` - LatestReply string `json:"latest_reply,omitempty"` + Replies []Reply `json:"replies,omitempty"` + ParentUserId string `json:"parent_user_id,omitempty"` + LatestReply string `json:"latest_reply,omitempty"` // file_share, file_comment, file_mention Files []File `json:"files,omitempty"` From 5babfb7f03ede5955e6026a38b386a4ec3ff74f5 Mon Sep 17 00:00:00 2001 From: Ian Burton Date: Mon, 24 Apr 2023 13:48:02 -0500 Subject: [PATCH 179/606] Add 'FIXME's for deadman timer reset race --- socketmode/deadman.go | 5 ++++- socketmode/socket_mode_managed_conn.go | 11 ++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/socketmode/deadman.go b/socketmode/deadman.go index 7aeea760e..a6eb1a1d8 100644 --- a/socketmode/deadman.go +++ b/socketmode/deadman.go @@ -19,7 +19,10 @@ func (smc *deadmanTimer) Elapsed() <-chan time.Time { } func (smc *deadmanTimer) Reset() { - // Note that this is the correct way to Reset a non-expired timer + // FIXME: Race on "deadmanTimer", timer channel cannot be read concurrently while resetting. + // "This should not be done concurrent to other receives from the Timer's channel." + // https://pkg.go.dev/time#Timer.Reset + // See socket_mode_managed_conn.go lines ~59 & ~151. if !smc.timer.Stop() { select { case <-smc.timer.C: diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index 77a35df3e..5bc6b752e 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -55,8 +55,12 @@ func (smc *Client) RunContext(ctx context.Context) error { func (smc *Client) run(ctx context.Context, connectionCount int) error { messages := make(chan json.RawMessage, 1) - deadmanTimer := newDeadmanTimer(smc.maxPingInterval) + // FIXME: Race on "deadmanTimer", timer channel cannot be read concurrently while resetting. + // "This should not be done concurrent to other receives from the Timer's channel." + // https://pkg.go.dev/time#Timer.Reset + // See deadman.go line ~22. + deadmanTimer := newDeadmanTimer(smc.maxPingInterval) pingHandler := func(_ string) error { deadmanTimer.Reset() @@ -143,6 +147,11 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { if err := conn.Close(); err != nil { smc.Debugf("Failed to close connection: %v", err) } + + // FIXME: Race on "deadmanTimer", timer channel cannot be read concurrently while resetting. + // "This should not be done concurrent to other receives from the Timer's channel." + // https://pkg.go.dev/time#Timer.Reset + // See deadman.go line ~22. case <-deadmanTimer.Elapsed(): sendErr(errors.New("ping timeout: Slack did not send us WebSocket PING for more than Client.maxInterval")) From 4dd1a1f9d9bd8f30625d0216b6052d5447180c5a Mon Sep 17 00:00:00 2001 From: Ian Burton Date: Mon, 24 Apr 2023 15:42:33 -0500 Subject: [PATCH 180/606] Fix race on deadman timer Elapsed+Reset by more efficient use of ticker and ping time channel. Remove deadman file as it is no longer needed --- socketmode/deadman.go | 34 -------------- socketmode/socket_mode_managed_conn.go | 62 +++++++++++++++++--------- 2 files changed, 40 insertions(+), 56 deletions(-) delete mode 100644 socketmode/deadman.go diff --git a/socketmode/deadman.go b/socketmode/deadman.go deleted file mode 100644 index a6eb1a1d8..000000000 --- a/socketmode/deadman.go +++ /dev/null @@ -1,34 +0,0 @@ -package socketmode - -import "time" - -type deadmanTimer struct { - timeout time.Duration - timer *time.Timer -} - -func newDeadmanTimer(timeout time.Duration) *deadmanTimer { - return &deadmanTimer{ - timeout: timeout, - timer: time.NewTimer(timeout), - } -} - -func (smc *deadmanTimer) Elapsed() <-chan time.Time { - return smc.timer.C -} - -func (smc *deadmanTimer) Reset() { - // FIXME: Race on "deadmanTimer", timer channel cannot be read concurrently while resetting. - // "This should not be done concurrent to other receives from the Timer's channel." - // https://pkg.go.dev/time#Timer.Reset - // See socket_mode_managed_conn.go lines ~59 & ~151. - if !smc.timer.Stop() { - select { - case <-smc.timer.C: - default: - } - } - - smc.timer.Reset(smc.timeout) -} diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index 5bc6b752e..b94456f49 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -56,13 +56,12 @@ func (smc *Client) RunContext(ctx context.Context) error { func (smc *Client) run(ctx context.Context, connectionCount int) error { messages := make(chan json.RawMessage, 1) - // FIXME: Race on "deadmanTimer", timer channel cannot be read concurrently while resetting. - // "This should not be done concurrent to other receives from the Timer's channel." - // https://pkg.go.dev/time#Timer.Reset - // See deadman.go line ~22. - deadmanTimer := newDeadmanTimer(smc.maxPingInterval) + pingChan := make(chan time.Time, 1) pingHandler := func(_ string) error { - deadmanTimer.Reset() + select { + case pingChan <- time.Now(): + default: + } return nil } @@ -138,26 +137,44 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { }() wg.Add(1) - go func() { + go func(pingInterval time.Duration) { defer wg.Done() - - select { - case <-ctx.Done(): + defer func() { // Detect when the connection is dead and try close connection. if err := conn.Close(); err != nil { smc.Debugf("Failed to close connection: %v", err) } + }() + + done := ctx.Done() + var lastPing time.Time + + // More efficient than constantly resetting a timer w/ Stop+Reset + ticker := time.NewTicker(pingInterval) + defer ticker.Stop() - // FIXME: Race on "deadmanTimer", timer channel cannot be read concurrently while resetting. - // "This should not be done concurrent to other receives from the Timer's channel." - // https://pkg.go.dev/time#Timer.Reset - // See deadman.go line ~22. - case <-deadmanTimer.Elapsed(): - sendErr(errors.New("ping timeout: Slack did not send us WebSocket PING for more than Client.maxInterval")) + for { + select { + case <-done: + return - cancel() + case lastPing = <-pingChan: + // This case gets the time of the last ping. + // If this case never fires then the pingHandler was never called + // in which case lastPing is the zero time.Time value, and will 'fail' + // the next tick, causing us to exit. + + case now := <-ticker.C: + // Our last ping is older than our interval + if now.Sub(lastPing) > pingInterval { + sendErr(errors.New("ping timeout: Slack did not send us WebSocket PING for more than Client.maxInterval")) + + cancel() + return + } + } } - }() + }(smc.maxPingInterval) wg.Wait() @@ -294,12 +311,13 @@ func (smc *Client) openAndDial(ctx context.Context, additionalPingHandler func(s smc.Debugf("Failed to dial to the websocket: %s", err) return nil, nil, err } + if additionalPingHandler == nil { + additionalPingHandler = func(_ string) error { return nil } + } conn.SetPingHandler(func(appData string) error { - if additionalPingHandler != nil { - if err := additionalPingHandler(appData); err != nil { - return err - } + if err := additionalPingHandler(appData); err != nil { + return err } smc.handlePing(conn, appData) From 26802678b64f118dc4809de14901e474eda4bcec Mon Sep 17 00:00:00 2001 From: Lauri Heiskanen Date: Thu, 22 Jun 2023 12:01:58 +0300 Subject: [PATCH 181/606] Add UnfurlLinks and UnfurlMedia to WebhookMessage * to support 'classic unfurl' configuration options in webhook messages. * https://api.slack.com/reference/messaging/link-unfurling#classic_unfurl --- webhooks.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/webhooks.go b/webhooks.go index e3233536a..8fc149d90 100644 --- a/webhooks.go +++ b/webhooks.go @@ -24,6 +24,8 @@ type WebhookMessage struct { ReplaceOriginal bool `json:"replace_original"` DeleteOriginal bool `json:"delete_original"` ReplyBroadcast bool `json:"reply_broadcast,omitempty"` + UnfurlLinks bool `json:"unfurl_links,omitempty"` + UnfurlMedia bool `json:"unfurl_media,omitempty"` } func PostWebhook(url string, msg *WebhookMessage) error { From 7302609e9015f0859335406c4cc83eebeb1052e1 Mon Sep 17 00:00:00 2001 From: guoguangwu Date: Mon, 10 Jul 2023 09:56:48 +0800 Subject: [PATCH 182/606] chore: unnecessary use of fmt.Sprintf --- slacktest/data.go | 4 ++-- slacktest/server_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/slacktest/data.go b/slacktest/data.go index e33b901dc..19b49e1b8 100644 --- a/slacktest/data.go +++ b/slacktest/data.go @@ -35,11 +35,11 @@ var okWebResponse = slack.SlackResponse{ Ok: true, } -var defaultOkJSON = fmt.Sprintf(` +var defaultOkJSON = ` { "ok": true } - `) + ` var defaultChannelsListJSON = fmt.Sprintf(` { diff --git a/slacktest/server_test.go b/slacktest/server_test.go index ac4244471..45f620858 100644 --- a/slacktest/server_test.go +++ b/slacktest/server_test.go @@ -48,7 +48,7 @@ func TestBotDirectMessageBotHandler(t *testing.T) { s := NewTestServer() go s.Start() s.SendDirectMessageToBot("some text") - expectedMsg := fmt.Sprintf("some text") + expectedMsg := "some text" time.Sleep(2 * time.Second) assert.True(t, s.SawOutgoingMessage(expectedMsg)) s.Stop() From e54a868045b1a399ca9c85033d504f9b3f55e6dc Mon Sep 17 00:00:00 2001 From: Daniel Abraham Date: Fri, 14 Jul 2023 16:01:44 -0700 Subject: [PATCH 183/606] Redact tokens in debug log --- chat.go | 21 +++++++++++++++++---- chat_test.go | 32 +++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/chat.go b/chat.go index 4448e9f40..21bf7c74d 100644 --- a/chat.go +++ b/chat.go @@ -7,6 +7,7 @@ import ( "io/ioutil" "net/http" "net/url" + "regexp" "strconv" "github.com/slack-go/slack/slackutilsx" @@ -29,9 +30,9 @@ const ( type chatResponseFull struct { Channel string `json:"channel"` - Timestamp string `json:"ts"` //Regular message timestamp - MessageTimeStamp string `json:"message_ts"` //Ephemeral message timestamp - ScheduledMessageID string `json:"scheduled_message_id,omitempty"` //Scheduled message id + Timestamp string `json:"ts"` // Regular message timestamp + MessageTimeStamp string `json:"message_ts"` // Ephemeral message timestamp + ScheduledMessageID string `json:"scheduled_message_id,omitempty"` // Scheduled message id Text string `json:"text"` SlackResponse } @@ -224,7 +225,7 @@ func (api *Client) SendMessageContext(ctx context.Context, channelID string, opt return "", "", "", err } req.Body = ioutil.NopCloser(bytes.NewBuffer(reqBody)) - api.Debugf("Sending request: %s", string(reqBody)) + api.Debugf("Sending request: %s", redactToken(reqBody)) } if err = doPost(ctx, api.httpclient, req, parser(&response), api); err != nil { @@ -234,6 +235,18 @@ func (api *Client) SendMessageContext(ctx context.Context, channelID string, opt return response.Channel, response.getMessageTimestamp(), response.Text, response.Err() } +func redactToken(b []byte) []byte { + // See https://api.slack.com/authentication/token-types + re, err := regexp.Compile(`(token=x[a-z]+)-[0-9A-Za-z-]+`) + if err != nil { + // The regular expression above should always work, but just in case, do no harm. + return b + } + // Keep "token=" and the first element of the token, which identifies its type + // (this could be useful for debugging, e.g. when using a wrong token). + return re.ReplaceAll(b, []byte("$1-REDACTED")) +} + // UnsafeApplyMsgOptions utility function for debugging/testing chat requests. // NOTE: USE AT YOUR OWN RISK: No issues relating to the use of this function // will be supported by the library. diff --git a/chat_test.go b/chat_test.go index 7a549fa90..b9cda0727 100644 --- a/chat_test.go +++ b/chat_test.go @@ -1,11 +1,14 @@ package slack import ( + "bytes" "encoding/json" "io/ioutil" + "log" "net/http" "net/url" "reflect" + "regexp" "testing" ) @@ -39,7 +42,6 @@ func TestGetPermalink(t *testing.T) { timeStamp := "p135854651500008" http.HandleFunc("/chat.getPermalink", func(rw http.ResponseWriter, r *http.Request) { - if got, want := r.Header.Get("Content-Type"), "application/x-www-form-urlencoded"; got != want { t.Errorf("request uses unexpected content type: got %s, want %s", got, want) } @@ -296,3 +298,31 @@ func TestPostMessageWhenMsgOptionDeleteOriginalApplied(t *testing.T) { _, _, _ = api.PostMessage("CXXX", MsgOptionDeleteOriginal(responseURL)) } + +func TestSendMessageContextRedactsTokenInDebugLog(t *testing.T) { + once.Do(startServer) + buf := bytes.NewBufferString("") + + token := "xtest-token-1234-abcd" + opts := []Option{ + OptionAPIURL("http://" + serverAddr + "/"), + OptionLog(log.New(buf, "", log.Lshortfile)), + OptionDebug(true), + } + api := New(token, opts...) + // Why send the token in the message text too? To test that we're not + // redacting substrings in the request which look like a token but aren't. + api.SendMessage("CXXX", MsgOptionText(token, false)) + s := buf.String() + + re := regexp.MustCompile(`token=[\w-]*`) + want := "token=xtest-REDACTED" + if got := re.FindString(s); got != want { + t.Errorf("Logged token in SendMessageContext(): got %q, want %q", got, want) + } + re = regexp.MustCompile(`text=[\w-]*`) + want = "text=" + token + if got := re.FindString(s); got != want { + t.Errorf("Logged text in SendMessageContext(): got %q, want %q", got, want) + } +} From 05af10ccc2c333ce900a99e8c12a85c70f687a48 Mon Sep 17 00:00:00 2001 From: Daniel Abraham Date: Fri, 14 Jul 2023 16:02:11 -0700 Subject: [PATCH 184/606] Tiny unrelated lint fix --- interactions_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/interactions_test.go b/interactions_test.go index f492b1fd0..989690e27 100644 --- a/interactions_test.go +++ b/interactions_test.go @@ -286,14 +286,14 @@ func TestViewSubmissionCallback(t *testing.T) { }, State: &ViewState{ Values: map[string]map[string]BlockAction{ - "multi-line": map[string]BlockAction{ - "ml-value": BlockAction{ + "multi-line": { + "ml-value": { Type: "plain_text_input", Value: "No onions", }, }, - "target_channel": map[string]BlockAction{ - "target_select": BlockAction{ + "target_channel": { + "target_select": { Type: "conversations_select", Value: "C1AB2C3DE", }, From fcee000beb31655570d7213e2102641dee6162a0 Mon Sep 17 00:00:00 2001 From: Daniel Abraham Date: Sat, 15 Jul 2023 17:43:00 -0700 Subject: [PATCH 185/606] Add support for refresh tokens --- chat.go | 6 +++-- chat_test.go | 65 +++++++++++++++++++++++++++++++++------------------- 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/chat.go b/chat.go index 21bf7c74d..35ffbbcf8 100644 --- a/chat.go +++ b/chat.go @@ -237,9 +237,11 @@ func (api *Client) SendMessageContext(ctx context.Context, channelID string, opt func redactToken(b []byte) []byte { // See https://api.slack.com/authentication/token-types - re, err := regexp.Compile(`(token=x[a-z]+)-[0-9A-Za-z-]+`) + // and https://api.slack.com/authentication/rotation + re, err := regexp.Compile(`(token=x[a-z.]+)-[0-9A-Za-z-]+`) if err != nil { - // The regular expression above should always work, but just in case, do no harm. + // The regular expression above should never result in errors, + // but just in case, do no harm. return b } // Keep "token=" and the first element of the token, which identifies its type diff --git a/chat_test.go b/chat_test.go index b9cda0727..fb372259a 100644 --- a/chat_test.go +++ b/chat_test.go @@ -300,29 +300,48 @@ func TestPostMessageWhenMsgOptionDeleteOriginalApplied(t *testing.T) { } func TestSendMessageContextRedactsTokenInDebugLog(t *testing.T) { - once.Do(startServer) - buf := bytes.NewBufferString("") - - token := "xtest-token-1234-abcd" - opts := []Option{ - OptionAPIURL("http://" + serverAddr + "/"), - OptionLog(log.New(buf, "", log.Lshortfile)), - OptionDebug(true), - } - api := New(token, opts...) - // Why send the token in the message text too? To test that we're not - // redacting substrings in the request which look like a token but aren't. - api.SendMessage("CXXX", MsgOptionText(token, false)) - s := buf.String() - - re := regexp.MustCompile(`token=[\w-]*`) - want := "token=xtest-REDACTED" - if got := re.FindString(s); got != want { - t.Errorf("Logged token in SendMessageContext(): got %q, want %q", got, want) + tests := []struct { + name string + token string + want string + }{ + { + name: "regular token", + token: "xtest-token-1234-abcd", + want: "xtest-REDACTED", + }, + { + name: "refresh token", + token: "xoxe.xtest-token-1234-abcd", + want: "xoxe.xtest-REDACTED", + }, } - re = regexp.MustCompile(`text=[\w-]*`) - want = "text=" + token - if got := re.FindString(s); got != want { - t.Errorf("Logged text in SendMessageContext(): got %q, want %q", got, want) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + once.Do(startServer) + buf := bytes.NewBufferString("") + + opts := []Option{ + OptionAPIURL("http://" + serverAddr + "/"), + OptionLog(log.New(buf, "", log.Lshortfile)), + OptionDebug(true), + } + api := New(tt.token, opts...) + // Why send the token in the message text too? To test that we're not + // redacting substrings in the request which look like a token but aren't. + api.SendMessage("CXXX", MsgOptionText(token, false)) + s := buf.String() + + re := regexp.MustCompile(`token=[\w.-]*`) + want := "token=" + tt.want + if got := re.FindString(s); got != want { + t.Errorf("Logged token in SendMessageContext(): got %q, want %q", got, want) + } + re = regexp.MustCompile(`text=[\w.-]*`) + want = "text=" + token + if got := re.FindString(s); got != want { + t.Errorf("Logged text in SendMessageContext(): got %q, want %q", got, want) + } + }) } } From db60a05aa551bed6f658312c0adf647ea662cafe Mon Sep 17 00:00:00 2001 From: Barak Amar Date: Sun, 16 Jul 2023 09:41:24 +0300 Subject: [PATCH 186/606] Conversation inviteShared support --- conversation.go | 47 ++++++++++++++++++++++++++++++++++++++ conversation_test.go | 52 +++++++++++++++++++++++++++++++++++++++++++ slacktest/data.go | 6 +++++ slacktest/handlers.go | 5 +++++ slacktest/server.go | 1 + 5 files changed, 111 insertions(+) diff --git a/conversation.go b/conversation.go index 44f06ea93..1a63c53bd 100644 --- a/conversation.go +++ b/conversation.go @@ -296,6 +296,53 @@ func (api *Client) InviteUsersToConversationContext(ctx context.Context, channel return response.Channel, response.Err() } +// InviteSharedEmailsToConversation invites users to a shared channels by email +func (api *Client) InviteSharedEmailsToConversation(channelID string, emails ...string) (string, bool, error) { + return api.inviteSharedToConversationHelper(context.Background(), channelID, emails, nil) +} + +// InviteSharedEmailsToConversationContext invites users to a shared channels by email using context +func (api *Client) InviteSharedEmailsToConversationContext(ctx context.Context, channelID string, emails ...string) (string, bool, error) { + return api.inviteSharedToConversationHelper(ctx, channelID, emails, nil) +} + +// InviteSharedUserIDsToConversation invites users to a shared channels by user id +func (api *Client) InviteSharedUserIDsToConversation(channelID string, userIDs ...string) (string, bool, error) { + return api.inviteSharedToConversationHelper(context.Background(), channelID, nil, userIDs) +} + +// InviteSharedUserIDsToConversationContext invites users to a shared channels by user id with context +func (api *Client) InviteSharedUserIDsToConversationContext(ctx context.Context, channelID string, userIDs ...string) (string, bool, error) { + return api.inviteSharedToConversationHelper(ctx, channelID, nil, userIDs) +} + +// inviteSharedToConversationHelper invites emails or userIDs to a channel with a custom context. +// This is a helper function for InviteSharedEmailsToConversation and InviteSharedUserIDsToConversation. +// It accepts either emails or userIDs, but not both. +func (api *Client) inviteSharedToConversationHelper(ctx context.Context, channelID string, emails []string, userIDs []string) (string, bool, error) { + values := url.Values{ + "token": {api.token}, + "channel": {channelID}, + } + if len(emails) > 0 { + values.Add("emails", strings.Join(emails, ",")) + } else if len(userIDs) > 0 { + values.Add("user_ids", strings.Join(userIDs, ",")) + } + response := struct { + SlackResponse + InviteID string `json:"invite_id"` + IsLegacySharedChannel bool `json:"is_legacy_shared_channel"` + }{} + + err := api.postMethod(ctx, "conversations.inviteShared", values, &response) + if err != nil { + return "", false, err + } + + return response.InviteID, response.IsLegacySharedChannel, response.Err() +} + // KickUserFromConversation removes a user from a conversation func (api *Client) KickUserFromConversation(channelID string, user string) error { return api.KickUserFromConversationContext(context.Background(), channelID, user) diff --git a/conversation_test.go b/conversation_test.go index 85011d5a6..1c6b8a211 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -287,6 +287,20 @@ func okChannelJsonHandler(rw http.ResponseWriter, r *http.Request) { rw.Write(response) } +func okInviteSharedJsonHandler(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(struct { + SlackResponse + InviteID string `json:"invite_id"` + IsLegacySharedChannel bool `json:"is_legacy_shared_channel"` + }{ + SlackResponse: SlackResponse{Ok: true}, + InviteID: "I01234567", + IsLegacySharedChannel: false, + }) + rw.Write(response) +} + func TestSetTopicOfConversation(t *testing.T) { http.HandleFunc("/conversations.setTopic", okChannelJsonHandler) once.Do(startServer) @@ -348,6 +362,44 @@ func TestInviteUsersToConversation(t *testing.T) { } } +func TestInviteSharedToConversation(t *testing.T) { + http.HandleFunc("/conversations.inviteShared", okInviteSharedJsonHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + t.Run("user_ids", func(t *testing.T) { + userIDs := []string{"UXXXXXXX1", "UXXXXXXX2"} + inviteID, isLegacySharedChannel, err := api.InviteSharedUserIDsToConversation("CXXXXXXXX", userIDs...) + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + if inviteID == "" { + t.Error("invite id should have a value") + return + } + if isLegacySharedChannel { + t.Error("is legacy shared channel should be false") + } + }) + + t.Run("emails", func(t *testing.T) { + emails := []string{"nopcoder@slack.com", "nopcoder@example.com"} + inviteID, isLegacySharedChannel, err := api.InviteSharedEmailsToConversation("CXXXXXXXX", emails...) + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + if inviteID == "" { + t.Error("invite id should have a value") + return + } + if isLegacySharedChannel { + t.Error("is legacy shared channel should be false") + } + }) +} + func TestKickUserFromConversation(t *testing.T) { http.HandleFunc("/conversations.kick", okJSONHandler) once.Do(startServer) diff --git a/slacktest/data.go b/slacktest/data.go index e33b901dc..c9a697197 100644 --- a/slacktest/data.go +++ b/slacktest/data.go @@ -250,3 +250,9 @@ var renameConversationJSON = fmt.Sprintf(templateConversationJSON, "newName", var inviteConversationJSON = fmt.Sprintf(templateConversationJSON, defaultConversationName, nowAsJSONTime(), defaultBotID, defaultConversationName, "", "", 0, "", "", 0, 1) + +const inviteSharedResponseJSON = `{ + "ok": true, + "invite_id": "I02UKAJ6RJA", + "is_legacy_shared_channel": false +}` diff --git a/slacktest/handlers.go b/slacktest/handlers.go index 6ac37c289..b4a0227f3 100644 --- a/slacktest/handlers.go +++ b/slacktest/handlers.go @@ -108,6 +108,11 @@ func inviteConversationHandler(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(inviteConversationJSON)) } +// handle conversations.inviteShared +func inviteSharedConversationHandler(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(inviteSharedResponseJSON)) +} + // handle groups.list func listGroupsHandler(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(defaultGroupsListJSON)) diff --git a/slacktest/server.go b/slacktest/server.go index 647031015..6d9849451 100644 --- a/slacktest/server.go +++ b/slacktest/server.go @@ -55,6 +55,7 @@ func NewTestServer(custom ...Binder) *Server { s.Handle("/conversations.setPurpose", setConversationPurposeHandler) s.Handle("/conversations.rename", renameConversationHandler) s.Handle("/conversations.invite", inviteConversationHandler) + s.Handle("/conversations.inviteShared", inviteSharedConversationHandler) s.Handle("/users.info", usersInfoHandler) s.Handle("/users.lookupByEmail", usersInfoHandler) s.Handle("/bots.info", botsInfoHandler) From f7c27ea5008a7fd1b34d5a702f6579a517c362f4 Mon Sep 17 00:00:00 2001 From: stijndcl Date: Thu, 3 Aug 2023 15:51:51 +0200 Subject: [PATCH 187/606] Create structs for manifest endpoint --- manifests.go | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 manifests.go diff --git a/manifests.go b/manifests.go new file mode 100644 index 000000000..e82c1cb41 --- /dev/null +++ b/manifests.go @@ -0,0 +1,112 @@ +package slack + +// Manifest is an application manifest schema +type Manifest struct { + Metadata ManifestMetadata `json:"_metadata,omitempty" yaml:"_metadata,omitempty"` + Display Display `json:"display_information" yaml:"display_information"` + Settings Settings `json:"settings,omitempty" yaml:"settings,omitempty"` + Features Features `json:"features,omitempty" yaml:"features,omitempty"` + OAuthConfig OAuthConfig `json:"oauth_config,omitempty" yaml:"oauth_config,omitempty"` +} + +// ManifestMetadata is a group of settings that describe the manifest +type ManifestMetadata struct { + MajorVersion int `json:"major_version,omitempty" yaml:"major_version,omitempty"` + MinorVersion int `json:"minor_version,omitempty" yaml:"minor_version,omitempty"` +} + +// Display is a group of settings that describe parts of an app's appearance within Slack +type Display struct { + Name string `json:"name" yaml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + LongDescription string `json:"long_description,omitempty" yaml:"long_description,omitempty"` + BackgroundColor string `json:"background_color,omitempty" yaml:"background_color,omitempty"` +} + +// Settings is a group of settings corresponding to the Settings section of the app config pages. +type Settings struct { + AllowedIPAddressRanges []string `json:"allowed_ip_address_ranges,omitempty" yaml:"allowed_ip_address_ranges,omitempty"` + EventSubscriptions EventSubscriptions `json:"event_subscriptions,omitempty" yaml:"event_subscriptions,omitempty"` + Interactivity Interactivity `json:"interactivity,omitempty" yaml:"interactivity,omitempty"` + OrgDeployEnabled bool `json:"org_deploy_enabled,omitempty" yaml:"org_deploy_enabled,omitempty"` + SocketModeEnabled bool `json:"socket_mode_enabled,omitempty" yaml:"socket_mode_enabled,omitempty"` +} + +// EventSubscriptions is a group of settings that describe the Events API configuration +type EventSubscriptions struct { + RequestUrl string `json:"request_url,omitempty" yaml:"request_url,omitempty"` + BotEvents []string `json:"bot_events,omitempty" yaml:"bot_events,omitempty"` + UserEvents []string `json:"user_events,omitempty" yaml:"user_events,omitempty"` +} + +// Interactivity is a group of settings that describe the interactivity configuration +type Interactivity struct { + IsEnabled bool `json:"is_enabled" yaml:"is_enabled"` + RequestUrl string `json:"request_url,omitempty" yaml:"request_url,omitempty"` + MessageMenuOptionsUrl string `json:"message_menu_options_url,omitempty" yaml:"message_menu_options_url,omitempty"` +} + +// Features is a group of settings corresponding to the Features section of the app config pages +type Features struct { + AppHome AppHome `json:"app_home,omitempty" yaml:"app_home,omitempty"` + BotUser BotUser `json:"bot_user,omitempty" yaml:"bot_user,omitempty"` + Shortcuts []Shortcut `json:"shortcuts,omitempty" yaml:"shortcuts,omitempty"` + SlashCommands []ManifestSlashCommand `json:"slash_commands,omitempty" yaml:"slash_commands,omitempty"` + WorkflowSteps []WorkflowStep `json:"workflow_steps,omitempty" yaml:"workflow_steps,omitempty"` +} + +// AppHome is a group of settings that describe the App Home configuration +type AppHome struct { + HomeTabEnabled bool `json:"home_tab_enabled,omitempty" yaml:"home_tab_enabled,omitempty"` + MessagesTabEnabled bool `json:"messages_tab_enabled,omitempty" yaml:"messages_tab_enabled,omitempty"` + MessagesTabReadOnlyEnabled bool `json:"messages_tab_read_only_enabled,omitempty" yaml:"messages_tab_read_only_enabled,omitempty"` +} + +// BotUser is a group of settings that describe bot user configuration +type BotUser struct { + DisplayName string `json:"display_name" yaml:"display_name"` + AlwaysOnline bool `json:"always_online,omitempty" yaml:"always_online,omitempty"` +} + +// Shortcut is a group of settings that describes shortcut configuration +type Shortcut struct { + Name string `json:"name" yaml:"name"` + CallbackID string `json:"callback_id" yaml:"callback_id"` + Description string `json:"description" yaml:"description"` + Type ShortcutType `json:"type" yaml:"type"` +} + +// ShortcutType is a new string type for the available types of shortcuts +type ShortcutType string + +const ( + MessageShortcut ShortcutType = "message" + GlobalShortcut ShortcutType = "global" +) + +// ManifestSlashCommand is a group of settings that describes slash command configuration +type ManifestSlashCommand struct { + Command string `json:"command" yaml:"command"` + Description string `json:"description" yaml:"description"` + ShouldEscape bool `json:"should_escape,omitempty" yaml:"should_escape,omitempty"` + Url string `json:"url,omitempty" yaml:"url,omitempty"` + UsageHint string `json:"usage_hint,omitempty" yaml:"usage_hint,omitempty"` +} + +// WorkflowStep is a group of settings that describes workflow steps configuration +type WorkflowStep struct { + Name string `json:"name" yaml:"name"` + CallbackID string `json:"callback_id", yaml:"callback_id"` +} + +// OAuthConfig is a group of settings that describe OAuth configuration for the app +type OAuthConfig struct { + RedirectUrls []string `json:"redirect_urls,omitempty" yaml:"redirect_urls,omitempty"` + Scopes OAuthScopes `json:"scopes,omitempty" yaml:"scopes,omitempty"` +} + +// OAuthScopes is a group of settings that describe permission scopes configuration +type OAuthScopes struct { + Bot []string `json:"bot,omitempty" yaml:"bot,omitempty"` + User []string `json:"user,omitempty" yaml:"user,omitempty"` +} From 00233edafb216429809b26a85d7b5a3d816b2c46 Mon Sep 17 00:00:00 2001 From: stijndcl Date: Thu, 3 Aug 2023 19:01:55 +0200 Subject: [PATCH 188/606] Add requests to validate manifests --- manifests.go | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/manifests.go b/manifests.go index e82c1cb41..93ecba6d9 100644 --- a/manifests.go +++ b/manifests.go @@ -1,5 +1,11 @@ package slack +import ( + "context" + "encoding/json" + "net/url" +) + // Manifest is an application manifest schema type Manifest struct { Metadata ManifestMetadata `json:"_metadata,omitempty" yaml:"_metadata,omitempty"` @@ -9,6 +15,41 @@ type Manifest struct { OAuthConfig OAuthConfig `json:"oauth_config,omitempty" yaml:"oauth_config,omitempty"` } +// ValidateManifest sends a request to apps.manifest.validate to validate your app manifest +func (api *Client) ValidateManifest(manifest *Manifest, token string, appId string) (*ValidateManifestResponse, error) { + return api.ValidateManifestContext(context.Background(), manifest, token, appId) +} + +// ValidateManifestContext sends a request to apps.manifest.validate to validate your app manifest with context +func (api *Client) ValidateManifestContext(ctx context.Context, manifest *Manifest, token string, appId string) (*ValidateManifestResponse, error) { + if token == "" { + token = api.appLevelToken + } + + // Marshal manifest into string + jsonBytes, err := json.Marshal(manifest) + if err != nil { + return nil, err + } + + values := url.Values{ + "token": {token}, + "manifest": {string(jsonBytes)}, + } + + if appId != "" { + values.Add("app_id", appId) + } + + response := &ValidateManifestResponse{} + err = api.postMethod(ctx, "apps.manifest.validate", values, response) + if err != nil { + return nil, err + } + + return response, nil +} + // ManifestMetadata is a group of settings that describe the manifest type ManifestMetadata struct { MajorVersion int `json:"major_version,omitempty" yaml:"major_version,omitempty"` @@ -110,3 +151,16 @@ type OAuthScopes struct { Bot []string `json:"bot,omitempty" yaml:"bot,omitempty"` User []string `json:"user,omitempty" yaml:"user,omitempty"` } + +// ValidateManifestResponse is the response returned by the API +type ValidateManifestResponse struct { + Ok bool `json:"ok"` + Error string `json:"error,omitempty"` + Errors []ManifestValidationError `json:"errors,omitempty"` +} + +// ManifestValidationError is an error message returned for invalid manifests +type ManifestValidationError struct { + Message string `json:"message"` + Pointer string `json:"pointer"` +} From 3b8d5b7d4b72ecc5fad6f8837215ca0eaf753235 Mon Sep 17 00:00:00 2001 From: stijndcl Date: Fri, 4 Aug 2023 08:48:09 +0200 Subject: [PATCH 189/606] Add create and delete methods, implement common interface --- manifests.go | 79 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 69 insertions(+), 10 deletions(-) diff --git a/manifests.go b/manifests.go index 93ecba6d9..3f0484232 100644 --- a/manifests.go +++ b/manifests.go @@ -15,17 +15,56 @@ type Manifest struct { OAuthConfig OAuthConfig `json:"oauth_config,omitempty" yaml:"oauth_config,omitempty"` } +func (api *Client) CreateManifest(manifest *Manifest, token string) (*ManifestResponse, error) { + return api.CreateManifestContext(context.Background(), manifest, token) +} + +func (api *Client) CreateManifestContext(ctx context.Context, manifest *Manifest, token string) (*ManifestResponse, error) { + jsonBytes, err := json.Marshal(manifest) + if err != nil { + return nil, err + } + + values := url.Values{ + "token": {token}, + "manifest": {string(jsonBytes)}, + } + + response := &ManifestResponse{} + err = api.postMethod(ctx, "apps.manifest.create", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +func (api *Client) DeleteManifest(token string, appId string) (*SlackResponse, error) { + return api.DeleteManifestContext(context.Background(), token, appId) +} + +func (api *Client) DeleteManifestContext(ctx context.Context, token string, appId string) (*SlackResponse, error) { + values := url.Values{ + "token": {token}, + "app_id": {appId}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "apps.manifest.delete", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + // ValidateManifest sends a request to apps.manifest.validate to validate your app manifest -func (api *Client) ValidateManifest(manifest *Manifest, token string, appId string) (*ValidateManifestResponse, error) { +func (api *Client) ValidateManifest(manifest *Manifest, token string, appId string) (*ManifestResponse, error) { return api.ValidateManifestContext(context.Background(), manifest, token, appId) } // ValidateManifestContext sends a request to apps.manifest.validate to validate your app manifest with context -func (api *Client) ValidateManifestContext(ctx context.Context, manifest *Manifest, token string, appId string) (*ValidateManifestResponse, error) { - if token == "" { - token = api.appLevelToken - } - +func (api *Client) ValidateManifestContext(ctx context.Context, manifest *Manifest, token string, appId string) (*ManifestResponse, error) { // Marshal manifest into string jsonBytes, err := json.Marshal(manifest) if err != nil { @@ -41,13 +80,13 @@ func (api *Client) ValidateManifestContext(ctx context.Context, manifest *Manife values.Add("app_id", appId) } - response := &ValidateManifestResponse{} + response := &ManifestResponse{} err = api.postMethod(ctx, "apps.manifest.validate", values, response) if err != nil { return nil, err } - return response, nil + return response, response.Err() } // ManifestMetadata is a group of settings that describe the manifest @@ -152,15 +191,35 @@ type OAuthScopes struct { User []string `json:"user,omitempty" yaml:"user,omitempty"` } -// ValidateManifestResponse is the response returned by the API -type ValidateManifestResponse struct { +// ManifestResponse is the response returned by the API +// this is a different format than SlackResponse, so we can't use that here +// However, it intentionally has an Err() method for similar usage +type ManifestResponse struct { Ok bool `json:"ok"` Error string `json:"error,omitempty"` Errors []ManifestValidationError `json:"errors,omitempty"` } +func (m ManifestResponse) Err() error { + if m.Ok { + return nil + } + + return ManifestErrorResponse{Err: m.Error, Errors: m.Errors} +} + // ManifestValidationError is an error message returned for invalid manifests type ManifestValidationError struct { Message string `json:"message"` Pointer string `json:"pointer"` } + +// ManifestErrorResponse is a helper struct to contain an error from a ManifestResponse +type ManifestErrorResponse struct { + Err string + Errors []ManifestValidationError +} + +func (m ManifestErrorResponse) Error() string { + return m.Err +} From 7fceb91debe075a8fa0426836facac3edb9bbe60 Mon Sep 17 00:00:00 2001 From: stijndcl Date: Fri, 4 Aug 2023 09:04:09 +0200 Subject: [PATCH 190/606] Add tokens --- manifests.go | 43 +++++++++++++++++++------------------------ slack.go | 14 ++++++++------ tokens.go | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 30 deletions(-) create mode 100644 tokens.go diff --git a/manifests.go b/manifests.go index 3f0484232..4a53448a2 100644 --- a/manifests.go +++ b/manifests.go @@ -15,11 +15,17 @@ type Manifest struct { OAuthConfig OAuthConfig `json:"oauth_config,omitempty" yaml:"oauth_config,omitempty"` } +// CreateManifest creates an app from an app manifest func (api *Client) CreateManifest(manifest *Manifest, token string) (*ManifestResponse, error) { return api.CreateManifestContext(context.Background(), manifest, token) } +// CreateManifestContext creates an app from an app manifest with a custom context func (api *Client) CreateManifestContext(ctx context.Context, manifest *Manifest, token string) (*ManifestResponse, error) { + if token == "" { + token = api.configToken + } + jsonBytes, err := json.Marshal(manifest) if err != nil { return nil, err @@ -39,11 +45,17 @@ func (api *Client) CreateManifestContext(ctx context.Context, manifest *Manifest return response, response.Err() } +// DeleteManifest permanently deletes an app created through app manifests func (api *Client) DeleteManifest(token string, appId string) (*SlackResponse, error) { return api.DeleteManifestContext(context.Background(), token, appId) } +// DeleteManifestContext permanently deletes an app created through app manifests with a custom context func (api *Client) DeleteManifestContext(ctx context.Context, token string, appId string) (*SlackResponse, error) { + if token == "" { + token = api.configToken + } + values := url.Values{ "token": {token}, "app_id": {appId}, @@ -63,8 +75,12 @@ func (api *Client) ValidateManifest(manifest *Manifest, token string, appId stri return api.ValidateManifestContext(context.Background(), manifest, token, appId) } -// ValidateManifestContext sends a request to apps.manifest.validate to validate your app manifest with context +// ValidateManifestContext sends a request to apps.manifest.validate to validate your app manifest with a custom context func (api *Client) ValidateManifestContext(ctx context.Context, manifest *Manifest, token string, appId string) (*ManifestResponse, error) { + if token == "" { + token = api.configToken + } + // Marshal manifest into string jsonBytes, err := json.Marshal(manifest) if err != nil { @@ -191,21 +207,10 @@ type OAuthScopes struct { User []string `json:"user,omitempty" yaml:"user,omitempty"` } -// ManifestResponse is the response returned by the API -// this is a different format than SlackResponse, so we can't use that here -// However, it intentionally has an Err() method for similar usage +// ManifestResponse is the response returned by the API for app.manifest.x endpoints type ManifestResponse struct { - Ok bool `json:"ok"` - Error string `json:"error,omitempty"` Errors []ManifestValidationError `json:"errors,omitempty"` -} - -func (m ManifestResponse) Err() error { - if m.Ok { - return nil - } - - return ManifestErrorResponse{Err: m.Error, Errors: m.Errors} + SlackResponse } // ManifestValidationError is an error message returned for invalid manifests @@ -213,13 +218,3 @@ type ManifestValidationError struct { Message string `json:"message"` Pointer string `json:"pointer"` } - -// ManifestErrorResponse is a helper struct to contain an error from a ManifestResponse -type ManifestErrorResponse struct { - Err string - Errors []ManifestValidationError -} - -func (m ManifestErrorResponse) Error() string { - return m.Err -} diff --git a/slack.go b/slack.go index ea3aab6d6..361e849cb 100644 --- a/slack.go +++ b/slack.go @@ -57,12 +57,14 @@ type authTestResponseFull struct { type ParamOption func(*url.Values) type Client struct { - token string - appLevelToken string - endpoint string - debug bool - log ilogger - httpclient httpClient + token string + appLevelToken string + configToken string + configRefreshToken string + endpoint string + debug bool + log ilogger + httpclient httpClient } // Option defines an option for a Client diff --git a/tokens.go b/tokens.go new file mode 100644 index 000000000..567a5eec2 --- /dev/null +++ b/tokens.go @@ -0,0 +1,40 @@ +package slack + +import ( + "context" + "net/url" +) + +// RotateTokens exchanges a refresh token for a new app configuration token +func (api *Client) RotateTokens(refreshToken string) (*TokenResponse, error) { + return api.RotateTokensContext(context.Background(), refreshToken) +} + +// RotateTokensContext exchanges a refresh token for a new app configuration token with a custom context +func (api *Client) RotateTokensContext(ctx context.Context, refreshToken string) (*TokenResponse, error) { + if refreshToken == "" { + refreshToken = api.configRefreshToken + } + + values := url.Values{ + "refresh_token": {refreshToken}, + } + + response := &TokenResponse{} + err := api.postMethod(ctx, "tooling.tokens.rotate", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +type TokenResponse struct { + Token string `json:"token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + TeamId string `json:"team_id,omitempty"` + UserId string `json:"user_id,omitempty"` + CreatedAt uint64 `json:"iat,omitempty"` + ExpiresAt uint64 `json:"exp,omitempty"` + SlackResponse +} From 4b09d4185459f69df4c1d0adfb4ba32183a21e85 Mon Sep 17 00:00:00 2001 From: stijndcl Date: Fri, 4 Aug 2023 09:14:34 +0200 Subject: [PATCH 191/606] Finish manifest methods --- manifests.go | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/manifests.go b/manifests.go index 4a53448a2..f64c45365 100644 --- a/manifests.go +++ b/manifests.go @@ -70,6 +70,62 @@ func (api *Client) DeleteManifestContext(ctx context.Context, token string, appI return response, response.Err() } +// ExportManifest exports an app manifest from an existing app +func (api *Client) ExportManifest(token string, appId string) (*ExportManifestResponse, error) { + return api.ExportManifestContext(context.Background(), token, appId) +} + +// ExportManifestContext exports an app manifest from an existing app with a custom context +func (api *Client) ExportManifestContext(ctx context.Context, token string, appId string) (*ExportManifestResponse, error) { + if token == "" { + token = api.configToken + } + + values := url.Values{ + "token": {token}, + "app_id": {appId}, + } + + response := &ExportManifestResponse{} + err := api.postMethod(ctx, "apps.manifest.export", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +// UpdateManifest updates an app from an app manifest +func (api *Client) UpdateManifest(manifest *Manifest, token string, appId string) (*UpdateManifestResponse, error) { + return api.UpdateManifestContext(context.Background(), manifest, token, appId) +} + +// UpdateManifestContext updates an app from an app manifest with a custom context +func (api *Client) UpdateManifestContext(ctx context.Context, manifest *Manifest, token string, appId string) (*UpdateManifestResponse, error) { + if token == "" { + token = api.configToken + } + + jsonBytes, err := json.Marshal(manifest) + if err != nil { + return nil, err + } + + values := url.Values{ + "token": {token}, + "app_id": {appId}, + "manifest": {string(jsonBytes)}, + } + + response := &UpdateManifestResponse{} + err = api.postMethod(ctx, "apps.manifest.update", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + // ValidateManifest sends a request to apps.manifest.validate to validate your app manifest func (api *Client) ValidateManifest(manifest *Manifest, token string, appId string) (*ManifestResponse, error) { return api.ValidateManifestContext(context.Background(), manifest, token, appId) @@ -207,7 +263,7 @@ type OAuthScopes struct { User []string `json:"user,omitempty" yaml:"user,omitempty"` } -// ManifestResponse is the response returned by the API for app.manifest.x endpoints +// ManifestResponse is the response returned by the API for apps.manifest.x endpoints type ManifestResponse struct { Errors []ManifestValidationError `json:"errors,omitempty"` SlackResponse @@ -218,3 +274,14 @@ type ManifestValidationError struct { Message string `json:"message"` Pointer string `json:"pointer"` } + +type ExportManifestResponse struct { + Manifest Manifest `json:"manifest,omitempty"` + SlackResponse +} + +type UpdateManifestResponse struct { + AppId string `json:"app_id,omitempty"` + PermissionsUpdated bool `json:"permissions_updated,omitempty"` + SlackResponse +} From 1abbd7a75d90e7f142d8ee8c26e2fab03956c518 Mon Sep 17 00:00:00 2001 From: stijndcl Date: Fri, 4 Aug 2023 09:18:28 +0200 Subject: [PATCH 192/606] Fix linting --- manifests.go | 2 +- tokens_test.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 tokens_test.go diff --git a/manifests.go b/manifests.go index f64c45365..6d3f5c10c 100644 --- a/manifests.go +++ b/manifests.go @@ -248,7 +248,7 @@ type ManifestSlashCommand struct { // WorkflowStep is a group of settings that describes workflow steps configuration type WorkflowStep struct { Name string `json:"name" yaml:"name"` - CallbackID string `json:"callback_id", yaml:"callback_id"` + CallbackID string `json:"callback_id" yaml:"callback_id"` } // OAuthConfig is a group of settings that describe OAuth configuration for the app diff --git a/tokens_test.go b/tokens_test.go new file mode 100644 index 000000000..578351f3c --- /dev/null +++ b/tokens_test.go @@ -0,0 +1 @@ +package slack From 6c46b7c91c1589c31736ed4f9a43e5501fff0447 Mon Sep 17 00:00:00 2001 From: stijndcl Date: Fri, 4 Aug 2023 10:14:14 +0200 Subject: [PATCH 193/606] Rename value --- tokens.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tokens.go b/tokens.go index 567a5eec2..0fa9201ae 100644 --- a/tokens.go +++ b/tokens.go @@ -34,7 +34,7 @@ type TokenResponse struct { RefreshToken string `json:"refresh_token,omitempty"` TeamId string `json:"team_id,omitempty"` UserId string `json:"user_id,omitempty"` - CreatedAt uint64 `json:"iat,omitempty"` + IssuedAt uint64 `json:"iat,omitempty"` ExpiresAt uint64 `json:"exp,omitempty"` SlackResponse } From dd3d9cc0a5e042ff2640a84b05dc28734ecfc16e Mon Sep 17 00:00:00 2001 From: stijndcl Date: Fri, 4 Aug 2023 10:54:07 +0200 Subject: [PATCH 194/606] More tests, return manifest itself when exporting --- manifests.go | 8 ++++---- manifests_test.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++ tokens_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 manifests_test.go diff --git a/manifests.go b/manifests.go index 6d3f5c10c..5922e6b74 100644 --- a/manifests.go +++ b/manifests.go @@ -71,12 +71,12 @@ func (api *Client) DeleteManifestContext(ctx context.Context, token string, appI } // ExportManifest exports an app manifest from an existing app -func (api *Client) ExportManifest(token string, appId string) (*ExportManifestResponse, error) { +func (api *Client) ExportManifest(token string, appId string) (*Manifest, error) { return api.ExportManifestContext(context.Background(), token, appId) } // ExportManifestContext exports an app manifest from an existing app with a custom context -func (api *Client) ExportManifestContext(ctx context.Context, token string, appId string) (*ExportManifestResponse, error) { +func (api *Client) ExportManifestContext(ctx context.Context, token string, appId string) (*Manifest, error) { if token == "" { token = api.configToken } @@ -92,7 +92,7 @@ func (api *Client) ExportManifestContext(ctx context.Context, token string, appI return nil, err } - return response, response.Err() + return response.Manifest, response.Err() } // UpdateManifest updates an app from an app manifest @@ -276,7 +276,7 @@ type ManifestValidationError struct { } type ExportManifestResponse struct { - Manifest Manifest `json:"manifest,omitempty"` + Manifest *Manifest `json:"manifest,omitempty"` SlackResponse } diff --git a/manifests_test.go b/manifests_test.go new file mode 100644 index 000000000..f68e70294 --- /dev/null +++ b/manifests_test.go @@ -0,0 +1,49 @@ +package slack + +import ( + "encoding/json" + "net/http" + "reflect" + "testing" +) + +func TestCreateManifest(t *testing.T) { + http.HandleFunc("/apps.manifest.create", handleCreateManifest) + once.Do(startServer) + + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + resp, err := api.CreateManifest(getTestManifest(), "token") + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if !reflect.DeepEqual(resp, getTestManifestResponse()) { + t.Fatal(ErrIncorrectResponse) + } +} + +func handleCreateManifest(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + response, _ := json.Marshal(getTestManifestResponse()) + rw.Write(response) +} + +func getTestManifest() *Manifest { + return &Manifest{ + Display: Display{ + Name: "test", + Description: "this is a test", + }, + } +} + +func getTestManifestResponse() *ManifestResponse { + return &ManifestResponse{ + SlackResponse: SlackResponse{ + Ok: true, + }, + } +} diff --git a/tokens_test.go b/tokens_test.go index 578351f3c..61b17bdfa 100644 --- a/tokens_test.go +++ b/tokens_test.go @@ -1 +1,45 @@ package slack + +import ( + "encoding/json" + "net/http" + "reflect" + "testing" +) + +func TestRotateTokens(t *testing.T) { + http.HandleFunc("/tooling.tokens.rotate", handleRotateToken) + expected := getTestTokenResponse() + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + tok, err := api.RotateTokens("old-refresh") + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if !reflect.DeepEqual(expected, *tok) { + t.Fatal(ErrIncorrectResponse) + } +} + +func getTestTokenResponse() TokenResponse { + return TokenResponse{ + Token: "token", + RefreshToken: "refresh", + UserId: "uid", + TeamId: "tid", + IssuedAt: 1, + ExpiresAt: 1, + SlackResponse: SlackResponse{Ok: true}, + } +} + +func handleRotateToken(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + response, _ := json.Marshal(getTestTokenResponse()) + rw.Write(response) +} From 6bfd3934bf4a41ca86c97689953c707d0611b3e4 Mon Sep 17 00:00:00 2001 From: stijndcl Date: Fri, 4 Aug 2023 10:54:37 +0200 Subject: [PATCH 195/606] Undo pointer and take reference manually --- manifests.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifests.go b/manifests.go index 5922e6b74..810bd4f6a 100644 --- a/manifests.go +++ b/manifests.go @@ -92,7 +92,7 @@ func (api *Client) ExportManifestContext(ctx context.Context, token string, appI return nil, err } - return response.Manifest, response.Err() + return &response.Manifest, response.Err() } // UpdateManifest updates an app from an app manifest @@ -276,7 +276,7 @@ type ManifestValidationError struct { } type ExportManifestResponse struct { - Manifest *Manifest `json:"manifest,omitempty"` + Manifest Manifest `json:"manifest,omitempty"` SlackResponse } From c33746bd2c5a147e44dc02553edc521ec77e6188 Mon Sep 17 00:00:00 2001 From: stijndcl Date: Fri, 4 Aug 2023 11:20:14 +0200 Subject: [PATCH 196/606] More tests --- manifests_test.go | 106 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 3 deletions(-) diff --git a/manifests_test.go b/manifests_test.go index f68e70294..9133e68c8 100644 --- a/manifests_test.go +++ b/manifests_test.go @@ -13,7 +13,8 @@ func TestCreateManifest(t *testing.T) { api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - resp, err := api.CreateManifest(getTestManifest(), "token") + manif := getTestManifest() + resp, err := api.CreateManifest(&manif, "token") if err != nil { t.Errorf("Unexpected error: %v", err) return @@ -31,8 +32,107 @@ func handleCreateManifest(rw http.ResponseWriter, r *http.Request) { rw.Write(response) } -func getTestManifest() *Manifest { - return &Manifest{ +func TestDeleteManifest(t *testing.T) { + http.HandleFunc("/apps.manifest.delete", handleDeleteManifest) + expectedResponse := SlackResponse{Ok: true} + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + resp, err := api.DeleteManifest("token", "app id") + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if !reflect.DeepEqual(expectedResponse, *resp) { + t.Fatal(ErrIncorrectResponse) + } +} + +func handleDeleteManifest(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + response, _ := json.Marshal(SlackResponse{Ok: true}) + rw.Write(response) +} + +func TestExportManifest(t *testing.T) { + http.HandleFunc("/apps.manifest.export", handleExportManifest) + expectedResponse := getTestManifest() + + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + resp, err := api.ExportManifest("token", "app id") + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if !reflect.DeepEqual(expectedResponse, *resp) { + t.Fatal(ErrIncorrectResponse) + } +} + +func handleExportManifest(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + response, _ := json.Marshal(ExportManifestResponse{Manifest: getTestManifest()}) + rw.Write(response) +} + +func TestUpdateManifest(t *testing.T) { + http.HandleFunc("/apps.manifest.update", handleUpdateManifest) + expectedResponse := UpdateManifestResponse{AppId: "app id"} + + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + manif := getTestManifest() + resp, err := api.UpdateManifest(&manif, "token", "app id") + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if !reflect.DeepEqual(expectedResponse, *resp) { + t.Fatal(ErrIncorrectResponse) + } +} + +func handleUpdateManifest(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + response, _ := json.Marshal(UpdateManifestResponse{AppId: "app id"}) + rw.Write(response) +} + +func TestValidateManifest(t *testing.T) { + http.HandleFunc("/apps.manifest.validate", handleValidateManifest) + expectedResponse := ManifestResponse{SlackResponse: SlackResponse{Ok: true}} + + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + manif := getTestManifest() + resp, err := api.ValidateManifest(&manif, "token", "app id") + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if !reflect.DeepEqual(expectedResponse, *resp) { + t.Fatal(ErrIncorrectResponse) + } +} + +func handleValidateManifest(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + response, _ := json.Marshal(ManifestResponse{SlackResponse: SlackResponse{Ok: true}}) + rw.Write(response) +} + +func getTestManifest() Manifest { + return Manifest{ Display: Display{ Name: "test", Description: "this is a test", From f7d479fe47bf13629def5a72cbb9a46f3223c2b5 Mon Sep 17 00:00:00 2001 From: stijndcl Date: Fri, 4 Aug 2023 11:24:43 +0200 Subject: [PATCH 197/606] Add options for tokens --- slack.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/slack.go b/slack.go index 361e849cb..756106fe4 100644 --- a/slack.go +++ b/slack.go @@ -101,6 +101,16 @@ func OptionAppLevelToken(token string) func(*Client) { return func(c *Client) { c.appLevelToken = token } } +// OptionConfigToken sets a configuration token for the client. +func OptionConfigToken(token string) func(*Client) { + return func(c *Client) { c.configToken = token } +} + +// OptionConfigRefreshToken sets a configuration refresh token for the client. +func OptionConfigRefreshToken(token string) func(*Client) { + return func(c *Client) { c.configRefreshToken = token } +} + // New builds a slack client from the provided token and options. func New(token string, options ...Option) *Client { s := &Client{ From 14d4607b891eed33657de55d43fbe8669327873e Mon Sep 17 00:00:00 2001 From: stijndcl Date: Fri, 4 Aug 2023 11:31:50 +0200 Subject: [PATCH 198/606] Use correct method --- tokens.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tokens.go b/tokens.go index 0fa9201ae..8f678cc05 100644 --- a/tokens.go +++ b/tokens.go @@ -21,7 +21,7 @@ func (api *Client) RotateTokensContext(ctx context.Context, refreshToken string) } response := &TokenResponse{} - err := api.postMethod(ctx, "tooling.tokens.rotate", values, response) + err := api.getMethod(ctx, "tooling.tokens.rotate", "", values, response) if err != nil { return nil, err } From a33e5ae10f9bf51dc5e014cf515676ff25717083 Mon Sep 17 00:00:00 2001 From: stijndcl Date: Fri, 4 Aug 2023 11:37:08 +0200 Subject: [PATCH 199/606] use config token --- tokens.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tokens.go b/tokens.go index 8f678cc05..602db1b76 100644 --- a/tokens.go +++ b/tokens.go @@ -21,7 +21,7 @@ func (api *Client) RotateTokensContext(ctx context.Context, refreshToken string) } response := &TokenResponse{} - err := api.getMethod(ctx, "tooling.tokens.rotate", "", values, response) + err := api.getMethod(ctx, "tooling.tokens.rotate", api.configToken, values, response) if err != nil { return nil, err } From b9a30c0d15654169122b6fe1fe8a0f6f77e647a7 Mon Sep 17 00:00:00 2001 From: stijndcl Date: Fri, 4 Aug 2023 11:39:48 +0200 Subject: [PATCH 200/606] Pass config token as param --- tokens.go | 12 ++++++++---- tokens_test.go | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/tokens.go b/tokens.go index 602db1b76..5968704d7 100644 --- a/tokens.go +++ b/tokens.go @@ -6,12 +6,16 @@ import ( ) // RotateTokens exchanges a refresh token for a new app configuration token -func (api *Client) RotateTokens(refreshToken string) (*TokenResponse, error) { - return api.RotateTokensContext(context.Background(), refreshToken) +func (api *Client) RotateTokens(configToken string, refreshToken string) (*TokenResponse, error) { + return api.RotateTokensContext(context.Background(), configToken, refreshToken) } // RotateTokensContext exchanges a refresh token for a new app configuration token with a custom context -func (api *Client) RotateTokensContext(ctx context.Context, refreshToken string) (*TokenResponse, error) { +func (api *Client) RotateTokensContext(ctx context.Context, configToken string, refreshToken string) (*TokenResponse, error) { + if configToken == "" { + configToken = api.configToken + } + if refreshToken == "" { refreshToken = api.configRefreshToken } @@ -21,7 +25,7 @@ func (api *Client) RotateTokensContext(ctx context.Context, refreshToken string) } response := &TokenResponse{} - err := api.getMethod(ctx, "tooling.tokens.rotate", api.configToken, values, response) + err := api.getMethod(ctx, "tooling.tokens.rotate", configToken, values, response) if err != nil { return nil, err } diff --git a/tokens_test.go b/tokens_test.go index 61b17bdfa..621174598 100644 --- a/tokens_test.go +++ b/tokens_test.go @@ -14,7 +14,7 @@ func TestRotateTokens(t *testing.T) { once.Do(startServer) api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - tok, err := api.RotateTokens("old-refresh") + tok, err := api.RotateTokens("expired-config", "old-refresh") if err != nil { t.Errorf("Unexpected error: %v", err) return From 2f5b296c9073a41d25f19f7aae1836188e574f2d Mon Sep 17 00:00:00 2001 From: stijndcl Date: Thu, 10 Aug 2023 16:31:31 +0200 Subject: [PATCH 201/606] Small bugfix --- manifests.go | 2 +- tokens.go | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/manifests.go b/manifests.go index 810bd4f6a..0041bf244 100644 --- a/manifests.go +++ b/manifests.go @@ -283,5 +283,5 @@ type ExportManifestResponse struct { type UpdateManifestResponse struct { AppId string `json:"app_id,omitempty"` PermissionsUpdated bool `json:"permissions_updated,omitempty"` - SlackResponse + ManifestResponse } diff --git a/tokens.go b/tokens.go index 5968704d7..4b83beebb 100644 --- a/tokens.go +++ b/tokens.go @@ -33,6 +33,12 @@ func (api *Client) RotateTokensContext(ctx context.Context, configToken string, return response, response.Err() } +// UpdateConfigTokens replaces the configuration tokens in the client with those returned by the API +func (api *Client) UpdateConfigTokens(response *TokenResponse) { + api.configToken = response.Token + api.configRefreshToken = response.RefreshToken +} + type TokenResponse struct { Token string `json:"token,omitempty"` RefreshToken string `json:"refresh_token,omitempty"` From 8f8f4e9daf1c2e1d617db445115c258fa22cc1ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Gw=C3=B3=C5=BAd=C5=BA?= Date: Mon, 14 Aug 2023 10:29:00 +0200 Subject: [PATCH 202/606] add `omitempty` to ConfirmationBlockObject.Deny a --- block_object.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/block_object.go b/block_object.go index f70405eba..b39ff2287 100644 --- a/block_object.go +++ b/block_object.go @@ -177,7 +177,7 @@ type ConfirmationBlockObject struct { Title *TextBlockObject `json:"title"` Text *TextBlockObject `json:"text"` Confirm *TextBlockObject `json:"confirm"` - Deny *TextBlockObject `json:"deny"` + Deny *TextBlockObject `json:"deny,omitempty"` Style Style `json:"style,omitempty"` } From 311ff6a5944d11fdc16e41c98ad18ac2abb7f943 Mon Sep 17 00:00:00 2001 From: KouWakai <35868597+KouWakai@users.noreply.github.com> Date: Tue, 15 Aug 2023 15:35:49 +0900 Subject: [PATCH 203/606] change case "/humboldttest": to case "/slash" to handle http request correctly --- examples/modal/modal.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/modal/modal.go b/examples/modal/modal.go index 92bfc8b00..890356163 100644 --- a/examples/modal/modal.go +++ b/examples/modal/modal.go @@ -128,7 +128,7 @@ func handleSlash(w http.ResponseWriter, r *http.Request) { } switch s.Command { - case "/humboldttest": + case "/slash": api := slack.New("YOUR_TOKEN_HERE") modalRequest := generateModalRequest() _, err = api.OpenView(s.TriggerID, modalRequest) From 7b783e650bcbfa5f773dcbd1c8b79dc31497e8c6 Mon Sep 17 00:00:00 2001 From: KouWakai <35868597+KouWakai@users.noreply.github.com> Date: Tue, 15 Aug 2023 15:37:30 +0900 Subject: [PATCH 204/606] remove post message because it's not related to sample of updating modal --- examples/modal/modal.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/examples/modal/modal.go b/examples/modal/modal.go index 890356163..b38c8e3d8 100644 --- a/examples/modal/modal.go +++ b/examples/modal/modal.go @@ -158,16 +158,7 @@ func handleModal(w http.ResponseWriter, r *http.Request) { return } - // Note there might be a better way to get this info, but I figured this structure out from looking at the json response - firstName := i.View.State.Values["First Name"]["firstName"].Value - lastName := i.View.State.Values["Last Name"]["lastName"].Value - - msg := fmt.Sprintf("Hello %s %s, nice to meet you!", firstName, lastName) - api := slack.New("YOUR_TOKEN_HERE") - _, _, err = api.PostMessage(i.User.ID, - slack.MsgOptionText(msg, false), - slack.MsgOptionAttachments()) if err != nil { fmt.Printf(err.Error()) w.WriteHeader(http.StatusUnauthorized) From 6fe1bec52fc953d4a91206c633ff27824a293921 Mon Sep 17 00:00:00 2001 From: KouWakai <35868597+KouWakai@users.noreply.github.com> Date: Tue, 15 Aug 2023 15:40:50 +0900 Subject: [PATCH 205/606] add time.sleep to wait to see updated modal --- examples/modal/modal.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/modal/modal.go b/examples/modal/modal.go index b38c8e3d8..49ffa863c 100644 --- a/examples/modal/modal.go +++ b/examples/modal/modal.go @@ -174,6 +174,8 @@ func handleModal(w http.ResponseWriter, r *http.Request) { // You must set one of external_id or view_id and you can use hash for avoiding race condition. // More details: https://api.slack.com/surfaces/modals/using#updating_apis _, err := api.UpdateView(updateModal, "", i.View.Hash, i.View.ID) + // Wait for a few seconds to see result this code is necesarry due to slack server modal is going to be closed after the update + time.Sleep(time.Second * 2) if err != nil { fmt.Printf("Error updating view: %s", err) w.WriteHeader(http.StatusInternalServerError) From 217f2de7e7a42428d7b3e7d03b2072fff65410fc Mon Sep 17 00:00:00 2001 From: Stijn De Clercq Date: Fri, 18 Aug 2023 23:47:58 +0200 Subject: [PATCH 206/606] Add examples --- examples/manifests/README.md | 38 ++++++++++++++++++++++++++++ examples/manifests/manifest.go | 45 ++++++++++++++++++++++++++++++++++ examples/tokens/README.md | 10 ++++++++ examples/tokens/tokens.go | 33 +++++++++++++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 examples/manifests/README.md create mode 100644 examples/manifests/manifest.go create mode 100644 examples/tokens/README.md create mode 100644 examples/tokens/tokens.go diff --git a/examples/manifests/README.md b/examples/manifests/README.md new file mode 100644 index 000000000..e2a8e2e3d --- /dev/null +++ b/examples/manifests/README.md @@ -0,0 +1,38 @@ +# Manifest examples + +This example tries to show how to interact with the +new [manifest endpoints](https://api.slack.com/reference/manifests#manifest_apis). These endpoints require a special set +of tokens: **configuration tokens**. Refer to +the [relevant documentation](https://api.slack.com/authentication/config-tokens) for how to create these tokens. + +For examples on how to use configuration tokens, see the [tokens example](../tokens). + +## Usage info + +The manifest endpoints allow you to configure your application programmatically instead of manually creating +a `manifest.yaml` file and uploading it on your Slack application's dashboard. + +A manifest should follow a specific structure and has a handful of required fields. These are describe in +the [manifest documentation](https://api.slack.com/reference/manifests#fields), but Slack additionally returns very +informative error messages for malformed templates to help you pin down what the issue is. The library itself does not +attempt to perform any form of validation on your manifest. + +**Note that each configuration token may only be used once before being invalidated. Again refer to the tokens example +for more information.** + +## Available methods + +- ``Slack.CreateManifest()`` +- ``Slack.DeleteManifest()`` +- ``Slack.ExportManifest()`` +- ``Slack.UpdateManifest()`` + +## Example details + +The example code here only shows how to _update_ an application using a manifest. The other available methods are either +identical in usage or trivial to use, so no full example is provided for them. + +The example doesn't rotate the configuration tokens after updating the manifest. **You should almost always do this**. +Your access token is invalidated after sending a request, and rotating your tokens will allow you to make another +request in the future. This example does not do this explicitly as it would just repeat the tokens example. For sake of +simplicity, it only focuses on the manifest part. diff --git a/examples/manifests/manifest.go b/examples/manifests/manifest.go new file mode 100644 index 000000000..bfcfa9cab --- /dev/null +++ b/examples/manifests/manifest.go @@ -0,0 +1,45 @@ +package manifests + +import ( + "fmt" + "github.com/slack-go/slack" +) + +// createManifest programmatically creates a Slack app manifest +func createManifest() *slack.Manifest { + return &slack.Manifest{ + Display: slack.Display{ + Name: "Your Application", + }, + // ... other configuration here + } +} + +func main() { + api := slack.New( + "YOUR_TOKEN_HERE", + // You may choose to provide your access token when creating your Slack client + // or when invoking the method calls + slack.OptionConfigToken("YOUR_CONFIG_ACCESS_TOKEN_HERE"), + ) + + // Create a new Manifest object + manifest := createManifest() + + // Update your application using the new manifest + // You may pass your token as a parameter here as well, if you didn't do it above + response, err := api.UpdateManifest(manifest, "", "YOUR_APP_ID_HERE") + if err != nil { + fmt.Printf("error updating Slack application: %v\n", err) + return + } + + if !response.Ok { + fmt.Printf("unable to update Slack application: %v\n", response.Errors) + } + + fmt.Println("successfully updated Slack application") + + // The access token is now invalid, so it should be rotated for future use + // Refer to the examples about tokens for more details +} diff --git a/examples/tokens/README.md b/examples/tokens/README.md new file mode 100644 index 000000000..7e8e163e7 --- /dev/null +++ b/examples/tokens/README.md @@ -0,0 +1,10 @@ +# Tokens examples + +The refresh token endpoint can be used to update +your [configuration tokenset](https://api.slack.com/authentication/config-tokens). These tokens may only be used **once +** before being invalidated, and are only valid for up to **12 hours**. + +Once a token has been used, or before it expires, you can use the `RotateTokens()` method to obtain a fresh set to use +for the next request. Depending on your use-case you may want to store these somewhere for a future run, so they are +only returned by the method call. If you wish to update the tokens inside the active Slack client, this can be done +using `UpdateConfigTokens()`. diff --git a/examples/tokens/tokens.go b/examples/tokens/tokens.go new file mode 100644 index 000000000..63d46d25f --- /dev/null +++ b/examples/tokens/tokens.go @@ -0,0 +1,33 @@ +package tokens + +import ( + "fmt" + "github.com/slack-go/slack" +) + +func main() { + api := slack.New( + "YOUR_TOKEN_HERE", + // You may choose to provide your config tokens when creating your Slack client + // or when invoking the method calls + slack.OptionConfigToken("YOUR_CONFIG_ACCESS_TOKEN_HERE"), + slack.OptionConfigRefreshToken("YOUR_REFRESH_TOKEN_HERE"), + ) + + // Obtain a fresh set of tokens + // You may pass your tokens as a parameter here as well, if you didn't do it above + freshTokens, err := api.RotateTokens("", "") + if err != nil { + fmt.Printf("error rotating tokens: %v\n", err) + return + } + + fmt.Printf("new access token: %s\n", freshTokens.Token) + fmt.Printf("new refresh token: %s\n", freshTokens.RefreshToken) + fmt.Printf("new tokenset expires at: %d\n", freshTokens.ExpiresAt) + + // Optionally: update the tokens inside the running Slack client + // This isn't necessary if you restart the application after storing the tokens elsewhere, + // or pass them as parameters to RotateTokens() explicitly + api.UpdateConfigTokens(freshTokens) +} From cf4e8bcedf039e5c9a6d9bcdaad069a5a9561db1 Mon Sep 17 00:00:00 2001 From: KouWakai <35868597+KouWakai@users.noreply.github.com> Date: Sat, 19 Aug 2023 12:26:03 +0900 Subject: [PATCH 207/606] add time pkg --- examples/modal/modal.go | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/modal/modal.go b/examples/modal/modal.go index 49ffa863c..155f2f714 100644 --- a/examples/modal/modal.go +++ b/examples/modal/modal.go @@ -19,6 +19,7 @@ import ( "net/http" "github.com/slack-go/slack" + "time" ) func generateModalRequest() slack.ModalViewRequest { From 3dc3db8986522e1a4b87b9b5250098e90c0bbc05 Mon Sep 17 00:00:00 2001 From: Stijn De Clercq Date: Sat, 19 Aug 2023 19:19:35 +0200 Subject: [PATCH 208/606] Re-phrase example docs --- examples/manifests/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/manifests/README.md b/examples/manifests/README.md index e2a8e2e3d..ba6db2332 100644 --- a/examples/manifests/README.md +++ b/examples/manifests/README.md @@ -1,8 +1,8 @@ # Manifest examples -This example tries to show how to interact with the +This example shows how to interact with the new [manifest endpoints](https://api.slack.com/reference/manifests#manifest_apis). These endpoints require a special set -of tokens: **configuration tokens**. Refer to +of tokens called `configuration tokens`. Refer to the [relevant documentation](https://api.slack.com/authentication/config-tokens) for how to create these tokens. For examples on how to use configuration tokens, see the [tokens example](../tokens). From b13e91de12d56442bae868f985cbf44d27705fe3 Mon Sep 17 00:00:00 2001 From: Hussachai Puripunpinyo Date: Sun, 10 Sep 2023 00:01:26 -0700 Subject: [PATCH 209/606] Add is_enterprise_install flag to command, interactions, and oauth2 response --- interactions.go | 47 ++++++++++++++++++++++++----------------------- oauth.go | 23 ++++++++++++----------- slash.go | 30 ++++++++++++++++-------------- 3 files changed, 52 insertions(+), 48 deletions(-) diff --git a/interactions.go b/interactions.go index e362caa86..8c6414707 100644 --- a/interactions.go +++ b/interactions.go @@ -33,29 +33,30 @@ const ( // InteractionCallback is sent from slack when a user interactions with a button or dialog. type InteractionCallback struct { - Type InteractionType `json:"type"` - Token string `json:"token"` - CallbackID string `json:"callback_id"` - ResponseURL string `json:"response_url"` - TriggerID string `json:"trigger_id"` - ActionTs string `json:"action_ts"` - Team Team `json:"team"` - Channel Channel `json:"channel"` - User User `json:"user"` - OriginalMessage Message `json:"original_message"` - Message Message `json:"message"` - Name string `json:"name"` - Value string `json:"value"` - MessageTs string `json:"message_ts"` - AttachmentID string `json:"attachment_id"` - ActionCallback ActionCallbacks `json:"actions"` - View View `json:"view"` - ActionID string `json:"action_id"` - APIAppID string `json:"api_app_id"` - BlockID string `json:"block_id"` - Container Container `json:"container"` - Enterprise Enterprise `json:"enterprise"` - WorkflowStep InteractionWorkflowStep `json:"workflow_step"` + Type InteractionType `json:"type"` + Token string `json:"token"` + CallbackID string `json:"callback_id"` + ResponseURL string `json:"response_url"` + TriggerID string `json:"trigger_id"` + ActionTs string `json:"action_ts"` + Team Team `json:"team"` + Channel Channel `json:"channel"` + User User `json:"user"` + OriginalMessage Message `json:"original_message"` + Message Message `json:"message"` + Name string `json:"name"` + Value string `json:"value"` + MessageTs string `json:"message_ts"` + AttachmentID string `json:"attachment_id"` + ActionCallback ActionCallbacks `json:"actions"` + View View `json:"view"` + ActionID string `json:"action_id"` + APIAppID string `json:"api_app_id"` + BlockID string `json:"block_id"` + Container Container `json:"container"` + Enterprise Enterprise `json:"enterprise"` + IsEnterpriseInstall bool `json:"is_enterprise_install"` + WorkflowStep InteractionWorkflowStep `json:"workflow_step"` DialogSubmissionCallback ViewSubmissionCallback ViewClosedCallback diff --git a/oauth.go b/oauth.go index 94b6546d5..e7969b020 100644 --- a/oauth.go +++ b/oauth.go @@ -33,17 +33,18 @@ type OAuthResponse struct { // OAuthV2Response ... type OAuthV2Response struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - Scope string `json:"scope"` - BotUserID string `json:"bot_user_id"` - AppID string `json:"app_id"` - Team OAuthV2ResponseTeam `json:"team"` - IncomingWebhook OAuthResponseIncomingWebhook `json:"incoming_webhook"` - Enterprise OAuthV2ResponseEnterprise `json:"enterprise"` - AuthedUser OAuthV2ResponseAuthedUser `json:"authed_user"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + BotUserID string `json:"bot_user_id"` + AppID string `json:"app_id"` + Team OAuthV2ResponseTeam `json:"team"` + IncomingWebhook OAuthResponseIncomingWebhook `json:"incoming_webhook"` + Enterprise OAuthV2ResponseEnterprise `json:"enterprise"` + IsEnterpriseInstall bool `json:"is_enterprise_install"` + AuthedUser OAuthV2ResponseAuthedUser `json:"authed_user"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` SlackResponse } diff --git a/slash.go b/slash.go index b2c509476..a41b7677a 100644 --- a/slash.go +++ b/slash.go @@ -6,20 +6,21 @@ import ( // SlashCommand contains information about a request of the slash command type SlashCommand struct { - Token string `json:"token"` - TeamID string `json:"team_id"` - TeamDomain string `json:"team_domain"` - EnterpriseID string `json:"enterprise_id,omitempty"` - EnterpriseName string `json:"enterprise_name,omitempty"` - ChannelID string `json:"channel_id"` - ChannelName string `json:"channel_name"` - UserID string `json:"user_id"` - UserName string `json:"user_name"` - Command string `json:"command"` - Text string `json:"text"` - ResponseURL string `json:"response_url"` - TriggerID string `json:"trigger_id"` - APIAppID string `json:"api_app_id"` + Token string `json:"token"` + TeamID string `json:"team_id"` + TeamDomain string `json:"team_domain"` + EnterpriseID string `json:"enterprise_id,omitempty"` + EnterpriseName string `json:"enterprise_name,omitempty"` + IsEnterpriseInstall bool `json:"is_enterprise_install"` + ChannelID string `json:"channel_id"` + ChannelName string `json:"channel_name"` + UserID string `json:"user_id"` + UserName string `json:"user_name"` + Command string `json:"command"` + Text string `json:"text"` + ResponseURL string `json:"response_url"` + TriggerID string `json:"trigger_id"` + APIAppID string `json:"api_app_id"` } // SlashCommandParse will parse the request of the slash command @@ -32,6 +33,7 @@ func SlashCommandParse(r *http.Request) (s SlashCommand, err error) { s.TeamDomain = r.PostForm.Get("team_domain") s.EnterpriseID = r.PostForm.Get("enterprise_id") s.EnterpriseName = r.PostForm.Get("enterprise_name") + s.IsEnterpriseInstall = r.PostForm.Get("is_enterprise_install") == "true" s.ChannelID = r.PostForm.Get("channel_id") s.ChannelName = r.PostForm.Get("channel_name") s.UserID = r.PostForm.Get("user_id") From b801caabd08ad2bff506a649f9f7b0af53614ca8 Mon Sep 17 00:00:00 2001 From: invzhi Date: Wed, 20 Sep 2023 15:47:02 +0800 Subject: [PATCH 210/606] Add blocks in slackevents.MessageEvent --- slackevents/inner_events.go | 1 + 1 file changed, 1 insertion(+) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index fb1ab132a..6be8b9201 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -267,6 +267,7 @@ type MessageEvent struct { Upload bool `json:"upload"` Files []File `json:"files"` + Blocks slack.Blocks `json:"blocks,omitempty"` Attachments []slack.Attachment `json:"attachments,omitempty"` // Root is the message that was broadcast to the channel when the SubType is From e439902053632fc258c141c253a17d6f9c473d19 Mon Sep 17 00:00:00 2001 From: Peiman Jafari Date: Mon, 16 Oct 2023 11:46:22 -0700 Subject: [PATCH 211/606] Add support for Rich Text Input --- block_conv.go | 10 ++++++++++ block_element.go | 30 ++++++++++++++++++++++++++++++ block_element_test.go | 6 ++++++ 3 files changed, 46 insertions(+) diff --git a/block_conv.go b/block_conv.go index 4ab58de3f..42dcd0c85 100644 --- a/block_conv.go +++ b/block_conv.go @@ -114,6 +114,8 @@ func (b *InputBlock) UnmarshalJSON(data []byte) error { e = &DateTimePickerBlockElement{} case "plain_text_input": e = &PlainTextInputBlockElement{} + case "rich_text_input": + e = &RichTextInputBlockElement{} case "email_text_input": e = &EmailTextInputBlockElement{} case "url_text_input": @@ -196,6 +198,8 @@ func (b *BlockElements) UnmarshalJSON(data []byte) error { blockElement = &DateTimePickerBlockElement{} case "plain_text_input": blockElement = &PlainTextInputBlockElement{} + case "rich_text_input": + blockElement = &RichTextInputBlockElement{} case "email_text_input": blockElement = &EmailTextInputBlockElement{} case "url_text_input": @@ -298,6 +302,12 @@ func (a *Accessory) UnmarshalJSON(data []byte) error { return err } a.PlainTextInputElement = element.(*PlainTextInputBlockElement) + case "rich_text_input": + element, err := unmarshalBlockElement(r, &RichTextInputBlockElement{}) + if err != nil { + return err + } + a.RichTextInputElement = element.(*RichTextInputBlockElement) case "radio_buttons": element, err := unmarshalBlockElement(r, &RadioButtonsBlockElement{}) if err != nil { diff --git a/block_element.go b/block_element.go index a70d8f2de..49232d540 100644 --- a/block_element.go +++ b/block_element.go @@ -12,6 +12,7 @@ const ( METDatetimepicker MessageElementType = "datetimepicker" METPlainTextInput MessageElementType = "plain_text_input" METRadioButtons MessageElementType = "radio_buttons" + METRichTextInput MessageElementType = "rich_text_input" METEmailTextInput MessageElementType = "email_text_input" METURLTextInput MessageElementType = "url_text_input" METNumber MessageElementType = "number_input" @@ -51,6 +52,7 @@ type Accessory struct { DatePickerElement *DatePickerBlockElement TimePickerElement *TimePickerBlockElement PlainTextInputElement *PlainTextInputBlockElement + RichTextInputElement *RichTextInputBlockElement RadioButtonsElement *RadioButtonsBlockElement SelectElement *SelectBlockElement MultiSelectElement *MultiSelectBlockElement @@ -73,6 +75,8 @@ func NewAccessory(element BlockElement) *Accessory { return &Accessory{TimePickerElement: element.(*TimePickerBlockElement)} case *PlainTextInputBlockElement: return &Accessory{PlainTextInputElement: element.(*PlainTextInputBlockElement)} + case *RichTextInputBlockElement: + return &Accessory{RichTextInputElement: element.(*RichTextInputBlockElement)} case *RadioButtonsBlockElement: return &Accessory{RadioButtonsElement: element.(*RadioButtonsBlockElement)} case *SelectBlockElement: @@ -509,6 +513,32 @@ func NewPlainTextInputBlockElement(placeholder *TextBlockObject, actionID string } } +// RichTextInputBlockElement creates a field where allows users to enter formatted text +// in a WYSIWYG composer, offering the same messaging writing experience as in Slack +// More Information: https://api.slack.com/reference/block-kit/block-elements#rich_text_input +type RichTextInputBlockElement struct { + Type MessageElementType `json:"type"` + ActionID string `json:"action_id,omitempty"` + Placeholder *TextBlockObject `json:"placeholder,omitempty"` + InitialValue string `json:"initial_value,omitempty"` + DispatchActionConfig *DispatchActionConfig `json:"dispatch_action_config,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` +} + +// ElementType returns the type of the Element +func (s RichTextInputBlockElement) ElementType() MessageElementType { + return s.Type +} + +// NewRichTextInputBlockElement returns an instance of a rich-text input element +func NewRichTextInputBlockElement(placeholder *TextBlockObject, actionID string) *RichTextInputBlockElement { + return &RichTextInputBlockElement{ + Type: METRichTextInput, + ActionID: actionID, + Placeholder: placeholder, + } +} + // CheckboxGroupsBlockElement defines an element which allows users to choose // one or more items from a list of possible options. // diff --git a/block_element_test.go b/block_element_test.go index 0c47affc3..19a0c964e 100644 --- a/block_element_test.go +++ b/block_element_test.go @@ -148,6 +148,12 @@ func TestNewPlainTextInputBlockElement(t *testing.T) { } +func TestNewRichTextInputBlockElement(t *testing.T) { + richTextInputElement := NewRichTextInputBlockElement(nil, "test") + assert.Equal(t, string(richTextInputElement.Type), "rich_text_input") + assert.Equal(t, richTextInputElement.ActionID, "test") +} + func TestNewEmailTextInputBlockElement(t *testing.T) { emailTextInputElement := NewEmailTextInputBlockElement(nil, "example@example.com") From e7152216dda52317d92b00a08a91e5b776c9a854 Mon Sep 17 00:00:00 2001 From: walkure Date: Sun, 22 Oct 2023 00:49:46 +0900 Subject: [PATCH 212/606] Add Video Block (#1210) Add support for Video Blocks Ref. https://api.slack.com/reference/block-kit/blocks#video cf. #1205 --------- Co-authored-by: David Parsley Co-authored-by: Ben Bourdin --- block.go | 1 + block_conv.go | 2 ++ block_video.go | 65 +++++++++++++++++++++++++++++++++++++++++++++ block_video_test.go | 23 ++++++++++++++++ 4 files changed, 91 insertions(+) create mode 100644 block_video.go create mode 100644 block_video_test.go diff --git a/block.go b/block.go index 18222bd2b..c28377aaa 100644 --- a/block.go +++ b/block.go @@ -18,6 +18,7 @@ const ( MBTInput MessageBlockType = "input" MBTHeader MessageBlockType = "header" MBTRichText MessageBlockType = "rich_text" + MBTVideo MessageBlockType = "video" ) // Block defines an interface all block types should implement diff --git a/block_conv.go b/block_conv.go index 4ab58de3f..d017c6626 100644 --- a/block_conv.go +++ b/block_conv.go @@ -68,6 +68,8 @@ func (b *Blocks) UnmarshalJSON(data []byte) error { block = &RichTextBlock{} case "section": block = &SectionBlock{} + case "video": + block = &VideoBlock{} default: block = &UnknownBlock{} } diff --git a/block_video.go b/block_video.go new file mode 100644 index 000000000..322c614f9 --- /dev/null +++ b/block_video.go @@ -0,0 +1,65 @@ +package slack + +// VideoBlock defines data required to display a video as a block element +// +// More Information: https://api.slack.com/reference/block-kit/blocks#video +type VideoBlock struct { + Type MessageBlockType `json:"type"` + VideoURL string `json:"video_url"` + ThumbnailURL string `json:"thumbnail_url"` + AltText string `json:"alt_text"` + Title *TextBlockObject `json:"title"` + BlockID string `json:"block_id,omitempty"` + TitleURL string `json:"title_url,omitempty"` + AuthorName string `json:"author_name,omitempty"` + ProviderName string `json:"provider_name,omitempty"` + ProviderIconURL string `json:"provider_icon_url,omitempty"` + Description *TextBlockObject `json:"description,omitempty"` +} + +// BlockType returns the type of the block +func (s VideoBlock) BlockType() MessageBlockType { + return s.Type +} + +// NewVideoBlock returns an instance of a new Video Block type +func NewVideoBlock(videoURL, thumbnailURL, altText, blockID string, title *TextBlockObject) *VideoBlock { + return &VideoBlock{ + Type: MBTVideo, + VideoURL: videoURL, + ThumbnailURL: thumbnailURL, + AltText: altText, + BlockID: blockID, + Title: title, + } +} + +// WithAuthorName sets the author name for the VideoBlock +func (s *VideoBlock) WithAuthorName(authorName string) *VideoBlock { + s.AuthorName = authorName + return s +} + +// WithTitleURL sets the title URL for the VideoBlock +func (s *VideoBlock) WithTitleURL(titleURL string) *VideoBlock { + s.TitleURL = titleURL + return s +} + +// WithDescription sets the description for the VideoBlock +func (s *VideoBlock) WithDescription(description *TextBlockObject) *VideoBlock { + s.Description = description + return s +} + +// WithProviderIconURL sets the provider icon URL for the VideoBlock +func (s *VideoBlock) WithProviderIconURL(providerIconURL string) *VideoBlock { + s.ProviderIconURL = providerIconURL + return s +} + +// WithProviderName sets the provider name for the VideoBlock +func (s *VideoBlock) WithProviderName(providerName string) *VideoBlock { + s.ProviderName = providerName + return s +} diff --git a/block_video_test.go b/block_video_test.go new file mode 100644 index 000000000..d4b791fab --- /dev/null +++ b/block_video_test.go @@ -0,0 +1,23 @@ +package slack + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewVideoBlock(t *testing.T) { + + videoTitle := NewTextBlockObject("plain_text", "VideoTitle", false, false) + videoBlock := NewVideoBlock( + "https://example.com/example.mp4", + "https://example.com/thumbnail.png", + "alternative text", "blockID", videoTitle) + + assert.Equal(t, string(videoBlock.Type), "video") + assert.Equal(t, videoBlock.Title.Type, "plain_text") + assert.Equal(t, videoBlock.BlockID, "blockID") + assert.Contains(t, videoBlock.Title.Text, "VideoTitle") + assert.Contains(t, videoBlock.VideoURL, "example.mp4") + +} From 1b0eb7dcbc83d6aebe842e87b6f187077907050c Mon Sep 17 00:00:00 2001 From: Peiman Jafari Date: Tue, 24 Oct 2023 11:08:27 -0700 Subject: [PATCH 213/606] Add rich_text_input block conv --- block_conv.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/block_conv.go b/block_conv.go index 42dcd0c85..48a9d0e9f 100644 --- a/block_conv.go +++ b/block_conv.go @@ -66,6 +66,8 @@ func (b *Blocks) UnmarshalJSON(data []byte) error { block = &InputBlock{} case "rich_text": block = &RichTextBlock{} + case "rich_text_input": + block = &RichTextBlock{} case "section": block = &SectionBlock{} default: From f2fe7b3abac6ebff05446a55760678db783d74e2 Mon Sep 17 00:00:00 2001 From: Daniel Michaels Date: Mon, 6 Nov 2023 20:30:25 +1100 Subject: [PATCH 214/606] Create openid.connect.token function to fetch access token --- oauth.go | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/oauth.go b/oauth.go index 94b6546d5..950b743bd 100644 --- a/oauth.go +++ b/oauth.go @@ -69,6 +69,15 @@ type OAuthV2ResponseAuthedUser struct { TokenType string `json:"token_type"` } +// OpenIDConnectResponse ... +type OpenIDConnectResponse struct { + Ok bool `json:"ok"` + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + IdToken string `json:"id_token"` + SlackResponse +} + // GetOAuthToken retrieves an AccessToken func GetOAuthToken(client httpClient, clientID, clientSecret, code, redirectURI string) (accessToken string, scope string, err error) { return GetOAuthTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI) @@ -156,3 +165,24 @@ func RefreshOAuthV2TokenContext(ctx context.Context, client httpClient, clientID } return response, response.Err() } + +// GetOpenIDConnectToken gets a temporary OAuth verifier code for an access token for Sign in with Slack. +// see: https://api.slack.com/methods/openid.connect.token +func GetOpenIDConnectToken(client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OpenIDConnectResponse, err error) { + return GetOpenIDConnectTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI) +} + +// GetOpenIDConnectTokenContext with a context, gets an access token for Sign in with Slack. +func GetOpenIDConnectTokenContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OpenIDConnectResponse, err error) { + values := url.Values{ + "client_id": {clientID}, + "client_secret": {clientSecret}, + "code": {code}, + "redirect_uri": {redirectURI}, + } + response := &OpenIDConnectResponse{} + if err = postForm(ctx, client, APIURL+"openid.connect.token", values, response, discard{}); err != nil { + return nil, err + } + return response, response.Err() +} From 311e30dab2208c58ef89dc5340d723250918afcd Mon Sep 17 00:00:00 2001 From: Daniel Michaels Date: Mon, 6 Nov 2023 20:30:51 +1100 Subject: [PATCH 215/606] Fix incorrectly commented functions --- oauth.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/oauth.go b/oauth.go index 950b743bd..851820a64 100644 --- a/oauth.go +++ b/oauth.go @@ -146,12 +146,12 @@ func GetOAuthV2ResponseContext(ctx context.Context, client httpClient, clientID, return response, response.Err() } -// RefreshOAuthV2AccessContext with a context, gets a V2 OAuth access token response +// RefreshOAuthV2Token with a context, gets a V2 OAuth access token response func RefreshOAuthV2Token(client httpClient, clientID, clientSecret, refreshToken string) (resp *OAuthV2Response, err error) { return RefreshOAuthV2TokenContext(context.Background(), client, clientID, clientSecret, refreshToken) } -// RefreshOAuthV2AccessContext with a context, gets a V2 OAuth access token response +// RefreshOAuthV2TokenContext with a context, gets a V2 OAuth access token response func RefreshOAuthV2TokenContext(ctx context.Context, client httpClient, clientID, clientSecret, refreshToken string) (resp *OAuthV2Response, err error) { values := url.Values{ "client_id": {clientID}, From 8019a9a7724c28b9486d0791c2c74d588f23e9a4 Mon Sep 17 00:00:00 2001 From: Daniel Michaels Date: Mon, 6 Nov 2023 20:53:31 +1100 Subject: [PATCH 216/606] Improved comment for GetOpenIDConnectToken --- oauth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth.go b/oauth.go index 851820a64..3c853faea 100644 --- a/oauth.go +++ b/oauth.go @@ -166,7 +166,7 @@ func RefreshOAuthV2TokenContext(ctx context.Context, client httpClient, clientID return response, response.Err() } -// GetOpenIDConnectToken gets a temporary OAuth verifier code for an access token for Sign in with Slack. +// GetOpenIDConnectToken exchanges a temporary OAuth verifier code for an access token for Sign in with Slack. // see: https://api.slack.com/methods/openid.connect.token func GetOpenIDConnectToken(client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OpenIDConnectResponse, err error) { return GetOpenIDConnectTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI) From fd060b6f4b620e2e10c3734eb6a524024fa8fefb Mon Sep 17 00:00:00 2001 From: Daniel Pieper Date: Tue, 29 Nov 2022 18:03:47 +0100 Subject: [PATCH 217/606] Add RichTextList support --- block_rich_text.go | 77 ++++++++++++++++++++++++++++++++++++++++- block_rich_text_test.go | 75 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 150 insertions(+), 2 deletions(-) diff --git a/block_rich_text.go b/block_rich_text.go index 01e5cdb96..ad5835440 100644 --- a/block_rich_text.go +++ b/block_rich_text.go @@ -40,6 +40,9 @@ func (e *RichTextBlock) UnmarshalJSON(b []byte) error { switch s.Type { case RTESection: elem = &RichTextSection{} + case RTEList: + elem = &RichTextList{} + default: elems = append(elems, &RichTextUnknown{ Type: s.Type, @@ -92,12 +95,84 @@ func (u RichTextUnknown) RichTextElementType() RichTextElementType { return u.Type } +type RichTextListElementType string + +const ( + RTEListOrdered RichTextListElementType = "ordered" + RTEListBullet RichTextListElementType = "bullet" +) + +type RichTextList struct { + Type RichTextElementType `json:"type"` + Elements []RichTextElement `json:"elements"` + Style RichTextListElementType `json:"style"` +} + +// NewRichTextList returns a new rich text list element. +func NewRichTextList(style RichTextListElementType, elements ...RichTextElement) *RichTextList { + return &RichTextList{ + Type: RTEList, + Elements: elements, + Style: style, + } +} + +// ElementType returns the type of the Element +func (s RichTextList) RichTextElementType() RichTextElementType { + return s.Type +} + +func (e *RichTextList) UnmarshalJSON(b []byte) error { + var raw struct { + RawElements []json.RawMessage `json:"elements"` + Style RichTextListElementType `json:"style"` + } + if string(b) == "{}" { + return nil + } + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + elems := make([]RichTextElement, 0, len(raw.RawElements)) + for _, r := range raw.RawElements { + var s struct { + Type RichTextElementType `json:"type"` + } + if err := json.Unmarshal(r, &s); err != nil { + return err + } + var elem RichTextElement + switch s.Type { + case RTESection: + elem = &RichTextSection{} + case RTEList: + elem = &RichTextList{} + default: + elems = append(elems, &RichTextUnknown{ + Type: s.Type, + Raw: string(r), + }) + continue + } + if err := json.Unmarshal(r, elem); err != nil { + return err + } + elems = append(elems, elem) + } + *e = RichTextList{ + Type: RTEList, + Elements: elems, + Style: raw.Style, + } + return nil +} + type RichTextSection struct { Type RichTextElementType `json:"type"` Elements []RichTextSectionElement `json:"elements"` } -// ElementType returns the type of the Element +// RichTextElementType returns the type of the Element func (s RichTextSection) RichTextElementType() RichTextElementType { return s.Type } diff --git a/block_rich_text_test.go b/block_rich_text_test.go index 270dde94c..69a917886 100644 --- a/block_rich_text_test.go +++ b/block_rich_text_test.go @@ -36,11 +36,12 @@ func TestRichTextBlock_UnmarshalJSON(t *testing.T) { err error }{ { - []byte(`{"elements":[{"type":"rich_text_unknown"},{"type":"rich_text_section"}]}`), + []byte(`{"elements":[{"type":"rich_text_unknown"},{"type":"rich_text_section"},{"type":"rich_text_list"}]}`), RichTextBlock{ Elements: []RichTextElement{ &RichTextUnknown{Type: RTEUnknown, Raw: `{"type":"rich_text_unknown"}`}, &RichTextSection{Type: RTESection, Elements: []RichTextSectionElement{}}, + &RichTextList{Type: RTEList, Elements: []RichTextElement{}}, }, }, nil, @@ -117,3 +118,75 @@ func TestRichTextSection_UnmarshalJSON(t *testing.T) { } } } + +func TestRichTextList_UnmarshalJSON(t *testing.T) { + cases := []struct { + raw []byte + expected RichTextList + err error + }{ + { + []byte(`{"style":"ordered","elements":[{"type":"rich_text_unknown","value":10},{"type":"rich_text_section","elements":[{"type":"text","text":"hi"}]}]}`), + RichTextList{ + Type: RTEList, + Style: RTEListOrdered, + Elements: []RichTextElement{ + &RichTextUnknown{Type: RTEUnknown, Raw: `{"type":"rich_text_unknown","value":10}`}, + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "hi"}, + }, + }, + }, + }, + nil, + }, + { + []byte(`{"style":"ordered","elements":[{"type":"rich_text_list","style":"bullet","elements":[{"type":"rich_text_section","elements":[{"type":"text","text":"hi"}]}]}]}`), + RichTextList{ + Type: RTEList, + Style: RTEListOrdered, + Elements: []RichTextElement{ + &RichTextList{ + Type: RTEList, + Style: RTEListBullet, + Elements: []RichTextElement{ + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "hi"}, + }, + }, + }, + }, + }, + }, + nil, + }, + { + []byte(`{"type": "rich_text_list","elements":[]}`), + RichTextList{ + Type: RTEList, + Elements: []RichTextElement{}, + }, + nil, + }, + } + for _, tc := range cases { + var actual RichTextList + err := json.Unmarshal(tc.raw, &actual) + if err != nil { + if tc.err == nil { + t.Errorf("unexpected error: %s", err) + } + t.Errorf("expected error is %s, but got %s", tc.err, err) + } + if tc.err != nil { + t.Errorf("expected to raise an error %s", tc.err) + } + if diff := deep.Equal(actual, tc.expected); diff != nil { + t.Errorf("actual value does not match expected one\n%s", diff) + } + } +} From 0f23d87de22ba7c23ff90b995c028b4074db25c4 Mon Sep 17 00:00:00 2001 From: Daniel Pieper <40295712+daniel-pieper-personio@users.noreply.github.com> Date: Wed, 8 Nov 2023 10:32:38 +0100 Subject: [PATCH 218/606] Add support for Indent field Co-authored-by: SimonLiuRoblox <84740141+SimonLiuRoblox@users.noreply.github.com> --- block_rich_text.go | 6 +++++- block_rich_text_test.go | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/block_rich_text.go b/block_rich_text.go index ad5835440..aea15974e 100644 --- a/block_rich_text.go +++ b/block_rich_text.go @@ -106,14 +106,16 @@ type RichTextList struct { Type RichTextElementType `json:"type"` Elements []RichTextElement `json:"elements"` Style RichTextListElementType `json:"style"` + Indent int `json:"indent"` } // NewRichTextList returns a new rich text list element. -func NewRichTextList(style RichTextListElementType, elements ...RichTextElement) *RichTextList { +func NewRichTextList(style RichTextListElementType, indent int, elements ...RichTextElement) *RichTextList { return &RichTextList{ Type: RTEList, Elements: elements, Style: style, + Indent: indent, } } @@ -126,6 +128,7 @@ func (e *RichTextList) UnmarshalJSON(b []byte) error { var raw struct { RawElements []json.RawMessage `json:"elements"` Style RichTextListElementType `json:"style"` + Indent int `json:"indent"` } if string(b) == "{}" { return nil @@ -163,6 +166,7 @@ func (e *RichTextList) UnmarshalJSON(b []byte) error { Type: RTEList, Elements: elems, Style: raw.Style, + Indent: raw.Indent, } return nil } diff --git a/block_rich_text_test.go b/block_rich_text_test.go index 69a917886..a6fa06216 100644 --- a/block_rich_text_test.go +++ b/block_rich_text_test.go @@ -172,6 +172,15 @@ func TestRichTextList_UnmarshalJSON(t *testing.T) { }, nil, }, + { + []byte(`{"type": "rich_text_list","elements":[],"indent":2}`), + RichTextList{ + Type: RTEList, + Indent: 2, + Elements: []RichTextElement{}, + }, + nil, + }, } for _, tc := range cases { var actual RichTextList From 2a4205db9583ea3c47ee1c8dfdae657cbf927a14 Mon Sep 17 00:00:00 2001 From: Aotokitsuruya Date: Wed, 20 Dec 2023 18:06:43 +0800 Subject: [PATCH 219/606] Add block element type FileInput --- block_conv.go | 2 ++ block_element.go | 38 ++++++++++++++++++++++++++++++++++++++ block_element_test.go | 16 ++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/block_conv.go b/block_conv.go index 4ab58de3f..cc84d7a58 100644 --- a/block_conv.go +++ b/block_conv.go @@ -130,6 +130,8 @@ func (b *InputBlock) UnmarshalJSON(data []byte) error { e = &RadioButtonsBlockElement{} case "number_input": e = &NumberInputBlockElement{} + case "file_input": + e = &FileInputBlockElement{} default: return errors.New("unsupported block element type") } diff --git a/block_element.go b/block_element.go index a70d8f2de..1077db780 100644 --- a/block_element.go +++ b/block_element.go @@ -15,6 +15,7 @@ const ( METEmailTextInput MessageElementType = "email_text_input" METURLTextInput MessageElementType = "url_text_input" METNumber MessageElementType = "number_input" + METFileInput MessageElementType = "file_input" MixedElementImage MixedElementType = "mixed_image" MixedElementText MixedElementType = "mixed_text" @@ -591,3 +592,40 @@ func NewNumberInputBlockElement(placeholder *TextBlockObject, actionID string, i IsDecimalAllowed: isDecimalAllowed, } } + +// FileInputBlockElement creates a field where a user can upload a file. +// +// File input elements are currently only available in modals. +// +// More Information: https://api.slack.com/reference/block-kit/block-elements#file_input +type FileInputBlockElement struct { + Type MessageElementType `json:"type"` + ActionID string `json:"action_id,omitempty"` + FileTypes []string `json:"filetypes,omitempty"` + MaxFiles int `json:"max_files,omitempty"` +} + +// ElementType returns the type of the Element +func (s FileInputBlockElement) ElementType() MessageElementType { + return s.Type +} + +// NewFileInputBlockElement returns an instance of a file input element +func NewFileInputBlockElement(actionID string) *FileInputBlockElement { + return &FileInputBlockElement{ + Type: METFileInput, + ActionID: actionID, + } +} + +// WithFileTypes sets the file types that can be uploaded +func (s *FileInputBlockElement) WithFileTypes(fileTypes ...string) *FileInputBlockElement { + s.FileTypes = fileTypes + return s +} + +// WithMaxFiles sets the maximum number of files that can be uploaded +func (s *FileInputBlockElement) WithMaxFiles(maxFiles int) *FileInputBlockElement { + s.MaxFiles = maxFiles + return s +} diff --git a/block_element_test.go b/block_element_test.go index 0c47affc3..84cfc85e2 100644 --- a/block_element_test.go +++ b/block_element_test.go @@ -215,3 +215,19 @@ func TestNewNumberInputBlockElement(t *testing.T) { assert.Equal(t, numberInputElement.IsDecimalAllowed, true) } + +func TestNewFileInputBlockElement(t *testing.T) { + + fileInputElement := NewFileInputBlockElement("test") + + assert.Equal(t, string(fileInputElement.Type), "file_input") + assert.Equal(t, fileInputElement.ActionID, "test") + + fileInputElement.WithFileTypes("jpg", "png") + assert.Equal(t, len(fileInputElement.FileTypes), 2) + assert.Contains(t, fileInputElement.FileTypes, "jpg") + assert.Contains(t, fileInputElement.FileTypes, "png") + + fileInputElement.WithMaxFiles(10) + assert.Equal(t, fileInputElement.MaxFiles, 10) +} From 59faa0e1f71473044317944da0ae1659776bffbf Mon Sep 17 00:00:00 2001 From: Aotokitsuruya Date: Thu, 21 Dec 2023 13:37:45 +0800 Subject: [PATCH 220/606] Add Files field related to FileInput element in the BlockAction --- block.go | 1 + 1 file changed, 1 insertion(+) diff --git a/block.go b/block.go index 18222bd2b..2351908fc 100644 --- a/block.go +++ b/block.go @@ -39,6 +39,7 @@ type BlockAction struct { Type ActionType `json:"type"` Text TextBlockObject `json:"text"` Value string `json:"value"` + Files []File `json:"files"` ActionTs string `json:"action_ts"` SelectedOption OptionBlockObject `json:"selected_option"` SelectedOptions []OptionBlockObject `json:"selected_options"` From 41603545dcec46301db59e2f019e08422505940e Mon Sep 17 00:00:00 2001 From: Peter Nguyen Date: Sat, 20 Jan 2024 23:17:27 +1100 Subject: [PATCH 221/606] add connected_team_ids, shared_team_ids, internal_team_ids, is_global_shared field to conversation --- conversation.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/conversation.go b/conversation.go index 1a63c53bd..787a96146 100644 --- a/conversation.go +++ b/conversation.go @@ -22,6 +22,7 @@ type Conversation struct { IsIM bool `json:"is_im"` IsExtShared bool `json:"is_ext_shared"` IsOrgShared bool `json:"is_org_shared"` + IsGlobalShared bool `json:"is_global_shared"` IsPendingExtShared bool `json:"is_pending_ext_shared"` IsPrivate bool `json:"is_private"` IsMpIM bool `json:"is_mpim"` @@ -30,6 +31,9 @@ type Conversation struct { NumMembers int `json:"num_members"` Priority float64 `json:"priority"` User string `json:"user"` + ConnectedTeamIDs []string `json:"connected_team_ids,omitempty"` + SharedTeamIDs []string `json:"shared_team_ids,omitempty"` + InternalTeamIDs []string `json:"internal_team_ids,omitempty"` // TODO support pending_shared // TODO support previous_names From 0ecd566ef7071340db6802cbf94e17690b93afa6 Mon Sep 17 00:00:00 2001 From: Alvin See Date: Thu, 1 Feb 2024 14:16:26 +1100 Subject: [PATCH 222/606] fix: expose is_enterprise_install field --- interactions.go | 47 ++++++++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/interactions.go b/interactions.go index e362caa86..8c6414707 100644 --- a/interactions.go +++ b/interactions.go @@ -33,29 +33,30 @@ const ( // InteractionCallback is sent from slack when a user interactions with a button or dialog. type InteractionCallback struct { - Type InteractionType `json:"type"` - Token string `json:"token"` - CallbackID string `json:"callback_id"` - ResponseURL string `json:"response_url"` - TriggerID string `json:"trigger_id"` - ActionTs string `json:"action_ts"` - Team Team `json:"team"` - Channel Channel `json:"channel"` - User User `json:"user"` - OriginalMessage Message `json:"original_message"` - Message Message `json:"message"` - Name string `json:"name"` - Value string `json:"value"` - MessageTs string `json:"message_ts"` - AttachmentID string `json:"attachment_id"` - ActionCallback ActionCallbacks `json:"actions"` - View View `json:"view"` - ActionID string `json:"action_id"` - APIAppID string `json:"api_app_id"` - BlockID string `json:"block_id"` - Container Container `json:"container"` - Enterprise Enterprise `json:"enterprise"` - WorkflowStep InteractionWorkflowStep `json:"workflow_step"` + Type InteractionType `json:"type"` + Token string `json:"token"` + CallbackID string `json:"callback_id"` + ResponseURL string `json:"response_url"` + TriggerID string `json:"trigger_id"` + ActionTs string `json:"action_ts"` + Team Team `json:"team"` + Channel Channel `json:"channel"` + User User `json:"user"` + OriginalMessage Message `json:"original_message"` + Message Message `json:"message"` + Name string `json:"name"` + Value string `json:"value"` + MessageTs string `json:"message_ts"` + AttachmentID string `json:"attachment_id"` + ActionCallback ActionCallbacks `json:"actions"` + View View `json:"view"` + ActionID string `json:"action_id"` + APIAppID string `json:"api_app_id"` + BlockID string `json:"block_id"` + Container Container `json:"container"` + Enterprise Enterprise `json:"enterprise"` + IsEnterpriseInstall bool `json:"is_enterprise_install"` + WorkflowStep InteractionWorkflowStep `json:"workflow_step"` DialogSubmissionCallback ViewSubmissionCallback ViewClosedCallback From e8211703a3c34d53892503c0af34901bd3dc8ef5 Mon Sep 17 00:00:00 2001 From: IbirbyZh <040796@gmail.com> Date: Thu, 1 Feb 2024 12:25:17 +0100 Subject: [PATCH 223/606] Add "edited" field to the app mention event --- slackevents/inner_events.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 6be8b9201..cb7229ef4 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -28,6 +28,9 @@ type AppMentionEvent struct { // BotID is filled out when a bot triggers the app_mention event BotID string `json:"bot_id,omitempty"` + + // When the app is mentioned in the edited message + Edited *Edited `json:"edited,omitempty"` } // AppHomeOpenedEvent Your Slack app home was opened. From bc6223be6b96e349b6e0e32699c7589cd9fdca20 Mon Sep 17 00:00:00 2001 From: Daniel Pieper Date: Fri, 2 Feb 2024 08:34:34 +0100 Subject: [PATCH 224/606] Fix linter --- block_rich_text_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/block_rich_text_test.go b/block_rich_text_test.go index a6fa06216..2788c9735 100644 --- a/block_rich_text_test.go +++ b/block_rich_text_test.go @@ -176,7 +176,7 @@ func TestRichTextList_UnmarshalJSON(t *testing.T) { []byte(`{"type": "rich_text_list","elements":[],"indent":2}`), RichTextList{ Type: RTEList, - Indent: 2, + Indent: 2, Elements: []RichTextElement{}, }, nil, From 53178c279955340f63d39a9a012f2e69a9fe36bf Mon Sep 17 00:00:00 2001 From: "kayos@tcp.direct" Date: Wed, 7 Feb 2024 01:14:47 -0800 Subject: [PATCH 225/606] Fix: always add context to errors during block serde Signed-off-by: kayos@tcp.direct --- block_conv.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/block_conv.go b/block_conv.go index d017c6626..a85b25129 100644 --- a/block_conv.go +++ b/block_conv.go @@ -2,7 +2,6 @@ package slack import ( "encoding/json" - "errors" "fmt" ) @@ -133,7 +132,7 @@ func (b *InputBlock) UnmarshalJSON(data []byte) error { case "number_input": e = &NumberInputBlockElement{} default: - return errors.New("unsupported block element type") + return fmt.Errorf("unsupported block element type %v", s.TypeVal) } if err := json.Unmarshal(a.Element, e); err != nil { @@ -431,7 +430,7 @@ func (e *ContextElements) UnmarshalJSON(data []byte) error { e.Elements = append(e.Elements, elem.(*ImageBlockElement)) default: - return errors.New("unsupported context element type") + return fmt.Errorf("unsupported context element type %v", contextElementType) } } From e743e65a32b554307d6cae75893f8aaa4ecff18e Mon Sep 17 00:00:00 2001 From: Josh Branham Date: Fri, 9 Feb 2024 13:56:40 -0700 Subject: [PATCH 226/606] Add WithURL support for ButtonBlockElement --- block_element.go | 6 ++++++ block_element_test.go | 10 ++++++++++ examples/blocks/README.md | 2 +- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/block_element.go b/block_element.go index a70d8f2de..4bd56aecf 100644 --- a/block_element.go +++ b/block_element.go @@ -177,6 +177,12 @@ func (s *ButtonBlockElement) WithConfirm(confirm *ConfirmationBlockObject) *Butt return s } +// WithURL adds a URL for the button to link to and returns the modified ButtonBlockElement +func (s *ButtonBlockElement) WithURL(url string) *ButtonBlockElement { + s.URL = url + return s +} + // NewButtonBlockElement returns an instance of a new button element to be used within a block func NewButtonBlockElement(actionID, value string, text *TextBlockObject) *ButtonBlockElement { return &ButtonBlockElement{ diff --git a/block_element_test.go b/block_element_test.go index 0c47affc3..8a96e2463 100644 --- a/block_element_test.go +++ b/block_element_test.go @@ -43,6 +43,16 @@ func TestWithStyleForButtonElement(t *testing.T) { } +func TestWithURLForButtonElement(t *testing.T) { + + btnTxt := NewTextBlockObject("plain_text", "Next 2 Results", false, false) + btnElement := NewButtonBlockElement("test", "click_me_123", btnTxt) + + btnElement.WithURL("https://foo.bar") + assert.Equal(t, btnElement.URL, "https://foo.bar") + +} + func TestNewOptionsSelectBlockElement(t *testing.T) { testOptionText := NewTextBlockObject("plain_text", "Option One", false, false) diff --git a/examples/blocks/README.md b/examples/blocks/README.md index 588f2485a..2e5d81fef 100644 --- a/examples/blocks/README.md +++ b/examples/blocks/README.md @@ -53,7 +53,7 @@ To preview this block on the builder website, you should copy just the contents The first example demonstrates usage of Sections, Fields and Action buttons. You can view the [Approval Example](https://api.slack.com/tools/block-kit-builder?blocks=%5B%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22You%20have%20a%20new%20request%3A%5Cn*%3CfakeLink.toEmployeeProfile.com%7CFred%20Enriquez%20-%20New%20device%20request%3E*%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22fields%22%3A%20%5B%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%09%22text%22%3A%20%22*Type%3A*%5CnComputer%20(laptop)%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%09%22text%22%3A%20%22*When%3A*%5CnSubmitted%20Aut%2010%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%09%22text%22%3A%20%22*Last%20Update%3A*%5CnMar%2010%2C%202015%20(3%20years%2C%205%20months)%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%09%22text%22%3A%20%22*Reason%3A*%5CnAll%20vowel%20keys%20aren%27t%20working.%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%09%22text%22%3A%20%22*Specs%3A*%5Cn%5C%22Cheetah%20Pro%2015%5C%22%20-%20Fast%2C%20really%20fast%5C%22%22%0A%09%09%09%7D%0A%09%09%5D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22actions%22%2C%0A%09%09%22elements%22%3A%20%5B%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%22text%22%3A%20%22Approve%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%22text%22%3A%20%22Deny%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%09%7D%0A%09%09%5D%0A%09%7D%0A%5D) on the block kit builder website. This example can be generated with the function named `exampleOne`. #### Example 2 - Approval - With Images -The secoond example adds additional complexity by introducing images as accessories to main blocks of text. You can view this [Approval Example with Images](https://api.slack.com/tools/block-kit-builder?blocks=%5B%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22You%20have%20a%20new%20request%3A%5Cn*%3Cgoogle.com%7CFred%20Enriquez%20-%20Time%20Off%20request%3E*%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*Type%3A*%5CnPaid%20time%20off%5Cn*When%3A*%5CnAug%2010-Aug%2013%5Cn*Hours%3A*%2016.0%20(2%20days)%5Cn*Remaining%20balance%3A*%2032.0%20hours%20(4%20days)%5Cn*Comments%3A*%20%5C%22Family%20in%20town%2C%20going%20camping!%5C%22%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22image%22%2C%0A%09%09%09%22image_url%22%3A%20%22https%3A%2F%2Fapi.slack.com%2Fimg%2Fblocks%2Fbkb_template_images%2FapprovalsNewDevice.png%22%2C%0A%09%09%09%22alt_text%22%3A%20%22computer%20thumbnail%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22actions%22%2C%0A%09%09%22elements%22%3A%20%5B%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%22text%22%3A%20%22Approve%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%22text%22%3A%20%22Deny%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%09%7D%0A%09%09%5D%0A%09%7D%0A%5D) on the block kit builder website. This example can be generated with the function named `exampleTwo`. +The second example adds additional complexity by introducing images as accessories to main blocks of text. You can view this [Approval Example with Images](https://api.slack.com/tools/block-kit-builder?blocks=%5B%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22You%20have%20a%20new%20request%3A%5Cn*%3Cgoogle.com%7CFred%20Enriquez%20-%20Time%20Off%20request%3E*%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*Type%3A*%5CnPaid%20time%20off%5Cn*When%3A*%5CnAug%2010-Aug%2013%5Cn*Hours%3A*%2016.0%20(2%20days)%5Cn*Remaining%20balance%3A*%2032.0%20hours%20(4%20days)%5Cn*Comments%3A*%20%5C%22Family%20in%20town%2C%20going%20camping!%5C%22%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22image%22%2C%0A%09%09%09%22image_url%22%3A%20%22https%3A%2F%2Fapi.slack.com%2Fimg%2Fblocks%2Fbkb_template_images%2FapprovalsNewDevice.png%22%2C%0A%09%09%09%22alt_text%22%3A%20%22computer%20thumbnail%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22actions%22%2C%0A%09%09%22elements%22%3A%20%5B%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%22text%22%3A%20%22Approve%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%22text%22%3A%20%22Deny%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%09%7D%0A%09%09%5D%0A%09%7D%0A%5D) on the block kit builder website. This example can be generated with the function named `exampleTwo`. #### Example 3 - Notifications This example shows how to add actions to your block that will trigger an interactive message to your application. You can view the rendered example for [Notifications](https://api.slack.com/tools/block-kit-builder?blocks=%5B%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%22text%22%3A%20%22Looks%20like%20you%20have%20a%20scheduling%20conflict%20with%20this%20event%3A%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22divider%22%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*%3CfakeLink.toUserProfiles.com%7CIris%20%2F%20Zelda%201-1%3E*%5CnTuesday%2C%20January%2021%204%3A00-4%3A30pm%5CnBuilding%202%20-%20Havarti%20Cheese%20(3)%5Cn2%20guests%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22image%22%2C%0A%09%09%09%22image_url%22%3A%20%22https%3A%2F%2Fapi.slack.com%2Fimg%2Fblocks%2Fbkb_template_images%2Fnotifications.png%22%2C%0A%09%09%09%22alt_text%22%3A%20%22calendar%20thumbnail%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22context%22%2C%0A%09%09%22elements%22%3A%20%5B%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22image%22%2C%0A%09%09%09%09%22image_url%22%3A%20%22https%3A%2F%2Fapi.slack.com%2Fimg%2Fblocks%2Fbkb_template_images%2FnotificationsWarningIcon.png%22%2C%0A%09%09%09%09%22alt_text%22%3A%20%22notifications%20warning%20icon%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%09%22text%22%3A%20%22*Conflicts%20with%20Team%20Huddle%3A%204%3A15-4%3A30pm*%22%0A%09%09%09%7D%0A%09%09%5D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22divider%22%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*Propose%20a%20new%20time%3A*%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*Today%20-%204%3A30-5pm*%5CnEveryone%20is%20available%3A%20%40iris%2C%20%40zelda%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Choose%22%0A%09%09%09%7D%2C%0A%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*Tomorrow%20-%204-4%3A30pm*%5CnEveryone%20is%20available%3A%20%40iris%2C%20%40zelda%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Choose%22%0A%09%09%09%7D%2C%0A%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*Tomorrow%20-%206-6%3A30pm*%5CnSome%20people%20aren%27t%20available%3A%20%40iris%2C%20~%40zelda~%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Choose%22%0A%09%09%09%7D%2C%0A%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*%3Cfakelink.ToMoreTimes.com%7CShow%20more%20times%3E*%22%0A%09%09%7D%0A%09%7D%0A%5D) on the block builder website. Refer to the function `exampleThree` for details on how this block can be generated. From e1cc0bfb8cffdc1a2d6b95589cd21dbfb006176d Mon Sep 17 00:00:00 2001 From: Rustam Gilyazov <16064414+rusq@users.noreply.github.com> Date: Sun, 11 Feb 2024 15:16:46 +1000 Subject: [PATCH 227/606] add rich_text_quote and rich_text_preformatted --- block_rich_text.go | 68 +++++++++++++++- block_rich_text_test.go | 166 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 231 insertions(+), 3 deletions(-) diff --git a/block_rich_text.go b/block_rich_text.go index aea15974e..b6a4b4ce4 100644 --- a/block_rich_text.go +++ b/block_rich_text.go @@ -42,7 +42,10 @@ func (e *RichTextBlock) UnmarshalJSON(b []byte) error { elem = &RichTextSection{} case RTEList: elem = &RichTextList{} - + case RTEQuote: + elem = &RichTextQuote{} + case RTEPreformatted: + elem = &RichTextPreformatted{} default: elems = append(elems, &RichTextUnknown{ Type: s.Type, @@ -150,6 +153,10 @@ func (e *RichTextList) UnmarshalJSON(b []byte) error { elem = &RichTextSection{} case RTEList: elem = &RichTextList{} + case RTEQuote: + elem = &RichTextQuote{} + case RTEPreformatted: + elem = &RichTextPreformatted{} default: elems = append(elems, &RichTextUnknown{ Type: s.Type, @@ -460,3 +467,62 @@ type RichTextSectionUnknownElement struct { func (r RichTextSectionUnknownElement) RichTextSectionElementType() RichTextSectionElementType { return r.Type } + +// RichTextQuote represents rich_text_quote element type. +type RichTextQuote RichTextSection + +// RichTextElementType returns the type of the Element +func (s *RichTextQuote) RichTextElementType() RichTextElementType { + return s.Type +} + +func (s *RichTextQuote) UnmarshalJSON(b []byte) error { + // reusing the RichTextSection struct, as it's the same as RichTextQuote. + var rts RichTextSection + if err := json.Unmarshal(b, &rts); err != nil { + return err + } + *s = RichTextQuote(rts) + s.Type = RTEQuote + return nil +} + +// RichTextPreformatted represents rich_text_quote element type. +type RichTextPreformatted struct { + RichTextSection + Border int `json:"border"` +} + +// RichTextElementType returns the type of the Element +func (s *RichTextPreformatted) RichTextElementType() RichTextElementType { + return s.Type +} + +func (s *RichTextPreformatted) UnmarshalJSON(b []byte) error { + var rts RichTextSection + if err := json.Unmarshal(b, &rts); err != nil { + return err + } + // we define standalone fields because we need to unmarshal the border + // field. We can not directly unmarshal the data into + // RichTextPreformatted because it will cause an infinite loop. We also + // can not define a struct with embedded RichTextSection and Border fields + // because the json package will not unmarshal the data into the + // standalone fields, once it sees UnmarshalJSON method on the embedded + // struct. The drawback is that we have to process the data twice, and + // have to define a standalone struct with the same set of fields as the + // original struct, which may become a maintenance burden (i.e. update the + // fields in two places, should it ever change). + var standalone struct { + Border int `json:"border"` + } + if err := json.Unmarshal(b, &standalone); err != nil { + return err + } + *s = RichTextPreformatted{ + RichTextSection: rts, + Border: standalone.Border, + } + s.Type = RTEPreformatted + return nil +} diff --git a/block_rich_text_test.go b/block_rich_text_test.go index 2788c9735..a9f04b7de 100644 --- a/block_rich_text_test.go +++ b/block_rich_text_test.go @@ -27,6 +27,60 @@ const ( } ] }` + + richTextQuotePayload = `{ + "type": "rich_text", + "block_id": "G7G", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Holy moly\n\n" + } + ] + }, + { + "type": "rich_text_preformatted", + "elements": [ + { + "type": "text", + "text": "Preformatted\n\n" + } + ], + "border": 2 + }, + { + "type": "rich_text_quote", + "elements": [ + { + "type": "text", + "text": "Quote\n\n" + } + ] + }, + { + "type": "rich_text_quote", + "elements": [ + { + "type": "text", + "text": "Another quote" + } + ] + }, + { + "type": "rich_text_preformatted", + "elements": [ + { + "type": "text", + "text": "Another preformatted\n\n" + } + ], + "border": 42 + } + ] + }` ) func TestRichTextBlock_UnmarshalJSON(t *testing.T) { @@ -55,6 +109,38 @@ func TestRichTextBlock_UnmarshalJSON(t *testing.T) { }, nil, }, + { + []byte(dummyPayload), + RichTextBlock{ + Type: MBTRichText, + BlockID: "FaYCD", + Elements: []RichTextElement{ + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionChannelElement{Type: RTSEChannel, ChannelID: "C012345678"}, + &RichTextSectionTextElement{Type: RTSEText, Text: "dummy_text"}, + }, + }, + }, + }, + nil, + }, + { + []byte(richTextQuotePayload), + RichTextBlock{ + Type: MBTRichText, + BlockID: "G7G", + Elements: []RichTextElement{ + &RichTextSection{Type: RTESection, Elements: []RichTextSectionElement{&RichTextSectionTextElement{Type: RTSEText, Text: "Holy moly\n\n"}}}, + &RichTextPreformatted{RichTextSection: RichTextSection{Type: RTEPreformatted, Elements: []RichTextSectionElement{&RichTextSectionTextElement{Type: RTSEText, Text: "Preformatted\n\n"}}}, Border: 2}, + &RichTextQuote{Type: RTEQuote, Elements: []RichTextSectionElement{&RichTextSectionTextElement{Type: RTSEText, Text: "Quote\n\n"}}}, + &RichTextQuote{Type: RTEQuote, Elements: []RichTextSectionElement{&RichTextSectionTextElement{Type: RTSEText, Text: "Another quote"}}}, + &RichTextPreformatted{RichTextSection: RichTextSection{Type: RTEPreformatted, Elements: []RichTextSectionElement{&RichTextSectionTextElement{Type: RTSEText, Text: "Another preformatted\n\n"}}}, Border: 42}, + }, + }, + nil, + }, } for _, tc := range cases { var actual RichTextBlock @@ -63,10 +149,10 @@ func TestRichTextBlock_UnmarshalJSON(t *testing.T) { if tc.err == nil { t.Errorf("unexpected error: %s", err) } - t.Errorf("expected error is %s, but got %s", tc.err, err) + t.Errorf("expected error is %v, but got %v", tc.err, err) } if tc.err != nil { - t.Errorf("expected to raise an error %s", tc.err) + t.Errorf("expected to raise an error %v", tc.err) } if diff := deep.Equal(actual, tc.expected); diff != nil { t.Errorf("actual value does not match expected one\n%s", diff) @@ -199,3 +285,79 @@ func TestRichTextList_UnmarshalJSON(t *testing.T) { } } } + +func TestRichTextQuote_Marshal(t *testing.T) { + t.Run("rich_text_section", func(t *testing.T) { + const rawRSE = "{\"type\":\"rich_text_section\",\"elements\":[{\"type\":\"text\",\"text\":\"Some Text\"}]}" + + var got RichTextSection + if err := json.Unmarshal([]byte(rawRSE), &got); err != nil { + t.Fatal(err) + } + want := RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "Some Text"}, + }, + } + + if diff := deep.Equal(got, want); diff != nil { + t.Errorf("actual value does not match expected one\n%s", diff) + } + b, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + if diff := deep.Equal(string(b), rawRSE); diff != nil { + t.Errorf("actual value does not match expected one\n%s", diff) + } + }) + t.Run("rich_text_quote", func(t *testing.T) { + const rawRTS = "{\"type\":\"rich_text_quote\",\"elements\":[{\"type\":\"text\",\"text\":\"Some text\"}]}" + + var got RichTextQuote + if err := json.Unmarshal([]byte(rawRTS), &got); err != nil { + t.Fatal(err) + } + want := RichTextQuote{ + Type: RTEQuote, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "Some text"}, + }, + } + if diff := deep.Equal(got, want); diff != nil { + t.Errorf("actual value does not match expected one\n%s", diff) + } + b, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + if diff := deep.Equal(string(b), rawRTS); diff != nil { + t.Errorf("actual value does not match expected one\n%s", diff) + } + }) + t.Run("rich_text_preformatted", func(t *testing.T) { + const rawRTP = "{\"type\":\"rich_text_preformatted\",\"elements\":[{\"type\":\"text\",\"text\":\"Some other text\"}],\"border\":2}" + want := RichTextPreformatted{ + RichTextSection: RichTextSection{ + Type: RTEPreformatted, + Elements: []RichTextSectionElement{&RichTextSectionTextElement{Type: RTSEText, Text: "Some other text"}}, + }, + Border: 2, + } + var got RichTextPreformatted + if err := json.Unmarshal([]byte(rawRTP), &got); err != nil { + t.Fatal(err) + } + if diff := deep.Equal(got, want); diff != nil { + t.Errorf("actual value does not match expected one\n%s", diff) + } + b, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + if diff := deep.Equal(string(b), rawRTP); diff != nil { + t.Errorf("actual value does not match expected one\n%s", diff) + } + }) +} From a31aeaa9b498bbd3a92a104d32cd4e33e5cc7acd Mon Sep 17 00:00:00 2001 From: Peter Nguyen Date: Mon, 12 Feb 2024 18:45:10 +1100 Subject: [PATCH 228/606] add deleted timestamp for deleted msg events --- slackevents/inner_events.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index fb1ab132a..23f8c0e3f 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -256,6 +256,9 @@ type MessageEvent struct { PreviousMessage *MessageEvent `json:"previous_message,omitempty"` Edited *Edited `json:"edited,omitempty"` + // Deleted Message + DeletedTimeStamp string `json:"deleted_ts,omitempty"` + // Message Subtypes SubType string `json:"subtype,omitempty"` From b6db972e5fcc97e6ba99ebfea820c49aff89bb9c Mon Sep 17 00:00:00 2001 From: Jeremy Birtola Date: Wed, 14 Feb 2024 11:48:56 -0800 Subject: [PATCH 229/606] Adding structure comments & definitions --- slackevents/inner_events.go | 188 ++++++++-- slackevents/inner_events_test.go | 600 ++++++++++++++++++++++++++++++- slackevents/parsers.go | 4 +- users.go | 19 +- 4 files changed, 753 insertions(+), 58 deletions(-) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 7366e08b8..bc584260e 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -551,6 +551,104 @@ type UserProfileChangedEvent struct { EventTs string `json:"event_ts"` } +// SharedChannelInviteApprovedEvent is sent if your invitation has been approved +type SharedChannelInviteApprovedEvent struct { + Type string `json:"type"` + Invite *SharedInvite `json:"invite"` + Channel *slack.Conversation `json:"channel"` + ApprovingTeamID string `json:"approving_team_id"` + TeamsInChannel []*SlackEventTeam `json:"teams_in_channel"` + ApprovingUser *SlackEventUser `json:"approving_user"` + EventTs string `json:"event_ts"` +} + +// SharedChannelInviteAcceptedEvent is sent if external org accepts a Slack Connect channel invite +type SharedChannelInviteAcceptedEvent struct { + Type string `json:"type"` + ApprovalRequired bool `json:"approval_required"` + Invite *SharedInvite `json:"invite"` + Channel *SharedChannel `json:"channel"` + TeamsInChannel []*SlackEventTeam `json:"teams_in_channel"` + AcceptingUser *SlackEventUser `json:"accepting_user"` + EventTs string `json:"event_ts"` + RequiresSponsorship bool `json:"requires_sponsorship,omitempty"` +} + +// SharedChannelInviteDeclinedEvent is sent if external or internal org declines the Slack Connect invite +type SharedChannelInviteDeclinedEvent struct { + Type string `json:"type"` + Invite *SharedInvite `json:"invite"` + Channel *SharedChannel `json:"channel"` + DecliningTeamID string `json:"declining_team_id"` + TeamsInChannel []*SlackEventTeam `json:"teams_in_channel"` + DecliningUser *SlackEventUser `json:"declining_user"` + EventTs string `json:"event_ts"` +} + +// SharedChannelInviteReceivedEvent is sent if a bot or app is invited to a Slack Connect channel +type SharedChannelInviteReceivedEvent struct { + Type string `json:"type"` + Invite *SharedInvite `json:"invite"` + Channel *SharedChannel `json:"channel"` + EventTs string `json:"event_ts"` +} + +// SlackEventTeam is a struct for teams in ShareChannel events +type SlackEventTeam struct { + ID string `json:"id"` + Name string `json:"name"` + Icon *SlackEventIcon `json:"icon,omitempty"` + AvatarBaseURL string `json:"avatar_base_url,omitempty"` + IsVerified bool `json:"is_verified"` + Domain string `json:"domain"` + DateCreated int `json:"date_created"` + RequiresSponsorship bool `json:"requires_sponsorship,omitempty"` + // TeamID string `json:"team_id,omitempty"` +} + +// SlackEventIcon is a struct for icons in ShareChannel events +type SlackEventIcon struct { + ImageDefault bool `json:"image_default,omitempty"` + Image34 string `json:"image_34,omitempty"` + Image44 string `json:"image_44,omitempty"` + Image68 string `json:"image_68,omitempty"` + Image88 string `json:"image_88,omitempty"` + Image102 string `json:"image_102,omitempty"` + Image132 string `json:"image_132,omitempty"` + Image230 string `json:"image_230,omitempty"` +} + +// SlackEventUser is a struct for users in ShareChannel events +type SlackEventUser struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + Name string `json:"name"` + Updated int `json:"updated,omitempty"` + Profile *slack.UserProfile `json:"profile,omitempty"` + WhoCanShareContactCard string `json:"who_can_share_contact_card,omitempty"` +} + +// SharedChannel is a struct for shared channels in ShareChannel events +type SharedChannel struct { + ID string `json:"id"` + IsPrivate bool `json:"is_private"` + IsIm bool `json:"is_im"` + Name string `json:"name,omitempty"` +} + +// SharedInvite is a struct for shared invites in ShareChannel events +type SharedInvite struct { + ID string `json:"id"` + DateCreated int `json:"date_created"` + DateInvalid int `json:"date_invalid"` + InvitingTeam *SlackEventTeam `json:"inviting_team,omitempty"` + InvitingUser *SlackEventUser `json:"inviting_user,omitempty"` + RecipientEmail string `json:"recipient_email,omitempty"` + RecipientUserID string `json:"recipient_user_id,omitempty"` + IsSponsored bool `json:"is_sponsored,omitempty"` + IsExternalLimited bool `json:"is_external_limited,omitempty"` +} + type EventsAPIType string const ( @@ -614,6 +712,14 @@ const ( ReactionRemoved = EventsAPIType("reaction_removed") // TeamJoin A new user joined the workspace TeamJoin = EventsAPIType("team_join") + // Slack connect app or bot invite received + SharedChannelInviteReceived = EventsAPIType("shared_channel_invite_received") + // Slack connect channel invite approved + SharedChannelInviteApproved = EventsAPIType("shared_channel_invite_approved") + // Slack connect channel invite declined + SharedChannelInviteDeclined = EventsAPIType("shared_channel_invite_declined") + // Slack connect channel invite accepted by an end user + SharedChannelInviteAccepted = EventsAPIType("shared_channel_invite_accepted") // TokensRevoked APP's API tokes are revoked TokensRevoked = EventsAPIType("tokens_revoked") // EmojiChanged A custom emoji has been added or changed @@ -638,43 +744,47 @@ const ( // implementations. The structs should be instances of the unmarshalling // target for the matching event type. var EventsAPIInnerEventMapping = map[EventsAPIType]interface{}{ - AppMention: AppMentionEvent{}, - AppHomeOpened: AppHomeOpenedEvent{}, - AppUninstalled: AppUninstalledEvent{}, - ChannelCreated: ChannelCreatedEvent{}, - ChannelDeleted: ChannelDeletedEvent{}, - ChannelArchive: ChannelArchiveEvent{}, - ChannelUnarchive: ChannelUnarchiveEvent{}, - ChannelLeft: ChannelLeftEvent{}, - ChannelRename: ChannelRenameEvent{}, - ChannelIDChanged: ChannelIDChangedEvent{}, - FileChange: FileChangeEvent{}, - FileDeleted: FileDeletedEvent{}, - FileShared: FileSharedEvent{}, - FileUnshared: FileUnsharedEvent{}, - GroupDeleted: GroupDeletedEvent{}, - GroupArchive: GroupArchiveEvent{}, - GroupUnarchive: GroupUnarchiveEvent{}, - GroupLeft: GroupLeftEvent{}, - GroupRename: GroupRenameEvent{}, - GridMigrationFinished: GridMigrationFinishedEvent{}, - GridMigrationStarted: GridMigrationStartedEvent{}, - LinkShared: LinkSharedEvent{}, - Message: MessageEvent{}, - MemberJoinedChannel: MemberJoinedChannelEvent{}, - MemberLeftChannel: MemberLeftChannelEvent{}, - PinAdded: PinAddedEvent{}, - PinRemoved: PinRemovedEvent{}, - ReactionAdded: ReactionAddedEvent{}, - ReactionRemoved: ReactionRemovedEvent{}, - TeamJoin: TeamJoinEvent{}, - TokensRevoked: TokensRevokedEvent{}, - EmojiChanged: EmojiChangedEvent{}, - WorkflowStepExecute: WorkflowStepExecuteEvent{}, - MessageMetadataPosted: MessageMetadataPostedEvent{}, - MessageMetadataUpdated: MessageMetadataUpdatedEvent{}, - MessageMetadataDeleted: MessageMetadataDeletedEvent{}, - TeamAccessGranted: TeamAccessGrantedEvent{}, - TeamAccessRevoked: TeamAccessRevokedEvent{}, - UserProfileChanged: UserProfileChangedEvent{}, + AppMention: AppMentionEvent{}, + AppHomeOpened: AppHomeOpenedEvent{}, + AppUninstalled: AppUninstalledEvent{}, + ChannelCreated: ChannelCreatedEvent{}, + ChannelDeleted: ChannelDeletedEvent{}, + ChannelArchive: ChannelArchiveEvent{}, + ChannelUnarchive: ChannelUnarchiveEvent{}, + ChannelLeft: ChannelLeftEvent{}, + ChannelRename: ChannelRenameEvent{}, + ChannelIDChanged: ChannelIDChangedEvent{}, + FileChange: FileChangeEvent{}, + FileDeleted: FileDeletedEvent{}, + FileShared: FileSharedEvent{}, + FileUnshared: FileUnsharedEvent{}, + GroupDeleted: GroupDeletedEvent{}, + GroupArchive: GroupArchiveEvent{}, + GroupUnarchive: GroupUnarchiveEvent{}, + GroupLeft: GroupLeftEvent{}, + GroupRename: GroupRenameEvent{}, + GridMigrationFinished: GridMigrationFinishedEvent{}, + GridMigrationStarted: GridMigrationStartedEvent{}, + LinkShared: LinkSharedEvent{}, + Message: MessageEvent{}, + MemberJoinedChannel: MemberJoinedChannelEvent{}, + MemberLeftChannel: MemberLeftChannelEvent{}, + PinAdded: PinAddedEvent{}, + PinRemoved: PinRemovedEvent{}, + ReactionAdded: ReactionAddedEvent{}, + ReactionRemoved: ReactionRemovedEvent{}, + SharedChannelInviteApproved: SharedChannelInviteApprovedEvent{}, + SharedChannelInviteAccepted: SharedChannelInviteAcceptedEvent{}, + SharedChannelInviteDeclined: SharedChannelInviteDeclinedEvent{}, + SharedChannelInviteReceived: SharedChannelInviteReceivedEvent{}, + TeamJoin: TeamJoinEvent{}, + TokensRevoked: TokensRevokedEvent{}, + EmojiChanged: EmojiChangedEvent{}, + WorkflowStepExecute: WorkflowStepExecuteEvent{}, + MessageMetadataPosted: MessageMetadataPostedEvent{}, + MessageMetadataUpdated: MessageMetadataUpdatedEvent{}, + MessageMetadataDeleted: MessageMetadataDeletedEvent{}, + TeamAccessGranted: TeamAccessGrantedEvent{}, + TeamAccessRevoked: TeamAccessRevokedEvent{}, + UserProfileChanged: UserProfileChangedEvent{}, } diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index f21f66beb..20b8bff5c 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -2,7 +2,10 @@ package slackevents import ( "encoding/json" + "fmt" "testing" + + "github.com/stretchr/testify/assert" ) func TestAppMention(t *testing.T) { @@ -376,19 +379,31 @@ func TestThreadBroadcastEvent(t *testing.T) { func TestMemberJoinedChannelEvent(t *testing.T) { rawE := []byte(` - { - "type": "member_joined_channel", - "user": "W06GH7XHN", - "channel": "C0698JE0H", - "channel_type": "C", - "team": "T024BE7LD", - "inviter": "U123456789" + { + "type": "member_joined_channel", + "user": "W06GH7XHN", + "channel": "C0698JE0H", + "channel_type": "C", + "team": "T024BE7LD", + "inviter": "U123456789" } `) - err := json.Unmarshal(rawE, &MemberJoinedChannelEvent{}) + evt := MemberJoinedChannelEvent{} + err := json.Unmarshal(rawE, &evt) if err != nil { t.Error(err) } + + expected := MemberJoinedChannelEvent{ + Type: "member_joined_channel", + User: "W06GH7XHN", + Channel: "C0698JE0H", + ChannelType: "C", + Team: "T024BE7LD", + Inviter: "U123456789", + } + + assert.Equal(t, expected, evt) } func TestMemberLeftChannelEvent(t *testing.T) { @@ -914,3 +929,572 @@ func TestUserProfileChanged(t *testing.T) { t.Fail() } } + +func TestSharedChannelInvite(t *testing.T) { + rawE := []byte(` + { + "token": "whatever", + "team_id": "whatever", + "api_app_id": "whatever", + "event": { + "type": "shared_channel_invite_received", + "invite": { + "id": "I028YDERZSQ", + "date_created": 1626876000, + "date_invalid": 1628085600, + "inviting_team": { + "id": "T12345678", + "name": "Corgis", + "icon": {}, + "is_verified": false, + "domain": "corgis", + "date_created": 1480946400 + }, + "inviting_user": { + "id": "U12345678", + "team_id": "T12345678", + "name": "crus", + "updated": 1608081902, + "profile": { + "real_name": "Corgis Rus", + "display_name": "Corgis Rus", + "real_name_normalized": "Corgis Rus", + "display_name_normalized": "Corgis Rus", + "team": "T12345678", + "avatar_hash": "gcfh83a4c72k", + "email": "corgisrus@slack-corp.com", + "image_24": "https://placekitten.com/24/24", + "image_32": "https://placekitten.com/32/32", + "image_48": "https://placekitten.com/48/48", + "image_72": "https://placekitten.com/72/72", + "image_192": "https://placekitten.com/192/192", + "image_512": "https://placekitten.com/512/512" + } + }, + "recipient_user_id": "U87654321" + }, + "channel": { + "id": "C12345678", + "is_private": false, + "is_im": false, + "name": "test-slack-connect" + }, + "event_ts": "1626876010.000100" + } + } + `) + + evt := &EventsAPICallbackEvent{} + err := json.Unmarshal(rawE, evt) + if err != nil { + t.Fatal(err) + } + + parsedEvent, err := parseInnerEvent(evt) + if err != nil { + t.Fatal(err) + } + + actual, ok := parsedEvent.InnerEvent.Data.(*SharedChannelInviteReceivedEvent) + if !ok { + t.Fail() + } + + if actual.Invite.ID != "I028YDERZSQ" { + t.Fail() + } + + if actual.Invite.InvitingTeam.ID != "T12345678" { + t.Fail() + } + + if actual.Invite.InvitingUser.ID != "U12345678" { + t.Fail() + } + + if actual.Invite.RecipientUserID != "U87654321" { + t.Fail() + } + + if actual.Channel.ID != "C12345678" { + t.Fail() + } + + if parsedEvent.InnerEvent.Type != "shared_channel_invite_received" { + t.Fail() + } + +} + +// Test that the shared_channel_invite_accepted event can be unmarshalled +func TestSharedChannelAccepted(t *testing.T) { + rawE := []byte(` + { + "token": "whatever", + "team_id": "whatever", + "api_app_id": "whatever", + "event": { + "type": "shared_channel_invite_accepted", + "approval_required": false, + "invite": { + "id": "I028YDERZSQ", + "date_created": 1626876000, + "date_invalid": 1628085600, + "inviting_team": { + "id": "T12345678", + "name": "Corgis", + "icon": { + "image_default": true, + "image_34": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-34.png", + "image_44": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-44.png", + "image_68": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-68.png", + "image_88": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-88.png", + "image_102": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-102.png", + "image_230": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-230.png", + "image_132": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-132.png" + }, + "is_verified": false, + "domain": "corgis", + "date_created": 1480946400 + }, + "inviting_user": { + "id": "U12345678", + "team_id": "T12345678", + "name": "crus", + "updated": 1608081902, + "profile": { + "real_name": "Corgis Rus", + "display_name": "Corgis Rus", + "real_name_normalized": "Corgis Rus", + "display_name_normalized": "Corgis Rus", + "team": "T12345678", + "avatar_hash": "gcfh83a4c72k", + "email": "corgisrus@slack-corp.com", + "image_24": "https://placekitten.com/24/24", + "image_32": "https://placekitten.com/32/32", + "image_48": "https://placekitten.com/48/48", + "image_72": "https://placekitten.com/72/72", + "image_192": "https://placekitten.com/192/192", + "image_512": "https://placekitten.com/512/512" + } + }, + "recipient_email": "golden@doodle.com", + "recipient_user_id": "U87654321" + }, + "channel": { + "id": "C12345678", + "is_private": false, + "is_im": false, + "name": "test-slack-connect" + }, + "teams_in_channel": [ + { + "id": "T12345678", + "name": "Corgis", + "icon": { + "image_default": true, + "image_34": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-34.png", + "image_44": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-44.png", + "image_68": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-68.png", + "image_88": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-88.png", + "image_102": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-102.png", + "image_230": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-230.png", + "image_132": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-132.png" + }, + "is_verified": false, + "domain": "corgis", + "date_created": 1626789600 + } + ], + "accepting_user": { + "id": "U87654321", + "team_id": "T87654321", + "name": "golden", + "updated": 1624406113, + "profile": { + "real_name": "Golden Doodle", + "display_name": "Golden", + "real_name_normalized": "Golden Doodle", + "display_name_normalized": "Golden", + "team": "T87654321", + "avatar_hash": "g717728b118x", + "email": "golden@doodle.com", + "image_24": "https://placekitten.com/24/24", + "image_32": "https://placekitten.com/32/32", + "image_48": "https://placekitten.com/48/48", + "image_72": "https://placekitten.com/72/72", + "image_192": "https://placekitten.com/192/192", + "image_512": "https://placekitten.com/512/512" + } + }, + "event_ts": "1626877800.000000" + } + } + `) + + evt := &EventsAPICallbackEvent{} + err := json.Unmarshal(rawE, evt) + if err != nil { + t.Fatal(err) + } + + parsedEvent, err := parseInnerEvent(evt) + if err != nil { + t.Fatal(err) + } + + actual, ok := parsedEvent.InnerEvent.Data.(*SharedChannelInviteAcceptedEvent) + if !ok { + t.Fail() + } + + if actual.Invite.ID != "I028YDERZSQ" { + t.Fail() + } + + if actual.Invite.InvitingTeam.ID != "T12345678" { + t.Fail() + } + + if actual.Invite.InvitingUser.ID != "U12345678" { + t.Fail() + } + + if actual.Invite.RecipientUserID != "U87654321" { + t.Fail() + } + + if actual.Channel.ID != "C12345678" { + t.Fail() + } + + if actual.Channel.Name != "test-slack-connect" { + t.Fail() + fmt.Println(actual.Channel.Name + ", does not match the test name.") + } + + if actual.AcceptingUser.ID != "U87654321" { + t.Fail() + } + + if actual.AcceptingUser.Profile.RealName != "Golden Doodle" { + t.Fail() + } + + if parsedEvent.InnerEvent.Type != "shared_channel_invite_accepted" { + t.Fail() + } + +} + +// Test that the shared_channel_invite_declined event can be unmarshalled +func TestSharedChannelApproved(t *testing.T) { + rawE := []byte(` + { + "token": "whatever", + "team_id": "whatever", + "api_app_id": "whatever", + "event": { + "type": "shared_channel_invite_approved", + "invite": { + "id": "I01354X80CA", + "date_created": 1626876000, + "date_invalid": 1628085600, + "inviting_team": { + "id": "T12345678", + "name": "Corgis", + "icon": { + "image_default": true, + "image_34": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-34.png", + "image_44": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-44.png", + "image_68": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-68.png", + "image_88": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-88.png", + "image_102": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-102.png", + "image_230": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-230.png", + "image_132": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-132.png" + }, + "is_verified": false, + "domain": "corgis", + "date_created": 1480946400 + }, + "inviting_user": { + "id": "U12345678", + "team_id": "T12345678", + "name": "crus", + "updated": 1608081902, + "profile": { + "real_name": "Corgis Rus", + "display_name": "Corgis Rus", + "real_name_normalized": "Corgis Rus", + "display_name_normalized": "Corgis Rus", + "team": "T12345678", + "avatar_hash": "gcfh83a4c72k", + "email": "corgisrus@slack-corp.com", + "image_24": "https://placekitten.com/24/24", + "image_32": "https://placekitten.com/32/32", + "image_48": "https://placekitten.com/48/48", + "image_72": "https://placekitten.com/72/72", + "image_192": "https://placekitten.com/192/192", + "image_512": "https://placekitten.com/512/512" + } + }, + "recipient_email": "golden@doodle.com", + "recipient_user_id": "U87654321" + }, + "channel": { + "id": "C12345678", + "is_private": false, + "is_im": false, + "name": "test-slack-connect" + }, + "approving_team_id": "T87654321", + "teams_in_channel": [ + { + "id": "T12345678", + "name": "Corgis", + "icon": { + "image_default": true, + "image_34": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-34.png", + "image_44": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-44.png", + "image_68": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-68.png", + "image_88": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-88.png", + "image_102": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-102.png", + "image_230": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-230.png", + "image_132": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-132.png" + }, + "is_verified": false, + "domain": "corgis", + "date_created": 1626789600 + } + ], + "approving_user": { + "id": "U012A3CDE", + "team_id": "T87654321", + "name": "spengler", + "updated": 1624406532, + "profile": { + "real_name": "Egon Spengler", + "display_name": "Egon", + "real_name_normalized": "Egon Spengler", + "display_name_normalized": "Egon", + "team": "T87654321", + "avatar_hash": "g216425b1681", + "email": "spengler@ghostbusters.example.com", + "image_24": "https://placekitten.com/24/24", + "image_32": "https://placekitten.com/32/32", + "image_48": "https://placekitten.com/48/48", + "image_72": "https://placekitten.com/72/72", + "image_192": "https://placekitten.com/192/192", + "image_512": "https://placekitten.com/512/512" + } + }, + "event_ts": "1626881400.000000" + } + } + `) + + evt := &EventsAPICallbackEvent{} + err := json.Unmarshal(rawE, evt) + if err != nil { + t.Fatal(err) + } + + parsedEvent, err := parseInnerEvent(evt) + if err != nil { + t.Fatal(err) + } + + actual, ok := parsedEvent.InnerEvent.Data.(*SharedChannelInviteApprovedEvent) + if !ok { + t.Fail() + } + + if actual.Invite.ID != "I01354X80CA" { + t.Fail() + } + + if actual.Invite.InvitingTeam.ID != "T12345678" { + t.Fail() + } + + if actual.Invite.InvitingUser.ID != "U12345678" { + t.Fail() + } + + if actual.Invite.RecipientUserID != "U87654321" { + t.Fail() + } + + if actual.Channel.ID != "C12345678" { + t.Fail() + } + + if actual.ApprovingTeamID != "T87654321" { + t.Fail() + } + + if actual.ApprovingUser.Name != "spengler" { + t.Fail() + } + + if actual.ApprovingUser.Profile.RealName != "Egon Spengler" { + t.Fail() + } + + if actual.TeamsInChannel[0].ID != "T12345678" { + t.Fail() + } + + if parsedEvent.InnerEvent.Type != "shared_channel_invite_approved" { + t.Fail() + } + +} + +func TestSharedChannelDeclined(t *testing.T) { + rawE := []byte(` + { + "token": "whatever", + "team_id": "whatever", + "api_app_id": "whatever", + "event": { + "type": "shared_channel_invite_declined", + "invite": { + "id": "I01354X80CA", + "date_created": 1626876000, + "date_invalid": 1628085600, + "inviting_team": { + "id": "T12345678", + "name": "Corgis", + "icon": {}, + "is_verified": false, + "domain": "corgis", + "date_created": 1480946400 + }, + "inviting_user": { + "id": "U12345678", + "team_id": "T12345678", + "name": "crus", + "updated": 1608081902, + "profile": { + "real_name": "Corgis Rus", + "display_name": "Corgis Rus", + "real_name_normalized": "Corgis Rus", + "display_name_normalized": "Corgis Rus", + "team": "T12345678", + "avatar_hash": "gcfh83a4c72k", + "email": "corgisrus@slack-corp.com", + "image_24": "https://placekitten.com/24/24", + "image_32": "https://placekitten.com/32/32", + "image_48": "https://placekitten.com/48/48", + "image_72": "https://placekitten.com/72/72", + "image_192": "https://placekitten.com/192/192", + "image_512": "https://placekitten.com/512/512" + } + }, + "recipient_email": "golden@doodle.com" + }, + "channel": { + "id": "C12345678", + "is_private": false, + "is_im": false, + "name": "test-slack-connect" + }, + "declining_team_id": "T87654321", + "teams_in_channel": [ + { + "id": "T12345678", + "name": "Corgis", + "icon": {}, + "is_verified": false, + "domain": "corgis", + "date_created": 1626789600 + } + ], + "declining_user": { + "id": "U012A3CDE", + "team_id": "T87654321", + "name": "spengler", + "updated": 1624406532, + "profile": { + "real_name": "Egon Spengler", + "display_name": "Egon", + "real_name_normalized": "Egon Spengler", + "display_name_normalized": "Egon", + "team": "T87654321", + "avatar_hash": "g216425b1681", + "email": "spengler@ghostbusters.example.com", + "image_24": "https://placekitten.com/24/24", + "image_32": "https://placekitten.com/32/32", + "image_48": "https://placekitten.com/48/48", + "image_72": "https://placekitten.com/72/72", + "image_192": "https://placekitten.com/192/192", + "image_512": "https://placekitten.com/512/512" + } + }, + "event_ts": "1626881400.000000" + } + } + `) + + evt := &EventsAPICallbackEvent{} + err := json.Unmarshal(rawE, evt) + if err != nil { + t.Fatal(err) + } + + parsedEvent, err := parseInnerEvent(evt) + if err != nil { + t.Fatal(err) + } + + actual, ok := parsedEvent.InnerEvent.Data.(*SharedChannelInviteDeclinedEvent) + if !ok { + t.Fail() + } + + if actual.Invite.ID != "I01354X80CA" { + t.Fail() + } + + if actual.Invite.InvitingTeam.ID != "T12345678" { + t.Fail() + } + + if actual.Invite.InvitingUser.ID != "U12345678" { + t.Fail() + } + + if actual.Invite.RecipientEmail != "golden@doodle.com" { + t.Fail() + } + + if actual.Channel.ID != "C12345678" { + t.Fail() + } + + if actual.DecliningTeamID != "T87654321" { + t.Fail() + } + + if actual.DecliningUser.Name != "spengler" { + t.Fail() + } + + if actual.DecliningUser.Profile.RealName != "Egon Spengler" { + t.Fail() + } + + if actual.TeamsInChannel[0].ID != "T12345678" { + t.Fail() + } + + if actual.EventTs != "1626881400.000000" { + t.Fail() + } + + if parsedEvent.InnerEvent.Type != "shared_channel_invite_declined" { + t.Fail() + } + +} diff --git a/slackevents/parsers.go b/slackevents/parsers.go index 96ba5681b..4daac8b16 100644 --- a/slackevents/parsers.go +++ b/slackevents/parsers.go @@ -117,7 +117,7 @@ func parseInnerEvent(e *EventsAPICallbackEvent) (EventsAPIEvent, error) { e.EnterpriseID, nil, EventsAPIInnerEvent{}, - }, fmt.Errorf("Inner Event does not exist! %s", iE.Type) + }, fmt.Errorf("inner Event does not exist! %s", iE.Type) } t := reflect.TypeOf(v) recvEvent := reflect.New(t).Interface() @@ -192,7 +192,7 @@ func ParseEvent(rawEvent json.RawMessage, opts ...Option) (EventsAPIEvent, error } if !cfg.TokenVerified { - return EventsAPIEvent{}, errors.New("Invalid verification token") + return EventsAPIEvent{}, errors.New("invalid verification token") } if e.Type == CallbackEvent { diff --git a/users.go b/users.go index a3b4c0664..a3d32b240 100644 --- a/users.go +++ b/users.go @@ -17,31 +17,32 @@ const ( // UserProfile contains all the information details of a given user type UserProfile struct { - FirstName string `json:"first_name"` - LastName string `json:"last_name"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` RealName string `json:"real_name"` RealNameNormalized string `json:"real_name_normalized"` DisplayName string `json:"display_name"` DisplayNameNormalized string `json:"display_name_normalized"` - Email string `json:"email"` - Skype string `json:"skype"` - Phone string `json:"phone"` + AvatarHash string `json:"avatar_hash"` + Email string `json:"email,omitempty"` + Skype string `json:"skyp,omitempty"` + Phone string `json:"phone,omitempty"` Image24 string `json:"image_24"` Image32 string `json:"image_32"` Image48 string `json:"image_48"` Image72 string `json:"image_72"` Image192 string `json:"image_192"` Image512 string `json:"image_512"` - ImageOriginal string `json:"image_original"` - Title string `json:"title"` + ImageOriginal string `json:"image_original,omitempty"` + Title string `json:"title,omitempty"` BotID string `json:"bot_id,omitempty"` ApiAppID string `json:"api_app_id,omitempty"` StatusText string `json:"status_text,omitempty"` StatusEmoji string `json:"status_emoji,omitempty"` StatusEmojiDisplayInfo []UserProfileStatusEmojiDisplayInfo `json:"status_emoji_display_info,omitempty"` - StatusExpiration int `json:"status_expiration"` + StatusExpiration int `json:"status_expiration,omitempty"` Team string `json:"team"` - Fields UserProfileCustomFields `json:"fields"` + Fields UserProfileCustomFields `json:"fields,omitempty"` } type UserProfileStatusEmojiDisplayInfo struct { From 9a23f7a387bea2de34ad1e22bd83cfdb6f91e6dd Mon Sep 17 00:00:00 2001 From: Kevin Paulisse Date: Tue, 20 Feb 2024 12:24:55 -0600 Subject: [PATCH 230/606] Parse string or boolean for SlashCommand.IsEnterpriseInstall --- slash.go | 34 ++++++++++++++++++++++++++ slash_test.go | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/slash.go b/slash.go index a41b7677a..fd46abfc4 100644 --- a/slash.go +++ b/slash.go @@ -1,7 +1,10 @@ package slack import ( + "encoding/json" + "fmt" "net/http" + "strconv" ) // SlashCommand contains information about a request of the slash command @@ -55,3 +58,34 @@ func (s SlashCommand) ValidateToken(verificationTokens ...string) bool { } return false } + +// UnmarshalJSON handles is_enterprise_install being either a boolean or a +// string when parsing JSON from various payloads +func (s *SlashCommand) UnmarshalJSON(data []byte) error { + type SlashCommandCopy SlashCommand + scopy := &struct { + *SlashCommandCopy + IsEnterpriseInstall interface{} `json:"is_enterprise_install"` + }{ + SlashCommandCopy: (*SlashCommandCopy)(s), + } + + if err := json.Unmarshal(data, scopy); err != nil { + return err + } + + switch rawValue := scopy.IsEnterpriseInstall.(type) { + case string: + b, err := strconv.ParseBool(rawValue) + if err != nil { + return fmt.Errorf("parsing boolean for is_enterprise_install: %w", err) + } + s.IsEnterpriseInstall = b + case bool: + s.IsEnterpriseInstall = rawValue + default: + return fmt.Errorf("wrong data type for is_enterprise_install: %T", scopy.IsEnterpriseInstall) + } + + return nil +} diff --git a/slash_test.go b/slash_test.go index e5c79e93f..e068fc40c 100644 --- a/slash_test.go +++ b/slash_test.go @@ -1,6 +1,7 @@ package slack import ( + "encoding/json" "fmt" "net/http" "net/url" @@ -99,3 +100,70 @@ func TestSlash_ServeHTTP(t *testing.T) { resp.Body.Close() } } + +func TestSlash_UnmarshalJSON(t *testing.T) { + tests := []struct { + body string + wantIsEnterpriseInstall bool + wantToken string + wantUnmarshalError string + }{ + { + body: `{"token":"blahblah","is_enterprise_install":"false"}`, + wantIsEnterpriseInstall: false, + wantToken: "blahblah", + wantUnmarshalError: "", + }, + { + body: `{"token":"blahblah","is_enterprise_install":false}`, + wantIsEnterpriseInstall: false, + wantToken: "blahblah", + wantUnmarshalError: "", + }, + { + body: `{"token":"blahblah","is_enterprise_install":"true"}`, + wantIsEnterpriseInstall: true, + wantToken: "blahblah", + wantUnmarshalError: "", + }, + { + body: `{"token":"blahblah","is_enterprise_install":true}`, + wantIsEnterpriseInstall: true, + wantToken: "blahblah", + wantUnmarshalError: "", + }, + { + body: `{"token":"blahblah","is_enterprise_install":42}`, + wantUnmarshalError: "wrong data type for is_enterprise_install: float64", + }, + { + body: `{"token":"blahblah","is_enterprise_install":"unconvertable to bool"}`, + wantUnmarshalError: "parsing boolean for is_enterprise_install: strconv.ParseBool: parsing \"unconvertable to bool\": invalid syntax", + }, + } + + for i, test := range tests { + var result SlashCommand + + err := json.Unmarshal([]byte(test.body), &result) + if err != nil { + if err.Error() != test.wantUnmarshalError { + t.Errorf("%d: Got error %v, want error %q", i, err, test.wantUnmarshalError) + } + continue + } + + if test.wantUnmarshalError != "" { + t.Errorf("%d: Got no error, want error %q", i, test.wantUnmarshalError) + continue + } + + if result.IsEnterpriseInstall != test.wantIsEnterpriseInstall { + t.Errorf("%d: Got IsEnterpriseInstall %v, want IsEnterpriseInstall %v", i, result.IsEnterpriseInstall, test.wantIsEnterpriseInstall) + } + + if result.Token != test.wantToken { + t.Errorf("%d: Got Token %v, want Token %v", i, result.Token, test.wantToken) + } + } +} From fddc1a622c2c0e3dc37b72728ea8b63d5dd775a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andres=20Pe=C3=B1aloza?= Date: Fri, 15 Mar 2024 12:13:23 -0300 Subject: [PATCH 231/606] validate text length --- block_object.go | 10 ++++++++++ block_object_test.go | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/block_object.go b/block_object.go index b39ff2287..a3e78d4ea 100644 --- a/block_object.go +++ b/block_object.go @@ -147,6 +147,16 @@ func (s TextBlockObject) Validate() error { return errors.New("emoji cannot be true in mrkdown") } + // https://api.slack.com/reference/block-kit/composition-objects#text__fields + if len(s.Text) == 0 { + return errors.New("text must have a minimum length of 1") + } + + // https://api.slack.com/reference/block-kit/composition-objects#text__fields + if len(s.Text) > 3000 { + return errors.New("text cannot be longer than 3000 characters") + } + return nil } diff --git a/block_object_test.go b/block_object_test.go index 9889fae41..1f4874a02 100644 --- a/block_object_test.go +++ b/block_object_test.go @@ -2,6 +2,7 @@ package slack import ( "errors" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -126,6 +127,24 @@ func TestValidateTextBlockObject(t *testing.T) { }, expected: errors.New("emoji cannot be true in mrkdown"), }, + { + input: TextBlockObject{ + Type: "mrkdwn", + Text: "", + Emoji: false, + Verbatim: false, + }, + expected: errors.New("text must have a minimum length of 1"), + }, + { + input: TextBlockObject{ + Type: "mrkdwn", + Text: strings.Repeat("a", 3001), + Emoji: false, + Verbatim: false, + }, + expected: errors.New("text cannot be longer than 3000 characters"), + }, } for _, test := range tests { From ce744b27625b12eb4af1c3c0ce0b6644abedb387 Mon Sep 17 00:00:00 2001 From: Robby Dyer Date: Thu, 18 Apr 2024 15:10:51 -0400 Subject: [PATCH 232/606] Add missing message option for link_names --- chat.go | 8 ++++++++ chat_test.go | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/chat.go b/chat.go index 35ffbbcf8..2668b3823 100644 --- a/chat.go +++ b/chat.go @@ -696,6 +696,14 @@ func MsgOptionMetadata(metadata SlackMetadata) MsgOption { } } +// MsgOptionLinkNames finds and links user groups. Does not support linking individual users +func MsgOptionLinkNames(linkName bool) MsgOption { + return func(config *sendConfig) error { + config.values.Set("link_names", strconv.FormatBool(linkName)) + return nil + } +} + // UnsafeMsgOptionEndpoint deliver the message to the specified endpoint. // NOTE: USE AT YOUR OWN RISK: No issues relating to the use of this Option // will be supported by the library, it is subject to change without notice that diff --git a/chat_test.go b/chat_test.go index fb372259a..ee93bddff 100644 --- a/chat_test.go +++ b/chat_test.go @@ -185,6 +185,28 @@ func TestPostMessage(t *testing.T) { "user_auth_message": []string{"Please!"}, }, }, + "LinkNames true": { + endpoint: "/chat.postMessage", + opt: []MsgOption{ + MsgOptionLinkNames(true), + }, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "link_names": []string{"true"}, + }, + }, + "LinkNames false": { + endpoint: "/chat.postMessage", + opt: []MsgOption{ + MsgOptionLinkNames(false), + }, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "link_names": []string{"false"}, + }, + }, } once.Do(startServer) From e337d7462307d27ef8b6da5e42aedfd5f04d4586 Mon Sep 17 00:00:00 2001 From: yutasb Date: Sun, 21 Apr 2024 22:17:42 +0900 Subject: [PATCH 233/606] fix some typos --- examples/buttons/buttons.go | 2 +- files.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/buttons/buttons.go b/examples/buttons/buttons.go index 82772faa1..b5ef09c49 100644 --- a/examples/buttons/buttons.go +++ b/examples/buttons/buttons.go @@ -50,7 +50,7 @@ func main() { if err != nil { fmt.Printf("Could not send message: %v", err) } - fmt.Printf("Message with buttons sucessfully sent to channel %s at %s", channelID, timestamp) + fmt.Printf("Message with buttons successfully sent to channel %s at %s", channelID, timestamp) http.HandleFunc("/actions", actionHandler) http.ListenAndServe(":3000", nil) } diff --git a/files.go b/files.go index 356284420..f724f54d8 100644 --- a/files.go +++ b/files.go @@ -253,12 +253,12 @@ func (api *Client) GetFileInfoContext(ctx context.Context, fileID string, count, return &response.File, response.Comments, &response.Paging, nil } -// GetFile retreives a given file from its private download URL +// GetFile retrieves a given file from its private download URL func (api *Client) GetFile(downloadURL string, writer io.Writer) error { return api.GetFileContext(context.Background(), downloadURL, writer) } -// GetFileContext retreives a given file from its private download URL with a custom context +// GetFileContext retrieves a given file from its private download URL with a custom context // // For more details, see GetFile documentation. func (api *Client) GetFileContext(ctx context.Context, downloadURL string, writer io.Writer) error { From b38493416693f5cfe49b4520cd5d6b8d8a392cb9 Mon Sep 17 00:00:00 2001 From: candiduslynx Date: Fri, 26 Apr 2024 14:22:27 +0300 Subject: [PATCH 234/606] ignore vendor --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ac6f3eeb3..027f4de5f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.test *~ .idea/ +/vendor/ \ No newline at end of file From 3a0fd11168301bf4410b25f616f18c83a65ec726 Mon Sep 17 00:00:00 2001 From: candiduslynx Date: Fri, 26 Apr 2024 14:22:39 +0300 Subject: [PATCH 235/606] bots.info --- bots.go | 19 ++++++++++++++----- bots_test.go | 2 +- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/bots.go b/bots.go index da21ba0c9..fbe5d25e2 100644 --- a/bots.go +++ b/bots.go @@ -35,19 +35,28 @@ func (api *Client) botRequest(ctx context.Context, path string, values url.Value return response, nil } +type GetBotInfoParameters struct { + Bot string + TeamID string +} + // GetBotInfo will retrieve the complete bot information -func (api *Client) GetBotInfo(bot string) (*Bot, error) { - return api.GetBotInfoContext(context.Background(), bot) +func (api *Client) GetBotInfo(parameters GetBotInfoParameters) (*Bot, error) { + return api.GetBotInfoContext(context.Background(), parameters) } // GetBotInfoContext will retrieve the complete bot information using a custom context -func (api *Client) GetBotInfoContext(ctx context.Context, bot string) (*Bot, error) { +func (api *Client) GetBotInfoContext(ctx context.Context, parameters GetBotInfoParameters) (*Bot, error) { values := url.Values{ "token": {api.token}, } - if bot != "" { - values.Add("bot", bot) + if parameters.Bot != "" { + values.Add("bot", parameters.Bot) + } + + if parameters.TeamID != "" { + values.Add("team_id", parameters.TeamID) } response, err := api.botRequest(ctx, "bots.info", values) diff --git a/bots_test.go b/bots_test.go index ce7f66805..14a509e5f 100644 --- a/bots_test.go +++ b/bots_test.go @@ -29,7 +29,7 @@ func TestGetBotInfo(t *testing.T) { once.Do(startServer) api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - bot, err := api.GetBotInfo("B02875YLA") + bot, err := api.GetBotInfo(GetBotInfoParameters{Bot: "B02875YLA"}) if err != nil { t.Errorf("Unexpected error: %s", err) return From 8b4dc5152da3d94314ff17ad3da63496b857cdf2 Mon Sep 17 00:00:00 2001 From: candiduslynx Date: Fri, 26 Apr 2024 14:25:09 +0300 Subject: [PATCH 236/606] chat.scheduledMessages.list --- chat.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/chat.go b/chat.go index 2668b3823..d043ef00e 100644 --- a/chat.go +++ b/chat.go @@ -807,6 +807,7 @@ func (api *Client) GetPermalinkContext(ctx context.Context, params *PermalinkPar type GetScheduledMessagesParameters struct { Channel string + TeamID string Cursor string Latest string Limit int @@ -826,6 +827,9 @@ func (api *Client) GetScheduledMessagesContext(ctx context.Context, params *GetS if params.Channel != "" { values.Add("channel", params.Channel) } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } if params.Cursor != "" { values.Add("cursor", params.Cursor) } From eb325a50c00ab55576f4ff7afa5da3ef4ba9dda2 Mon Sep 17 00:00:00 2001 From: candiduslynx Date: Fri, 26 Apr 2024 14:28:36 +0300 Subject: [PATCH 237/606] files.list --- files.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/files.go b/files.go index f724f54d8..b2b40d822 100644 --- a/files.go +++ b/files.go @@ -129,6 +129,7 @@ type FileUploadParameters struct { type GetFilesParameters struct { User string Channel string + TeamID string TimestampFrom JSONTime TimestampTo JSONTime Types string @@ -142,6 +143,7 @@ type ListFilesParameters struct { Limit int User string Channel string + TeamID string Types string Cursor string } @@ -281,6 +283,9 @@ func (api *Client) GetFilesContext(ctx context.Context, params GetFilesParameter if params.Channel != DEFAULT_FILES_CHANNEL { values.Add("channel", params.Channel) } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } if params.TimestampFrom != DEFAULT_FILES_TS_FROM { values.Add("ts_from", strconv.FormatInt(int64(params.TimestampFrom), 10)) } @@ -326,6 +331,9 @@ func (api *Client) ListFilesContext(ctx context.Context, params ListFilesParamet if params.Channel != DEFAULT_FILES_CHANNEL { values.Add("channel", params.Channel) } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } if params.Limit != DEFAULT_FILES_COUNT { values.Add("limit", strconv.Itoa(params.Limit)) } From 55bdd38d7cff47c4c44aaa288f043d502379e6f7 Mon Sep 17 00:00:00 2001 From: candiduslynx Date: Fri, 26 Apr 2024 14:31:09 +0300 Subject: [PATCH 238/606] usergroups.create --- usergroups.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/usergroups.go b/usergroups.go index c5d7a176b..8c4246aef 100644 --- a/usergroups.go +++ b/usergroups.go @@ -62,6 +62,10 @@ func (api *Client) CreateUserGroupContext(ctx context.Context, userGroup UserGro "name": {userGroup.Name}, } + if userGroup.TeamID != "" { + values["team_id"] = []string{userGroup.TeamID} + } + if userGroup.Handle != "" { values["handle"] = []string{userGroup.Handle} } From 0c1ba09d06a3778d43144bd05b0f4c56e381345d Mon Sep 17 00:00:00 2001 From: candiduslynx Date: Fri, 26 Apr 2024 14:33:12 +0300 Subject: [PATCH 239/606] usergroups.list --- usergroups.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/usergroups.go b/usergroups.go index 8c4246aef..7244aba58 100644 --- a/usergroups.go +++ b/usergroups.go @@ -126,6 +126,12 @@ func (api *Client) EnableUserGroupContext(ctx context.Context, userGroup string) // GetUserGroupsOption options for the GetUserGroups method call. type GetUserGroupsOption func(*GetUserGroupsParams) +func GetUserGroupsOptionWithTeamID(teamID string) GetUserGroupsOption { + return func(params *GetUserGroupsParams) { + params.TeamID = teamID + } +} + // GetUserGroupsOptionIncludeCount include the number of users in each User Group (default: false) func GetUserGroupsOptionIncludeCount(b bool) GetUserGroupsOption { return func(params *GetUserGroupsParams) { @@ -149,6 +155,7 @@ func GetUserGroupsOptionIncludeUsers(b bool) GetUserGroupsOption { // GetUserGroupsParams contains arguments for GetUserGroups method call type GetUserGroupsParams struct { + TeamID string IncludeCount bool IncludeDisabled bool IncludeUsers bool @@ -170,6 +177,9 @@ func (api *Client) GetUserGroupsContext(ctx context.Context, options ...GetUserG values := url.Values{ "token": {api.token}, } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } if params.IncludeCount { values.Add("include_count", "true") } From a47d65987607653ea0929979b23c628cebe94ee5 Mon Sep 17 00:00:00 2001 From: candiduslynx Date: Fri, 26 Apr 2024 14:34:29 +0300 Subject: [PATCH 240/606] reactions.list --- reactions.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/reactions.go b/reactions.go index 2a9bd42e7..39aa5ba1f 100644 --- a/reactions.go +++ b/reactions.go @@ -67,10 +67,11 @@ const ( // ListReactionsParameters is the inputs to find all reactions by a user. type ListReactionsParameters struct { - User string - Count int - Page int - Full bool + User string + TeamID string + Count int + Page int + Full bool } // NewListReactionsParameters initializes the inputs to find all reactions @@ -246,6 +247,9 @@ func (api *Client) ListReactionsContext(ctx context.Context, params ListReaction if params.User != DEFAULT_REACTIONS_USER { values.Add("user", params.User) } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } if params.Count != DEFAULT_REACTIONS_COUNT { values.Add("count", strconv.Itoa(params.Count)) } From 7138048b7c1f69d1c581ea45f5a1a5f064d3cb33 Mon Sep 17 00:00:00 2001 From: candiduslynx Date: Fri, 26 Apr 2024 14:35:31 +0300 Subject: [PATCH 241/606] search.all search.files search.messages --- search.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/search.go b/search.go index de6b40acb..d27497aae 100644 --- a/search.go +++ b/search.go @@ -15,6 +15,7 @@ const ( ) type SearchParameters struct { + TeamID string Sort string SortDirection string Highlight bool @@ -93,6 +94,9 @@ func (api *Client) _search(ctx context.Context, path, query string, params Searc "token": {api.token}, "query": {query}, } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } if params.Sort != DEFAULT_SEARCH_SORT { values.Add("sort", params.Sort) } From 2824b312b5533c8626ecff6927c55d6228c1ef5e Mon Sep 17 00:00:00 2001 From: candiduslynx Date: Fri, 26 Apr 2024 14:37:33 +0300 Subject: [PATCH 242/606] team.accessLogs --- team.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/team.go b/team.go index d21a1b642..de0e5f1a8 100644 --- a/team.go +++ b/team.go @@ -74,8 +74,9 @@ type BillingActive struct { // AccessLogParameters contains all the parameters necessary (including the optional ones) for a GetAccessLogs() request type AccessLogParameters struct { - Count int - Page int + TeamID string + Count int + Page int } // NewAccessLogParameters provides an instance of AccessLogParameters with all the sane default values set @@ -192,6 +193,9 @@ func (api *Client) GetAccessLogsContext(ctx context.Context, params AccessLogPar values := url.Values{ "token": {api.token}, } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } if params.Count != DEFAULT_LOGINS_COUNT { values.Add("count", strconv.Itoa(params.Count)) } From 204bf374e2c3f901ee3bbd6b3d595fb2fcb302ac Mon Sep 17 00:00:00 2001 From: candiduslynx Date: Fri, 26 Apr 2024 14:40:14 +0300 Subject: [PATCH 243/606] team.billableInfo --- team.go | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/team.go b/team.go index de0e5f1a8..d804472cb 100644 --- a/team.go +++ b/team.go @@ -210,30 +210,28 @@ func (api *Client) GetAccessLogsContext(ctx context.Context, params AccessLogPar return response.Logins, &response.Paging, nil } +type GetBillableInfoParams struct { + User string + TeamID string +} + // GetBillableInfo ... -func (api *Client) GetBillableInfo(user string) (map[string]BillingActive, error) { - return api.GetBillableInfoContext(context.Background(), user) +func (api *Client) GetBillableInfo(params GetBillableInfoParams) (map[string]BillingActive, error) { + return api.GetBillableInfoContext(context.Background(), params) } // GetBillableInfoContext ... -func (api *Client) GetBillableInfoContext(ctx context.Context, user string) (map[string]BillingActive, error) { +func (api *Client) GetBillableInfoContext(ctx context.Context, params GetBillableInfoParams) (map[string]BillingActive, error) { values := url.Values{ "token": {api.token}, - "user": {user}, } - return api.billableInfoRequest(ctx, "team.billableInfo", values) -} - -// GetBillableInfoForTeam returns the billing_active status of all users on the team. -func (api *Client) GetBillableInfoForTeam() (map[string]BillingActive, error) { - return api.GetBillableInfoForTeamContext(context.Background()) -} + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } -// GetBillableInfoForTeamContext returns the billing_active status of all users on the team with a custom context -func (api *Client) GetBillableInfoForTeamContext(ctx context.Context) (map[string]BillingActive, error) { - values := url.Values{ - "token": {api.token}, + if params.User != "" { + values.Add("user", params.User) } return api.billableInfoRequest(ctx, "team.billableInfo", values) From ceced007d20efc31accf45312cbcc661781b230f Mon Sep 17 00:00:00 2001 From: candiduslynx Date: Fri, 26 Apr 2024 14:45:18 +0300 Subject: [PATCH 244/606] team.profile.get --- team.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/team.go b/team.go index d804472cb..40a0f00ad 100644 --- a/team.go +++ b/team.go @@ -165,22 +165,24 @@ func (api *Client) GetTeamInfoContext(ctx context.Context) (*TeamInfo, error) { } // GetTeamProfile gets the Team Profile settings of the user -func (api *Client) GetTeamProfile() (*TeamProfile, error) { - return api.GetTeamProfileContext(context.Background()) +func (api *Client) GetTeamProfile(teamID ...string) (*TeamProfile, error) { + return api.GetTeamProfileContext(context.Background(), teamID...) } // GetTeamProfileContext gets the Team Profile settings of the user with a custom context -func (api *Client) GetTeamProfileContext(ctx context.Context) (*TeamProfile, error) { +func (api *Client) GetTeamProfileContext(ctx context.Context, teamID ...string) (*TeamProfile, error) { values := url.Values{ "token": {api.token}, } + if len(teamID) > 0 { + values["team_id"] = teamID + } response, err := api.teamProfileRequest(ctx, api.httpclient, "team.profile.get", values) if err != nil { return nil, err } return &response.Profile, nil - } // GetAccessLogs retrieves a page of logins according to the parameters given From c530ebbffb61dd9764dddef832f8ca633144b2e0 Mon Sep 17 00:00:00 2001 From: candiduslynx Date: Fri, 26 Apr 2024 14:51:42 +0300 Subject: [PATCH 245/606] pr-prep --- examples/team/team.go | 7 +++---- slacktest/handlers_test.go | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/examples/team/team.go b/examples/team/team.go index 8d2fcdbc6..cba71aa74 100644 --- a/examples/team/team.go +++ b/examples/team/team.go @@ -9,17 +9,16 @@ import ( func main() { api := slack.New("YOUR_TOKEN_HERE") //Example for single user - billingActive, err := api.GetBillableInfo("U023BECGF") + billingActive, err := api.GetBillableInfo(slack.GetBillableInfoParams{User: "U023BECGF"}) if err != nil { fmt.Printf("%s\n", err) return } fmt.Printf("ID: U023BECGF, BillingActive: %v\n\n\n", billingActive["U023BECGF"]) - //Example for team - billingActiveForTeam, _ := api.GetBillableInfoForTeam() + //Example for team. Note: passing empty TeamID just uses the current user team. + billingActiveForTeam, _ := api.GetBillableInfo(slack.GetBillableInfoParams{}) for id, value := range billingActiveForTeam { fmt.Printf("ID: %v, BillingActive: %v\n", id, value) } - } diff --git a/slacktest/handlers_test.go b/slacktest/handlers_test.go index dec4c99be..8ccb704cd 100644 --- a/slacktest/handlers_test.go +++ b/slacktest/handlers_test.go @@ -117,7 +117,7 @@ func TestBotInfoHandler(t *testing.T) { go s.Start() client := slack.New("ABCDEFG", slack.OptionAPIURL(s.GetAPIURL())) - bot, err := client.GetBotInfo(s.BotID) + bot, err := client.GetBotInfo(slack.GetBotInfoParameters{Bot: s.BotID}) assert.NoError(t, err) assert.Equal(t, s.BotID, bot.ID) assert.Equal(t, s.BotName, bot.Name) From d959a378bcbc526c1c6a36dd189e9f2a6249bb62 Mon Sep 17 00:00:00 2001 From: Caleb McKay <11079725+calebmckay@users.noreply.github.com> Date: Thu, 6 Jun 2024 12:57:32 -0400 Subject: [PATCH 246/606] Fix UploadFileV2 when using text content (#1291) --- files.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/files.go b/files.go index b2b40d822..40a5d16b4 100644 --- a/files.go +++ b/files.go @@ -504,9 +504,8 @@ func (api *Client) getUploadURLExternal(ctx context.Context, params getUploadURL func (api *Client) uploadToURL(ctx context.Context, params uploadToURLParameters) (err error) { values := url.Values{} if params.Content != "" { - values.Add("content", params.Content) - values.Add("token", api.token) - err = postForm(ctx, api.httpclient, params.UploadURL, values, nil, api) + contentReader := strings.NewReader(params.Content) + err = postWithMultipartResponse(ctx, api.httpclient, params.UploadURL, params.Filename, "file", api.token, values, contentReader, nil, api) } else if params.File != "" { err = postLocalWithMultipartResponse(ctx, api.httpclient, params.UploadURL, params.File, "file", api.token, values, nil, api) } else if params.Reader != nil { From f7a929c301a98294e7d78fc0a049c71c2d1c533b Mon Sep 17 00:00:00 2001 From: Lorenzo Aiello Date: Sat, 13 Jul 2024 18:30:09 -0700 Subject: [PATCH 247/606] Trigger GitHub Actions From f7cac820fa84b4d815fd435e598503c95f349504 Mon Sep 17 00:00:00 2001 From: guoguangwu Date: Mon, 15 Jul 2024 09:09:03 +0800 Subject: [PATCH 248/606] chore: slice replace loop (#1211) * chore: slice replace loop * Trigger GitHub Actions --------- Co-authored-by: Lorenzo Aiello --- reminders.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/reminders.go b/reminders.go index 53d67c03c..5543a1577 100644 --- a/reminders.go +++ b/reminders.go @@ -41,10 +41,7 @@ func (api *Client) doReminders(ctx context.Context, path string, values url.Valu // create an array of pointers to reminders var reminders = make([]*Reminder, 0, len(response.Reminders)) - for _, reminder := range response.Reminders { - reminders = append(reminders, reminder) - } - + reminders = append(reminders, response.Reminders...) return reminders, response.Err() } From bc70fad5e92104b80931493a3e0e0abf12540e54 Mon Sep 17 00:00:00 2001 From: Miguel Campos Date: Sun, 14 Jul 2024 18:10:00 -0700 Subject: [PATCH 249/606] Add isReadOnly property to Conversation struct (#1232) Co-authored-by: Miguel Campos --- conversation.go | 1 + 1 file changed, 1 insertion(+) diff --git a/conversation.go b/conversation.go index 787a96146..555237583 100644 --- a/conversation.go +++ b/conversation.go @@ -25,6 +25,7 @@ type Conversation struct { IsGlobalShared bool `json:"is_global_shared"` IsPendingExtShared bool `json:"is_pending_ext_shared"` IsPrivate bool `json:"is_private"` + IsReadOnly bool `json:"is_read_only"` IsMpIM bool `json:"is_mpim"` Unlinked int `json:"unlinked"` NameNormalized string `json:"name_normalized"` From ef53c0c5789e1eec38b7b730fa14944b980e0a9b Mon Sep 17 00:00:00 2001 From: Alexander Forbes-Reed Date: Mon, 15 Jul 2024 02:10:28 +0100 Subject: [PATCH 250/606] chore: make channel optional on `UploadFileV2` (#1293) * Support no channel * added test * fix channel id param name --- files.go | 14 +++++++------- files_test.go | 9 +++++++++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/files.go b/files.go index 40a5d16b4..9db1662b0 100644 --- a/files.go +++ b/files.go @@ -522,11 +522,13 @@ func (api *Client) completeUploadExternal(ctx context.Context, fileID string, pa return nil, err } values := url.Values{ - "token": {api.token}, - "files": {string(requestBytes)}, - "channel_id": {params.channel}, + "token": {api.token}, + "files": {string(requestBytes)}, } + if params.channel != "" { + values.Add("channel_id", params.channel) + } if params.initialComment != "" { values.Add("initial_comment", params.initialComment) } @@ -555,7 +557,7 @@ func (api *Client) UploadFileV2(params UploadFileV2Parameters) (*FileSummary, er // UploadFileV2 uploads file to a given slack channel using 3 steps with a custom context - // 1. Get an upload URL using files.getUploadURLExternal API // 2. Send the file as a post to the URL provided by slack -// 3. Complete the upload and share it to the specified channel using files.completeUploadExternal +// 3. Complete the upload and if a channel is specified, post it to the specified channel using files.completeUploadExternal func (api *Client) UploadFileV2Context(ctx context.Context, params UploadFileV2Parameters) (file *FileSummary, err error) { if params.Filename == "" { return nil, fmt.Errorf("file.upload.v2: filename cannot be empty") @@ -563,9 +565,7 @@ func (api *Client) UploadFileV2Context(ctx context.Context, params UploadFileV2P if params.FileSize == 0 { return nil, fmt.Errorf("file.upload.v2: file size cannot be 0") } - if params.Channel == "" { - return nil, fmt.Errorf("file.upload.v2: channel cannot be empty") - } + u, err := api.getUploadURLExternal(ctx, getUploadURLExternalParameters{ altText: params.AltTxt, fileName: params.Filename, diff --git a/files_test.go b/files_test.go index f304a4e95..59afc1c4b 100644 --- a/files_test.go +++ b/files_test.go @@ -272,4 +272,13 @@ func TestUploadFileV2(t *testing.T) { if _, err := api.UploadFileV2(params); err != nil { t.Errorf("Unexpected error: %s", err) } + + reader = bytes.NewBufferString("test no channel") + params = UploadFileV2Parameters{ + Filename: "test.txt", + Reader: reader, + FileSize: 15} + if _, err := api.UploadFileV2(params); err != nil { + t.Errorf("Unexpected error: %s", err) + } } From 5094cdf2ae144b8a05985b380605296cd771705c Mon Sep 17 00:00:00 2001 From: Lorenzo Aiello Date: Sun, 14 Jul 2024 18:10:53 -0700 Subject: [PATCH 251/606] chore: Adding Deprecation Notice for files.upload (#1300) --- files.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/files.go b/files.go index 9db1662b0..eae8407bc 100644 --- a/files.go +++ b/files.go @@ -351,12 +351,16 @@ func (api *Client) ListFilesContext(ctx context.Context, params ListFilesParamet return response.Files, ¶ms, nil } -// UploadFile uploads a file +// UploadFile uploads a file. +// DEPRECATED: Use UploadFileV2 instead. This will stop functioning on March 11, 2025. +// For more details, see: https://api.slack.com/methods/files.upload#markdown func (api *Client) UploadFile(params FileUploadParameters) (file *File, err error) { return api.UploadFileContext(context.Background(), params) } -// UploadFileContext uploads a file and setting a custom context +// UploadFileContext uploads a file and setting a custom context. +// DEPRECATED: Use UploadFileV2Context instead. This will stop functioning on March 11, 2025. +// For more details, see: https://api.slack.com/methods/files.upload#markdown func (api *Client) UploadFileContext(ctx context.Context, params FileUploadParameters) (file *File, err error) { // Test if user token is valid. This helps because client.Do doesn't like this for some reason. XXX: More // investigation needed, but for now this will do. From cab4284b746964a2404918d632a19e515f81445e Mon Sep 17 00:00:00 2001 From: Lorenzo Aiello Date: Sun, 14 Jul 2024 18:11:24 -0700 Subject: [PATCH 252/606] ci: Adding Go Test Coverage for 1.21 and 1.22 (#1298) --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7906afbdc..cbf91c669 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,6 +15,8 @@ jobs: - '1.18' - '1.19' - '1.20' + - '1.21' + - '1.22' name: test go-${{ matrix.go }} steps: - uses: actions/checkout@v3 From 0a5c9e108b9fb76b9efc6251e21d4e784ee0c35f Mon Sep 17 00:00:00 2001 From: Lorenzo Aiello Date: Sun, 14 Jul 2024 18:18:36 -0700 Subject: [PATCH 253/606] docs: Updating Inline Docs and API References (#1299) --- apps.go | 10 +++- auth.go | 11 +++-- bookmarks.go | 18 +++++-- bots.go | 6 ++- chat.go | 73 +++++++++++++++------------ conversation.go | 125 +++++++++++++++++++++++++++++++---------------- dnd.go | 27 ++++++---- emoji.go | 6 ++- files.go | 61 +++++++++++++---------- manifests.go | 30 ++++++++---- oauth.go | 31 ++++++++---- pins.go | 14 ++++-- reactions.go | 10 +++- reminders.go | 31 ++++-------- remotefiles.go | 36 ++++++-------- stars.go | 19 ++++--- team.go | 30 ++++++++---- tokens.go | 6 ++- usergroups.go | 42 ++++++++++------ users.go | 100 ++++++++++++++++++++++--------------- views.go | 10 +++- workflow_step.go | 5 +- 22 files changed, 439 insertions(+), 262 deletions(-) diff --git a/apps.go b/apps.go index 10d429752..7322b15b9 100644 --- a/apps.go +++ b/apps.go @@ -19,11 +19,15 @@ type EventAuthorization struct { IsEnterpriseInstall bool `json:"is_enterprise_install"` } +// ListEventAuthorizations lists authed users and teams for the given event_context. +// You must provide an app-level token to the client using OptionAppLevelToken. +// For more details, see ListEventAuthorizationsContext documentation. func (api *Client) ListEventAuthorizations(eventContext string) ([]EventAuthorization, error) { return api.ListEventAuthorizationsContext(context.Background(), eventContext) } -// ListEventAuthorizationsContext lists authed users and teams for the given event_context. You must provide an app-level token to the client using OptionAppLevelToken. More info: https://api.slack.com/methods/apps.event.authorizations.list +// ListEventAuthorizationsContext lists authed users and teams for the given event_context with a custom context. +// Slack API docs: https://api.slack.com/methods/apps.event.authorizations.list func (api *Client) ListEventAuthorizationsContext(ctx context.Context, eventContext string) ([]EventAuthorization, error) { resp := &listEventAuthorizationsResponse{} @@ -43,10 +47,14 @@ func (api *Client) ListEventAuthorizationsContext(ctx context.Context, eventCont return resp.Authorizations, nil } +// UninstallApp uninstalls your app from a workspace. +// For more details, see UninstallAppContext documentation. func (api *Client) UninstallApp(clientID, clientSecret string) error { return api.UninstallAppContext(context.Background(), clientID, clientSecret) } +// UninstallAppContext uninstalls your app from a workspace with a custom context. +// Slack API docs: https://api.slack.com/methods/apps.uninstall func (api *Client) UninstallAppContext(ctx context.Context, clientID, clientSecret string) error { values := url.Values{ "client_id": {clientID}, diff --git a/auth.go b/auth.go index bf6e80d36..7fff1a168 100644 --- a/auth.go +++ b/auth.go @@ -22,12 +22,14 @@ func (api *Client) authRequest(ctx context.Context, path string, values url.Valu return response, response.Err() } -// SendAuthRevoke will send a revocation for our token +// SendAuthRevoke will send a revocation for our token. +// For more details, see SendAuthRevokeContext documentation. func (api *Client) SendAuthRevoke(token string) (*AuthRevokeResponse, error) { return api.SendAuthRevokeContext(context.Background(), token) } -// SendAuthRevokeContext will send a revocation request for our token to api.revoke with context +// SendAuthRevokeContext will send a revocation request for our token to api.revoke with a custom context. +// Slack API docs: https://api.slack.com/methods/auth.revoke func (api *Client) SendAuthRevokeContext(ctx context.Context, token string) (*AuthRevokeResponse, error) { if token == "" { token = api.token @@ -50,12 +52,13 @@ type ListTeamsParameters struct { } // ListTeams returns all workspaces a token can access. -// More info: https://api.slack.com/methods/admin.teams.list +// For more details, see ListTeamsContext documentation. func (api *Client) ListTeams(params ListTeamsParameters) ([]Team, string, error) { return api.ListTeamsContext(context.Background(), params) } -// ListTeams returns all workspaces a token can access with a custom context. +// ListTeamsContext returns all workspaces a token can access with a custom context. +// Slack API docs: https://api.slack.com/methods/auth.teams.list func (api *Client) ListTeamsContext(ctx context.Context, params ListTeamsParameters) ([]Team, string, error) { values := url.Values{ "token": {api.token}, diff --git a/bookmarks.go b/bookmarks.go index 78753507b..1f07e59b0 100644 --- a/bookmarks.go +++ b/bookmarks.go @@ -55,12 +55,14 @@ type listBookmarksResponse struct { SlackResponse } -// AddBookmark adds a bookmark in a channel +// AddBookmark adds a bookmark in a channel. +// For more details, see AddBookmarkContext documentation. func (api *Client) AddBookmark(channelID string, params AddBookmarkParameters) (Bookmark, error) { return api.AddBookmarkContext(context.Background(), channelID, params) } -// AddBookmarkContext adds a bookmark in a channel with a custom context +// AddBookmarkContext adds a bookmark in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/bookmarks.add func (api *Client) AddBookmarkContext(ctx context.Context, channelID string, params AddBookmarkParameters) (Bookmark, error) { values := url.Values{ "channel_id": {channelID}, @@ -89,12 +91,14 @@ func (api *Client) AddBookmarkContext(ctx context.Context, channelID string, par return response.Bookmark, response.Err() } -// RemoveBookmark removes a bookmark from a channel +// RemoveBookmark removes a bookmark from a channel. +// For more details, see RemoveBookmarkContext documentation. func (api *Client) RemoveBookmark(channelID, bookmarkID string) error { return api.RemoveBookmarkContext(context.Background(), channelID, bookmarkID) } -// RemoveBookmarkContext removes a bookmark from a channel with a custom context +// RemoveBookmarkContext removes a bookmark from a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/bookmarks.remove func (api *Client) RemoveBookmarkContext(ctx context.Context, channelID, bookmarkID string) error { values := url.Values{ "channel_id": {channelID}, @@ -111,11 +115,13 @@ func (api *Client) RemoveBookmarkContext(ctx context.Context, channelID, bookmar } // ListBookmarks returns all bookmarks for a channel. +// For more details, see ListBookmarksContext documentation. func (api *Client) ListBookmarks(channelID string) ([]Bookmark, error) { return api.ListBookmarksContext(context.Background(), channelID) } // ListBookmarksContext returns all bookmarks for a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/bookmarks.edit func (api *Client) ListBookmarksContext(ctx context.Context, channelID string) ([]Bookmark, error) { values := url.Values{ "channel_id": {channelID}, @@ -130,10 +136,14 @@ func (api *Client) ListBookmarksContext(ctx context.Context, channelID string) ( return response.Bookmarks, response.Err() } +// EditBookmark edits a bookmark in a channel. +// For more details, see EditBookmarkContext documentation. func (api *Client) EditBookmark(channelID, bookmarkID string, params EditBookmarkParameters) (Bookmark, error) { return api.EditBookmarkContext(context.Background(), channelID, bookmarkID, params) } +// EditBookmarkContext edits a bookmark in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/bookmarks.edit func (api *Client) EditBookmarkContext(ctx context.Context, channelID, bookmarkID string, params EditBookmarkParameters) (Bookmark, error) { values := url.Values{ "channel_id": {channelID}, diff --git a/bots.go b/bots.go index fbe5d25e2..1ab946962 100644 --- a/bots.go +++ b/bots.go @@ -40,12 +40,14 @@ type GetBotInfoParameters struct { TeamID string } -// GetBotInfo will retrieve the complete bot information +// GetBotInfo will retrieve the complete bot information. +// For more details, see GetBotInfoContext documentation. func (api *Client) GetBotInfo(parameters GetBotInfoParameters) (*Bot, error) { return api.GetBotInfoContext(context.Background(), parameters) } -// GetBotInfoContext will retrieve the complete bot information using a custom context +// GetBotInfoContext will retrieve the complete bot information using a custom context. +// Slack API docs: https://api.slack.com/methods/bots.info func (api *Client) GetBotInfoContext(ctx context.Context, parameters GetBotInfoParameters) (*Bot, error) { values := url.Values{ "token": {api.token}, diff --git a/chat.go b/chat.go index d043ef00e..916d05a93 100644 --- a/chat.go +++ b/chat.go @@ -37,7 +37,7 @@ type chatResponseFull struct { SlackResponse } -// getMessageTimestamp will inspect the `chatResponseFull` to ruturn a timestamp value +// getMessageTimestamp will inspect the `chatResponseFull` to return a timestamp value // in `chat.postMessage` its under `ts` // in `chat.postEphemeral` its under `message_ts` func (c chatResponseFull) getMessageTimestamp() string { @@ -88,12 +88,14 @@ func NewPostMessageParameters() PostMessageParameters { } } -// DeleteMessage deletes a message in a channel +// DeleteMessage deletes a message in a channel. +// For more details, see DeleteMessageContext documentation. func (api *Client) DeleteMessage(channel, messageTimestamp string) (string, string, error) { return api.DeleteMessageContext(context.Background(), channel, messageTimestamp) } -// DeleteMessageContext deletes a message in a channel with a custom context +// DeleteMessageContext deletes a message in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.delete func (api *Client) DeleteMessageContext(ctx context.Context, channel, messageTimestamp string) (string, string, error) { respChannel, respTimestamp, _, err := api.SendMessageContext( ctx, @@ -106,13 +108,13 @@ func (api *Client) DeleteMessageContext(ctx context.Context, channel, messageTim // ScheduleMessage sends a message to a channel. // Message is escaped by default according to https://api.slack.com/docs/formatting // Use http://davestevens.github.io/slack-message-builder/ to help crafting your message. +// For more details, see ScheduleMessageContext documentation. func (api *Client) ScheduleMessage(channelID, postAt string, options ...MsgOption) (string, string, error) { return api.ScheduleMessageContext(context.Background(), channelID, postAt, options...) } -// ScheduleMessageContext sends a message to a channel with a custom context -// -// For more details, see ScheduleMessage documentation. +// ScheduleMessageContext sends a message to a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.scheduleMessage func (api *Client) ScheduleMessageContext(ctx context.Context, channelID, postAt string, options ...MsgOption) (string, string, error) { respChannel, respTimestamp, _, err := api.SendMessageContext( ctx, @@ -126,12 +128,13 @@ func (api *Client) ScheduleMessageContext(ctx context.Context, channelID, postAt // PostMessage sends a message to a channel. // Message is escaped by default according to https://api.slack.com/docs/formatting // Use http://davestevens.github.io/slack-message-builder/ to help crafting your message. +// For more details, see PostMessageContext documentation. func (api *Client) PostMessage(channelID string, options ...MsgOption) (string, string, error) { return api.PostMessageContext(context.Background(), channelID, options...) } -// PostMessageContext sends a message to a channel with a custom context -// For more details, see PostMessage documentation. +// PostMessageContext sends a message to a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.postMessage func (api *Client) PostMessageContext(ctx context.Context, channelID string, options ...MsgOption) (string, string, error) { respChannel, respTimestamp, _, err := api.SendMessageContext( ctx, @@ -145,12 +148,13 @@ func (api *Client) PostMessageContext(ctx context.Context, channelID string, opt // PostEphemeral sends an ephemeral message to a user in a channel. // Message is escaped by default according to https://api.slack.com/docs/formatting // Use http://davestevens.github.io/slack-message-builder/ to help crafting your message. +// For more details, see PostEphemeralContext documentation. func (api *Client) PostEphemeral(channelID, userID string, options ...MsgOption) (string, error) { return api.PostEphemeralContext(context.Background(), channelID, userID, options...) } -// PostEphemeralContext sends an ephemeal message to a user in a channel with a custom context -// For more details, see PostEphemeral documentation +// PostEphemeralContext sends an ephemeral message to a user in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.postEphemeral func (api *Client) PostEphemeralContext(ctx context.Context, channelID, userID string, options ...MsgOption) (timestamp string, err error) { _, timestamp, _, err = api.SendMessageContext( ctx, @@ -161,12 +165,14 @@ func (api *Client) PostEphemeralContext(ctx context.Context, channelID, userID s return timestamp, err } -// UpdateMessage updates a message in a channel +// UpdateMessage updates a message in a channel. +// For more details, see UpdateMessageContext documentation. func (api *Client) UpdateMessage(channelID, timestamp string, options ...MsgOption) (string, string, string, error) { return api.UpdateMessageContext(context.Background(), channelID, timestamp, options...) } -// UpdateMessageContext updates a message in a channel +// UpdateMessageContext updates a message in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.update func (api *Client) UpdateMessageContext(ctx context.Context, channelID, timestamp string, options ...MsgOption) (string, string, string, error) { return api.SendMessageContext( ctx, @@ -176,38 +182,38 @@ func (api *Client) UpdateMessageContext(ctx context.Context, channelID, timestam ) } -// UnfurlMessage unfurls a message in a channel +// UnfurlMessage unfurls a message in a channel. +// For more details, see UnfurlMessageContext documentation. func (api *Client) UnfurlMessage(channelID, timestamp string, unfurls map[string]Attachment, options ...MsgOption) (string, string, string, error) { return api.UnfurlMessageContext(context.Background(), channelID, timestamp, unfurls, options...) } -// UnfurlMessageContext unfurls a message in a channel with a custom context +// UnfurlMessageContext unfurls a message in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.unfurl func (api *Client) UnfurlMessageContext(ctx context.Context, channelID, timestamp string, unfurls map[string]Attachment, options ...MsgOption) (string, string, string, error) { return api.SendMessageContext(ctx, channelID, MsgOptionUnfurl(timestamp, unfurls), MsgOptionCompose(options...)) } -// UnfurlMessageWithAuthURL sends an unfurl request containing an -// authentication URL. -// For more details see: -// https://api.slack.com/reference/messaging/link-unfurling#authenticated_unfurls +// UnfurlMessageWithAuthURL sends an unfurl request containing an authentication URL. +// For more details, see UnfurlMessageWithAuthURLContext documentation. func (api *Client) UnfurlMessageWithAuthURL(channelID, timestamp string, userAuthURL string, options ...MsgOption) (string, string, string, error) { return api.UnfurlMessageWithAuthURLContext(context.Background(), channelID, timestamp, userAuthURL, options...) } -// UnfurlMessageWithAuthURLContext sends an unfurl request containing an -// authentication URL. -// For more details see: -// https://api.slack.com/reference/messaging/link-unfurling#authenticated_unfurls +// UnfurlMessageWithAuthURLContext sends an unfurl request containing an authentication URL with a custom context. +// For more details see: https://api.slack.com/reference/messaging/link-unfurling#authenticated_unfurls func (api *Client) UnfurlMessageWithAuthURLContext(ctx context.Context, channelID, timestamp string, userAuthURL string, options ...MsgOption) (string, string, string, error) { return api.SendMessageContext(ctx, channelID, MsgOptionUnfurlAuthURL(timestamp, userAuthURL), MsgOptionCompose(options...)) } // SendMessage more flexible method for configuring messages. +// For more details, see SendMessageContext documentation. func (api *Client) SendMessage(channel string, options ...MsgOption) (string, string, string, error) { return api.SendMessageContext(context.Background(), channel, options...) } // SendMessageContext more flexible method for configuring messages with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.postMessage func (api *Client) SendMessageContext(ctx context.Context, channelID string, options ...MsgOption) (_channel string, _timestamp string, _text string, err error) { var ( req *http.Request @@ -771,22 +777,21 @@ func MsgOptionPostMessageParameters(params PostMessageParameters) MsgOption { } } -// PermalinkParameters are the parameters required to get a permalink to a -// message. Slack documentation can be found here: -// https://api.slack.com/methods/chat.getPermalink +// PermalinkParameters are the parameters required to get a permalink to a message. type PermalinkParameters struct { Channel string Ts string } -// GetPermalink returns the permalink for a message. It takes -// PermalinkParameters and returns a string containing the permalink. It -// returns an error if unable to retrieve the permalink. +// GetPermalink returns the permalink for a message. It takes PermalinkParameters and returns a string containing the +// permalink. It returns an error if unable to retrieve the permalink. +// For more details, see GetPermalinkContext documentation. func (api *Client) GetPermalink(params *PermalinkParameters) (string, error) { return api.GetPermalinkContext(context.Background(), params) } // GetPermalinkContext returns the permalink for a message using a custom context. +// Slack API docs: https://api.slack.com/methods/chat.getPermalink func (api *Client) GetPermalinkContext(ctx context.Context, params *PermalinkParameters) (string, error) { values := url.Values{ "channel": {params.Channel}, @@ -814,12 +819,14 @@ type GetScheduledMessagesParameters struct { Oldest string } -// GetScheduledMessages returns the list of scheduled messages based on params +// GetScheduledMessages returns the list of scheduled messages based on params. +// For more details, see GetScheduledMessagesContext documentation. func (api *Client) GetScheduledMessages(params *GetScheduledMessagesParameters) (channels []ScheduledMessage, nextCursor string, err error) { return api.GetScheduledMessagesContext(context.Background(), params) } -// GetScheduledMessagesContext returns the list of scheduled messages in a Slack team with a custom context +// GetScheduledMessagesContext returns the list of scheduled messages based on params with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.getScheduledMessages.list func (api *Client) GetScheduledMessagesContext(ctx context.Context, params *GetScheduledMessagesParameters) (channels []ScheduledMessage, nextCursor string, err error) { values := url.Values{ "token": {api.token}, @@ -862,12 +869,14 @@ type DeleteScheduledMessageParameters struct { AsUser bool } -// DeleteScheduledMessage returns the list of scheduled messages based on params +// DeleteScheduledMessage deletes a pending scheduled message. +// For more details, see DeleteScheduledMessageContext documentation. func (api *Client) DeleteScheduledMessage(params *DeleteScheduledMessageParameters) (bool, error) { return api.DeleteScheduledMessageContext(context.Background(), params) } -// DeleteScheduledMessageContext returns the list of scheduled messages in a Slack team with a custom context +// DeleteScheduledMessageContext deletes a pending scheduled message with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.deleteScheduledMessage func (api *Client) DeleteScheduledMessageContext(ctx context.Context, params *DeleteScheduledMessageParameters) (bool, error) { values := url.Values{ "token": {api.token}, diff --git a/conversation.go b/conversation.go index 555237583..6e608eba5 100644 --- a/conversation.go +++ b/conversation.go @@ -84,12 +84,14 @@ type responseMetaData struct { NextCursor string `json:"next_cursor"` } -// GetUsersInConversation returns the list of users in a conversation +// GetUsersInConversation returns the list of users in a conversation. +// For more details, see GetUsersInConversationContext documentation. func (api *Client) GetUsersInConversation(params *GetUsersInConversationParameters) ([]string, string, error) { return api.GetUsersInConversationContext(context.Background(), params) } -// GetUsersInConversationContext returns the list of users in a conversation with a custom context +// GetUsersInConversationContext returns the list of users in a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.members func (api *Client) GetUsersInConversationContext(ctx context.Context, params *GetUsersInConversationParameters) ([]string, string, error) { values := url.Values{ "token": {api.token}, @@ -119,12 +121,14 @@ func (api *Client) GetUsersInConversationContext(ctx context.Context, params *Ge return response.Members, response.ResponseMetaData.NextCursor, nil } -// GetConversationsForUser returns the list conversations for a given user +// GetConversationsForUser returns the list conversations for a given user. +// For more details, see GetConversationsForUserContext documentation. func (api *Client) GetConversationsForUser(params *GetConversationsForUserParameters) (channels []Channel, nextCursor string, err error) { return api.GetConversationsForUserContext(context.Background(), params) } // GetConversationsForUserContext returns the list conversations for a given user with a custom context +// Slack API docs: https://api.slack.com/methods/users.conversations func (api *Client) GetConversationsForUserContext(ctx context.Context, params *GetConversationsForUserParameters) (channels []Channel, nextCursor string, err error) { values := url.Values{ "token": {api.token}, @@ -161,12 +165,14 @@ func (api *Client) GetConversationsForUserContext(ctx context.Context, params *G return response.Channels, response.ResponseMetaData.NextCursor, response.Err() } -// ArchiveConversation archives a conversation +// ArchiveConversation archives a conversation. +// For more details, see ArchiveConversationContext documentation. func (api *Client) ArchiveConversation(channelID string) error { return api.ArchiveConversationContext(context.Background(), channelID) } -// ArchiveConversationContext archives a conversation with a custom context +// ArchiveConversationContext archives a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.archive func (api *Client) ArchiveConversationContext(ctx context.Context, channelID string) error { values := url.Values{ "token": {api.token}, @@ -182,12 +188,14 @@ func (api *Client) ArchiveConversationContext(ctx context.Context, channelID str return response.Err() } -// UnArchiveConversation reverses conversation archival +// UnArchiveConversation reverses conversation archival. +// For more details, see UnArchiveConversationContext documentation. func (api *Client) UnArchiveConversation(channelID string) error { return api.UnArchiveConversationContext(context.Background(), channelID) } -// UnArchiveConversationContext reverses conversation archival with a custom context +// UnArchiveConversationContext reverses conversation archival with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.unarchive func (api *Client) UnArchiveConversationContext(ctx context.Context, channelID string) error { values := url.Values{ "token": {api.token}, @@ -202,12 +210,14 @@ func (api *Client) UnArchiveConversationContext(ctx context.Context, channelID s return response.Err() } -// SetTopicOfConversation sets the topic for a conversation +// SetTopicOfConversation sets the topic for a conversation. +// For more details, see SetTopicOfConversationContext documentation. func (api *Client) SetTopicOfConversation(channelID, topic string) (*Channel, error) { return api.SetTopicOfConversationContext(context.Background(), channelID, topic) } -// SetTopicOfConversationContext sets the topic for a conversation with a custom context +// SetTopicOfConversationContext sets the topic for a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.setTopic func (api *Client) SetTopicOfConversationContext(ctx context.Context, channelID, topic string) (*Channel, error) { values := url.Values{ "token": {api.token}, @@ -226,12 +236,14 @@ func (api *Client) SetTopicOfConversationContext(ctx context.Context, channelID, return response.Channel, response.Err() } -// SetPurposeOfConversation sets the purpose for a conversation +// SetPurposeOfConversation sets the purpose for a conversation. +// For more details, see SetPurposeOfConversationContext documentation. func (api *Client) SetPurposeOfConversation(channelID, purpose string) (*Channel, error) { return api.SetPurposeOfConversationContext(context.Background(), channelID, purpose) } -// SetPurposeOfConversationContext sets the purpose for a conversation with a custom context +// SetPurposeOfConversationContext sets the purpose for a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.setPurpose func (api *Client) SetPurposeOfConversationContext(ctx context.Context, channelID, purpose string) (*Channel, error) { values := url.Values{ "token": {api.token}, @@ -251,12 +263,14 @@ func (api *Client) SetPurposeOfConversationContext(ctx context.Context, channelI return response.Channel, response.Err() } -// RenameConversation renames a conversation +// RenameConversation renames a conversation. +// For more details, see RenameConversationContext documentation. func (api *Client) RenameConversation(channelID, channelName string) (*Channel, error) { return api.RenameConversationContext(context.Background(), channelID, channelName) } -// RenameConversationContext renames a conversation with a custom context +// RenameConversationContext renames a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.rename func (api *Client) RenameConversationContext(ctx context.Context, channelID, channelName string) (*Channel, error) { values := url.Values{ "token": {api.token}, @@ -276,12 +290,14 @@ func (api *Client) RenameConversationContext(ctx context.Context, channelID, cha return response.Channel, response.Err() } -// InviteUsersToConversation invites users to a channel +// InviteUsersToConversation invites users to a channel. +// For more details, see InviteUsersToConversation documentation. func (api *Client) InviteUsersToConversation(channelID string, users ...string) (*Channel, error) { return api.InviteUsersToConversationContext(context.Background(), channelID, users...) } -// InviteUsersToConversationContext invites users to a channel with a custom context +// InviteUsersToConversationContext invites users to a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.invite func (api *Client) InviteUsersToConversationContext(ctx context.Context, channelID string, users ...string) (*Channel, error) { values := url.Values{ "token": {api.token}, @@ -301,22 +317,26 @@ func (api *Client) InviteUsersToConversationContext(ctx context.Context, channel return response.Channel, response.Err() } -// InviteSharedEmailsToConversation invites users to a shared channels by email +// InviteSharedEmailsToConversation invites users to a shared channels by email. +// For more details, see InviteSharedEmailsToConversationContext documentation. func (api *Client) InviteSharedEmailsToConversation(channelID string, emails ...string) (string, bool, error) { return api.inviteSharedToConversationHelper(context.Background(), channelID, emails, nil) } -// InviteSharedEmailsToConversationContext invites users to a shared channels by email using context +// InviteSharedEmailsToConversationContext invites users to a shared channels by email using context. +// For more details, see inviteSharedToConversationHelper documentation. func (api *Client) InviteSharedEmailsToConversationContext(ctx context.Context, channelID string, emails ...string) (string, bool, error) { return api.inviteSharedToConversationHelper(ctx, channelID, emails, nil) } -// InviteSharedUserIDsToConversation invites users to a shared channels by user id +// InviteSharedUserIDsToConversation invites users to a shared channels by user id. +// For more details, see InviteSharedUserIDsToConversationContext documentation. func (api *Client) InviteSharedUserIDsToConversation(channelID string, userIDs ...string) (string, bool, error) { return api.inviteSharedToConversationHelper(context.Background(), channelID, nil, userIDs) } -// InviteSharedUserIDsToConversationContext invites users to a shared channels by user id with context +// InviteSharedUserIDsToConversationContext invites users to a shared channels by user id with context. +// For more details, see inviteSharedToConversationHelper documentation. func (api *Client) InviteSharedUserIDsToConversationContext(ctx context.Context, channelID string, userIDs ...string) (string, bool, error) { return api.inviteSharedToConversationHelper(ctx, channelID, nil, userIDs) } @@ -324,6 +344,7 @@ func (api *Client) InviteSharedUserIDsToConversationContext(ctx context.Context, // inviteSharedToConversationHelper invites emails or userIDs to a channel with a custom context. // This is a helper function for InviteSharedEmailsToConversation and InviteSharedUserIDsToConversation. // It accepts either emails or userIDs, but not both. +// Slack API docs: https://api.slack.com/methods/conversations.inviteShared func (api *Client) inviteSharedToConversationHelper(ctx context.Context, channelID string, emails []string, userIDs []string) (string, bool, error) { values := url.Values{ "token": {api.token}, @@ -348,12 +369,14 @@ func (api *Client) inviteSharedToConversationHelper(ctx context.Context, channel return response.InviteID, response.IsLegacySharedChannel, response.Err() } -// KickUserFromConversation removes a user from a conversation +// KickUserFromConversation removes a user from a conversation. +// For more details, see KickUserFromConversationContext documentation. func (api *Client) KickUserFromConversation(channelID string, user string) error { return api.KickUserFromConversationContext(context.Background(), channelID, user) } -// KickUserFromConversationContext removes a user from a conversation with a custom context +// KickUserFromConversationContext removes a user from a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.kick func (api *Client) KickUserFromConversationContext(ctx context.Context, channelID string, user string) error { values := url.Values{ "token": {api.token}, @@ -370,12 +393,14 @@ func (api *Client) KickUserFromConversationContext(ctx context.Context, channelI return response.Err() } -// CloseConversation closes a direct message or multi-person direct message +// CloseConversation closes a direct message or multi-person direct message. +// For more details, see CloseConversationContext documentation. func (api *Client) CloseConversation(channelID string) (noOp bool, alreadyClosed bool, err error) { return api.CloseConversationContext(context.Background(), channelID) } -// CloseConversationContext closes a direct message or multi-person direct message with a custom context +// CloseConversationContext closes a direct message or multi-person direct message with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.close func (api *Client) CloseConversationContext(ctx context.Context, channelID string) (noOp bool, alreadyClosed bool, err error) { values := url.Values{ "token": {api.token}, @@ -401,12 +426,14 @@ type CreateConversationParams struct { TeamID string } -// CreateConversation initiates a public or private channel-based conversation +// CreateConversation initiates a public or private channel-based conversation. +// For more details, see CreateConversationContext documentation. func (api *Client) CreateConversation(params CreateConversationParams) (*Channel, error) { return api.CreateConversationContext(context.Background(), params) } -// CreateConversationContext initiates a public or private channel-based conversation with a custom context +// CreateConversationContext initiates a public or private channel-based conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.create func (api *Client) CreateConversationContext(ctx context.Context, params CreateConversationParams) (*Channel, error) { values := url.Values{ "token": {api.token}, @@ -431,12 +458,14 @@ type GetConversationInfoInput struct { IncludeNumMembers bool } -// GetConversationInfo retrieves information about a conversation +// GetConversationInfo retrieves information about a conversation. +// For more details, see GetConversationInfoContext documentation. func (api *Client) GetConversationInfo(input *GetConversationInfoInput) (*Channel, error) { return api.GetConversationInfoContext(context.Background(), input) } -// GetConversationInfoContext retrieves information about a conversation with a custom context +// GetConversationInfoContext retrieves information about a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.info func (api *Client) GetConversationInfoContext(ctx context.Context, input *GetConversationInfoInput) (*Channel, error) { if input == nil { return nil, errors.New("GetConversationInfoInput must not be nil") @@ -460,12 +489,14 @@ func (api *Client) GetConversationInfoContext(ctx context.Context, input *GetCon return &response.Channel, response.Err() } -// LeaveConversation leaves a conversation +// LeaveConversation leaves a conversation. +// For more details, see LeaveConversationContext documentation. func (api *Client) LeaveConversation(channelID string) (bool, error) { return api.LeaveConversationContext(context.Background(), channelID) } -// LeaveConversationContext leaves a conversation with a custom context +// LeaveConversationContext leaves a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.leave func (api *Client) LeaveConversationContext(ctx context.Context, channelID string) (bool, error) { values := url.Values{ "token": {api.token}, @@ -491,12 +522,14 @@ type GetConversationRepliesParameters struct { IncludeAllMetadata bool } -// GetConversationReplies retrieves a thread of messages posted to a conversation +// GetConversationReplies retrieves a thread of messages posted to a conversation. +// For more details, see GetConversationRepliesContext documentation. func (api *Client) GetConversationReplies(params *GetConversationRepliesParameters) (msgs []Message, hasMore bool, nextCursor string, err error) { return api.GetConversationRepliesContext(context.Background(), params) } -// GetConversationRepliesContext retrieves a thread of messages posted to a conversation with a custom context +// GetConversationRepliesContext retrieves a thread of messages posted to a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.replies func (api *Client) GetConversationRepliesContext(ctx context.Context, params *GetConversationRepliesParameters) (msgs []Message, hasMore bool, nextCursor string, err error) { values := url.Values{ "token": {api.token}, @@ -550,12 +583,14 @@ type GetConversationsParameters struct { TeamID string } -// GetConversations returns the list of channels in a Slack team +// GetConversations returns the list of channels in a Slack team. +// For more details, see GetConversationsContext documentation. func (api *Client) GetConversations(params *GetConversationsParameters) (channels []Channel, nextCursor string, err error) { return api.GetConversationsContext(context.Background(), params) } -// GetConversationsContext returns the list of channels in a Slack team with a custom context +// GetConversationsContext returns the list of channels in a Slack team with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.list func (api *Client) GetConversationsContext(ctx context.Context, params *GetConversationsParameters) (channels []Channel, nextCursor string, err error) { values := url.Values{ "token": {api.token}, @@ -596,12 +631,14 @@ type OpenConversationParameters struct { Users []string } -// OpenConversation opens or resumes a direct message or multi-person direct message +// OpenConversation opens or resumes a direct message or multi-person direct message. +// For more details, see OpenConversationContext documentation. func (api *Client) OpenConversation(params *OpenConversationParameters) (*Channel, bool, bool, error) { return api.OpenConversationContext(context.Background(), params) } -// OpenConversationContext opens or resumes a direct message or multi-person direct message with a custom context +// OpenConversationContext opens or resumes a direct message or multi-person direct message with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.open func (api *Client) OpenConversationContext(ctx context.Context, params *OpenConversationParameters) (*Channel, bool, bool, error) { values := url.Values{ "token": {api.token}, @@ -628,12 +665,14 @@ func (api *Client) OpenConversationContext(ctx context.Context, params *OpenConv return response.Channel, response.NoOp, response.AlreadyOpen, response.Err() } -// JoinConversation joins an existing conversation +// JoinConversation joins an existing conversation. +// For more details, see JoinConversationContext documentation. func (api *Client) JoinConversation(channelID string) (*Channel, string, []string, error) { return api.JoinConversationContext(context.Background(), channelID) } -// JoinConversationContext joins an existing conversation with a custom context +// JoinConversationContext joins an existing conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.join func (api *Client) JoinConversationContext(ctx context.Context, channelID string) (*Channel, string, []string, error) { values := url.Values{"token": {api.token}, "channel": {channelID}} response := struct { @@ -680,12 +719,14 @@ type GetConversationHistoryResponse struct { Messages []Message `json:"messages"` } -// GetConversationHistory joins an existing conversation +// GetConversationHistory joins an existing conversation. +// For more details, see GetConversationHistoryContext documentation. func (api *Client) GetConversationHistory(params *GetConversationHistoryParameters) (*GetConversationHistoryResponse, error) { return api.GetConversationHistoryContext(context.Background(), params) } -// GetConversationHistoryContext joins an existing conversation with a custom context +// GetConversationHistoryContext joins an existing conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.history func (api *Client) GetConversationHistoryContext(ctx context.Context, params *GetConversationHistoryParameters) (*GetConversationHistoryResponse, error) { values := url.Values{"token": {api.token}, "channel": {params.ChannelID}} if params.Cursor != "" { @@ -721,12 +762,14 @@ func (api *Client) GetConversationHistoryContext(ctx context.Context, params *Ge return &response, response.Err() } -// MarkConversation sets the read mark of a conversation to a specific point +// MarkConversation sets the read mark of a conversation to a specific point. +// For more details, see MarkConversationContext documentation. func (api *Client) MarkConversation(channel, ts string) (err error) { return api.MarkConversationContext(context.Background(), channel, ts) } -// MarkConversationContext sets the read mark of a conversation to a specific point with a custom context +// MarkConversationContext sets the read mark of a conversation to a specific point with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.mark func (api *Client) MarkConversationContext(ctx context.Context, channel, ts string) error { values := url.Values{ "token": {api.token}, diff --git a/dnd.go b/dnd.go index a3aa680cd..81eaf5024 100644 --- a/dnd.go +++ b/dnd.go @@ -45,12 +45,14 @@ func (api *Client) dndRequest(ctx context.Context, path string, values url.Value return response, response.Err() } -// EndDND ends the user's scheduled Do Not Disturb session +// EndDND ends the user's scheduled Do Not Disturb session. +// For more information see the EndDNDContext documentation. func (api *Client) EndDND() error { return api.EndDNDContext(context.Background()) } -// EndDNDContext ends the user's scheduled Do Not Disturb session with a custom context +// EndDNDContext ends the user's scheduled Do Not Disturb session with a custom context. +// Slack API docs: https://api.slack.com/methods/dnd.endDnd func (api *Client) EndDNDContext(ctx context.Context) error { values := url.Values{ "token": {api.token}, @@ -65,12 +67,14 @@ func (api *Client) EndDNDContext(ctx context.Context) error { return response.Err() } -// EndSnooze ends the current user's snooze mode +// EndSnooze ends the current user's snooze mode. +// For more information see the EndSnoozeContext documentation. func (api *Client) EndSnooze() (*DNDStatus, error) { return api.EndSnoozeContext(context.Background()) } -// EndSnoozeContext ends the current user's snooze mode with a custom context +// EndSnoozeContext ends the current user's snooze mode with a custom context. +// Slack API docs: https://api.slack.com/methods/dnd.endSnooze func (api *Client) EndSnoozeContext(ctx context.Context) (*DNDStatus, error) { values := url.Values{ "token": {api.token}, @@ -84,11 +88,13 @@ func (api *Client) EndSnoozeContext(ctx context.Context) (*DNDStatus, error) { } // GetDNDInfo provides information about a user's current Do Not Disturb settings. +// For more information see the GetDNDInfoContext documentation. func (api *Client) GetDNDInfo(user *string) (*DNDStatus, error) { return api.GetDNDInfoContext(context.Background(), user) } // GetDNDInfoContext provides information about a user's current Do Not Disturb settings with a custom context. +// Slack API docs: https://api.slack.com/methods/dnd.info func (api *Client) GetDNDInfoContext(ctx context.Context, user *string) (*DNDStatus, error) { values := url.Values{ "token": {api.token}, @@ -105,11 +111,13 @@ func (api *Client) GetDNDInfoContext(ctx context.Context, user *string) (*DNDSta } // GetDNDTeamInfo provides information about a user's current Do Not Disturb settings. +// For more information see the GetDNDTeamInfoContext documentation. func (api *Client) GetDNDTeamInfo(users []string) (map[string]DNDStatus, error) { return api.GetDNDTeamInfoContext(context.Background(), users) } // GetDNDTeamInfoContext provides information about a user's current Do Not Disturb settings with a custom context. +// Slack API docs: https://api.slack.com/methods/dnd.teamInfo func (api *Client) GetDNDTeamInfoContext(ctx context.Context, users []string) (map[string]DNDStatus, error) { values := url.Values{ "token": {api.token}, @@ -128,15 +136,16 @@ func (api *Client) GetDNDTeamInfoContext(ctx context.Context, users []string) (m return response.Users, nil } -// SetSnooze adjusts the snooze duration for a user's Do Not Disturb -// settings. If a snooze session is not already active for the user, invoking -// this method will begin one for the specified duration. +// SetSnooze adjusts the snooze duration for a user's Do Not Disturb settings. +// For more information see the SetSnoozeContext documentation. func (api *Client) SetSnooze(minutes int) (*DNDStatus, error) { return api.SetSnoozeContext(context.Background(), minutes) } -// SetSnoozeContext adjusts the snooze duration for a user's Do Not Disturb settings with a custom context. -// For more information see the SetSnooze docs +// SetSnoozeContext adjusts the snooze duration for a user's Do Not Disturb settings. +// If a snooze session is not already active for the user, invoking this method will +// begin one for the specified duration. +// Slack API docs: https://api.slack.com/methods/dnd.setSnooze func (api *Client) SetSnoozeContext(ctx context.Context, minutes int) (*DNDStatus, error) { values := url.Values{ "token": {api.token}, diff --git a/emoji.go b/emoji.go index b2b0c6c90..139df0fd2 100644 --- a/emoji.go +++ b/emoji.go @@ -10,12 +10,14 @@ type emojiResponseFull struct { SlackResponse } -// GetEmoji retrieves all the emojis +// GetEmoji retrieves all the emojis. +// For more details see GetEmojiContext documentation. func (api *Client) GetEmoji() (map[string]string, error) { return api.GetEmojiContext(context.Background()) } -// GetEmojiContext retrieves all the emojis with a custom context +// GetEmojiContext retrieves all the emojis with a custom context. +// Slack API docs: https://api.slack.com/methods/emoji.list func (api *Client) GetEmojiContext(ctx context.Context) (map[string]string, error) { values := url.Values{ "token": {api.token}, diff --git a/files.go b/files.go index eae8407bc..6844edd51 100644 --- a/files.go +++ b/files.go @@ -11,7 +11,7 @@ import ( ) const ( - // Add here the defaults in the siten + // Add here the defaults in the site DEFAULT_FILES_USER = "" DEFAULT_FILES_CHANNEL = "" DEFAULT_FILES_TS_FROM = 0 @@ -234,12 +234,14 @@ func (api *Client) fileRequest(ctx context.Context, path string, values url.Valu return response, response.Err() } -// GetFileInfo retrieves a file and related comments +// GetFileInfo retrieves a file and related comments. +// For more details, see GetFileInfoContext documentation. func (api *Client) GetFileInfo(fileID string, count, page int) (*File, []Comment, *Paging, error) { return api.GetFileInfoContext(context.Background(), fileID, count, page) } -// GetFileInfoContext retrieves a file and related comments with a custom context +// GetFileInfoContext retrieves a file and related comments with a custom context. +// Slack API docs: https://api.slack.com/methods/files.info func (api *Client) GetFileInfoContext(ctx context.Context, fileID string, count, page int) (*File, []Comment, *Paging, error) { values := url.Values{ "token": {api.token}, @@ -255,24 +257,25 @@ func (api *Client) GetFileInfoContext(ctx context.Context, fileID string, count, return &response.File, response.Comments, &response.Paging, nil } -// GetFile retrieves a given file from its private download URL +// GetFile retrieves a given file from its private download URL. func (api *Client) GetFile(downloadURL string, writer io.Writer) error { return api.GetFileContext(context.Background(), downloadURL, writer) } -// GetFileContext retrieves a given file from its private download URL with a custom context -// +// GetFileContext retrieves a given file from its private download URL with a custom context. // For more details, see GetFile documentation. func (api *Client) GetFileContext(ctx context.Context, downloadURL string, writer io.Writer) error { return downloadFile(ctx, api.httpclient, api.token, downloadURL, writer, api) } -// GetFiles retrieves all files according to the parameters given +// GetFiles retrieves all files according to the parameters given. +// For more details, see GetFilesContext documentation. func (api *Client) GetFiles(params GetFilesParameters) ([]File, *Paging, error) { return api.GetFilesContext(context.Background(), params) } -// GetFilesContext retrieves all files according to the parameters given with a custom context +// GetFilesContext retrieves all files according to the parameters given with a custom context. +// Slack API docs: https://api.slack.com/methods/files.list func (api *Client) GetFilesContext(ctx context.Context, params GetFilesParameters) ([]File, *Paging, error) { values := url.Values{ "token": {api.token}, @@ -313,13 +316,13 @@ func (api *Client) GetFilesContext(ctx context.Context, params GetFilesParameter } // ListFiles retrieves all files according to the parameters given. Uses cursor based pagination. +// For more details, see ListFilesContext documentation. func (api *Client) ListFiles(params ListFilesParameters) ([]File, *ListFilesParameters, error) { return api.ListFilesContext(context.Background(), params) } // ListFilesContext retrieves all files according to the parameters given with a custom context. -// -// For more details, see ListFiles documentation. +// Slack API docs: https://api.slack.com/methods/files.list func (api *Client) ListFilesContext(ctx context.Context, params ListFilesParameters) ([]File, *ListFilesParameters, error) { values := url.Values{ "token": {api.token}, @@ -408,12 +411,14 @@ func (api *Client) UploadFileContext(ctx context.Context, params FileUploadParam return &response.File, response.Err() } -// DeleteFileComment deletes a file's comment +// DeleteFileComment deletes a file's comment. +// For more details, see DeleteFileCommentContext documentation. func (api *Client) DeleteFileComment(commentID, fileID string) error { return api.DeleteFileCommentContext(context.Background(), fileID, commentID) } -// DeleteFileCommentContext deletes a file's comment with a custom context +// DeleteFileCommentContext deletes a file's comment with a custom context. +// Slack API docs: https://api.slack.com/methods/files.comments.delete func (api *Client) DeleteFileCommentContext(ctx context.Context, fileID, commentID string) (err error) { if fileID == "" || commentID == "" { return ErrParametersMissing @@ -428,12 +433,14 @@ func (api *Client) DeleteFileCommentContext(ctx context.Context, fileID, comment return err } -// DeleteFile deletes a file +// DeleteFile deletes a file. +// For more details, see DeleteFileContext documentation. func (api *Client) DeleteFile(fileID string) error { return api.DeleteFileContext(context.Background(), fileID) } -// DeleteFileContext deletes a file with a custom context +// DeleteFileContext deletes a file with a custom context. +// Slack API docs: https://api.slack.com/methods/files.delete func (api *Client) DeleteFileContext(ctx context.Context, fileID string) (err error) { values := url.Values{ "token": {api.token}, @@ -444,12 +451,14 @@ func (api *Client) DeleteFileContext(ctx context.Context, fileID string) (err er return err } -// RevokeFilePublicURL disables public/external sharing for a file +// RevokeFilePublicURL disables public/external sharing for a file. +// For more details, see RevokeFilePublicURLContext documentation. func (api *Client) RevokeFilePublicURL(fileID string) (*File, error) { return api.RevokeFilePublicURLContext(context.Background(), fileID) } -// RevokeFilePublicURLContext disables public/external sharing for a file with a custom context +// RevokeFilePublicURLContext disables public/external sharing for a file with a custom context. +// Slack API docs: https://api.slack.com/methods/files.revokePublicURL func (api *Client) RevokeFilePublicURLContext(ctx context.Context, fileID string) (*File, error) { values := url.Values{ "token": {api.token}, @@ -463,12 +472,14 @@ func (api *Client) RevokeFilePublicURLContext(ctx context.Context, fileID string return &response.File, nil } -// ShareFilePublicURL enabled public/external sharing for a file +// ShareFilePublicURL enabled public/external sharing for a file. +// For more details, see ShareFilePublicURLContext documentation. func (api *Client) ShareFilePublicURL(fileID string) (*File, []Comment, *Paging, error) { return api.ShareFilePublicURLContext(context.Background(), fileID) } -// ShareFilePublicURLContext enabled public/external sharing for a file with a custom context +// ShareFilePublicURLContext enabled public/external sharing for a file with a custom context. +// Slack API docs: https://api.slack.com/methods/files.sharedPublicURL func (api *Client) ShareFilePublicURLContext(ctx context.Context, fileID string) (*File, []Comment, *Paging, error) { values := url.Values{ "token": {api.token}, @@ -482,7 +493,7 @@ func (api *Client) ShareFilePublicURLContext(ctx context.Context, fileID string) return &response.File, response.Comments, &response.Paging, nil } -// getUploadURLExternal gets a URL and fileID from slack which can later be used to upload a file +// getUploadURLExternal gets a URL and fileID from slack which can later be used to upload a file. func (api *Client) getUploadURLExternal(ctx context.Context, params getUploadURLExternalParameters) (*getUploadURLExternalResponse, error) { values := url.Values{ "token": {api.token}, @@ -550,18 +561,18 @@ func (api *Client) completeUploadExternal(ctx context.Context, fileID string, pa return response, nil } -// UploadFileV2 uploads file to a given slack channel using 3 steps - -// 1. Get an upload URL using files.getUploadURLExternal API -// 2. Send the file as a post to the URL provided by slack -// 3. Complete the upload and share it to the specified channel using files.completeUploadExternal +// UploadFileV2 uploads file to a given slack channel using 3 steps. +// For more details, see UploadFileV2Context documentation. func (api *Client) UploadFileV2(params UploadFileV2Parameters) (*FileSummary, error) { return api.UploadFileV2Context(context.Background(), params) } -// UploadFileV2 uploads file to a given slack channel using 3 steps with a custom context - +// UploadFileV2Context uploads file to a given slack channel using 3 steps - // 1. Get an upload URL using files.getUploadURLExternal API // 2. Send the file as a post to the URL provided by slack -// 3. Complete the upload and if a channel is specified, post it to the specified channel using files.completeUploadExternal +// 3. Complete the upload and share it to the specified channel using files.completeUploadExternal +// +// Slack Docs: https://api.slack.com/messaging/files#uploading_files func (api *Client) UploadFileV2Context(ctx context.Context, params UploadFileV2Parameters) (file *FileSummary, err error) { if params.Filename == "" { return nil, fmt.Errorf("file.upload.v2: filename cannot be empty") diff --git a/manifests.go b/manifests.go index 0041bf244..0a972a25d 100644 --- a/manifests.go +++ b/manifests.go @@ -15,12 +15,14 @@ type Manifest struct { OAuthConfig OAuthConfig `json:"oauth_config,omitempty" yaml:"oauth_config,omitempty"` } -// CreateManifest creates an app from an app manifest +// CreateManifest creates an app from an app manifest. +// For more details, see CreateManifestContext documentation. func (api *Client) CreateManifest(manifest *Manifest, token string) (*ManifestResponse, error) { return api.CreateManifestContext(context.Background(), manifest, token) } -// CreateManifestContext creates an app from an app manifest with a custom context +// CreateManifestContext creates an app from an app manifest with a custom context. +// Slack API docs: https://api.slack.com/methods/apps.manifest.create func (api *Client) CreateManifestContext(ctx context.Context, manifest *Manifest, token string) (*ManifestResponse, error) { if token == "" { token = api.configToken @@ -45,12 +47,14 @@ func (api *Client) CreateManifestContext(ctx context.Context, manifest *Manifest return response, response.Err() } -// DeleteManifest permanently deletes an app created through app manifests +// DeleteManifest permanently deletes an app created through app manifests. +// For more details, see DeleteManifestContext documentation. func (api *Client) DeleteManifest(token string, appId string) (*SlackResponse, error) { return api.DeleteManifestContext(context.Background(), token, appId) } -// DeleteManifestContext permanently deletes an app created through app manifests with a custom context +// DeleteManifestContext permanently deletes an app created through app manifests with a custom context. +// Slack API docs: https://api.slack.com/methods/apps.manifest.delete func (api *Client) DeleteManifestContext(ctx context.Context, token string, appId string) (*SlackResponse, error) { if token == "" { token = api.configToken @@ -70,12 +74,14 @@ func (api *Client) DeleteManifestContext(ctx context.Context, token string, appI return response, response.Err() } -// ExportManifest exports an app manifest from an existing app +// ExportManifest exports an app manifest from an existing app. +// For more details, see ExportManifestContext documentation. func (api *Client) ExportManifest(token string, appId string) (*Manifest, error) { return api.ExportManifestContext(context.Background(), token, appId) } -// ExportManifestContext exports an app manifest from an existing app with a custom context +// ExportManifestContext exports an app manifest from an existing app with a custom context. +// Slack API docs: https://api.slack.com/methods/apps.manifest.export func (api *Client) ExportManifestContext(ctx context.Context, token string, appId string) (*Manifest, error) { if token == "" { token = api.configToken @@ -95,12 +101,14 @@ func (api *Client) ExportManifestContext(ctx context.Context, token string, appI return &response.Manifest, response.Err() } -// UpdateManifest updates an app from an app manifest +// UpdateManifest updates an app from an app manifest. +// For more details, see UpdateManifestContext documentation. func (api *Client) UpdateManifest(manifest *Manifest, token string, appId string) (*UpdateManifestResponse, error) { return api.UpdateManifestContext(context.Background(), manifest, token, appId) } -// UpdateManifestContext updates an app from an app manifest with a custom context +// UpdateManifestContext updates an app from an app manifest with a custom context. +// Slack API docs: https://api.slack.com/methods/apps.manifest.update func (api *Client) UpdateManifestContext(ctx context.Context, manifest *Manifest, token string, appId string) (*UpdateManifestResponse, error) { if token == "" { token = api.configToken @@ -126,12 +134,14 @@ func (api *Client) UpdateManifestContext(ctx context.Context, manifest *Manifest return response, response.Err() } -// ValidateManifest sends a request to apps.manifest.validate to validate your app manifest +// ValidateManifest sends a request to apps.manifest.validate to validate your app manifest. +// For more details, see ValidateManifestContext documentation. func (api *Client) ValidateManifest(manifest *Manifest, token string, appId string) (*ManifestResponse, error) { return api.ValidateManifestContext(context.Background(), manifest, token, appId) } -// ValidateManifestContext sends a request to apps.manifest.validate to validate your app manifest with a custom context +// ValidateManifestContext sends a request to apps.manifest.validate to validate your app manifest with a custom context. +// Slack API docs: https://api.slack.com/methods/apps.manifest.validate func (api *Client) ValidateManifestContext(ctx context.Context, manifest *Manifest, token string, appId string) (*ManifestResponse, error) { if token == "" { token = api.configToken diff --git a/oauth.go b/oauth.go index ded8c73af..0c77eca40 100644 --- a/oauth.go +++ b/oauth.go @@ -79,12 +79,14 @@ type OpenIDConnectResponse struct { SlackResponse } -// GetOAuthToken retrieves an AccessToken +// GetOAuthToken retrieves an AccessToken. +// For more details, see GetOAuthTokenContext documentation. func GetOAuthToken(client httpClient, clientID, clientSecret, code, redirectURI string) (accessToken string, scope string, err error) { return GetOAuthTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI) } -// GetOAuthTokenContext retrieves an AccessToken with a custom context +// GetOAuthTokenContext retrieves an AccessToken with a custom context. +// For more details, see GetOAuthResponseContext documentation. func GetOAuthTokenContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (accessToken string, scope string, err error) { response, err := GetOAuthResponseContext(ctx, client, clientID, clientSecret, code, redirectURI) if err != nil { @@ -94,11 +96,13 @@ func GetOAuthTokenContext(ctx context.Context, client httpClient, clientID, clie } // GetBotOAuthToken retrieves top-level and bot AccessToken - https://api.slack.com/legacy/oauth#bot_user_access_tokens +// For more details, see GetBotOAuthTokenContext documentation. func GetBotOAuthToken(client httpClient, clientID, clientSecret, code, redirectURI string) (accessToken string, scope string, bot OAuthResponseBot, err error) { return GetBotOAuthTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI) } -// GetBotOAuthTokenContext retrieves top-level and bot AccessToken with a custom context +// GetBotOAuthTokenContext retrieves top-level and bot AccessToken with a custom context. +// For more details, see GetOAuthResponseContext documentation. func GetBotOAuthTokenContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (accessToken string, scope string, bot OAuthResponseBot, err error) { response, err := GetOAuthResponseContext(ctx, client, clientID, clientSecret, code, redirectURI) if err != nil { @@ -107,12 +111,14 @@ func GetBotOAuthTokenContext(ctx context.Context, client httpClient, clientID, c return response.AccessToken, response.Scope, response.Bot, nil } -// GetOAuthResponse retrieves OAuth response +// GetOAuthResponse retrieves OAuth response. +// For more details, see GetOAuthResponseContext documentation. func GetOAuthResponse(client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OAuthResponse, err error) { return GetOAuthResponseContext(context.Background(), client, clientID, clientSecret, code, redirectURI) } -// GetOAuthResponseContext retrieves OAuth response with custom context +// GetOAuthResponseContext retrieves OAuth response with custom context. +// Slack API docs: https://api.slack.com/methods/oauth.access func GetOAuthResponseContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OAuthResponse, err error) { values := url.Values{ "client_id": {clientID}, @@ -127,12 +133,14 @@ func GetOAuthResponseContext(ctx context.Context, client httpClient, clientID, c return response, response.Err() } -// GetOAuthV2Response gets a V2 OAuth access token response - https://api.slack.com/methods/oauth.v2.access +// GetOAuthV2Response gets a V2 OAuth access token response. +// For more details, see GetOAuthV2ResponseContext documentation. func GetOAuthV2Response(client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OAuthV2Response, err error) { return GetOAuthV2ResponseContext(context.Background(), client, clientID, clientSecret, code, redirectURI) } -// GetOAuthV2ResponseContext with a context, gets a V2 OAuth access token response +// GetOAuthV2ResponseContext with a context, gets a V2 OAuth access token response. +// Slack API docs: https://api.slack.com/methods/oauth.v2.access func GetOAuthV2ResponseContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OAuthV2Response, err error) { values := url.Values{ "client_id": {clientID}, @@ -147,12 +155,14 @@ func GetOAuthV2ResponseContext(ctx context.Context, client httpClient, clientID, return response, response.Err() } -// RefreshOAuthV2Token with a context, gets a V2 OAuth access token response +// RefreshOAuthV2Token with a context, gets a V2 OAuth access token response. +// For more details, see RefreshOAuthV2TokenContext documentation. func RefreshOAuthV2Token(client httpClient, clientID, clientSecret, refreshToken string) (resp *OAuthV2Response, err error) { return RefreshOAuthV2TokenContext(context.Background(), client, clientID, clientSecret, refreshToken) } -// RefreshOAuthV2TokenContext with a context, gets a V2 OAuth access token response +// RefreshOAuthV2TokenContext with a context, gets a V2 OAuth access token response. +// Slack API docs: https://api.slack.com/methods/oauth.v2.access func RefreshOAuthV2TokenContext(ctx context.Context, client httpClient, clientID, clientSecret, refreshToken string) (resp *OAuthV2Response, err error) { values := url.Values{ "client_id": {clientID}, @@ -168,12 +178,13 @@ func RefreshOAuthV2TokenContext(ctx context.Context, client httpClient, clientID } // GetOpenIDConnectToken exchanges a temporary OAuth verifier code for an access token for Sign in with Slack. -// see: https://api.slack.com/methods/openid.connect.token +// For more details, see GetOpenIDConnectTokenContext documentation. func GetOpenIDConnectToken(client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OpenIDConnectResponse, err error) { return GetOpenIDConnectTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI) } // GetOpenIDConnectTokenContext with a context, gets an access token for Sign in with Slack. +// Slack API docs: https://api.slack.com/methods/openid.connect.token func GetOpenIDConnectTokenContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OpenIDConnectResponse, err error) { values := url.Values{ "client_id": {clientID}, diff --git a/pins.go b/pins.go index ef97c8dfb..5e6cf0c7f 100644 --- a/pins.go +++ b/pins.go @@ -12,12 +12,14 @@ type listPinsResponseFull struct { SlackResponse } -// AddPin pins an item in a channel +// AddPin pins an item in a channel. +// For more details, see AddPinContext documentation. func (api *Client) AddPin(channel string, item ItemRef) error { return api.AddPinContext(context.Background(), channel, item) } -// AddPinContext pins an item in a channel with a custom context +// AddPinContext pins an item in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/pins.add func (api *Client) AddPinContext(ctx context.Context, channel string, item ItemRef) error { values := url.Values{ "channel": {channel}, @@ -41,12 +43,14 @@ func (api *Client) AddPinContext(ctx context.Context, channel string, item ItemR return response.Err() } -// RemovePin un-pins an item from a channel +// RemovePin un-pins an item from a channel. +// For more details, see RemovePinContext documentation. func (api *Client) RemovePin(channel string, item ItemRef) error { return api.RemovePinContext(context.Background(), channel, item) } -// RemovePinContext un-pins an item from a channel with a custom context +// RemovePinContext un-pins an item from a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/pins.remove func (api *Client) RemovePinContext(ctx context.Context, channel string, item ItemRef) error { values := url.Values{ "channel": {channel}, @@ -71,11 +75,13 @@ func (api *Client) RemovePinContext(ctx context.Context, channel string, item It } // ListPins returns information about the items a user reacted to. +// For more details, see ListPinsContext documentation. func (api *Client) ListPins(channel string) ([]Item, *Paging, error) { return api.ListPinsContext(context.Background(), channel) } // ListPinsContext returns information about the items a user reacted to with a custom context. +// Slack API docs: https://api.slack.com/methods/pins.list func (api *Client) ListPinsContext(ctx context.Context, channel string) ([]Item, *Paging, error) { values := url.Values{ "channel": {channel}, diff --git a/reactions.go b/reactions.go index 39aa5ba1f..240f5ba92 100644 --- a/reactions.go +++ b/reactions.go @@ -129,11 +129,13 @@ func (res listReactionsResponseFull) extractReactedItems() []ReactedItem { } // AddReaction adds a reaction emoji to a message, file or file comment. +// For more details, see AddReactionContext documentation. func (api *Client) AddReaction(name string, item ItemRef) error { return api.AddReactionContext(context.Background(), name, item) } // AddReactionContext adds a reaction emoji to a message, file or file comment with a custom context. +// Slack API docs: https://api.slack.com/methods/reactions.add func (api *Client) AddReactionContext(ctx context.Context, name string, item ItemRef) error { values := url.Values{ "token": {api.token}, @@ -163,11 +165,13 @@ func (api *Client) AddReactionContext(ctx context.Context, name string, item Ite } // RemoveReaction removes a reaction emoji from a message, file or file comment. +// For more details, see RemoveReactionContext documentation. func (api *Client) RemoveReaction(name string, item ItemRef) error { return api.RemoveReactionContext(context.Background(), name, item) } // RemoveReactionContext removes a reaction emoji from a message, file or file comment with a custom context. +// Slack API docs: https://api.slack.com/methods/reactions.remove func (api *Client) RemoveReactionContext(ctx context.Context, name string, item ItemRef) error { values := url.Values{ "token": {api.token}, @@ -197,11 +201,13 @@ func (api *Client) RemoveReactionContext(ctx context.Context, name string, item } // GetReactions returns details about the reactions on an item. +// For more details, see GetReactionsContext documentation. func (api *Client) GetReactions(item ItemRef, params GetReactionsParameters) ([]ItemReaction, error) { return api.GetReactionsContext(context.Background(), item, params) } -// GetReactionsContext returns details about the reactions on an item with a custom context +// GetReactionsContext returns details about the reactions on an item with a custom context. +// Slack API docs: https://api.slack.com/methods/reactions.get func (api *Client) GetReactionsContext(ctx context.Context, item ItemRef, params GetReactionsParameters) ([]ItemReaction, error) { values := url.Values{ "token": {api.token}, @@ -235,11 +241,13 @@ func (api *Client) GetReactionsContext(ctx context.Context, item ItemRef, params } // ListReactions returns information about the items a user reacted to. +// For more details, see ListReactionsContext documentation. func (api *Client) ListReactions(params ListReactionsParameters) ([]ReactedItem, *Paging, error) { return api.ListReactionsContext(context.Background(), params) } // ListReactionsContext returns information about the items a user reacted to with a custom context. +// Slack API docs: https://api.slack.com/methods/reactions.list func (api *Client) ListReactionsContext(ctx context.Context, params ListReactionsParameters) ([]ReactedItem, *Paging, error) { values := url.Values{ "token": {api.token}, diff --git a/reminders.go b/reminders.go index 5543a1577..e025bc9b6 100644 --- a/reminders.go +++ b/reminders.go @@ -46,15 +46,13 @@ func (api *Client) doReminders(ctx context.Context, path string, values url.Valu } // ListReminders lists all the reminders created by or for the authenticated user -// -// See https://api.slack.com/methods/reminders.list +// For more details, see ListRemindersContext documentation. func (api *Client) ListReminders() ([]*Reminder, error) { return api.ListRemindersContext(context.Background()) } -// ListRemindersContext lists all the reminders created by or for the authenticated user with a custom context -// -// For more details, see ListReminders documentation. +// ListRemindersContext lists all the reminders created by or for the authenticated user with a custom context. +// Slack API docs: https://api.slack.com/methods/reminders.list func (api *Client) ListRemindersContext(ctx context.Context) ([]*Reminder, error) { values := url.Values{ "token": {api.token}, @@ -63,17 +61,14 @@ func (api *Client) ListRemindersContext(ctx context.Context) ([]*Reminder, error } // AddChannelReminder adds a reminder for a channel. -// -// See https://api.slack.com/methods/reminders.add (NOTE: the ability to set -// reminders on a channel is currently undocumented but has been tested to -// work) +// For more details, see AddChannelReminderContext documentation. func (api *Client) AddChannelReminder(channelID, text, time string) (*Reminder, error) { return api.AddChannelReminderContext(context.Background(), channelID, text, time) } // AddChannelReminderContext adds a reminder for a channel with a custom context -// -// For more details, see AddChannelReminder documentation. +// NOTE: the ability to set reminders on a channel is currently undocumented but has been tested to work. +// Slack API docs: https://api.slack.com/methods/reminders.add func (api *Client) AddChannelReminderContext(ctx context.Context, channelID, text, time string) (*Reminder, error) { values := url.Values{ "token": {api.token}, @@ -85,17 +80,13 @@ func (api *Client) AddChannelReminderContext(ctx context.Context, channelID, tex } // AddUserReminder adds a reminder for a user. -// -// See https://api.slack.com/methods/reminders.add (NOTE: the ability to set -// reminders on a channel is currently undocumented but has been tested to -// work) +// For more details, see AddUserReminderContext documentation. func (api *Client) AddUserReminder(userID, text, time string) (*Reminder, error) { return api.AddUserReminderContext(context.Background(), userID, text, time) } // AddUserReminderContext adds a reminder for a user with a custom context -// -// For more details, see AddUserReminder documentation. +// Slack API docs: https://api.slack.com/methods/reminders.add func (api *Client) AddUserReminderContext(ctx context.Context, userID, text, time string) (*Reminder, error) { values := url.Values{ "token": {api.token}, @@ -107,15 +98,13 @@ func (api *Client) AddUserReminderContext(ctx context.Context, userID, text, tim } // DeleteReminder deletes an existing reminder. -// -// See https://api.slack.com/methods/reminders.delete +// For more details, see DeleteReminderContext documentation. func (api *Client) DeleteReminder(id string) error { return api.DeleteReminderContext(context.Background(), id) } // DeleteReminderContext deletes an existing reminder with a custom context -// -// For more details, see DeleteReminder documentation. +// Slack API docs: https://api.slack.com/methods/reminders.delete func (api *Client) DeleteReminderContext(ctx context.Context, id string) error { values := url.Values{ "token": {api.token}, diff --git a/remotefiles.go b/remotefiles.go index 8a908a8f3..0467d7e7c 100644 --- a/remotefiles.go +++ b/remotefiles.go @@ -97,14 +97,13 @@ func (api *Client) remoteFileRequest(ctx context.Context, path string, values ur } // AddRemoteFile adds a remote file. Unlike regular files, remote files must be explicitly shared. -// For more details: -// https://api.slack.com/methods/files.remote.add +// For more details see the AddRemoteFileContext documentation. func (api *Client) AddRemoteFile(params RemoteFileParameters) (*RemoteFile, error) { return api.AddRemoteFileContext(context.Background(), params) } // AddRemoteFileContext adds a remote file and setting a custom context -// For more details see the AddRemoteFile documentation. +// Slack API docs: https://api.slack.com/methods/files.remote.add func (api *Client) AddRemoteFileContext(ctx context.Context, params RemoteFileParameters) (remotefile *RemoteFile, err error) { if params.ExternalID == "" || params.ExternalURL == "" || params.Title == "" { return nil, ErrParametersMissing @@ -138,14 +137,13 @@ func (api *Client) AddRemoteFileContext(ctx context.Context, params RemoteFilePa } // ListRemoteFiles retrieves all remote files according to the parameters given. Uses cursor based pagination. -// For more details: -// https://api.slack.com/methods/files.remote.list +// For more details see the ListRemoteFilesContext documentation. func (api *Client) ListRemoteFiles(params ListRemoteFilesParameters) ([]RemoteFile, error) { return api.ListRemoteFilesContext(context.Background(), params) } // ListRemoteFilesContext retrieves all remote files according to the parameters given with a custom context. Uses cursor based pagination. -// For more details see the ListRemoteFiles documentation. +// Slack API docs: https://api.slack.com/methods/files.remote.list func (api *Client) ListRemoteFilesContext(ctx context.Context, params ListRemoteFilesParameters) ([]RemoteFile, error) { values := url.Values{ "token": {api.token}, @@ -177,14 +175,13 @@ func (api *Client) ListRemoteFilesContext(ctx context.Context, params ListRemote } // GetRemoteFileInfo retrieves the complete remote file information. -// For more details: -// https://api.slack.com/methods/files.remote.info +// For more details see the GetRemoteFileInfoContext documentation. func (api *Client) GetRemoteFileInfo(externalID, fileID string) (remotefile *RemoteFile, err error) { return api.GetRemoteFileInfoContext(context.Background(), externalID, fileID) } // GetRemoteFileInfoContext retrieves the complete remote file information given with a custom context. -// For more details see the GetRemoteFileInfo documentation. +// Slack API docs: https://api.slack.com/methods/files.remote.info func (api *Client) GetRemoteFileInfoContext(ctx context.Context, externalID, fileID string) (remotefile *RemoteFile, err error) { if fileID == "" && externalID == "" { return nil, fmt.Errorf("either externalID or fileID is required") @@ -208,15 +205,14 @@ func (api *Client) GetRemoteFileInfoContext(ctx context.Context, externalID, fil return &response.RemoteFile, err } -// ShareRemoteFile shares a remote file to channels -// For more details: -// https://api.slack.com/methods/files.remote.share +// ShareRemoteFile shares a remote file to channels. +// For more details see the ShareRemoteFileContext documentation. func (api *Client) ShareRemoteFile(channels []string, externalID, fileID string) (file *RemoteFile, err error) { return api.ShareRemoteFileContext(context.Background(), channels, externalID, fileID) } // ShareRemoteFileContext shares a remote file to channels with a custom context. -// For more details see the ShareRemoteFile documentation. +// Slack API docs: https://api.slack.com/methods/files.remote.share func (api *Client) ShareRemoteFileContext(ctx context.Context, channels []string, externalID, fileID string) (file *RemoteFile, err error) { if channels == nil || len(channels) == 0 { return nil, ErrParametersMissing @@ -241,15 +237,14 @@ func (api *Client) ShareRemoteFileContext(ctx context.Context, channels []string return &response.RemoteFile, err } -// UpdateRemoteFile updates a remote file -// For more details: -// https://api.slack.com/methods/files.remote.update +// UpdateRemoteFile updates a remote file. +// For more details see the UpdateRemoteFileContext documentation. func (api *Client) UpdateRemoteFile(fileID string, params RemoteFileParameters) (remotefile *RemoteFile, err error) { return api.UpdateRemoteFileContext(context.Background(), fileID, params) } -// UpdateRemoteFileContext updates a remote file with a custom context -// For more details see the UpdateRemoteFile documentation. +// UpdateRemoteFileContext updates a remote file with a custom context. +// Slack API docs: https://api.slack.com/methods/files.remote.update func (api *Client) UpdateRemoteFileContext(ctx context.Context, fileID string, params RemoteFileParameters) (remotefile *RemoteFile, err error) { response := &remoteFileResponseFull{} values := url.Values{ @@ -287,14 +282,13 @@ func (api *Client) UpdateRemoteFileContext(ctx context.Context, fileID string, p } // RemoveRemoteFile removes a remote file. -// For more details: -// https://api.slack.com/methods/files.remote.remove +// For more information see the RemoveRemoteFileContext documentation. func (api *Client) RemoveRemoteFile(externalID, fileID string) (err error) { return api.RemoveRemoteFileContext(context.Background(), externalID, fileID) } // RemoveRemoteFileContext removes a remote file with a custom context -// For more information see the RemoveRemoteFiles documentation. +// Slack API docs: https://api.slack.com/methods/files.remote.remove func (api *Client) RemoveRemoteFileContext(ctx context.Context, externalID, fileID string) (err error) { if fileID == "" && externalID == "" { return fmt.Errorf("either externalID or fileID is required") diff --git a/stars.go b/stars.go index 6e0ebbe32..51926854e 100644 --- a/stars.go +++ b/stars.go @@ -36,12 +36,14 @@ func NewStarsParameters() StarsParameters { } } -// AddStar stars an item in a channel +// AddStar stars an item in a channel. +// For more information see the AddStarContext documentation. func (api *Client) AddStar(channel string, item ItemRef) error { return api.AddStarContext(context.Background(), channel, item) } -// AddStarContext stars an item in a channel with a custom context +// AddStarContext stars an item in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/stars.add func (api *Client) AddStarContext(ctx context.Context, channel string, item ItemRef) error { values := url.Values{ "channel": {channel}, @@ -65,12 +67,14 @@ func (api *Client) AddStarContext(ctx context.Context, channel string, item Item return response.Err() } -// RemoveStar removes a starred item from a channel +// RemoveStar removes a starred item from a channel. +// For more information see the RemoveStarContext documentation. func (api *Client) RemoveStar(channel string, item ItemRef) error { return api.RemoveStarContext(context.Background(), channel, item) } -// RemoveStarContext removes a starred item from a channel with a custom context +// RemoveStarContext removes a starred item from a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/stars.remove func (api *Client) RemoveStarContext(ctx context.Context, channel string, item ItemRef) error { values := url.Values{ "channel": {channel}, @@ -94,12 +98,14 @@ func (api *Client) RemoveStarContext(ctx context.Context, channel string, item I return response.Err() } -// ListStars returns information about the stars a user added +// ListStars returns information about the stars a user added. +// For more information see the ListStarsContext documentation. func (api *Client) ListStars(params StarsParameters) ([]Item, *Paging, error) { return api.ListStarsContext(context.Background(), params) } -// ListStarsContext returns information about the stars a user added with a custom context +// ListStarsContext returns information about the stars a user added with a custom context. +// Slack API docs: https://api.slack.com/methods/stars.list func (api *Client) ListStarsContext(ctx context.Context, params StarsParameters) ([]Item, *Paging, error) { values := url.Values{ "token": {api.token}, @@ -147,7 +153,6 @@ func (api *Client) GetStarred(params StarsParameters) ([]StarredItem, *Paging, e } // GetStarredContext returns a list of StarredItem items with a custom context -// // For more details see GetStarred func (api *Client) GetStarredContext(ctx context.Context, params StarsParameters) ([]StarredItem, *Paging, error) { items, paging, err := api.ListStarsContext(ctx, params) diff --git a/team.go b/team.go index 40a0f00ad..4e890c2be 100644 --- a/team.go +++ b/team.go @@ -125,12 +125,14 @@ func (api *Client) teamProfileRequest(ctx context.Context, client httpClient, pa return response, response.Err() } -// GetTeamInfo gets the Team Information of the user +// GetTeamInfo gets the Team Information of the user. +// For more information see the GetTeamInfoContext documentation. func (api *Client) GetTeamInfo() (*TeamInfo, error) { return api.GetTeamInfoContext(context.Background()) } -// GetOtherTeamInfoContext gets Team information for any team with a custom context +// GetOtherTeamInfoContext gets Team information for any team with a custom context. +// Slack API docs: https://api.slack.com/methods/team.info func (api *Client) GetOtherTeamInfoContext(ctx context.Context, team string) (*TeamInfo, error) { if team == "" { return api.GetTeamInfoContext(ctx) @@ -146,12 +148,14 @@ func (api *Client) GetOtherTeamInfoContext(ctx context.Context, team string) (*T return &response.Team, nil } -// GetOtherTeamInfo gets Team information for any team +// GetOtherTeamInfo gets Team information for any team. +// For more information see the GetOtherTeamInfoContext documentation. func (api *Client) GetOtherTeamInfo(team string) (*TeamInfo, error) { return api.GetOtherTeamInfoContext(context.Background(), team) } -// GetTeamInfoContext gets the Team Information of the user with a custom context +// GetTeamInfoContext gets the Team Information of the user with a custom context. +// Slack API docs: https://api.slack.com/methods/team.info func (api *Client) GetTeamInfoContext(ctx context.Context) (*TeamInfo, error) { values := url.Values{ "token": {api.token}, @@ -164,12 +168,14 @@ func (api *Client) GetTeamInfoContext(ctx context.Context) (*TeamInfo, error) { return &response.Team, nil } -// GetTeamProfile gets the Team Profile settings of the user +// GetTeamProfile gets the Team Profile settings of the user. +// For more information see the GetTeamProfileContext documentation. func (api *Client) GetTeamProfile(teamID ...string) (*TeamProfile, error) { return api.GetTeamProfileContext(context.Background(), teamID...) } -// GetTeamProfileContext gets the Team Profile settings of the user with a custom context +// GetTeamProfileContext gets the Team Profile settings of the user with a custom context. +// Slack API docs: https://api.slack.com/methods/team.profile.get func (api *Client) GetTeamProfileContext(ctx context.Context, teamID ...string) (*TeamProfile, error) { values := url.Values{ "token": {api.token}, @@ -185,12 +191,14 @@ func (api *Client) GetTeamProfileContext(ctx context.Context, teamID ...string) return &response.Profile, nil } -// GetAccessLogs retrieves a page of logins according to the parameters given +// GetAccessLogs retrieves a page of logins according to the parameters given. +// For more information see the GetAccessLogsContext documentation. func (api *Client) GetAccessLogs(params AccessLogParameters) ([]Login, *Paging, error) { return api.GetAccessLogsContext(context.Background(), params) } -// GetAccessLogsContext retrieves a page of logins according to the parameters given with a custom context +// GetAccessLogsContext retrieves a page of logins according to the parameters given with a custom context. +// Slack API docs: https://api.slack.com/methods/team.accessLogs func (api *Client) GetAccessLogsContext(ctx context.Context, params AccessLogParameters) ([]Login, *Paging, error) { values := url.Values{ "token": {api.token}, @@ -217,12 +225,14 @@ type GetBillableInfoParams struct { TeamID string } -// GetBillableInfo ... +// GetBillableInfo gets the billable users information of the team. +// For more information see the GetBillableInfoContext documentation. func (api *Client) GetBillableInfo(params GetBillableInfoParams) (map[string]BillingActive, error) { return api.GetBillableInfoContext(context.Background(), params) } -// GetBillableInfoContext ... +// GetBillableInfoContext gets the billable users information of the team with a custom context. +// Slack API docs: https://api.slack.com/methods/team.billableInfo func (api *Client) GetBillableInfoContext(ctx context.Context, params GetBillableInfoParams) (map[string]BillingActive, error) { values := url.Values{ "token": {api.token}, diff --git a/tokens.go b/tokens.go index 4b83beebb..49bbde9b1 100644 --- a/tokens.go +++ b/tokens.go @@ -5,12 +5,14 @@ import ( "net/url" ) -// RotateTokens exchanges a refresh token for a new app configuration token +// RotateTokens exchanges a refresh token for a new app configuration token. +// For more information see the RotateTokensContext documentation. func (api *Client) RotateTokens(configToken string, refreshToken string) (*TokenResponse, error) { return api.RotateTokensContext(context.Background(), configToken, refreshToken) } -// RotateTokensContext exchanges a refresh token for a new app configuration token with a custom context +// RotateTokensContext exchanges a refresh token for a new app configuration token with a custom context. +// Slack API docs: https://api.slack.com/methods/tooling.tokens.rotate func (api *Client) RotateTokensContext(ctx context.Context, configToken string, refreshToken string) (*TokenResponse, error) { if configToken == "" { configToken = api.configToken diff --git a/usergroups.go b/usergroups.go index 7244aba58..0c2ec4c9a 100644 --- a/usergroups.go +++ b/usergroups.go @@ -50,12 +50,14 @@ func (api *Client) userGroupRequest(ctx context.Context, path string, values url return response, response.Err() } -// CreateUserGroup creates a new user group +// CreateUserGroup creates a new user group. +// For more information see the CreateUserGroupContext documentation. func (api *Client) CreateUserGroup(userGroup UserGroup) (UserGroup, error) { return api.CreateUserGroupContext(context.Background(), userGroup) } -// CreateUserGroupContext creates a new user group with a custom context +// CreateUserGroupContext creates a new user group with a custom context. +// Slack API docs: https://api.slack.com/methods/usergroups.create func (api *Client) CreateUserGroupContext(ctx context.Context, userGroup UserGroup) (UserGroup, error) { values := url.Values{ "token": {api.token}, @@ -85,12 +87,14 @@ func (api *Client) CreateUserGroupContext(ctx context.Context, userGroup UserGro return response.UserGroup, nil } -// DisableUserGroup disables an existing user group +// DisableUserGroup disables an existing user group. +// For more information see the DisableUserGroupContext documentation. func (api *Client) DisableUserGroup(userGroup string) (UserGroup, error) { return api.DisableUserGroupContext(context.Background(), userGroup) } -// DisableUserGroupContext disables an existing user group with a custom context +// DisableUserGroupContext disables an existing user group with a custom context. +// Slack API docs: https://api.slack.com/methods/usergroups.disable func (api *Client) DisableUserGroupContext(ctx context.Context, userGroup string) (UserGroup, error) { values := url.Values{ "token": {api.token}, @@ -104,12 +108,14 @@ func (api *Client) DisableUserGroupContext(ctx context.Context, userGroup string return response.UserGroup, nil } -// EnableUserGroup enables an existing user group +// EnableUserGroup enables an existing user group. +// For more information see the EnableUserGroupContext documentation. func (api *Client) EnableUserGroup(userGroup string) (UserGroup, error) { return api.EnableUserGroupContext(context.Background(), userGroup) } -// EnableUserGroupContext enables an existing user group with a custom context +// EnableUserGroupContext enables an existing user group with a custom context. +// Slack API docs: https://api.slack.com/methods/usergroups.enable func (api *Client) EnableUserGroupContext(ctx context.Context, userGroup string) (UserGroup, error) { values := url.Values{ "token": {api.token}, @@ -161,12 +167,14 @@ type GetUserGroupsParams struct { IncludeUsers bool } -// GetUserGroups returns a list of user groups for the team +// GetUserGroups returns a list of user groups for the team. +// For more information see the GetUserGroupsContext documentation. func (api *Client) GetUserGroups(options ...GetUserGroupsOption) ([]UserGroup, error) { return api.GetUserGroupsContext(context.Background(), options...) } -// GetUserGroupsContext returns a list of user groups for the team with a custom context +// GetUserGroupsContext returns a list of user groups for the team with a custom context. +// Slack API docs: https://api.slack.com/methods/usergroups.list func (api *Client) GetUserGroupsContext(ctx context.Context, options ...GetUserGroupsOption) ([]UserGroup, error) { params := GetUserGroupsParams{} @@ -236,12 +244,14 @@ type UpdateUserGroupsParams struct { Channels *[]string } -// UpdateUserGroup will update an existing user group +// UpdateUserGroup will update an existing user group. +// For more information see the UpdateUserGroupContext documentation. func (api *Client) UpdateUserGroup(userGroupID string, options ...UpdateUserGroupsOption) (UserGroup, error) { return api.UpdateUserGroupContext(context.Background(), userGroupID, options...) } -// UpdateUserGroupContext will update an existing user group with a custom context +// UpdateUserGroupContext will update an existing user group with a custom context. +// Slack API docs: https://api.slack.com/methods/usergroups.update func (api *Client) UpdateUserGroupContext(ctx context.Context, userGroupID string, options ...UpdateUserGroupsOption) (UserGroup, error) { params := UpdateUserGroupsParams{} @@ -277,12 +287,14 @@ func (api *Client) UpdateUserGroupContext(ctx context.Context, userGroupID strin return response.UserGroup, nil } -// GetUserGroupMembers will retrieve the current list of users in a group +// GetUserGroupMembers will retrieve the current list of users in a group. +// For more information see the GetUserGroupMembersContext documentation. func (api *Client) GetUserGroupMembers(userGroup string) ([]string, error) { return api.GetUserGroupMembersContext(context.Background(), userGroup) } -// GetUserGroupMembersContext will retrieve the current list of users in a group with a custom context +// GetUserGroupMembersContext will retrieve the current list of users in a group with a custom context. +// Slack API docs: https://api.slack.com/methods/usergroups.users.list func (api *Client) GetUserGroupMembersContext(ctx context.Context, userGroup string) ([]string, error) { values := url.Values{ "token": {api.token}, @@ -296,12 +308,14 @@ func (api *Client) GetUserGroupMembersContext(ctx context.Context, userGroup str return response.Users, nil } -// UpdateUserGroupMembers will update the members of an existing user group +// UpdateUserGroupMembers will update the members of an existing user group. +// For more information see the UpdateUserGroupMembersContext documentation. func (api *Client) UpdateUserGroupMembers(userGroup string, members string) (UserGroup, error) { return api.UpdateUserGroupMembersContext(context.Background(), userGroup, members) } -// UpdateUserGroupMembersContext will update the members of an existing user group with a custom context +// UpdateUserGroupMembersContext will update the members of an existing user group with a custom context. +// Slack API docs: https://api.slack.com/methods/usergroups.update func (api *Client) UpdateUserGroupMembersContext(ctx context.Context, userGroup string, members string) (UserGroup, error) { values := url.Values{ "token": {api.token}, diff --git a/users.go b/users.go index a3d32b240..b51b1721f 100644 --- a/users.go +++ b/users.go @@ -227,11 +227,13 @@ func (api *Client) userRequest(ctx context.Context, path string, values url.Valu } // GetUserPresence will retrieve the current presence status of given user. +// For more information see the GetUserPresenceContext documentation. func (api *Client) GetUserPresence(user string) (*UserPresence, error) { return api.GetUserPresenceContext(context.Background(), user) } // GetUserPresenceContext will retrieve the current presence status of given user with a custom context. +// Slack API docs: https://api.slack.com/methods/users.getPresence func (api *Client) GetUserPresenceContext(ctx context.Context, user string) (*UserPresence, error) { values := url.Values{ "token": {api.token}, @@ -245,12 +247,14 @@ func (api *Client) GetUserPresenceContext(ctx context.Context, user string) (*Us return &response.UserPresence, nil } -// GetUserInfo will retrieve the complete user information +// GetUserInfo will retrieve the complete user information. +// For more information see the GetUserInfoContext documentation. func (api *Client) GetUserInfo(user string) (*User, error) { return api.GetUserInfoContext(context.Background(), user) } -// GetUserInfoContext will retrieve the complete user information with a custom context +// GetUserInfoContext will retrieve the complete user information with a custom context. +// Slack API docs: https://api.slack.com/methods/users.info func (api *Client) GetUserInfoContext(ctx context.Context, user string) (*User, error) { values := url.Values{ "token": {api.token}, @@ -265,12 +269,14 @@ func (api *Client) GetUserInfoContext(ctx context.Context, user string) (*User, return &response.User, nil } -// GetUsersInfo will retrieve the complete multi-users information +// GetUsersInfo will retrieve the complete multi-users information. +// For more information see the GetUsersInfoContext documentation. func (api *Client) GetUsersInfo(users ...string) (*[]User, error) { return api.GetUsersInfoContext(context.Background(), users...) } -// GetUsersInfoContext will retrieve the complete multi-users information with a custom context +// GetUsersInfoContext will retrieve the complete multi-users information with a custom context. +// Slack API docs: https://api.slack.com/methods/users.info func (api *Client) GetUsersInfoContext(ctx context.Context, users ...string) (*[]User, error) { values := url.Values{ "token": {api.token}, @@ -407,12 +413,14 @@ func (api *Client) GetUsersContext(ctx context.Context, options ...GetUsersOptio return results, p.Failure(err) } -// GetUserByEmail will retrieve the complete user information by email +// GetUserByEmail will retrieve the complete user information by email. +// For more information see the GetUserByEmailContext documentation. func (api *Client) GetUserByEmail(email string) (*User, error) { return api.GetUserByEmailContext(context.Background(), email) } -// GetUserByEmailContext will retrieve the complete user information by email with a custom context +// GetUserByEmailContext will retrieve the complete user information by email with a custom context. +// Slack API docs: https://api.slack.com/methods/users.lookupByEmail func (api *Client) GetUserByEmailContext(ctx context.Context, email string) (*User, error) { values := url.Values{ "token": {api.token}, @@ -425,12 +433,14 @@ func (api *Client) GetUserByEmailContext(ctx context.Context, email string) (*Us return &response.User, nil } -// SetUserAsActive marks the currently authenticated user as active +// SetUserAsActive marks the currently authenticated user as active. +// For more information see the SetUserAsActiveContext documentation. func (api *Client) SetUserAsActive() error { return api.SetUserAsActiveContext(context.Background()) } -// SetUserAsActiveContext marks the currently authenticated user as active with a custom context +// SetUserAsActiveContext marks the currently authenticated user as active with a custom context. +// Slack API docs: https://api.slack.com/methods/users.setActive func (api *Client) SetUserAsActiveContext(ctx context.Context) (err error) { values := url.Values{ "token": {api.token}, @@ -440,12 +450,14 @@ func (api *Client) SetUserAsActiveContext(ctx context.Context) (err error) { return err } -// SetUserPresence changes the currently authenticated user presence +// SetUserPresence changes the currently authenticated user presence. +// For more information see the SetUserPresenceContext documentation. func (api *Client) SetUserPresence(presence string) error { return api.SetUserPresenceContext(context.Background(), presence) } -// SetUserPresenceContext changes the currently authenticated user presence with a custom context +// SetUserPresenceContext changes the currently authenticated user presence with a custom context. +// Slack API docs: https://api.slack.com/methods/users.setPresence func (api *Client) SetUserPresenceContext(ctx context.Context, presence string) error { values := url.Values{ "token": {api.token}, @@ -456,12 +468,14 @@ func (api *Client) SetUserPresenceContext(ctx context.Context, presence string) return err } -// GetUserIdentity will retrieve user info available per identity scopes +// GetUserIdentity will retrieve user info available per identity scopes. +// For more information see the GetUserIdentityContext documentation. func (api *Client) GetUserIdentity() (*UserIdentityResponse, error) { return api.GetUserIdentityContext(context.Background()) } -// GetUserIdentityContext will retrieve user info available per identity scopes with a custom context +// GetUserIdentityContext will retrieve user info available per identity scopes with a custom context. +// Slack API docs: https://api.slack.com/methods/users.identity func (api *Client) GetUserIdentityContext(ctx context.Context) (response *UserIdentityResponse, err error) { values := url.Values{ "token": {api.token}, @@ -480,12 +494,14 @@ func (api *Client) GetUserIdentityContext(ctx context.Context) (response *UserId return response, nil } -// SetUserPhoto changes the currently authenticated user's profile image +// SetUserPhoto changes the currently authenticated user's profile image. +// For more information see the SetUserPhotoContext documentation. func (api *Client) SetUserPhoto(image string, params UserSetPhotoParams) error { return api.SetUserPhotoContext(context.Background(), image, params) } -// SetUserPhotoContext changes the currently authenticated user's profile image using a custom context +// SetUserPhotoContext changes the currently authenticated user's profile image using a custom context. +// Slack API docs: https://api.slack.com/methods/users.setPhoto func (api *Client) SetUserPhotoContext(ctx context.Context, image string, params UserSetPhotoParams) (err error) { response := &SlackResponse{} values := url.Values{} @@ -507,12 +523,14 @@ func (api *Client) SetUserPhotoContext(ctx context.Context, image string, params return response.Err() } -// DeleteUserPhoto deletes the current authenticated user's profile image +// DeleteUserPhoto deletes the current authenticated user's profile image. +// For more information see the DeleteUserPhotoContext documentation. func (api *Client) DeleteUserPhoto() error { return api.DeleteUserPhotoContext(context.Background()) } -// DeleteUserPhotoContext deletes the current authenticated user's profile image with a custom context +// DeleteUserPhotoContext deletes the current authenticated user's profile image with a custom context. +// Slack API docs: https://api.slack.com/methods/users.deletePhoto func (api *Client) DeleteUserPhotoContext(ctx context.Context) (err error) { response := &SlackResponse{} values := url.Values{ @@ -528,13 +546,13 @@ func (api *Client) DeleteUserPhotoContext(ctx context.Context) (err error) { } // SetUserRealName changes the currently authenticated user's realName -// -// For more information see SetUserRealNameContextWithUser +// For more information see the SetUserRealNameContextWithUser documentation. func (api *Client) SetUserRealName(realName string) error { return api.SetUserRealNameContextWithUser(context.Background(), "", realName) } -// SetUserRealNameContextWithUser will set a real name for the provided user with a custom context +// SetUserRealNameContextWithUser will set a real name for the provided user with a custom context. +// Slack API docs: https://api.slack.com/methods/users.profile.set func (api *Client) SetUserRealNameContextWithUser(ctx context.Context, user, realName string) error { profile, err := json.Marshal( &struct { @@ -566,20 +584,22 @@ func (api *Client) SetUserRealNameContextWithUser(ctx context.Context, user, rea return response.Err() } -// SetUserCustomFields sets Custom Profile fields on the provided users account. Due to the non-repeating elements -// within the request, a map fields is required. The key in the map signifies the field that will be updated. -// -// Note: You may need to change the way the custom field is populated within the Profile section of the Admin Console from -// SCIM or User Entered to API. -// -// See GetTeamProfile for information to retrieve possible fields for your account. +// SetUserCustomFields sets Custom Profile fields on the provided users account. +// For more information see the SetUserCustomFieldsContext documentation. func (api *Client) SetUserCustomFields(userID string, customFields map[string]UserProfileCustomField) error { return api.SetUserCustomFieldsContext(context.Background(), userID, customFields) } -// SetUserCustomFieldsContext will set a users custom profile field with context. +// SetUserCustomFieldsContext sets Custom Profile fields on the provided users account. +// Due to the non-repeating elements within the request, a map fields is required. +// The key in the map signifies the field that will be updated. +// +// Note: You may need to change the way the custom field is populated within the Profile section of the Admin Console +// from SCIM or User Entered to API. // -// For more information see SetUserCustomFields +// See GetTeamProfile for information to retrieve possible fields for your account. +// +// Slack API docs: https://api.slack.com/methods/users.profile.set func (api *Client) SetUserCustomFieldsContext(ctx context.Context, userID string, customFields map[string]UserProfileCustomField) error { // Convert data to data type with custom marshall / unmarshall @@ -615,32 +635,30 @@ func (api *Client) SetUserCustomFieldsContext(ctx context.Context, userID string } -// SetUserCustomStatus will set a custom status and emoji for the currently -// authenticated user. If statusEmoji is "" and statusText is not, the Slack API -// will automatically set it to ":speech_balloon:". Otherwise, if both are "" -// the Slack API will unset the custom status/emoji. If statusExpiration is set to 0 -// the status will not expire. +// SetUserCustomStatus will set a custom status and emoji for the currently authenticated user. +// For more information see the SetUserCustomStatusContext documentation. func (api *Client) SetUserCustomStatus(statusText, statusEmoji string, statusExpiration int64) error { return api.SetUserCustomStatusContextWithUser(context.Background(), "", statusText, statusEmoji, statusExpiration) } -// SetUserCustomStatusContext will set a custom status and emoji for the currently authenticated user with a custom context -// -// For more information see SetUserCustomStatus +// SetUserCustomStatusContext will set a custom status and emoji for the currently authenticated user with a custom context. +// For more information see the SetUserCustomStatusContextWithUser documentation. func (api *Client) SetUserCustomStatusContext(ctx context.Context, statusText, statusEmoji string, statusExpiration int64) error { return api.SetUserCustomStatusContextWithUser(ctx, "", statusText, statusEmoji, statusExpiration) } // SetUserCustomStatusWithUser will set a custom status and emoji for the provided user. -// -// For more information see SetUserCustomStatus +// For more information see the SetUserCustomStatusContextWithUser documentation. func (api *Client) SetUserCustomStatusWithUser(user, statusText, statusEmoji string, statusExpiration int64) error { return api.SetUserCustomStatusContextWithUser(context.Background(), user, statusText, statusEmoji, statusExpiration) } -// SetUserCustomStatusContextWithUser will set a custom status and emoji for the provided user with a custom context +// SetUserCustomStatusContextWithUser will set a custom status and emoji for the currently authenticated user. +// If statusEmoji is "" and statusText is not, the Slack API will automatically set it to ":speech_balloon:". +// Otherwise, if both are "" the Slack API will unset the custom status/emoji. If statusExpiration is set to 0 +// the status will not expire. // -// For more information see SetUserCustomStatus +// Slack API docs: https://api.slack.com/methods/users.profile.set func (api *Client) SetUserCustomStatusContextWithUser(ctx context.Context, user, statusText, statusEmoji string, statusExpiration int64) error { // XXX(theckman): this anonymous struct is for making requests to the Slack // API for setting and unsetting a User's Custom Status/Emoji. To change @@ -705,6 +723,7 @@ type GetUserProfileParameters struct { } // GetUserProfile retrieves a user's profile information. +// For more information see the GetUserProfileContext documentation. func (api *Client) GetUserProfile(params *GetUserProfileParameters) (*UserProfile, error) { return api.GetUserProfileContext(context.Background(), params) } @@ -715,6 +734,7 @@ type getUserProfileResponse struct { } // GetUserProfileContext retrieves a user's profile information with a context. +// Slack API docs: https://api.slack.com/methods/users.profile.get func (api *Client) GetUserProfileContext(ctx context.Context, params *GetUserProfileParameters) (*UserProfile, error) { values := url.Values{"token": {api.token}} diff --git a/views.go b/views.go index e6a961788..0822d2cb2 100644 --- a/views.go +++ b/views.go @@ -155,6 +155,7 @@ type ViewResponse struct { } // OpenView opens a view for a user. +// For more information see the OpenViewContext documentation. func (api *Client) OpenView(triggerID string, view ModalViewRequest) (*ViewResponse, error) { return api.OpenViewContext(context.Background(), triggerID, view) } @@ -177,6 +178,7 @@ func ValidateUniqueBlockID(view ModalViewRequest) bool { } // OpenViewContext opens a view for a user with a custom context. +// Slack API docs: https://api.slack.com/methods/views.open func (api *Client) OpenViewContext( ctx context.Context, triggerID string, @@ -208,11 +210,13 @@ func (api *Client) OpenViewContext( } // PublishView publishes a static view for a user. +// For more information see the PublishViewContext documentation. func (api *Client) PublishView(userID string, view HomeTabViewRequest, hash string) (*ViewResponse, error) { return api.PublishViewContext(context.Background(), userID, view, hash) } // PublishViewContext publishes a static view for a user with a custom context. +// Slack API docs: https://api.slack.com/methods/views.publish func (api *Client) PublishViewContext( ctx context.Context, userID string, @@ -241,11 +245,13 @@ func (api *Client) PublishViewContext( } // PushView pushes a view onto the stack of a root view. +// For more information see the PushViewContext documentation. func (api *Client) PushView(triggerID string, view ModalViewRequest) (*ViewResponse, error) { return api.PushViewContext(context.Background(), triggerID, view) } -// PublishViewContext pushes a view onto the stack of a root view with a custom context. +// PushViewContext pushes a view onto the stack of a root view with a custom context. +// Slack API docs: https://api.slack.com/methods/views.push func (api *Client) PushViewContext( ctx context.Context, triggerID string, @@ -272,11 +278,13 @@ func (api *Client) PushViewContext( } // UpdateView updates an existing view. +// For more information see the UpdateViewContext documentation. func (api *Client) UpdateView(view ModalViewRequest, externalID, hash, viewID string) (*ViewResponse, error) { return api.UpdateViewContext(context.Background(), view, externalID, hash, viewID) } // UpdateViewContext updates an existing view with a custom context. +// Slack API docs: https://api.slack.com/methods/views.update func (api *Client) UpdateViewContext( ctx context.Context, view ModalViewRequest, diff --git a/workflow_step.go b/workflow_step.go index bcc892c5a..747a5dc00 100644 --- a/workflow_step.go +++ b/workflow_step.go @@ -44,12 +44,15 @@ func NewConfigurationModalRequest(blocks Blocks, privateMetaData string, externa } } +// SaveWorkflowStepConfiguration opens a configuration modal for a workflow step. +// For more information see the SaveWorkflowStepConfigurationContext documentation. func (api *Client) SaveWorkflowStepConfiguration(workflowStepEditID string, inputs *WorkflowStepInputs, outputs *[]WorkflowStepOutput) error { return api.SaveWorkflowStepConfigurationContext(context.Background(), workflowStepEditID, inputs, outputs) } +// SaveWorkflowStepConfigurationContext saves the configuration of a workflow step with a custom context. +// Slack API docs: https://api.slack.com/methods/workflows.updateStep func (api *Client) SaveWorkflowStepConfigurationContext(ctx context.Context, workflowStepEditID string, inputs *WorkflowStepInputs, outputs *[]WorkflowStepOutput) error { - // More information: https://api.slack.com/methods/workflows.updateStep wscr := WorkflowStepCompleteResponse{ WorkflowStepEditID: workflowStepEditID, Inputs: inputs, From ecfe504b60b03a18f8448f2407c8dd09ea07afcd Mon Sep 17 00:00:00 2001 From: Adam Rothman Date: Sun, 14 Jul 2024 18:48:09 -0700 Subject: [PATCH 254/606] docs: Add an example demonstrating correct usage of GetUsersPaginated (#1201) * Add an example demonstrating correct usage of GetUsersPaginated * Requeue GitHub Actions --------- Co-authored-by: Lorenzo Aiello --- examples/pagination/pagination.go | 59 +++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 examples/pagination/pagination.go diff --git a/examples/pagination/pagination.go b/examples/pagination/pagination.go new file mode 100644 index 000000000..265910137 --- /dev/null +++ b/examples/pagination/pagination.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/slack-go/slack" +) + +func getAllUserUIDs(ctx context.Context, client *slack.Client, pageSize int) ([]string, error) { + var uids []string + var err error + + pages := 0 + pager := client.GetUsersPaginated(slack.GetUsersOptionLimit(pageSize)) + for { + // Note reassignment of pager to the value returned by Next() + pager, err = pager.Next(ctx) + if failedErr := pager.Failure(err); failedErr != nil { + var rateLimited *slack.RateLimitedError + if errors.As(failedErr, &rateLimited) && rateLimited.Retryable() { + fmt.Println("Rate limited by Slack API; sleeping", rateLimited.RetryAfter) + select { + case <-ctx.Done(): + return uids, ctx.Err() + case <-time.After(rateLimited.RetryAfter): + continue + } + } + return uids, fmt.Errorf("paginating users: %w", failedErr) + } + if pager.Done(err) { + break + } + + for _, user := range pager.Users { + uids = append(uids, user.ID) + } + + pages++ + } + + fmt.Printf("Pagination complete after %d pages\n", pages) + + return uids, nil +} + +func main() { + client := slack.New("YOUR_TOKEN_HERE") + + uids, err := getAllUserUIDs(context.Background(), client, 1000) + if err != nil { + panic(err) + } + + fmt.Printf("Collected %d UIDs\n", len(uids)) +} From 7ddfd023913d6645ac77034e4cda0810e13a3c3f Mon Sep 17 00:00:00 2001 From: Lorenzo Aiello Date: Sun, 14 Jul 2024 18:48:20 -0700 Subject: [PATCH 255/606] docs: Adding an example for AddRemoteFile and DeleteFile (#1303) --- examples/files_remote/files_remote.go | 30 +++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 examples/files_remote/files_remote.go diff --git a/examples/files_remote/files_remote.go b/examples/files_remote/files_remote.go new file mode 100644 index 000000000..60bcfa967 --- /dev/null +++ b/examples/files_remote/files_remote.go @@ -0,0 +1,30 @@ +package main + +import ( + "context" + "fmt" + + "github.com/slack-go/slack" +) + +func main() { + api := slack.New("YOUR_TOKEN") + params := slack.RemoteFileParameters{ + Title: "My File", + ExternalID: "my-file-123", + ExternalURL: "https://raw.githubusercontent.com/slack-go/slack/master/README.md", + } + file, err := api.AddRemoteFileContext(context.Background(), params) + if err != nil { + fmt.Printf("%s\n", err) + return + } + fmt.Printf("Name: %s, URL: %s\n", file.Name, file.URLPrivate) + + err = api.DeleteFileContext(context.Background(), file.ID) + if err != nil { + fmt.Printf("%s\n", err) + return + } + fmt.Printf("File %s deleted successfully.\n", file.Name) +} From 1dfd1df1f07c71e47aee260ebb0b70f44b3276ea Mon Sep 17 00:00:00 2001 From: Lorenzo Aiello Date: Sun, 14 Jul 2024 18:48:28 -0700 Subject: [PATCH 256/606] docs: Adding an example for ConversationHistory (#1302) --- .../conversation_history.go | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 examples/conversation_history/conversation_history.go diff --git a/examples/conversation_history/conversation_history.go b/examples/conversation_history/conversation_history.go new file mode 100644 index 000000000..569c12d68 --- /dev/null +++ b/examples/conversation_history/conversation_history.go @@ -0,0 +1,23 @@ +package main + +import ( + "context" + "fmt" + + "github.com/slack-go/slack" +) + +func main() { + api := slack.New("YOUR_TOKEN") + params := slack.GetConversationHistoryParameters{ + ChannelID: "C0123456789", + } + messages, err := api.GetConversationHistoryContext(context.Background(), ¶ms) + if err != nil { + fmt.Printf("%s\n", err) + return + } + for _, message := range messages.Messages { + fmt.Printf("Message: %s\n", message.Attachments[0].Color) + } +} From b9d4317540003c4f14c15c0c1e02d6cb95e6f1fb Mon Sep 17 00:00:00 2001 From: Robert Fratto Date: Fri, 19 Jul 2024 23:13:34 -0400 Subject: [PATCH 257/606] slackevents: support metadata in MessageEvent (#1307) Support for receiving metadata when reading conversation history was first added in PR #1083. This commit extends metadata support to message events when connected to the Slack WebSocket API. Signed-off-by: Robert Fratto --- slackevents/inner_events.go | 2 ++ slackevents/inner_events_test.go | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index bc584260e..e0494fe41 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -276,6 +276,8 @@ type MessageEvent struct { Blocks slack.Blocks `json:"blocks,omitempty"` Attachments []slack.Attachment `json:"attachments,omitempty"` + Metadata slack.SlackMetadata `json:"metadata,omitempty"` + // Root is the message that was broadcast to the channel when the SubType is // thread_broadcast. If this is not a thread_broadcast message event, this // value is nil. diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index 20b8bff5c..71eb402f8 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -300,6 +300,12 @@ func TestMessageEvent(t *testing.T) { "ts": "1355517524.000000" } }, + "metadata": { + "event_type": "example", + "event_payload": { + "key": "value" + } + }, "previous_message": { "text": "Live long and prospect." } From 25fefc8d6f397d51ab310472936f4e25846a15ae Mon Sep 17 00:00:00 2001 From: Naka Masato Date: Fri, 16 Aug 2024 02:10:52 +0900 Subject: [PATCH 258/606] chore: replace ioutil with io or os package (#1310) --- chat.go | 6 +++--- chat_test.go | 10 +++++----- examples/dialog/dialog.go | 4 ++-- examples/eventsapi/events.go | 4 ++-- examples/modal/modal.go | 6 +++--- examples/slash/slash.go | 3 +-- examples/workflow_step/handler.go | 6 +++--- examples/workflow_step/middleware.go | 6 +++--- files_test.go | 4 ++-- misc.go | 5 ++--- reminders_test.go | 4 ++-- slacktest/handlers.go | 10 +++++----- users_test.go | 5 ++--- webhooks.go | 3 +-- 14 files changed, 36 insertions(+), 40 deletions(-) diff --git a/chat.go b/chat.go index 916d05a93..18f8e933d 100644 --- a/chat.go +++ b/chat.go @@ -4,7 +4,7 @@ import ( "bytes" "context" "encoding/json" - "io/ioutil" + "io" "net/http" "net/url" "regexp" @@ -226,11 +226,11 @@ func (api *Client) SendMessageContext(ctx context.Context, channelID string, opt } if api.Debug() { - reqBody, err := ioutil.ReadAll(req.Body) + reqBody, err := io.ReadAll(req.Body) if err != nil { return "", "", "", err } - req.Body = ioutil.NopCloser(bytes.NewBuffer(reqBody)) + req.Body = io.NopCloser(bytes.NewBuffer(reqBody)) api.Debugf("Sending request: %s", redactToken(reqBody)) } diff --git a/chat_test.go b/chat_test.go index ee93bddff..917ff965e 100644 --- a/chat_test.go +++ b/chat_test.go @@ -3,7 +3,7 @@ package slack import ( "bytes" "encoding/json" - "io/ioutil" + "io" "log" "net/http" "net/url" @@ -216,7 +216,7 @@ func TestPostMessage(t *testing.T) { t.Run(name, func(t *testing.T) { http.DefaultServeMux = new(http.ServeMux) http.HandleFunc(test.endpoint, func(rw http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { t.Errorf("unexpected error: %v", err) return @@ -242,7 +242,7 @@ func TestPostMessageWithBlocksWhenMsgOptionResponseURLApplied(t *testing.T) { http.DefaultServeMux = new(http.ServeMux) http.HandleFunc("/response-url", func(rw http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { t.Errorf("unexpected error: %v", err) return @@ -270,7 +270,7 @@ func TestPostMessageWithBlocksWhenMsgOptionResponseURLApplied(t *testing.T) { func TestPostMessageWhenMsgOptionReplaceOriginalApplied(t *testing.T) { http.DefaultServeMux = new(http.ServeMux) http.HandleFunc("/response-url", func(rw http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { t.Errorf("unexpected error: %v", err) return @@ -297,7 +297,7 @@ func TestPostMessageWhenMsgOptionReplaceOriginalApplied(t *testing.T) { func TestPostMessageWhenMsgOptionDeleteOriginalApplied(t *testing.T) { http.DefaultServeMux = new(http.ServeMux) http.HandleFunc("/response-url", func(rw http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { t.Errorf("unexpected error: %v", err) return diff --git a/examples/dialog/dialog.go b/examples/dialog/dialog.go index 4b4d6ab18..dc0d431c3 100644 --- a/examples/dialog/dialog.go +++ b/examples/dialog/dialog.go @@ -2,7 +2,7 @@ package main import ( "encoding/json" - "io/ioutil" + "io" "log" "net/http" "net/url" @@ -26,7 +26,7 @@ func handler(w http.ResponseWriter, r *http.Request) { // Read request body defer r.Body.Close() - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusInternalServerError) log.Printf("[ERROR] Fail to read request body: %v", err) diff --git a/examples/eventsapi/events.go b/examples/eventsapi/events.go index 25049ad07..b3bb70ab4 100644 --- a/examples/eventsapi/events.go +++ b/examples/eventsapi/events.go @@ -3,7 +3,7 @@ package main import ( "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "os" @@ -18,7 +18,7 @@ func main() { signingSecret := os.Getenv("SLACK_SIGNING_SECRET") http.HandleFunc("/events-endpoint", func(w http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) return diff --git a/examples/modal/modal.go b/examples/modal/modal.go index 155f2f714..13115a826 100644 --- a/examples/modal/modal.go +++ b/examples/modal/modal.go @@ -15,7 +15,7 @@ import ( "bytes" "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "github.com/slack-go/slack" @@ -95,13 +95,13 @@ func verifySigningSecret(r *http.Request) error { return err } - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { fmt.Println(err.Error()) return err } // Need to use r.Body again when unmarshalling SlashCommand and InteractionCallback - r.Body = ioutil.NopCloser(bytes.NewBuffer(body)) + r.Body = io.NopCloser(bytes.NewBuffer(body)) verifier.Write(body) if err = verifier.Ensure(); err != nil { diff --git a/examples/slash/slash.go b/examples/slash/slash.go index c70c865ff..e35ab10a8 100644 --- a/examples/slash/slash.go +++ b/examples/slash/slash.go @@ -5,7 +5,6 @@ import ( "flag" "fmt" "io" - "io/ioutil" "net/http" "github.com/slack-go/slack" @@ -27,7 +26,7 @@ func main() { return } - r.Body = ioutil.NopCloser(io.TeeReader(r.Body, &verifier)) + r.Body = io.NopCloser(io.TeeReader(r.Body, &verifier)) s, err := slack.SlashCommandParse(r) if err != nil { w.WriteHeader(http.StatusInternalServerError) diff --git a/examples/workflow_step/handler.go b/examples/workflow_step/handler.go index b25a5be42..d0eee101f 100644 --- a/examples/workflow_step/handler.go +++ b/examples/workflow_step/handler.go @@ -3,7 +3,7 @@ package main import ( "encoding/json" "fmt" - "io/ioutil" + "io" "log" "net/http" "net/url" @@ -25,7 +25,7 @@ func handleMyWorkflowStep(w http.ResponseWriter, r *http.Request) { } // see: https://github.com/slack-go/slack/blob/master/examples/eventsapi/events.go - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) return @@ -88,7 +88,7 @@ func handleInteraction(w http.ResponseWriter, r *http.Request) { return } - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) return diff --git a/examples/workflow_step/middleware.go b/examples/workflow_step/middleware.go index 0684e50d6..6fe826910 100644 --- a/examples/workflow_step/middleware.go +++ b/examples/workflow_step/middleware.go @@ -2,20 +2,20 @@ package main import ( "bytes" - "io/ioutil" + "io" "net/http" "github.com/slack-go/slack" ) func (v *SecretsVerifierMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) return } r.Body.Close() - r.Body = ioutil.NopCloser(bytes.NewBuffer(body)) + r.Body = io.NopCloser(bytes.NewBuffer(body)) sv, err := slack.NewSecretsVerifier(r.Header, appCtx.config.signingSecret) if err != nil { diff --git a/files_test.go b/files_test.go index 59afc1c4b..1df46aa6e 100644 --- a/files_test.go +++ b/files_test.go @@ -3,7 +3,7 @@ package slack import ( "bytes" "encoding/json" - "io/ioutil" + "io" "log" "net/http" "net/url" @@ -44,7 +44,7 @@ func (h *fileCommentHandler) handler(w http.ResponseWriter, r *http.Request) { type mockHTTPClient struct{} func (m *mockHTTPClient) Do(*http.Request) (*http.Response, error) { - return &http.Response{StatusCode: 200, Body: ioutil.NopCloser(bytes.NewBufferString(`OK`))}, nil + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewBufferString(`OK`))}, nil } func TestSlack_GetFile(t *testing.T) { diff --git a/misc.go b/misc.go index 7e5a8d54a..a25afb65e 100644 --- a/misc.go +++ b/misc.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "io/ioutil" "mime" "mime/multipart" "net/http" @@ -127,7 +126,7 @@ func jsonReq(ctx context.Context, endpoint string, body interface{}) (req *http. } func parseResponseBody(body io.ReadCloser, intf interface{}, d Debug) error { - response, err := ioutil.ReadAll(body) + response, err := io.ReadAll(body) if err != nil { return err } @@ -316,7 +315,7 @@ func newJSONParser(dst interface{}) responseParser { func newTextParser(dst interface{}) responseParser { return func(resp *http.Response) error { - b, err := ioutil.ReadAll(resp.Body) + b, err := io.ReadAll(resp.Body) if err != nil { return err } diff --git a/reminders_test.go b/reminders_test.go index 25291b543..09dd6a096 100644 --- a/reminders_test.go +++ b/reminders_test.go @@ -2,7 +2,7 @@ package slack import ( "bytes" - "io/ioutil" + "io" "net/http" "reflect" "testing" @@ -185,7 +185,7 @@ func (m *mockRemindersListHTTPClient) Do(*http.Request) (*http.Response, error) ] }` - return &http.Response{StatusCode: 200, Body: ioutil.NopCloser(bytes.NewBufferString(responseString))}, nil + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewBufferString(responseString))}, nil } func TestSlack_ListReminders(t *testing.T) { diff --git a/slacktest/handlers.go b/slacktest/handlers.go index b4a0227f3..22c1680f6 100644 --- a/slacktest/handlers.go +++ b/slacktest/handlers.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" "fmt" - "io/ioutil" + "io" "log" "net/http" "net/url" @@ -46,7 +46,7 @@ type GroupConversationResponse struct { } func (sts *Server) conversationsInfoHandler(w http.ResponseWriter, r *http.Request) { - data, err := ioutil.ReadAll(r.Body) + data, err := io.ReadAll(r.Body) if err != nil { msg := fmt.Sprintf("error reading body: %s", err.Error()) log.Printf(msg) @@ -126,7 +126,7 @@ func reactionAddHandler(w http.ResponseWriter, _ *http.Request) { // handle chat.postMessage func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) { serverAddr := r.Context().Value(ServerBotHubNameContextKey).(string) - data, err := ioutil.ReadAll(r.Body) + data, err := io.ReadAll(r.Body) if err != nil { msg := fmt.Sprintf("error reading body: %s", err.Error()) log.Printf(msg) @@ -218,7 +218,7 @@ func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) { // RTMConnectHandler generates a valid connection func RTMConnectHandler(w http.ResponseWriter, r *http.Request) { - _, err := ioutil.ReadAll(r.Body) + _, err := io.ReadAll(r.Body) if err != nil { msg := fmt.Sprintf("Error reading body: %s", err.Error()) log.Printf(msg) @@ -248,7 +248,7 @@ func RTMConnectHandler(w http.ResponseWriter, r *http.Request) { } func rtmStartHandler(w http.ResponseWriter, r *http.Request) { - _, err := ioutil.ReadAll(r.Body) + _, err := io.ReadAll(r.Body) if err != nil { msg := fmt.Sprintf("Error reading body: %s", err.Error()) log.Printf(msg) diff --git a/users_test.go b/users_test.go index 5ba915995..eedfd2d60 100644 --- a/users_test.go +++ b/users_test.go @@ -8,7 +8,6 @@ import ( "image/draw" "image/png" "io" - "io/ioutil" "net/http" "os" "reflect" @@ -556,7 +555,7 @@ func setUserPhotoHandler(wantBytes []byte, wantParams UserSetPhotoParams) http.H httpTestErrReply(w, true, fmt.Sprintf("failed to open uploaded file: %+v", err)) return } - gotBytes, err := ioutil.ReadAll(file) + gotBytes, err := io.ReadAll(file) if err != nil { httpTestErrReply(w, true, fmt.Sprintf("failed to read uploaded file: %+v", err)) return @@ -577,7 +576,7 @@ func createUserPhoto(t *testing.T) (*os.File, []byte, func()) { photo := image.NewRGBA(image.Rect(0, 0, 64, 64)) draw.Draw(photo, photo.Bounds(), image.Black, image.ZP, draw.Src) - f, err := ioutil.TempFile(os.TempDir(), "profile.png") + f, err := os.CreateTemp(os.TempDir(), "profile.png") if err != nil { t.Fatalf("failed to create test photo: %+v\n", err) } diff --git a/webhooks.go b/webhooks.go index 8fc149d90..5a854f38b 100644 --- a/webhooks.go +++ b/webhooks.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "net/http" ) @@ -57,7 +56,7 @@ func PostWebhookCustomHTTPContext(ctx context.Context, url string, httpClient *h return fmt.Errorf("failed to post webhook: %w", err) } defer func() { - io.Copy(ioutil.Discard, resp.Body) + io.Copy(io.Discard, resp.Body) resp.Body.Close() }() From 75103a96618c2b61e026a9b8b4656d1be5b9f2c1 Mon Sep 17 00:00:00 2001 From: Peter Nguyen <33224337+zFlabmonsta@users.noreply.github.com> Date: Fri, 16 Aug 2024 03:11:03 +1000 Subject: [PATCH 259/606] add file access field to file struct for slackevents (#1312) --- slackevents/inner_events.go | 1 + 1 file changed, 1 insertion(+) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index e0494fe41..391bd3b4b 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -465,6 +465,7 @@ type File struct { DisplayAsBot bool `json:"display_as_bot"` Username string `json:"username"` URLPrivate string `json:"url_private"` + FileAccess string `json:"file_access"` URLPrivateDownload string `json:"url_private_download"` Thumb64 string `json:"thumb_64"` Thumb80 string `json:"thumb_80"` From 99b3ebefe7d6d29bbb4152748e27f598de0c0157 Mon Sep 17 00:00:00 2001 From: rhysm Date: Fri, 16 Aug 2024 05:11:29 +1200 Subject: [PATCH 260/606] Add slack_file to image block (#1311) Co-authored-by: Rhys M --- block_image.go | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/block_image.go b/block_image.go index 90cbd14e4..b3d2cb8cf 100644 --- a/block_image.go +++ b/block_image.go @@ -4,11 +4,21 @@ package slack // // More Information: https://api.slack.com/reference/messaging/blocks#image type ImageBlock struct { - Type MessageBlockType `json:"type"` - ImageURL string `json:"image_url"` - AltText string `json:"alt_text"` - BlockID string `json:"block_id,omitempty"` - Title *TextBlockObject `json:"title,omitempty"` + Type MessageBlockType `json:"type"` + ImageURL string `json:"image_url,omitempty"` + AltText string `json:"alt_text"` + BlockID string `json:"block_id,omitempty"` + Title *TextBlockObject `json:"title,omitempty"` + SlackFile *SlackFileObject `json:"slack_file,omitempty"` +} + +// SlackFileObject Defines an object containing Slack file information to be used in an +// image block or image element. +// +// More Information: https://api.slack.com/reference/block-kit/composition-objects#slack_file +type SlackFileObject struct { + ID string `json:"id,omitempty"` + URL string `json:"url,omitempty"` } // BlockType returns the type of the block From e947079302882b182ee1bfae177023405fb747a2 Mon Sep 17 00:00:00 2001 From: Arya Khochare <91268931+Aryakoste@users.noreply.github.com> Date: Thu, 15 Aug 2024 22:41:50 +0530 Subject: [PATCH 261/606] feat: Events api reconcilation (#1306) * events added with test * added events with tests * added all events and changes done --- slackevents/inner_events.go | 646 +++++++++++++++-- slackevents/inner_events_test.go | 1149 ++++++++++++++++++++++++++++++ 2 files changed, 1752 insertions(+), 43 deletions(-) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 391bd3b4b..8f8effaae 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -652,6 +652,462 @@ type SharedInvite struct { IsExternalLimited bool `json:"is_external_limited,omitempty"` } +type ChannelHistoryChangedEvent struct { + Type string `json:"type"` + Latest string `json:"latest"` + Ts string `json:"ts"` + EventTs string `json:"event_ts"` +} + +type CommandsChangedEvent struct { + Type string `json:"type"` + EventTs string `json:"event_ts"` +} + +type DndUpdatedEvent struct { + Type string `json:"type"` + User string `json:"user"` + DndStatus struct { + DndEnabled bool `json:"dnd_enabled"` + NextDndStartTs int64 `json:"next_dnd_start_ts"` + NextDndEndTs int64 `json:"next_dnd_end_ts"` + SnoozeEnabled bool `json:"snooze_enabled"` + SnoozeEndtime int64 `json:"snooze_endtime"` + } `json:"dnd_status"` +} + +type DndUpdatedUserEvent struct { + Type string `json:"type"` + User string `json:"user"` + DndStatus struct { + DndEnabled bool `json:"dnd_enabled"` + NextDndStartTs int64 `json:"next_dnd_start_ts"` + NextDndEndTs int64 `json:"next_dnd_end_ts"` + } `json:"dnd_status"` +} + +type EmailDomainChangedEvent struct { + Type string `json:"type"` + EmailDomain string `json:"email_domain"` + EventTs string `json:"event_ts"` +} + +type GroupCloseEvent struct { + Type string `json:"type"` + User string `json:"user"` + Channel string `json:"channel"` +} + +type GroupHistoryChangedEvent struct { + Type string `json:"type"` + Latest string `json:"latest"` + Ts string `json:"ts"` + EventTs string `json:"event_ts"` +} + +type GroupOpenEvent struct { + Type string `json:"type"` + User string `json:"user"` + Channel string `json:"channel"` +} + +type ImCloseEvent struct { + Type string `json:"type"` + User string `json:"user"` + Channel string `json:"channel"` +} + +type ImCreatedEvent struct { + Type string `json:"type"` + User string `json:"user"` + Channel struct { + ID string `json:"id"` + } `json:"channel"` +} + +type ImHistoryChangedEvent struct { + Type string `json:"type"` + Latest string `json:"latest"` + Ts string `json:"ts"` + EventTs string `json:"event_ts"` +} + +type ImOpenEvent struct { + Type string `json:"type"` + User string `json:"user"` + Channel string `json:"channel"` +} + +type SubTeam struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + IsUsergroup bool `json:"is_usergroup"` + Name string `json:"name"` + Description string `json:"description"` + Handle string `json:"handle"` + IsExternal bool `json:"is_external"` + DateCreate int64 `json:"date_create"` + DateUpdate int64 `json:"date_update"` + DateDelete int64 `json:"date_delete"` + AutoType string `json:"auto_type"` + CreatedBy string `json:"created_by"` + UpdatedBy string `json:"updated_by"` + DeletedBy string `json:"deleted_by"` + Prefs struct { + Channels []string `json:"channels"` + Groups []string `json:"groups"` + } `json:"prefs"` + Users []string `json:"users"` + UserCount int `json:"user_count"` +} + +type SubteamCreatedEvent struct { + Type string `json:"type"` + Subteam SubTeam `json:"subteam"` +} + +type SubteamMembersChangedEvent struct { + Type string `json:"type"` + SubteamID string `json:"subteam_id"` + TeamID string `json:"team_id"` + DatePreviousUpdate int `json:"date_previous_update"` + DateUpdate int64 `json:"date_update"` + AddedUsers []string `json:"added_users"` + AddedUsersCount string `json:"added_users_count"` + RemovedUsers []string `json:"removed_users"` + RemovedUsersCount string `json:"removed_users_count"` +} + +type SubteamSelfAddedEvent struct { + Type string `json:"type"` + SubteamID string `json:"subteam_id"` +} + +type SubteamSelfRemovedEvent struct { + Type string `json:"type"` + SubteamID string `json:"subteam_id"` +} + +type SubteamUpdatedEvent struct { + Type string `json:"type"` + Subteam SubTeam `json:"subteam"` +} + +type TeamDomainChangeEvent struct { + Type string `json:"type"` + URL string `json:"url"` + Domain string `json:"domain"` + TeamID string `json:"team_id"` +} + +type TeamRenameEvent struct { + Type string `json:"type"` + Name string `json:"name"` + TeamID string `json:"team_id"` +} + +type UserChangeEvent struct { + Type string `json:"type"` + User User `json:"user"` + CacheTS int64 `json:"cache_ts"` + EventTS string `json:"event_ts"` +} + +type AppDeletedEvent struct { + Type string `json:"type"` + AppID string `json:"app_id"` + AppName string `json:"app_name"` + AppOwnerID string `json:"app_owner_id"` + TeamID string `json:"team_id"` + TeamDomain string `json:"team_domain"` + EventTs string `json:"event_ts"` +} + +type AppInstalledEvent struct { + Type string `json:"type"` + AppID string `json:"app_id"` + AppName string `json:"app_name"` + AppOwnerID string `json:"app_owner_id"` + UserID string `json:"user_id"` + TeamID string `json:"team_id"` + TeamDomain string `json:"team_domain"` + EventTs string `json:"event_ts"` +} + +type AppRequestedEvent struct { + Type string `json:"type"` + AppRequest struct { + ID string `json:"id"` + App struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + HelpURL string `json:"help_url"` + PrivacyPolicyURL string `json:"privacy_policy_url"` + AppHomepageURL string `json:"app_homepage_url"` + AppDirectoryURL string `json:"app_directory_url"` + IsAppDirectoryApproved bool `json:"is_app_directory_approved"` + IsInternal bool `json:"is_internal"` + AdditionalInfo string `json:"additional_info"` + } `json:"app"` + PreviousResolution struct { + Status string `json:"status"` + Scopes []struct { + Name string `json:"name"` + Description string `json:"description"` + IsSensitive bool `json:"is_sensitive"` + TokenType string `json:"token_type"` + } `json:"scopes"` + } `json:"previous_resolution"` + User struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + } `json:"user"` + Team struct { + ID string `json:"id"` + Name string `json:"name"` + Domain string `json:"domain"` + } `json:"team"` + Enterprise interface{} `json:"enterprise"` + Scopes []struct { + Name string `json:"name"` + Description string `json:"description"` + IsSensitive bool `json:"is_sensitive"` + TokenType string `json:"token_type"` + } `json:"scopes"` + Message string `json:"message"` + } `json:"app_request"` +} + +type AppUninstalledTeamEvent struct { + Type string `json:"type"` + AppID string `json:"app_id"` + AppName string `json:"app_name"` + AppOwnerID string `json:"app_owner_id"` + UserID string `json:"user_id"` + TeamID string `json:"team_id"` + TeamDomain string `json:"team_domain"` + EventTs string `json:"event_ts"` +} + +type CallRejectedEvent struct { + Token string `json:"token"` + TeamID string `json:"team_id"` + APIAppID string `json:"api_app_id"` + Event struct { + Type string `json:"type"` + CallID string `json:"call_id"` + UserID string `json:"user_id"` + ChannelID string `json:"channel_id"` + ExternalUniqueID string `json:"external_unique_id"` + } `json:"event"` + Type string `json:"type"` + EventID string `json:"event_id"` + AuthedUsers []string `json:"authed_users"` +} + +type ChannelSharedEvent struct { + Type string `json:"type"` + ConnectedTeamID string `json:"connected_team_id"` + Channel string `json:"channel"` + EventTs string `json:"event_ts"` +} + +type FileCreatedEvent struct { + Type string `json:"type"` + FileID string `json:"file_id"` + File struct { + ID string `json:"id"` + } `json:"file"` +} + +type FilePublicEvent struct { + Type string `json:"type"` + FileID string `json:"file_id"` + File struct { + ID string `json:"id"` + } `json:"file"` +} + +type FunctionExecutedEvent struct { + Type string `json:"type"` + Function struct { + ID string `json:"id"` + CallbackID string `json:"callback_id"` + Title string `json:"title"` + Description string `json:"description"` + Type string `json:"type"` + InputParameters []struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Title string `json:"title"` + IsRequired bool `json:"is_required"` + } `json:"input_parameters"` + OutputParameters []struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Title string `json:"title"` + IsRequired bool `json:"is_required"` + } `json:"output_parameters"` + AppID string `json:"app_id"` + DateCreated int64 `json:"date_created"` + DateUpdated int64 `json:"date_updated"` + DateDeleted int64 `json:"date_deleted"` + } `json:"function"` + Inputs map[string]string `json:"inputs"` + FunctionExecutionID string `json:"function_execution_id"` + WorkflowExecutionID string `json:"workflow_execution_id"` + EventTs string `json:"event_ts"` + BotAccessToken string `json:"bot_access_token"` +} + +type InviteRequestedEvent struct { + Type string `json:"type"` + InviteRequest struct { + ID string `json:"id"` + Email string `json:"email"` + DateCreated int64 `json:"date_created"` + RequesterIDs []string `json:"requester_ids"` + ChannelIDs []string `json:"channel_ids"` + InviteType string `json:"invite_type"` + RealName string `json:"real_name"` + DateExpire int64 `json:"date_expire"` + RequestReason string `json:"request_reason"` + Team struct { + ID string `json:"id"` + Name string `json:"name"` + Domain string `json:"domain"` + } `json:"team"` + } `json:"invite_request"` +} + +type StarAddedEvent struct { + Type string `json:"type"` + User string `json:"user"` + Item struct { + } `json:"item"` + EventTS string `json:"event_ts"` +} + +type StarRemovedEvent struct { + Type string `json:"type"` + User string `json:"user"` + Item struct { + } `json:"item"` + EventTS string `json:"event_ts"` +} + +type UserHuddleChangedEvent struct { + Type string `json:"type"` + User User `json:"user"` + CacheTS int64 `json:"cache_ts"` + EventTS string `json:"event_ts"` +} + +type User struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + Name string `json:"name"` + Deleted bool `json:"deleted"` + Color string `json:"color"` + RealName string `json:"real_name"` + TZ string `json:"tz"` + TZLabel string `json:"tz_label"` + TZOffset int `json:"tz_offset"` + Profile Profile `json:"profile"` + IsAdmin bool `json:"is_admin"` + IsOwner bool `json:"is_owner"` + IsPrimaryOwner bool `json:"is_primary_owner"` + IsRestricted bool `json:"is_restricted"` + IsUltraRestricted bool `json:"is_ultra_restricted"` + IsBot bool `json:"is_bot"` + IsAppUser bool `json:"is_app_user"` + Updated int64 `json:"updated"` + IsEmailConfirmed bool `json:"is_email_confirmed"` + WhoCanShareContactCard string `json:"who_can_share_contact_card"` + Locale string `json:"locale"` +} + +type Profile struct { + Title string `json:"title"` + Phone string `json:"phone"` + Skype string `json:"skype"` + RealName string `json:"real_name"` + RealNameNormalized string `json:"real_name_normalized"` + DisplayName string `json:"display_name"` + DisplayNameNormalized string `json:"display_name_normalized"` + Fields map[string]interface{} `json:"fields"` + StatusText string `json:"status_text"` + StatusEmoji string `json:"status_emoji"` + StatusEmojiDisplayInfo []interface{} `json:"status_emoji_display_info"` + StatusExpiration int `json:"status_expiration"` + AvatarHash string `json:"avatar_hash"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Image24 string `json:"image_24"` + Image32 string `json:"image_32"` + Image48 string `json:"image_48"` + Image72 string `json:"image_72"` + Image192 string `json:"image_192"` + Image512 string `json:"image_512"` + StatusTextCanonical string `json:"status_text_canonical"` + Team string `json:"team"` +} + +type UserStatusChangedEvent struct { + Type string `json:"type"` + User User `json:"user"` + CacheTS int64 `json:"cache_ts"` + EventTS string `json:"event_ts"` +} + +type Actor struct { + ID string `json:"id"` + Name string `json:"name"` + IsBot bool `json:"is_bot"` + TeamID string `json:"team_id"` + Timezone string `json:"timezone"` + RealName string `json:"real_name"` + DisplayName string `json:"display_name"` +} + +type TargetUser struct { + Email string `json:"email"` + InviteID string `json:"invite_id"` +} + +type TeamIcon struct { + Image34 string `json:"image_34"` + ImageDefault bool `json:"image_default"` +} + +type Team struct { + ID string `json:"id"` + Icon TeamIcon `json:"icon"` + Name string `json:"name"` + Domain string `json:"domain"` + IsVerified bool `json:"is_verified"` + DateCreated int64 `json:"date_created"` + AvatarBaseURL string `json:"avatar_base_url"` + RequiresSponsorship bool `json:"requires_sponsorship"` +} + +type SharedChannelInviteRequestedEvent struct { + Actor Actor `json:"actor"` + ChannelID string `json:"channel_id"` + EventType string `json:"event_type"` + ChannelName string `json:"channel_name"` + ChannelType string `json:"channel_type"` + TargetUsers []TargetUser `json:"target_users"` + TeamsInChannel []Team `json:"teams_in_channel"` + IsExternalLimited bool `json:"is_external_limited"` + ChannelDateCreated int64 `json:"channel_date_created"` + ChannelMessageLatestCounted int64 `json:"channel_message_latest_counted_timestamp"` +} + type EventsAPIType string const ( @@ -741,53 +1197,157 @@ const ( TeamAccessRevoked = EventsAPIType("team_access_revoked") // UserProfileChanged is sent if a user's profile information has changed. UserProfileChanged = EventsAPIType("user_profile_changed") + // ChannelHistoryChanged The history of a channel changed + ChannelHistoryChanged = EventsAPIType("channel_history_changed") + // CommandsChanged A command was changed + CommandsChanged = EventsAPIType("commands_changed") + // DndUpdated Do Not Disturb settings were updated + DndUpdated = EventsAPIType("dnd_updated") + // DndUpdatedUser Do Not Disturb settings for a user were updated + DndUpdatedUser = EventsAPIType("dnd_updated_user") + // EmailDomainChanged The email domain changed + EmailDomainChanged = EventsAPIType("email_domain_changed") + // GroupClose A group was closed + GroupClose = EventsAPIType("group_close") + // GroupHistoryChanged The history of a group changed + GroupHistoryChanged = EventsAPIType("group_history_changed") + // GroupOpen A group was opened + GroupOpen = EventsAPIType("group_open") + // ImClose An instant message channel was closed + ImClose = EventsAPIType("im_close") + // ImCreated An instant message channel was created + ImCreated = EventsAPIType("im_created") + // ImHistoryChanged The history of an instant message channel changed + ImHistoryChanged = EventsAPIType("im_history_changed") + // ImOpen An instant message channel was opened + ImOpen = EventsAPIType("im_open") + // SubteamCreated A subteam was created + SubteamCreated = EventsAPIType("subteam_created") + // SubteamMembersChanged The members of a subteam changed + SubteamMembersChanged = EventsAPIType("subteam_members_changed") + // SubteamSelfAdded The current user was added to a subteam + SubteamSelfAdded = EventsAPIType("subteam_self_added") + // SubteamSelfRemoved The current user was removed from a subteam + SubteamSelfRemoved = EventsAPIType("subteam_self_removed") + // SubteamUpdated A subteam was updated + SubteamUpdated = EventsAPIType("subteam_updated") + // TeamDomainChange The team's domain changed + TeamDomainChange = EventsAPIType("team_domain_change") + // TeamRename The team was renamed + TeamRename = EventsAPIType("team_rename") + // UserChange A user object has changed + UserChange = EventsAPIType("user_change") + // AppDeleted is an event when an app is deleted from a workspace + AppDeleted = EventsAPIType("app_deleted") + // AppInstalled is an event when an app is installed to a workspace + AppInstalled = EventsAPIType("app_installed") + // AppRequested is an event when a user requests to install an app to a workspace + AppRequested = EventsAPIType("app_requested") + // AppUninstalledTeam is an event when an app is uninstalled from a team + AppUninstalledTeam = EventsAPIType("app_uninstalled_team") + // CallRejected is an event when a Slack call is rejected + CallRejected = EventsAPIType("call_rejected") + // ChannelShared is an event when a channel is shared with another workspace + ChannelShared = EventsAPIType("channel_shared") + // FileCreated is an event when a file is created in a workspace + FileCreated = EventsAPIType("file_created") + // FilePublic is an event when a file is made public in a workspace + FilePublic = EventsAPIType("file_public") + // FunctionExecuted is an event when a Slack function is executed + FunctionExecuted = EventsAPIType("function_executed") + // InviteRequested is an event when a user requests an invite to a workspace + InviteRequested = EventsAPIType("invite_requested") + // SharedChannelInviteRequested is an event when an invitation to share a channel is requested + SharedChannelInviteRequested = EventsAPIType("shared_channel_invite_requested") + // StarAdded is an event when a star is added to a message or file + StarAdded = EventsAPIType("star_added") + // StarRemoved is an event when a star is removed from a message or file + StarRemoved = EventsAPIType("star_removed") + // UserHuddleChanged is an event when a user's huddle status changes + UserHuddleChanged = EventsAPIType("user_huddle_changed") + // UserStatusChanged is an event when a user's status changes + UserStatusChanged = EventsAPIType("user_status_changed") ) // EventsAPIInnerEventMapping maps INNER Event API events to their corresponding struct // implementations. The structs should be instances of the unmarshalling // target for the matching event type. var EventsAPIInnerEventMapping = map[EventsAPIType]interface{}{ - AppMention: AppMentionEvent{}, - AppHomeOpened: AppHomeOpenedEvent{}, - AppUninstalled: AppUninstalledEvent{}, - ChannelCreated: ChannelCreatedEvent{}, - ChannelDeleted: ChannelDeletedEvent{}, - ChannelArchive: ChannelArchiveEvent{}, - ChannelUnarchive: ChannelUnarchiveEvent{}, - ChannelLeft: ChannelLeftEvent{}, - ChannelRename: ChannelRenameEvent{}, - ChannelIDChanged: ChannelIDChangedEvent{}, - FileChange: FileChangeEvent{}, - FileDeleted: FileDeletedEvent{}, - FileShared: FileSharedEvent{}, - FileUnshared: FileUnsharedEvent{}, - GroupDeleted: GroupDeletedEvent{}, - GroupArchive: GroupArchiveEvent{}, - GroupUnarchive: GroupUnarchiveEvent{}, - GroupLeft: GroupLeftEvent{}, - GroupRename: GroupRenameEvent{}, - GridMigrationFinished: GridMigrationFinishedEvent{}, - GridMigrationStarted: GridMigrationStartedEvent{}, - LinkShared: LinkSharedEvent{}, - Message: MessageEvent{}, - MemberJoinedChannel: MemberJoinedChannelEvent{}, - MemberLeftChannel: MemberLeftChannelEvent{}, - PinAdded: PinAddedEvent{}, - PinRemoved: PinRemovedEvent{}, - ReactionAdded: ReactionAddedEvent{}, - ReactionRemoved: ReactionRemovedEvent{}, - SharedChannelInviteApproved: SharedChannelInviteApprovedEvent{}, - SharedChannelInviteAccepted: SharedChannelInviteAcceptedEvent{}, - SharedChannelInviteDeclined: SharedChannelInviteDeclinedEvent{}, - SharedChannelInviteReceived: SharedChannelInviteReceivedEvent{}, - TeamJoin: TeamJoinEvent{}, - TokensRevoked: TokensRevokedEvent{}, - EmojiChanged: EmojiChangedEvent{}, - WorkflowStepExecute: WorkflowStepExecuteEvent{}, - MessageMetadataPosted: MessageMetadataPostedEvent{}, - MessageMetadataUpdated: MessageMetadataUpdatedEvent{}, - MessageMetadataDeleted: MessageMetadataDeletedEvent{}, - TeamAccessGranted: TeamAccessGrantedEvent{}, - TeamAccessRevoked: TeamAccessRevokedEvent{}, - UserProfileChanged: UserProfileChangedEvent{}, + AppMention: AppMentionEvent{}, + AppHomeOpened: AppHomeOpenedEvent{}, + AppUninstalled: AppUninstalledEvent{}, + ChannelCreated: ChannelCreatedEvent{}, + ChannelDeleted: ChannelDeletedEvent{}, + ChannelArchive: ChannelArchiveEvent{}, + ChannelUnarchive: ChannelUnarchiveEvent{}, + ChannelLeft: ChannelLeftEvent{}, + ChannelRename: ChannelRenameEvent{}, + ChannelIDChanged: ChannelIDChangedEvent{}, + FileChange: FileChangeEvent{}, + FileDeleted: FileDeletedEvent{}, + FileShared: FileSharedEvent{}, + FileUnshared: FileUnsharedEvent{}, + GroupDeleted: GroupDeletedEvent{}, + GroupArchive: GroupArchiveEvent{}, + GroupUnarchive: GroupUnarchiveEvent{}, + GroupLeft: GroupLeftEvent{}, + GroupRename: GroupRenameEvent{}, + GridMigrationFinished: GridMigrationFinishedEvent{}, + GridMigrationStarted: GridMigrationStartedEvent{}, + LinkShared: LinkSharedEvent{}, + Message: MessageEvent{}, + MemberJoinedChannel: MemberJoinedChannelEvent{}, + MemberLeftChannel: MemberLeftChannelEvent{}, + PinAdded: PinAddedEvent{}, + PinRemoved: PinRemovedEvent{}, + ReactionAdded: ReactionAddedEvent{}, + ReactionRemoved: ReactionRemovedEvent{}, + SharedChannelInviteApproved: SharedChannelInviteApprovedEvent{}, + SharedChannelInviteAccepted: SharedChannelInviteAcceptedEvent{}, + SharedChannelInviteDeclined: SharedChannelInviteDeclinedEvent{}, + SharedChannelInviteReceived: SharedChannelInviteReceivedEvent{}, + TeamJoin: TeamJoinEvent{}, + TokensRevoked: TokensRevokedEvent{}, + EmojiChanged: EmojiChangedEvent{}, + WorkflowStepExecute: WorkflowStepExecuteEvent{}, + MessageMetadataPosted: MessageMetadataPostedEvent{}, + MessageMetadataUpdated: MessageMetadataUpdatedEvent{}, + MessageMetadataDeleted: MessageMetadataDeletedEvent{}, + TeamAccessGranted: TeamAccessGrantedEvent{}, + TeamAccessRevoked: TeamAccessRevokedEvent{}, + UserProfileChanged: UserProfileChangedEvent{}, + ChannelHistoryChanged: ChannelHistoryChangedEvent{}, + DndUpdated: DndUpdatedEvent{}, + DndUpdatedUser: DndUpdatedUserEvent{}, + EmailDomainChanged: EmailDomainChangedEvent{}, + GroupClose: GroupCloseEvent{}, + GroupHistoryChanged: GroupHistoryChangedEvent{}, + GroupOpen: GroupOpenEvent{}, + ImClose: ImCloseEvent{}, + ImCreated: ImCreatedEvent{}, + ImHistoryChanged: ImHistoryChangedEvent{}, + ImOpen: ImOpenEvent{}, + SubteamCreated: SubteamCreatedEvent{}, + SubteamMembersChanged: SubteamMembersChangedEvent{}, + SubteamSelfAdded: SubteamSelfAddedEvent{}, + SubteamSelfRemoved: SubteamSelfRemovedEvent{}, + SubteamUpdated: SubteamUpdatedEvent{}, + TeamDomainChange: TeamDomainChangeEvent{}, + TeamRename: TeamRenameEvent{}, + UserChange: UserChangeEvent{}, + AppDeleted: AppDeletedEvent{}, + AppInstalled: AppInstalledEvent{}, + AppRequested: AppRequestedEvent{}, + AppUninstalledTeam: AppUninstalledTeamEvent{}, + CallRejected: CallRejectedEvent{}, + ChannelShared: ChannelSharedEvent{}, + FileCreated: FileCreatedEvent{}, + FilePublic: FilePublicEvent{}, + FunctionExecuted: FunctionExecutedEvent{}, + InviteRequested: InviteRequestedEvent{}, + SharedChannelInviteRequested: SharedChannelInviteRequestedEvent{}, + StarAdded: StarAddedEvent{}, + StarRemoved: StarRemovedEvent{}, + UserHuddleChanged: UserHuddleChangedEvent{}, + UserStatusChanged: UserStatusChangedEvent{}, } diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index 71eb402f8..4307e8ad6 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -1504,3 +1504,1152 @@ func TestSharedChannelDeclined(t *testing.T) { } } + +func TestChannelHistoryChangedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "channel_history_changed", + "latest": "1358877455.000010", + "ts": "1358877455.000008", + "event_ts": "1358877455.000011" + } + `) + + var e ChannelHistoryChangedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "channel_history_changed" { + t.Errorf("type should be channel_history_changed, was %s", e.Type) + } + if e.Latest != "1358877455.000010" { + t.Errorf("latest should be 1358877455.000010, was %s", e.Latest) + } + if e.Ts != "1358877455.000008" { + t.Errorf("ts should be 1358877455.000008, was %s", e.Ts) + } + if e.EventTs != "1358877455.000011" { + t.Errorf("event_ts should be 1358877455.000011, was %s", e.EventTs) + } +} + +func TestDndUpdatedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "dnd_updated", + "user": "U1234567890", + "dnd_status": { + "dnd_enabled": true, + "next_dnd_start_ts": 1624473600, + "next_dnd_end_ts": 1624516800, + "snooze_enabled": false + } + } + `) + + var e DndUpdatedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "dnd_updated" { + t.Errorf("type should be dnd_updated, was %s", e.Type) + } + if e.User != "U1234567890" { + t.Errorf("user should be U1234567890, was %s", e.User) + } + if !e.DndStatus.DndEnabled { + t.Errorf("dnd_enabled should be true, was %v", e.DndStatus.DndEnabled) + } + if e.DndStatus.NextDndStartTs != 1624473600 { + t.Errorf("next_dnd_start_ts should be 1624473600, was %d", e.DndStatus.NextDndStartTs) + } + if e.DndStatus.NextDndEndTs != 1624516800 { + t.Errorf("next_dnd_end_ts should be 1624516800, was %d", e.DndStatus.NextDndEndTs) + } + if e.DndStatus.SnoozeEnabled { + t.Errorf("snooze_enabled should be false, was %v", e.DndStatus.SnoozeEnabled) + } +} + +func TestDndUpdatedUserEvent(t *testing.T) { + rawE := []byte(` + { + "type": "dnd_updated_user", + "user": "U1234", + "dnd_status": { + "dnd_enabled": true, + "next_dnd_start_ts": 1450387800, + "next_dnd_end_ts": 1450423800 + } + } + `) + + var e DndUpdatedUserEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "dnd_updated_user" { + t.Errorf("type should be dnd_updated_user, was %s", e.Type) + } + if e.User != "U1234" { + t.Errorf("user should be U1234, was %s", e.User) + } + if !e.DndStatus.DndEnabled { + t.Errorf("dnd_enabled should be true, was %v", e.DndStatus.DndEnabled) + } + if e.DndStatus.NextDndStartTs != 1450387800 { + t.Errorf("next_dnd_start_ts should be 1450387800, was %d", e.DndStatus.NextDndStartTs) + } + if e.DndStatus.NextDndEndTs != 1450423800 { + t.Errorf("next_dnd_end_ts should be 1450423800, was %d", e.DndStatus.NextDndEndTs) + } +} + +func TestEmailDomainChangedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "email_domain_changed", + "email_domain": "example.com", + "event_ts": "1234567890.123456" + } + `) + + var e EmailDomainChangedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "email_domain_changed" { + t.Errorf("type should be email_domain_changed, was %s", e.Type) + } + if e.EmailDomain != "example.com" { + t.Errorf("email_domain should be example.com, was %s", e.EmailDomain) + } + if e.EventTs != "1234567890.123456" { + t.Errorf("event_ts should be 1234567890.123456, was %s", e.EventTs) + } +} + +func TestGroupHistoryChangedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "group_history_changed", + "latest": "1358877455.000010", + "ts": "1361482916.000003", + "event_ts": "1361482916.000004" + } + `) + + var e GroupHistoryChangedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "group_history_changed" { + t.Errorf("type should be group_history_changed, was %s", e.Type) + } + if e.Latest != "1358877455.000010" { + t.Errorf("latest should be 1358877455.000010, was %s", e.Latest) + } + if e.Ts != "1361482916.000003" { + t.Errorf("ts should be 1361482916.000003, was %s", e.Ts) + } +} + +func TestGroupOpenEvent(t *testing.T) { + rawE := []byte(` + { + "type": "group_open", + "user": "U024BE7LH", + "channel": "G024BE91L" + } + `) + + var e GroupOpenEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "group_open" { + t.Errorf("type should be group_open, was %s", e.Type) + } + if e.User != "U024BE7LH" { + t.Errorf("user should be U024BE7LH, was %s", e.User) + } + if e.Channel != "G024BE91L" { + t.Errorf("channel should be G024BE91L, was %s", e.Channel) + } +} + +func TestGroupCloseEvent(t *testing.T) { + rawE := []byte(` + { + "type": "group_close", + "user": "U1234567890", + "channel": "G1234567890" + } + `) + + var e GroupCloseEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "group_close" { + t.Errorf("type should be group_close, was %s", e.Type) + } + if e.User != "U1234567890" { + t.Errorf("user should be U1234567890, was %s", e.User) + } + if e.Channel != "G1234567890" { + t.Errorf("channel should be G1234567890, was %s", e.Channel) + } +} + +func TestImCloseEvent(t *testing.T) { + rawE := []byte(` + { + "type": "im_close", + "user": "U1234567890", + "channel": "D1234567890" + } + `) + + var e ImCloseEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "im_close" { + t.Errorf("type should be im_close, was %s", e.Type) + } + if e.User != "U1234567890" { + t.Errorf("user should be U1234567890, was %s", e.User) + } + if e.Channel != "D1234567890" { + t.Errorf("channel should be D1234567890, was %s", e.Channel) + } +} + +func TestImCreatedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "im_created", + "user": "U1234567890", + "channel": { + "id": "C12345678" + } + } + `) + + var e ImCreatedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "im_created" { + t.Errorf("type should be im_created, was %s", e.Type) + } + if e.User != "U1234567890" { + t.Errorf("user should be U1234567890, was %s", e.User) + } + if e.Channel.ID != "C12345678" { + t.Errorf("channel.id should be C12345678, was %s", e.Channel.ID) + } +} + +func TestImHistoryChangedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "im_history_changed", + "latest": "1358877455.000010", + "ts": "1361482916.000003", + "event_ts": "1361482916.000004" + } + `) + + var e ImHistoryChangedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "im_history_changed" { + t.Errorf("type should be im_created, was %s", e.Type) + } + if e.Latest != "1358877455.000010" { + t.Errorf("latest should be 1358877455.000010, was %s", e.Latest) + } + if e.Ts != "1361482916.000003" { + t.Errorf("ts should be 1361482916.000003, was %s", e.Ts) + } + if e.EventTs != "1361482916.000004" { + t.Errorf("event_ts should be 1361482916.000004, was %s", e.EventTs) + } +} + +func TestImOpenEvent(t *testing.T) { + rawE := []byte(` + { + "type": "im_open", + "user": "U1234567890", + "channel": "D1234567890" + } + `) + + var e ImOpenEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "im_open" { + t.Errorf("type should be im_open, was %s", e.Type) + } + if e.User != "U1234567890" { + t.Errorf("user should be U1234567890, was %s", e.User) + } + if e.Channel != "D1234567890" { + t.Errorf("channel should be D1234567890, was %s", e.Channel) + } +} + +func TestSubteamCreatedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "subteam_created", + "subteam": { + "id": "S1234567890", + "team_id": "T1234567890", + "is_usergroup": true, + "name": "subteam", + "description": "A test subteam", + "handle": "subteam_handle", + "is_external": false, + "date_create": 1624473600, + "date_update": 1624473600, + "date_delete": 0, + "auto_type": "auto", + "created_by": "U1234567890", + "updated_by": "U1234567890", + "deleted_by": "", + "prefs": { + "channels": ["C1234567890"], + "groups": ["G1234567890"] + }, + "users": ["U1234567890"], + "user_count": 1 + } + } + `) + + var e SubteamCreatedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "subteam_created" { + t.Errorf("type should be subteam_created, was %s", e.Type) + } + if e.Subteam.ID != "S1234567890" { + t.Errorf("subteam.id should be S1234567890, was %s", e.Subteam.ID) + } + if e.Subteam.TeamID != "T1234567890" { + t.Errorf("subteam.team_id should be T1234567890, was %s", e.Subteam.TeamID) + } + if !e.Subteam.IsUsergroup { + t.Errorf("subteam.is_usergroup should be true, was %v", e.Subteam.IsUsergroup) + } + if e.Subteam.Name != "subteam" { + t.Errorf("subteam.name should be subteam, was %s", e.Subteam.Name) + } + if e.Subteam.Description != "A test subteam" { + t.Errorf("subteam.description should be 'A test subteam', was %s", e.Subteam.Description) + } + if e.Subteam.Handle != "subteam_handle" { + t.Errorf("subteam.handle should be subteam_handle, was %s", e.Subteam.Handle) + } + if e.Subteam.IsExternal { + t.Errorf("subteam.is_external should be false, was %v", e.Subteam.IsExternal) + } + if e.Subteam.DateCreate != 1624473600 { + t.Errorf("subteam.date_create should be 1624473600, was %d", e.Subteam.DateCreate) + } + if e.Subteam.DateUpdate != 1624473600 { + t.Errorf("subteam.date_update should be 1624473600, was %d", e.Subteam.DateUpdate) + } + if e.Subteam.DateDelete != 0 { + t.Errorf("subteam.date_delete should be 0, was %d", e.Subteam.DateDelete) + } + if e.Subteam.AutoType != "auto" { + t.Errorf("subteam.auto_type should be auto, was %s", e.Subteam.AutoType) + } + if e.Subteam.CreatedBy != "U1234567890" { + t.Errorf("subteam.created_by should be U1234567890, was %s", e.Subteam.CreatedBy) + } + if e.Subteam.UpdatedBy != "U1234567890" { + t.Errorf("subteam.updated_by should be U1234567890, was %s", e.Subteam.UpdatedBy) + } + if e.Subteam.DeletedBy != "" { + t.Errorf("subteam.deleted_by should be empty, was %s", e.Subteam.DeletedBy) + } + if len(e.Subteam.Prefs.Channels) != 1 || e.Subteam.Prefs.Channels[0] != "C1234567890" { + t.Errorf("subteam.prefs.channels should contain C1234567890, was %v", e.Subteam.Prefs.Channels) + } + if len(e.Subteam.Prefs.Groups) != 1 || e.Subteam.Prefs.Groups[0] != "G1234567890" { + t.Errorf("subteam.prefs.groups should contain G1234567890, was %v", e.Subteam.Prefs.Groups) + } + if len(e.Subteam.Users) != 1 || e.Subteam.Users[0] != "U1234567890" { + t.Errorf("subteam.users should contain U1234567890, was %v", e.Subteam.Users) + } + if e.Subteam.UserCount != 1 { + t.Errorf("subteam.user_count should be 1, was %d", e.Subteam.UserCount) + } +} + +func TestSubteamMembersChangedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "subteam_members_changed", + "subteam_id": "S1234567890", + "team_id": "T1234567890", + "date_previous_update": 1446670362, + "date_update": 1624473600, + "added_users": ["U1234567890"], + "added_users_count": "3", + "removed_users": ["U0987654321"], + "removed_users_count": "1" + } + `) + + var e SubteamMembersChangedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "subteam_members_changed" { + t.Errorf("type should be subteam_members_changed, was %s", e.Type) + } + if e.SubteamID != "S1234567890" { + t.Errorf("subteam_id should be S1234567890, was %s", e.SubteamID) + } + if e.TeamID != "T1234567890" { + t.Errorf("team_id should be T1234567890, was %s", e.TeamID) + } + if e.DateUpdate != 1624473600 { + t.Errorf("date_update should be 1624473600, was %d", e.DateUpdate) + } + if len(e.AddedUsers) != 1 || e.AddedUsers[0] != "U1234567890" { + t.Errorf("subteam.users should contain U1234567890, was %v", e.AddedUsers) + } + if len(e.RemovedUsers) != 1 || e.RemovedUsers[0] != "U0987654321" { + t.Errorf("subteam.users should contain U0987654321, was %v", e.RemovedUsers) + } +} + +func TestSubteamSelfAddedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "subteam_self_added", + "subteam_id": "S1234567890" + } + `) + + var e SubteamSelfAddedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "subteam_self_added" { + t.Errorf("type should be subteam_self_added, was %s", e.Type) + } + if e.SubteamID != "S1234567890" { + t.Errorf("subteam_id should be S1234567890, was %s", e.SubteamID) + } +} + +func TestSubteamSelfRemovedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "subteam_self_removed", + "subteam_id": "S1234567890" + } + `) + + var e SubteamSelfRemovedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "subteam_self_removed" { + t.Errorf("type should be subteam_self_removed, was %s", e.Type) + } + if e.SubteamID != "S1234567890" { + t.Errorf("subteam_id should be S1234567890, was %s", e.SubteamID) + } +} + +func TestSubteamUpdatedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "subteam_updated", + "subteam": { + "id": "S1234567890", + "team_id": "T1234567890", + "is_usergroup": true, + "name": "updated_subteam", + "description": "An updated test subteam", + "handle": "updated_subteam_handle", + "is_external": false, + "date_create": 1624473600, + "date_update": 1624473600, + "date_delete": 0, + "auto_type": "auto", + "created_by": "U1234567890", + "updated_by": "U1234567890", + "deleted_by": "", + "prefs": { + "channels": ["C1234567890"], + "groups": ["G1234567890"] + }, + "users": ["U1234567890"], + "user_count": 1 + } + } + `) + + var e SubteamUpdatedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "subteam_updated" { + t.Errorf("type should be subteam_updated, was %s", e.Type) + } + if e.Subteam.ID != "S1234567890" { + t.Errorf("subteam.id should be S1234567890, was %s", e.Subteam.ID) + } + if e.Subteam.TeamID != "T1234567890" { + t.Errorf("subteam.team_id should be T1234567890, was %s", e.Subteam.TeamID) + } + if !e.Subteam.IsUsergroup { + t.Errorf("subteam.is_usergroup should be true, was %v", e.Subteam.IsUsergroup) + } + if e.Subteam.Name != "updated_subteam" { + t.Errorf("subteam.name should be updated_subteam, was %s", e.Subteam.Name) + } + if e.Subteam.Description != "An updated test subteam" { + t.Errorf("subteam.description should be 'An updated test subteam', was %s", e.Subteam.Description) + } + if e.Subteam.Handle != "updated_subteam_handle" { + t.Errorf("subteam.handle should be updated_subteam_handle, was %s", e.Subteam.Handle) + } + if e.Subteam.IsExternal { + t.Errorf("subteam.is_external should be false, was %v", e.Subteam.IsExternal) + } + if e.Subteam.DateCreate != 1624473600 { + t.Errorf("subteam.date_create should be 1624473600, was %d", e.Subteam.DateCreate) + } + if e.Subteam.DateUpdate != 1624473600 { + t.Errorf("subteam.date_update should be 1624473600, was %d", e.Subteam.DateUpdate) + } + if e.Subteam.DateDelete != 0 { + t.Errorf("subteam.date_delete should be 0, was %d", e.Subteam.DateDelete) + } + if e.Subteam.AutoType != "auto" { + t.Errorf("subteam.auto_type should be auto, was %s", e.Subteam.AutoType) + } + if e.Subteam.CreatedBy != "U1234567890" { + t.Errorf("subteam.created_by should be U1234567890, was %s", e.Subteam.CreatedBy) + } + if e.Subteam.UpdatedBy != "U1234567890" { + t.Errorf("subteam.updated_by should be U1234567890, was %s", e.Subteam.UpdatedBy) + } + if e.Subteam.DeletedBy != "" { + t.Errorf("subteam.deleted_by should be empty, was %s", e.Subteam.DeletedBy) + } + if len(e.Subteam.Prefs.Channels) != 1 || e.Subteam.Prefs.Channels[0] != "C1234567890" { + t.Errorf("subteam.prefs.channels should contain C1234567890, was %v", e.Subteam.Prefs.Channels) + } + if len(e.Subteam.Prefs.Groups) != 1 || e.Subteam.Prefs.Groups[0] != "G1234567890" { + t.Errorf("subteam.prefs.groups should contain G1234567890, was %v", e.Subteam.Prefs.Groups) + } + if len(e.Subteam.Users) != 1 || e.Subteam.Users[0] != "U1234567890" { + t.Errorf("subteam.users should contain U1234567890, was %v", e.Subteam.Users) + } + if e.Subteam.UserCount != 1 { + t.Errorf("subteam.user_count should be 1, was %d", e.Subteam.UserCount) + } +} + +func TestTeamDomainChangeEvent(t *testing.T) { + rawE := []byte(` + { + "type": "team_domain_change", + "url": "https://newdomain.slack.com", + "domain": "newdomain", + "team_id": "T1234" + } + `) + + var e TeamDomainChangeEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "team_domain_change" { + t.Errorf("type should be team_domain_change, was %s", e.Type) + } + if e.URL != "https://newdomain.slack.com" { + t.Errorf("url should be https://newdomain.slack.com, was %s", e.URL) + } + if e.Domain != "newdomain" { + t.Errorf("domain should be newdomain, was %s", e.Domain) + } + if e.TeamID != "T1234" { + t.Errorf("team_id should be 'T1234', was %s", e.TeamID) + } +} + +func TestTeamRenameEvent(t *testing.T) { + rawE := []byte(` + { + "type": "team_rename", + "name": "new_team_name", + "team_id": "T1234" + } + `) + + var e TeamRenameEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "team_rename" { + t.Errorf("type should be team_rename, was %s", e.Type) + } + if e.Name != "new_team_name" { + t.Errorf("name should be new_team_name, was %s", e.Name) + } + if e.TeamID != "T1234" { + t.Errorf("team_id should be 'T1234', was %s", e.TeamID) + } +} + +func TestUserChangeEvent(t *testing.T) { + jsonStr := `{ + "user": { + "id": "U1234567", + "team_id": "T1234567", + "name": "some-user", + "deleted": false, + "color": "4bbe2e", + "real_name": "Some User", + "tz": "America/Los_Angeles", + "tz_label": "Pacific Daylight Time", + "tz_offset": -25200, + "profile": { + "title": "", + "phone": "", + "skype": "", + "real_name": "Some User", + "real_name_normalized": "Some User", + "display_name": "", + "display_name_normalized": "", + "fields": {}, + "status_text": "riding a train", + "status_emoji": ":mountain_railway:", + "status_emoji_display_info": [], + "status_expiration": 0, + "avatar_hash": "g12345678910", + "first_name": "Some", + "last_name": "User", + "image_24": "https://secure.gravatar.com/avatar/cb0c2b2ca5e8de16be31a55a734d0f31.jpg?s=24&d=https%3A%2F%2Fdev.slack.com%2Fdev-cdn%2Fv1648136338%2Fimg%2Favatars%2Fuser_shapes%2Fava_0001-24.png", + "image_32": "https://secure.gravatar.com/avatar/cb0c2b2ca5e8de16be31a55a734d0f31.jpg?s=32&d=https%3A%2F%2Fdev.slack.com%2Fdev-cdn%2Fv1648136338%2Fimg%2Favatars%2Fuser_shapes%2Fava_0001-32.png", + "image_48": "https://secure.gravatar.com/avatar/cb0c2b2ca5e8de16be31a55a734d0f31.jpg?s=48&d=https%3A%2F%2Fdev.slack.com%2Fdev-cdn%2Fv1648136338%2Fimg%2Favatars%2Fuser_shapes%2Fava_0001-48.png", + "image_72": "https://secure.gravatar.com/avatar/cb0c2b2ca5e8de16be31a55a734d0f31.jpg?s=72&d=https%3A%2F%2Fdev.slack.com%2Fdev-cdn%2Fv1648136338%2Fimg%2Favatars%2Fuser_shapes%2Fava_0001-72.png", + "image_192": "https://secure.gravatar.com/avatar/cb0c2b2ca5e8de16be31a55a734d0f31.jpg?s=192&d=https%3A%2F%2Fdev.slack.com%2Fdev-cdn%2Fv1648136338%2Fimg%2Favatars%2Fuser_shapes%2Fava_0001-192.png", + "image_512": "https://secure.gravatar.com/avatar/cb0c2b2ca5e8de16be31a55a734d0f31.jpg?s=512&d=https%3A%2F%2Fdev.slack.com%2Fdev-cdn%2Fv1648136338%2Fimg%2Favatars%2Fuser_shapes%2Fava_0001-512.png", + "status_text_canonical": "", + "team": "T1234567" + }, + "is_admin": false, + "is_owner": false, + "is_primary_owner": false, + "is_restricted": false, + "is_ultra_restricted": false, + "is_bot": false, + "is_app_user": false, + "updated": 1648596421, + "is_email_confirmed": true, + "who_can_share_contact_card": "EVERYONE", + "locale": "en-US" + }, + "cache_ts": 1648596421, + "type": "user_change", + "event_ts": "1648596712.000001" + }` + + var event UserChangeEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal UserChangeEvent: %v", err) + } + + if event.Type != "user_change" { + t.Errorf("Expected type to be 'user_change', got %s", event.Type) + } + + if event.User.ID != "U1234567" { + t.Errorf("Expected user ID to be 'U1234567', got %s", event.User.ID) + } + + if event.User.Profile.StatusText != "riding a train" { + t.Errorf("Expected status text to be 'riding a train', got %s", event.User.Profile.StatusText) + } + + if event.User.Profile.StatusEmoji != ":mountain_railway:" { + t.Errorf("Expected status emoji to be ':mountain_railway:', got %s", event.User.Profile.StatusEmoji) + } + + if event.CacheTS != 1648596421 { + t.Errorf("Expected cache_ts to be 1648596421, got %d", event.CacheTS) + } + + if event.EventTS != "1648596712.000001" { + t.Errorf("Expected event_ts to be '1648596712.000001', got %s", event.EventTS) + } +} + +func TestAppDeletedEvent(t *testing.T) { + jsonStr := `{ + "type": "app_deleted", + "app_id": "A015CA1LGHG", + "app_name": "my-admin-app", + "app_owner_id": "U013B64J7MSZ", + "team_id": "E073D7H7BBE", + "team_domain": "ACME Enterprises", + "event_ts": "1700001891.279278" + }` + + var event AppDeletedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal AppDeletedEvent: %v", err) + } + + if event.Type != "app_deleted" { + t.Errorf("Expected type to be 'app_deleted', got %s", event.Type) + } + + if event.AppName != "my-admin-app" { + t.Errorf("app_name should be 'my-admin-app', was %s", event.AppName) + } + + if event.AppOwnerID != "U013B64J7MSZ" { + t.Errorf("app_owner_id should be 'U013B64J7MSZ', was %s", event.AppOwnerID) + } + + if event.TeamID != "E073D7H7BBE" { + t.Errorf("team_id should be 'E073D7H7BBE', was %s", event.TeamID) + } +} + +func TestAppInstalledEvent(t *testing.T) { + jsonStr := `{ + "type": "app_installed", + "app_id": "A015CA1LGHG", + "app_name": "my-admin-app", + "app_owner_id": "U013B64J7MSZ", + "user_id": "U013B64J7SZ", + "team_id": "E073D7H7BBE", + "team_domain": "ACME Enterprises", + "event_ts": "1700001891.279278" + }` + + var event AppInstalledEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal AppInstalledEvent: %v", err) + } + + if event.Type != "app_installed" { + t.Errorf("Expected type to be 'app_installed', got %s", event.Type) + } + + if event.AppName != "my-admin-app" { + t.Errorf("app_name should be 'my-admin-app', was %s", event.AppName) + } + + if event.AppOwnerID != "U013B64J7MSZ" { + t.Errorf("app_owner_id should be 'U013B64J7MSZ', was %s", event.AppOwnerID) + } + + if event.TeamID != "E073D7H7BBE" { + t.Errorf("team_id should be 'E073D7H7BBE', was %s", event.TeamID) + } +} + +func TestAppRequestedEvent(t *testing.T) { + jsonStr := `{ + "type": "app_requested", + "app_request": { + "id": "1234", + "app": { + "id": "A5678", + "name": "Brent's app", + "description": "They're good apps, Bront.", + "help_url": "brontsapp.com", + "privacy_policy_url": "brontsapp.com", + "app_homepage_url": "brontsapp.com", + "app_directory_url": "https://slack.slack.com/apps/A102ARD7Y", + "is_app_directory_approved": true, + "is_internal": false, + "additional_info": "none" + }, + "previous_resolution": { + "status": "approved", + "scopes": [{ + "name": "app_requested", + "description": "allows this app to listen for app install requests", + "is_sensitive": false, + "token_type": "user" + }] + }, + "user": { + "id": "U1234", + "name": "Bront", + "email": "bront@brent.com" + }, + "team": { + "id": "T1234", + "name": "Brant App Team", + "domain": "brantappteam" + }, + "enterprise": null, + "scopes": [{ + "name": "app_requested", + "description": "allows this app to listen for app install requests", + "is_sensitive": false, + "token_type": "user" + }], + "message": "none" + } + }` + + var event AppRequestedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal AppRequestedEvent: %v", err) + } + + if event.Type != "app_requested" { + t.Errorf("Expected type to be 'app_requested', got %s", event.Type) + } + + if event.AppRequest.ID != "1234" { + t.Errorf("app_request.id should be '1234', was %s", event.AppRequest.ID) + } + + if event.AppRequest.App.ID != "A5678" { + t.Fail() + } + + if event.AppRequest.User.ID != "U1234" { + t.Errorf("app_request.user.id should be 'U1234', was %s", event.AppRequest.User.ID) + } + + if event.AppRequest.Team.ID != "T1234" { + t.Fail() + } +} + +func TestAppUninstalledTeamEvent(t *testing.T) { + jsonStr := `{ + "type": "app_uninstalled_team", + "app_id": "A015CA1LGHG", + "app_name": "my-admin-app", + "app_owner_id": "U013B64J7MSZ", + "user_id": "U013B64J7SZ", + "team_id": "E073D7H7BBE", + "team_domain": "ACME Enterprises", + "event_ts": "1700001891.279278" + }` + + var event AppUninstalledTeamEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal AppUninstalledTeamEvent: %v", err) + } + + if event.Type != "app_uninstalled_team" { + t.Errorf("Expected type to be 'app_uninstalled_team', got %s", event.Type) + } + + if event.AppName != "my-admin-app" { + t.Errorf("app_name should be 'my-admin-app', was %s", event.AppName) + } + + if event.AppOwnerID != "U013B64J7MSZ" { + t.Errorf("app_owner_id should be 'U013B64J7MSZ', was %s", event.AppOwnerID) + } + + if event.TeamID != "E073D7H7BBE" { + t.Errorf("team_id should be 'E073D7H7BBE', was %s", event.TeamID) + } +} + +func TestCallRejectedEvent(t *testing.T) { + jsonStr := `{ + "token": "12345FVmRUzNDOAu12345h", + "team_id": "T123ABC456", + "api_app_id": "BBBU04BB4", + "event": { + "type": "call_rejected", + "call_id": "R123ABC456", + "user_id": "U123ABC456", + "channel_id": "D123ABC456", + "external_unique_id": "123-456-7890" + }, + "type": "event_callback", + "event_id": "Ev123ABC456", + "event_time": 1563448153, + "authed_users": ["U123ABC456"] + }` + + var event CallRejectedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal CallRejectedEvent: %v", err) + } + + if event.Event.Type != "call_rejected" { + t.Errorf("Expected event type to be 'call_rejected', got %s", event.Event.Type) + } + if event.TeamID != "T123ABC456" { + t.Errorf("Expected team_id to be 'T123ABC456', got %s", event.TeamID) + } + if event.Event.CallID != "R123ABC456" { + t.Fail() + } + +} + +func TestChannelSharedEvent(t *testing.T) { + jsonStr := `{ + "type": "channel_shared", + "connected_team_id": "E163Q94DX", + "channel": "C123ABC456", + "event_ts": "1561064063.001100" + }` + + var event ChannelSharedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal ChannelSharedEvent: %v", err) + } + + if event.Type != "channel_shared" { + t.Errorf("Expected type to be 'channel_shared', got %s", event.Type) + } + + if event.ConnectedTeamID != "E163Q94DX" { + t.Errorf("Expected connected_team_id to be 'E163Q94DX', got %s", event.ConnectedTeamID) + } + + if event.Channel != "C123ABC456" { + t.Fail() + } +} + +func TestFileCreatedEvent(t *testing.T) { + jsonStr := `{ + "type": "file_created", + "file_id": "F2147483862", + "file": { + "id": "F2147483862" + } + }` + + var event FileCreatedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal FileCreatedEvent: %v", err) + } + + if event.Type != "file_created" { + t.Errorf("Expected type to be 'file_created', got %s", event.Type) + } + if event.FileID != "F2147483862" { + t.Errorf("Expected file_id to be 'F2147483862', got %s", event.FileID) + } +} + +func TestFilePublicEvent(t *testing.T) { + jsonStr := `{ + "type": "file_public", + "file_id": "F2147483862", + "file": { + "id": "F2147483862" + } + }` + + var event FilePublicEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal FilePublicEvent: %v", err) + } + + if event.Type != "file_public" { + t.Errorf("Expected type to be 'file_public', got %s", event.Type) + } + + if event.FileID != "F2147483862" { + t.Errorf("Expected file_id to be 'F2147483862', got %s", event.FileID) + } +} + +func TestFunctionExecutedEvent(t *testing.T) { + jsonStr := `{ + "type": "function_executed", + "function": { + "id": "Fn123456789O", + "callback_id": "sample_function", + "title": "Sample function", + "description": "Runs sample function", + "type": "app", + "input_parameters": [ + { + "type": "slack#/types/user_id", + "name": "user_id", + "description": "Message recipient", + "title": "User", + "is_required": true + } + ], + "output_parameters": [ + { + "type": "slack#/types/user_id", + "name": "user_id", + "description": "User that completed the function", + "title": "Greeting", + "is_required": true + } + ], + "app_id": "AP123456789", + "date_created": 1694727597, + "date_updated": 1698947481, + "date_deleted": 0 + }, + "inputs": { "user_id": "USER12345678" }, + "function_execution_id": "Fx1234567O9L", + "workflow_execution_id": "WxABC123DEF0", + "event_ts": "1698958075.998738", + "bot_access_token": "abcd-1325532282098-1322446258629-6123648410839-527a1cab3979cad288c9e20330d212cf" + }` + + var event FunctionExecutedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal FunctionExecutedEvent: %v", err) + } + + if event.Type != "function_executed" { + t.Errorf("Expected type to be 'function_executed', got %s", event.Type) + } + + if event.Function.ID != "Fn123456789O" { + t.Errorf("Expected function.id to be 'Fn123456789O', got %s", event.Function.ID) + } + + if event.FunctionExecutionID != "Fx1234567O9L" { + t.Fail() + } +} + +func TestInviteRequestedEvent(t *testing.T) { + jsonStr := `{ + "type": "invite_requested", + "invite_request": { + "id": "12345", + "email": "bront@puppies.com", + "date_created": 123455, + "requester_ids": ["U123ABC456"], + "channel_ids": ["C123ABC456"], + "invite_type": "full_member", + "real_name": "Brent", + "date_expire": 123456, + "request_reason": "They're good dogs, Brant", + "team": { + "id": "T12345", + "name": "Puppy ratings workspace incorporated", + "domain": "puppiesrus" + } + } + }` + + var event InviteRequestedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal InviteRequestedEvent: %v", err) + } + + if event.Type != "invite_requested" { + t.Errorf("Expected type to be 'invite_requested', got %s", event.Type) + } + + if event.InviteRequest.ID != "12345" { + t.Errorf("invite_request.id should be '12345', was %s", event.InviteRequest.ID) + } + + if event.InviteRequest.Email != "bront@puppies.com" { + t.Fail() + } +} + +func TestSharedChannelInviteRequested_UnmarshalJSON(t *testing.T) { + jsonData := ` + { + "actor": { + "id": "U012345ABCD", + "name": "primary-owner", + "is_bot": false, + "team_id": "E0123456ABC", + "timezone": "", + "real_name": "primary-owner", + "display_name": "" + }, + "channel_id": "C0123ABCDEF", + "event_type": "slack#/events/shared_channel_invite_requested", + "channel_name": "our-channel", + "channel_type": "public", + "target_users": [ + { + "email": "user@some-corp.com", + "invite_id": "I0123456ABC" + } + ], + "teams_in_channel": [ + { + "id": "E0123456ABC", + "icon": { + "image_34": "https://slack.com/some-corp/v123/img/abc_0123.png", + "image_default": true + }, + "name": "some_enterprise", + "domain": "someenterprise", + "is_verified": false, + "date_created": 1637947110, + "avatar_base_url": "https://slack.com/some-corp/", + "requires_sponsorship": false + }, + { + "id": "T012345ABCD", + "icon": { + "image_34": "https://slack.com/another-corp/v456/img/def_4567.png", + "image_default": true + }, + "name": "another_enterprise", + "domain": "anotherenterprise", + "is_verified": false, + "date_created": 1645550933, + "avatar_base_url": "https://slack.com/another-corp/", + "requires_sponsorship": false + } + ], + "is_external_limited": true, + "channel_date_created": 1718725442, + "channel_message_latest_counted_timestamp": 1718745614025449 + }` + + var event SharedChannelInviteRequestedEvent + err := json.Unmarshal([]byte(jsonData), &event) + if err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + + if event.Actor.ID != "U012345ABCD" { + t.Errorf("Expected Actor.ID to be 'U012345ABCD', got '%s'", event.Actor.ID) + } + if event.ChannelID != "C0123ABCDEF" { + t.Errorf("Expected ChannelID to be 'C0123ABCDEF', got '%s'", event.ChannelID) + } + if len(event.TargetUsers) != 1 || event.TargetUsers[0].Email != "user@some-corp.com" { + t.Errorf("Expected one TargetUser with Email 'user@some-corp.com', got '%v'", event.TargetUsers) + } + if len(event.TeamsInChannel) != 2 || event.TeamsInChannel[1].Name != "another_enterprise" { + t.Errorf("Expected second team to have name 'another_enterprise', got '%v'", event.TeamsInChannel) + } +} From 50e7414b58e49e610c1d18f46b35cdb34556f9ed Mon Sep 17 00:00:00 2001 From: "K.Utsunomiya" <32708603+nemuvski@users.noreply.github.com> Date: Fri, 16 Aug 2024 02:11:58 +0900 Subject: [PATCH 262/606] feat: Add support for parsing AppRateLimited events (#1308) The code changes in this commit add support for parsing AppRateLimited events in the `ParseEvent` function. This allows the application to handle rate-limited events from the Slack API. --- slackevents/parsers.go | 26 ++++++++++++++++++++++++++ slackevents/parsers_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/slackevents/parsers.go b/slackevents/parsers.go index 4daac8b16..9e8c22b7f 100644 --- a/slackevents/parsers.go +++ b/slackevents/parsers.go @@ -212,6 +212,32 @@ func ParseEvent(rawEvent json.RawMessage, opts ...Option) (EventsAPIEvent, error } return innerEvent, nil } + + if e.Type == AppRateLimited { + appRateLimitedEvent := &EventsAPIAppRateLimited{} + err = json.Unmarshal(rawEvent, appRateLimitedEvent) + if err != nil { + return EventsAPIEvent{ + "", + "", + "unmarshalling_error", + "", + "", + &slack.UnmarshallingErrorEvent{ErrorObj: err}, + EventsAPIInnerEvent{}, + }, err + } + return EventsAPIEvent{ + e.Token, + e.TeamID, + e.Type, + e.APIAppID, + e.EnterpriseID, + appRateLimitedEvent, + EventsAPIInnerEvent{}, + }, nil + } + urlVerificationEvent := &EventsAPIURLVerificationEvent{} err = json.Unmarshal(rawEvent, urlVerificationEvent) if err != nil { diff --git a/slackevents/parsers_test.go b/slackevents/parsers_test.go index 821d50f70..2e2b63401 100644 --- a/slackevents/parsers_test.go +++ b/slackevents/parsers_test.go @@ -73,6 +73,33 @@ func TestParseURLVerificationEvent(t *testing.T) { } } +func TestParseAppRateLimitedEvent(t *testing.T) { + event := ` + { + "token": "fake-token", + "team_id": "T123ABC456", + "minute_rate_limited": 1518467820, + "api_app_id": "A123ABC456", + "type": "app_rate_limited" + } + ` + msg, e := ParseEvent(json.RawMessage(event), OptionVerifyToken(&TokenComparator{"fake-token"})) + if e != nil { + fmt.Println(e) + t.Fail() + } + switch ev := msg.Data.(type) { + case *EventsAPIAppRateLimited: + { + } + default: + { + fmt.Println(ev) + t.Fail() + } + } +} + func TestThatOuterCallbackEventHasInnerEvent(t *testing.T) { eventsAPIRawCallbackEvent := ` { From 5345c06b764bae9d8ba9838a8f47303c764c26e4 Mon Sep 17 00:00:00 2001 From: ku KUMAGAI Date: Fri, 16 Aug 2024 02:12:04 +0900 Subject: [PATCH 263/606] feat: Add Properties.Canvas to Channel (#1228) * Add Properties.Canvas to Channel * Trigger GitHub Actions --------- Co-authored-by: Lorenzo Aiello --- channels.go | 9 ++--- conversation.go | 11 ++++++ conversation_test.go | 79 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/channels.go b/channels.go index 2fca8b92e..88d567bff 100644 --- a/channels.go +++ b/channels.go @@ -19,10 +19,11 @@ type channelResponseFull struct { // Channel contains information about the channel type Channel struct { GroupConversation - IsChannel bool `json:"is_channel"` - IsGeneral bool `json:"is_general"` - IsMember bool `json:"is_member"` - Locale string `json:"locale"` + IsChannel bool `json:"is_channel"` + IsGeneral bool `json:"is_general"` + IsMember bool `json:"is_member"` + Locale string `json:"locale"` + Properties *Properties `json:"properties"` } func (api *Client) channelRequest(ctx context.Context, path string, values url.Values) (*channelResponseFull, error) { diff --git a/conversation.go b/conversation.go index 6e608eba5..bfc17fd3b 100644 --- a/conversation.go +++ b/conversation.go @@ -65,6 +65,17 @@ type Purpose struct { LastSet JSONTime `json:"last_set"` } +// Properties contains the Canvas associated to the channel. +type Properties struct { + Canvas Canvas `json:"canvas"` +} + +type Canvas struct { + FileId string `json:"file_id"` + IsEmpty bool `json:"is_empty"` + QuipThreadId string `json:"quip_thread_id"` +} + type GetUsersInConversationParameters struct { ChannelID string Cursor string diff --git a/conversation_test.go b/conversation_test.go index 1c6b8a211..77dfd0191 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -149,6 +149,85 @@ func TestCreateSimpleGroup(t *testing.T) { assertSimpleGroup(t, group) } +// Channel with Canvas +var channelWithCanvas = `{ + "id": "C024BE91L", + "name": "fun", + "is_channel": true, + "created": 1360782804, + "creator": "U024BE7LH", + "is_archived": false, + "is_general": false, + "members": [ + "U024BE7LH" + ], + "topic": { + "value": "Fun times", + "creator": "U024BE7LV", + "last_set": 1369677212 + }, + "purpose": { + "value": "This channel is for fun", + "creator": "U024BE7LH", + "last_set": 1360782804 + }, + "is_member": true, + "last_read": "1401383885.000061", + "unread_count": 0, + "unread_count_display": 0, + "properties": { + "canvas": { + "file_id": "F05RQ01LJU0", + "is_empty": true, + "quip_thread_id": "XFB9AAlvIyJ" + } + } +}` + +func unmarshalChannelWithCanvas(j string) (*Channel, error) { + channel := &Channel{} + if err := json.Unmarshal([]byte(j), &channel); err != nil { + return nil, err + } + return channel, nil +} + +func TestChannelWithCanvas(t *testing.T) { + channel, err := unmarshalChannelWithCanvas(channelWithCanvas) + assert.Nil(t, err) + assertChannelWithCanvas(t, channel) +} + +func assertChannelWithCanvas(t *testing.T, channel *Channel) { + assertSimpleChannel(t, channel) + assert.Equal(t, "F05RQ01LJU0", channel.Properties.Canvas.FileId) + assert.Equal(t, true, channel.Properties.Canvas.IsEmpty) + assert.Equal(t, "XFB9AAlvIyJ", channel.Properties.Canvas.QuipThreadId) +} + +func TestCreateChannelWithCanvas(t *testing.T) { + channel := &Channel{} + channel.ID = "C024BE91L" + channel.Name = "fun" + channel.IsChannel = true + channel.Created = JSONTime(1360782804) + channel.Creator = "U024BE7LH" + channel.IsArchived = false + channel.IsGeneral = false + channel.IsMember = true + channel.LastRead = "1401383885.000061" + channel.UnreadCount = 0 + channel.UnreadCountDisplay = 0 + channel.Properties = &Properties{ + Canvas: Canvas{ + FileId: "F05RQ01LJU0", + IsEmpty: true, + QuipThreadId: "XFB9AAlvIyJ", + }, + } + assertChannelWithCanvas(t, channel) +} + // IM var simpleIM = `{ "id": "D024BFF1M", From 242df4614edb261e5f4f4a3907c979caf6b4977c Mon Sep 17 00:00:00 2001 From: mineo Date: Fri, 16 Aug 2024 02:12:11 +0900 Subject: [PATCH 264/606] fix: create multipart form when multipart request (#1117) * fix: create multipart form when multipart request * call createFormFields in go func() del coment * Trigger GitHub Actions --------- Co-authored-by: Lorenzo Aiello --- misc.go | 27 ++++++++++++++++++++++++--- remotefiles.go | 5 ++--- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/misc.go b/misc.go index a25afb65e..df779c1ad 100644 --- a/misc.go +++ b/misc.go @@ -62,13 +62,12 @@ func (e *RateLimitedError) Retryable() bool { return true } -func fileUploadReq(ctx context.Context, path string, values url.Values, r io.Reader) (*http.Request, error) { +func fileUploadReq(ctx context.Context, path string, r io.Reader) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, path, r) if err != nil { return nil, err } - req.URL.RawQuery = values.Encode() return req, nil } @@ -155,9 +154,16 @@ func postLocalWithMultipartResponse(ctx context.Context, client httpClient, meth func postWithMultipartResponse(ctx context.Context, client httpClient, path, name, fieldname, token string, values url.Values, r io.Reader, intf interface{}, d Debug) error { pipeReader, pipeWriter := io.Pipe() wr := multipart.NewWriter(pipeWriter) + errc := make(chan error) go func() { defer pipeWriter.Close() + defer wr.Close() + err := createFormFields(wr, values) + if err != nil { + errc <- err + return + } ioWriter, err := wr.CreateFormFile(fieldname, name) if err != nil { errc <- err @@ -173,7 +179,8 @@ func postWithMultipartResponse(ctx context.Context, client httpClient, path, nam return } }() - req, err := fileUploadReq(ctx, path, values, pipeReader) + + req, err := fileUploadReq(ctx, path, pipeReader) if err != nil { return err } @@ -199,6 +206,20 @@ func postWithMultipartResponse(ctx context.Context, client httpClient, path, nam } } +func createFormFields(mw *multipart.Writer, values url.Values) error { + for key, value := range values { + writer, err := mw.CreateFormField(key) + if err != nil { + return err + } + _, err = writer.Write([]byte(value[0])) + if err != nil { + return err + } + } + return nil +} + func doPost(ctx context.Context, client httpClient, req *http.Request, parser responseParser, d Debug) error { resp, err := client.Do(req) if err != nil { diff --git a/remotefiles.go b/remotefiles.go index 0467d7e7c..42639a178 100644 --- a/remotefiles.go +++ b/remotefiles.go @@ -247,9 +247,7 @@ func (api *Client) UpdateRemoteFile(fileID string, params RemoteFileParameters) // Slack API docs: https://api.slack.com/methods/files.remote.update func (api *Client) UpdateRemoteFileContext(ctx context.Context, fileID string, params RemoteFileParameters) (remotefile *RemoteFile, err error) { response := &remoteFileResponseFull{} - values := url.Values{ - "token": {api.token}, - } + values := url.Values{} if fileID != "" { values.Add("file", fileID) } @@ -271,6 +269,7 @@ func (api *Client) UpdateRemoteFileContext(ctx context.Context, fileID string, p if params.PreviewImageReader != nil { err = postWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.update", "preview.png", "preview_image", api.token, values, params.PreviewImageReader, response, api) } else { + values.Add("token", api.token) response, err = api.remoteFileRequest(ctx, "files.remote.update", values) } From 11b3b95c3c55ec97d4caccf5fe03fd90067479fb Mon Sep 17 00:00:00 2001 From: "K.Utsunomiya" <32708603+nemuvski@users.noreply.github.com> Date: Wed, 21 Aug 2024 00:45:22 +0900 Subject: [PATCH 265/606] feat: Add go version 1.23 to test matrix (test.yml) (#1315) --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cbf91c669..aeda6beb7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,7 @@ jobs: - '1.20' - '1.21' - '1.22' + - '1.23' name: test go-${{ matrix.go }} steps: - uses: actions/checkout@v3 From 38949f9c767f6f3fea175cffa08a1c35f8af93a2 Mon Sep 17 00:00:00 2001 From: ICHINOSE Shogo Date: Wed, 21 Aug 2024 01:05:35 +0900 Subject: [PATCH 266/606] ci: Bump GitHub Actions to Latest Versions --- .github/workflows/stale.yaml | 2 +- .github/workflows/test.yml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml index e99b5e620..e860f783b 100644 --- a/.github/workflows/stale.yaml +++ b/.github/workflows/stale.yaml @@ -13,7 +13,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0 + - uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0 with: any-of-labels: 'feedback given' days-before-stale: 45 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aeda6beb7..61eaa92dc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,8 +20,8 @@ jobs: - '1.23' name: test go-${{ matrix.go }} steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: go-version: ${{ matrix.go }} - name: run test @@ -32,14 +32,14 @@ jobs: runs-on: ubuntu-22.04 name: lint steps: - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: go-version: '1.20' cache: false - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: golangci-lint - uses: golangci/golangci-lint-action@08e2f20817b15149a52b5b3ebe7de50aff2ba8c5 # v3.4.0 + uses: golangci/golangci-lint-action@aaa42aa0628b4ae2578232a66b541047968fac86 # v6.1.0 with: version: v1.52.2 From 5386d65cc483b335ebbc9e2030294ab6e380ef9d Mon Sep 17 00:00:00 2001 From: ICHINOSE Shogo Date: Thu, 22 Aug 2024 00:28:55 +0900 Subject: [PATCH 267/606] fix: fix deprecated comment for UploadFile and UploadFileContext (#1316) --- files.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/files.go b/files.go index 6844edd51..b26317145 100644 --- a/files.go +++ b/files.go @@ -355,14 +355,16 @@ func (api *Client) ListFilesContext(ctx context.Context, params ListFilesParamet } // UploadFile uploads a file. -// DEPRECATED: Use UploadFileV2 instead. This will stop functioning on March 11, 2025. +// +// Deprecated: Use [Client.UploadFileV2] instead. This will stop functioning on March 11, 2025. // For more details, see: https://api.slack.com/methods/files.upload#markdown func (api *Client) UploadFile(params FileUploadParameters) (file *File, err error) { return api.UploadFileContext(context.Background(), params) } // UploadFileContext uploads a file and setting a custom context. -// DEPRECATED: Use UploadFileV2Context instead. This will stop functioning on March 11, 2025. +// +// Deprecated: Use [Client.UploadFileV2Context] instead. This will stop functioning on March 11, 2025. // For more details, see: https://api.slack.com/methods/files.upload#markdown func (api *Client) UploadFileContext(ctx context.Context, params FileUploadParameters) (file *File, err error) { // Test if user token is valid. This helps because client.Do doesn't like this for some reason. XXX: More From 6c4585b0289a5d04113cbd68404ecd3e9d53a84e Mon Sep 17 00:00:00 2001 From: Nikolai Date: Tue, 17 Sep 2024 22:54:50 +0300 Subject: [PATCH 268/606] Support publishing a messge to a specific thread (#1309) Support publishing a messge to a specific thread https://api.slack.com/interactivity/handling#publishing_in_thread From slack interactivity documentation: Publishing responses in thread If you want to publish a message to a specific thread, you'll need to include an attribute response_type and set its value to in_channel. Then, to specify the thread, include a thread_ts. Also, be sure to set replace_original to false or you'll overwrite the message you're wanting to respond to! --- chat.go | 1 + 1 file changed, 1 insertion(+) diff --git a/chat.go b/chat.go index 18f8e933d..96843d68d 100644 --- a/chat.go +++ b/chat.go @@ -377,6 +377,7 @@ func (t responseURLSender) BuildRequestContext(ctx context.Context) (*http.Reque req, err := jsonReq(ctx, t.endpoint, Msg{ Text: t.values.Get("text"), Timestamp: t.values.Get("ts"), + ThreadTimestamp: t.values.Get("thread_ts"), Attachments: t.attachments, Blocks: t.blocks, Metadata: t.metadata, From cd4e26e5ec35543cf2e17dffc2fc447aa88e6468 Mon Sep 17 00:00:00 2001 From: Luke Josh <92695731+luke-josh@users.noreply.github.com> Date: Wed, 18 Sep 2024 05:57:39 +1000 Subject: [PATCH 269/606] fix: Add required `format` field to rich text date blocks (#1317) As per [block kit docs](https://api.slack.com/reference/block-kit/blocks#date-element-type), the date element requires a format string to be included. Currently, submitting a block kit with this element results in a slack API error. Also added the two optional fields `url` and `fallback` for posterity. ##### PR preparation Run `make pr-prep` from the root of the repository to run formatting, linting and tests. ##### Should this be an issue instead - [ ] is it a convenience method? (no new functionality, streamlines some use case) - [ ] exposes a previously private type, const, method, etc. - [ ] is it application specific (caching, retry logic, rate limiting, etc) - [ ] is it performance related. ##### API changes Since API changes have to be maintained they undergo a more detailed review and are more likely to require changes. - no tests, if you're adding to the API include at least a single test of the happy case. - If you can accomplish your goal without changing the API, then do so. - dependency changes. updates are okay. adding/removing need justification. ###### Examples of API changes that do not meet guidelines: - in library cache for users. caches are use case specific. - Convenience methods for Sending Messages, update, post, ephemeral, etc. consider opening an issue instead. --- block_rich_text.go | 8 +++++++- block_rich_text_test.go | 7 +++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/block_rich_text.go b/block_rich_text.go index b6a4b4ce4..611763ad4 100644 --- a/block_rich_text.go +++ b/block_rich_text.go @@ -414,16 +414,22 @@ func NewRichTextSectionUserGroupElement(usergroupID string) *RichTextSectionUser type RichTextSectionDateElement struct { Type RichTextSectionElementType `json:"type"` Timestamp JSONTime `json:"timestamp"` + Format string `json:"format"` + URL *string `json:"url,omitempty"` + Fallback *string `json:"fallback,omitempty"` } func (r RichTextSectionDateElement) RichTextSectionElementType() RichTextSectionElementType { return r.Type } -func NewRichTextSectionDateElement(timestamp int64) *RichTextSectionDateElement { +func NewRichTextSectionDateElement(timestamp int64, format string, url *string, fallback *string) *RichTextSectionDateElement { return &RichTextSectionDateElement{ Type: RTSEDate, Timestamp: JSONTime(timestamp), + Format: format, + URL: url, + Fallback: fallback, } } diff --git a/block_rich_text_test.go b/block_rich_text_test.go index a9f04b7de..dec73ad9f 100644 --- a/block_rich_text_test.go +++ b/block_rich_text_test.go @@ -167,13 +167,14 @@ func TestRichTextSection_UnmarshalJSON(t *testing.T) { err error }{ { - []byte(`{"elements":[{"type":"unknown","value":10},{"type":"text","text":"hi"},{"type":"date","timestamp":1636961629}]}`), + []byte(`{"elements":[{"type":"unknown","value":10},{"type":"text","text":"hi"},{"type":"date","timestamp":1636961629,"format":"{date_short_pretty}"},{"type":"date","timestamp":1636961629,"format":"{date_short_pretty}","url":"https://example.com","fallback":"default"}]}`), RichTextSection{ Type: RTESection, Elements: []RichTextSectionElement{ &RichTextSectionUnknownElement{Type: RTSEUnknown, Raw: `{"type":"unknown","value":10}`}, &RichTextSectionTextElement{Type: RTSEText, Text: "hi"}, - &RichTextSectionDateElement{Type: RTSEDate, Timestamp: JSONTime(1636961629)}, + &RichTextSectionDateElement{Type: RTSEDate, Timestamp: JSONTime(1636961629), Format: "{date_short_pretty}"}, + &RichTextSectionDateElement{Type: RTSEDate, Timestamp: JSONTime(1636961629), Format: "{date_short_pretty}", URL: strp("https://example.com"), Fallback: strp("default")}, }, }, nil, @@ -361,3 +362,5 @@ func TestRichTextQuote_Marshal(t *testing.T) { } }) } + +func strp(in string) *string { return &in } From 69981898f827dcf9603d6980cedfd7ec6bea3776 Mon Sep 17 00:00:00 2001 From: Manjish Date: Thu, 19 Sep 2024 20:28:16 +0545 Subject: [PATCH 270/606] fix: Updated RichTextInputBlockElement InitialValue data type (#1320) ##### Pull Request Guidelines These are recommendations for pull requests. They are strictly guidelines to help manage expectations. ##### PR preparation Run `make pr-prep` from the root of the repository to run formatting, linting and tests. ##### Should this be an issue instead - [x] is it a convenience method? (no new functionality, streamlines some use case) - [ ] exposes a previously private type, const, method, etc. - [ ] is it application specific (caching, retry logic, rate limiting, etc) - [ ] is it performance related. Fix for [issue #1276](https://github.com/slack-go/slack/issues/1276) Updated the datatype of RichTextInputBlockElement InitialValue from string to *RichTextBlock --- block_element.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/block_element.go b/block_element.go index a2b755be2..06bffe358 100644 --- a/block_element.go +++ b/block_element.go @@ -527,7 +527,7 @@ type RichTextInputBlockElement struct { Type MessageElementType `json:"type"` ActionID string `json:"action_id,omitempty"` Placeholder *TextBlockObject `json:"placeholder,omitempty"` - InitialValue string `json:"initial_value,omitempty"` + InitialValue *RichTextBlock `json:"initial_value,omitempty"` DispatchActionConfig *DispatchActionConfig `json:"dispatch_action_config,omitempty"` FocusOnLoad bool `json:"focus_on_load,omitempty"` } From 447b7cdae0f1c89ecc516bfa5faf7ddbef76dd8c Mon Sep 17 00:00:00 2001 From: YutoKashiwagi <58618766+YutoKashiwagi@users.noreply.github.com> Date: Thu, 19 Sep 2024 23:45:48 +0900 Subject: [PATCH 271/606] feat: Add support for unicode parameter in emoji type of rich text blocks (#1319) This PR adds support for the `unicode` parameter to the `RichTextSectionEmojiElement` struct for rich text blocks. While this parameter is not officially documented in Slack's API, it is present in the JSON payload of actual Slack messages and represents the Unicode code point of the emoji. https://api.slack.com/reference/block-kit/blocks#emoji-element-type For example, a rich text block with an emoji can include the unicode field like this: ```json "blocks": [ { "type": "rich_text", "block_id": "xxxxx", "elements": [ { "type": "rich_text_section", "elements": [ { "type": "emoji", "name": "+1", "unicode": "1f44d" } ] } ] } ] ``` The unicode parameter behaves similarly to the skin-tone parameter, which is also undocumented but has already been included in the structure. This PR aligns the handling of unicode in the same way to ensure emojis are fully supported in Slack message payloads. Please review, and feel free to provide feedback if any adjustments are needed. Thank you! ##### Pull Request Guidelines These are recommendations for pull requests. They are strictly guidelines to help manage expectations. ##### PR preparation Run `make pr-prep` from the root of the repository to run formatting, linting and tests. ##### Should this be an issue instead - [ ] is it a convenience method? (no new functionality, streamlines some use case) - [ ] exposes a previously private type, const, method, etc. - [ ] is it application specific (caching, retry logic, rate limiting, etc) - [ ] is it performance related. ##### API changes Since API changes have to be maintained they undergo a more detailed review and are more likely to require changes. - no tests, if you're adding to the API include at least a single test of the happy case. - If you can accomplish your goal without changing the API, then do so. - dependency changes. updates are okay. adding/removing need justification. ###### Examples of API changes that do not meet guidelines: - in library cache for users. caches are use case specific. - Convenience methods for Sending Messages, update, post, ephemeral, etc. consider opening an issue instead. --- block_rich_text.go | 1 + block_rich_text_test.go | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/block_rich_text.go b/block_rich_text.go index 611763ad4..c6eb0b1ba 100644 --- a/block_rich_text.go +++ b/block_rich_text.go @@ -341,6 +341,7 @@ type RichTextSectionEmojiElement struct { Type RichTextSectionElementType `json:"type"` Name string `json:"name"` SkinTone int `json:"skin_tone"` + Unicode string `json:"unicode,omitempty"` Style *RichTextSectionTextStyle `json:"style,omitempty"` } diff --git a/block_rich_text_test.go b/block_rich_text_test.go index dec73ad9f..dc4a0cf5b 100644 --- a/block_rich_text_test.go +++ b/block_rich_text_test.go @@ -187,6 +187,16 @@ func TestRichTextSection_UnmarshalJSON(t *testing.T) { }, nil, }, + { + []byte(`{"type": "rich_text_section","elements":[{"type": "emoji","name": "+1","unicode": "1f44d-1f3fb","skin_tone": 2}]}`), + RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionEmojiElement{Type: RTSEEmoji, Name: "+1", Unicode: "1f44d-1f3fb", SkinTone: 2}, + }, + }, + nil, + }, } for _, tc := range cases { var actual RichTextSection From 57aa84d9a8de89e967b28fef947821fd83c62136 Mon Sep 17 00:00:00 2001 From: Winston Durand <68715117+winston-stripe@users.noreply.github.com> Date: Mon, 14 Oct 2024 08:41:46 -0700 Subject: [PATCH 272/606] Add endpoints for `calls.*` apis and `Type: call` in blockkit (#1190) Implement the API methods for the Calls API in Slack https://api.slack.com/apis/calls Implemented methods - `calls.add` - Indicate a new call has been started - `calls.end` - Indicate to slack that the call has ended - `calls.info` - Get information about an ongoing slack call object - `calls.update` - update call information - `calls.participants.add` - `calls.participants.remove` Additionally, I've added the minimal version of `Block{Type: "call", CallID: string}` which slack recommends/requires be posted back to the channel https://api.slack.com/apis/calls#post_to_channel. All implemented functionality is publicly documented. There appear to be additional attributes on the `type: call` block, however those appear to be internal values for slack's rendering, so I have left them out. See this gist for specific responses https://gist.github.com/winston-stripe/0cac608bd63b42d73a352be53577f7fd ##### Pull Request Guidelines These are recommendations for pull requests. They are strictly guidelines to help manage expectations. ##### PR preparation Run `make pr-prep` from the root of the repository to run formatting, linting and tests. ##### Should this be an issue instead - [ ] is it a convenience method? (no new functionality, streamlines some use case) - [ ] exposes a previously private type, const, method, etc. - [ ] is it application specific (caching, retry logic, rate limiting, etc) - [ ] is it performance related. ##### API changes Since API changes have to be maintained they undergo a more detailed review and are more likely to require changes. - no tests, if you're adding to the API include at least a single test of the happy case. - If you can accomplish your goal without changing the API, then do so. - dependency changes. updates are okay. adding/removing need justification. ###### Examples of API changes that do not meet guidelines: - in library cache for users. caches are use case specific. - Convenience methods for Sending Messages, update, post, ephemeral, etc. consider opening an issue instead. --------- Co-authored-by: Winston Durand --- block.go | 1 + block_call.go | 23 +++++ block_call_test.go | 13 +++ block_conv.go | 2 + calls.go | 216 +++++++++++++++++++++++++++++++++++++++++++++ calls_test.go | 189 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 444 insertions(+) create mode 100644 block_call.go create mode 100644 block_call_test.go create mode 100644 calls.go create mode 100644 calls_test.go diff --git a/block.go b/block.go index 1d6ba2d95..a3fb1a0a7 100644 --- a/block.go +++ b/block.go @@ -18,6 +18,7 @@ const ( MBTInput MessageBlockType = "input" MBTHeader MessageBlockType = "header" MBTRichText MessageBlockType = "rich_text" + MBTCall MessageBlockType = "call" MBTVideo MessageBlockType = "video" ) diff --git a/block_call.go b/block_call.go new file mode 100644 index 000000000..98f2c0255 --- /dev/null +++ b/block_call.go @@ -0,0 +1,23 @@ +package slack + +// CallBlock defines data that is used to display a call in slack. +// +// More Information: https://api.slack.com/apis/calls#post_to_channel +type CallBlock struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + CallID string `json:"call_id"` +} + +// BlockType returns the type of the block +func (s CallBlock) BlockType() MessageBlockType { + return s.Type +} + +// NewFileBlock returns a new instance of a file block +func NewCallBlock(callID string) *CallBlock { + return &CallBlock{ + Type: MBTCall, + CallID: callID, + } +} diff --git a/block_call_test.go b/block_call_test.go new file mode 100644 index 000000000..c118542a5 --- /dev/null +++ b/block_call_test.go @@ -0,0 +1,13 @@ +package slack + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewCallBlock(t *testing.T) { + callBlock := NewCallBlock("ACallID") + assert.Equal(t, string(callBlock.Type), "call") + assert.Equal(t, callBlock.CallID, "ACallID") +} diff --git a/block_conv.go b/block_conv.go index 7570be2ab..26c57bbbb 100644 --- a/block_conv.go +++ b/block_conv.go @@ -69,6 +69,8 @@ func (b *Blocks) UnmarshalJSON(data []byte) error { block = &RichTextBlock{} case "section": block = &SectionBlock{} + case "call": + block = &CallBlock{} case "video": block = &VideoBlock{} default: diff --git a/calls.go b/calls.go new file mode 100644 index 000000000..2d6e91f16 --- /dev/null +++ b/calls.go @@ -0,0 +1,216 @@ +package slack + +import ( + "context" + "encoding/json" + "net/url" + "strconv" + "time" +) + +type Call struct { + ID string `json:"id"` + Title string `json:"title"` + DateStart JSONTime `json:"date_start"` + DateEnd JSONTime `json:"date_end"` + ExternalUniqueID string `json:"external_unique_id"` + JoinURL string `json:"join_url"` + DesktopAppJoinURL string `json:"desktop_app_join_url"` + ExternalDisplayID string `json:"external_display_id"` + Participants []CallParticipant `json:"users"` + Channels []string `json:"channels"` +} + +// CallParticipant is a thin user representation which has a SlackID, ExternalID, or both. +// +// See: https://api.slack.com/apis/calls#users +type CallParticipant struct { + SlackID string `json:"slack_id,omitempty"` + ExternalID string `json:"external_id,omitempty"` + DisplayName string `json:"display_name,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` +} + +// Valid checks if the CallUser has a is valid with a SlackID or ExternalID or both. +func (u CallParticipant) Valid() bool { + return u.SlackID != "" || u.ExternalID != "" +} + +type AddCallParameters struct { + JoinURL string // Required + ExternalUniqueID string // Required + CreatedBy string // Required if using a bot token + Title string + DesktopAppJoinURL string + ExternalDisplayID string + DateStart JSONTime + Participants []CallParticipant +} + +type UpdateCallParameters struct { + Title string + DesktopAppJoinURL string + JoinURL string +} + +type EndCallParameters struct { + // Duration is the duration of the call in seconds. Omitted if 0. + Duration time.Duration +} + +type callResponse struct { + Call Call `json:"call"` + SlackResponse +} + +// AddCall adds a new Call to the Slack API. +func (api *Client) AddCall(params AddCallParameters) (Call, error) { + return api.AddCallContext(context.Background(), params) +} + +// AddCallContext adds a new Call to the Slack API. +func (api *Client) AddCallContext(ctx context.Context, params AddCallParameters) (Call, error) { + values := url.Values{ + "token": {api.token}, + "join_url": {params.JoinURL}, + "external_unique_id": {params.ExternalUniqueID}, + } + if params.CreatedBy != "" { + values.Set("created_by", params.CreatedBy) + } + if params.DateStart != 0 { + values.Set("date_start", strconv.FormatInt(int64(params.DateStart), 10)) + } + if params.DesktopAppJoinURL != "" { + values.Set("desktop_app_join_url", params.DesktopAppJoinURL) + } + if params.ExternalDisplayID != "" { + values.Set("external_display_id", params.ExternalDisplayID) + } + if params.Title != "" { + values.Set("title", params.Title) + } + if len(params.Participants) > 0 { + data, err := json.Marshal(params.Participants) + if err != nil { + return Call{}, err + } + values.Set("users", string(data)) + } + + response := &callResponse{} + if err := api.postMethod(ctx, "calls.add", values, response); err != nil { + return Call{}, err + } + + return response.Call, response.Err() +} + +// GetCallInfo returns information about a Call. +func (api *Client) GetCall(callID string) (Call, error) { + return api.GetCallContext(context.Background(), callID) +} + +// GetCallInfoContext returns information about a Call. +func (api *Client) GetCallContext(ctx context.Context, callID string) (Call, error) { + values := url.Values{ + "token": {api.token}, + "id": {callID}, + } + + response := &callResponse{} + if err := api.postMethod(ctx, "calls.info", values, response); err != nil { + return Call{}, err + } + return response.Call, response.Err() +} + +func (api *Client) UpdateCall(callID string, params UpdateCallParameters) (Call, error) { + return api.UpdateCallContext(context.Background(), callID, params) +} + +// UpdateCallContext updates a Call with the given parameters. +func (api *Client) UpdateCallContext(ctx context.Context, callID string, params UpdateCallParameters) (Call, error) { + values := url.Values{ + "token": {api.token}, + "id": {callID}, + } + + if params.DesktopAppJoinURL != "" { + values.Set("desktop_app_join_url", params.DesktopAppJoinURL) + } + if params.JoinURL != "" { + values.Set("join_url", params.JoinURL) + } + if params.Title != "" { + values.Set("title", params.Title) + } + + response := &callResponse{} + if err := api.postMethod(ctx, "calls.update", values, response); err != nil { + return Call{}, err + } + return response.Call, response.Err() +} + +// EndCall ends a Call. +func (api *Client) EndCall(callID string, params EndCallParameters) error { + return api.EndCallContext(context.Background(), callID, params) +} + +// EndCallContext ends a Call. +func (api *Client) EndCallContext(ctx context.Context, callID string, params EndCallParameters) error { + values := url.Values{ + "token": {api.token}, + "id": {callID}, + } + + if params.Duration != 0 { + values.Set("duration", strconv.FormatInt(int64(params.Duration.Seconds()), 10)) + } + + response := &SlackResponse{} + if err := api.postMethod(ctx, "calls.end", values, response); err != nil { + return err + } + return response.Err() +} + +// CallAddParticipants adds users to a Call. +func (api *Client) CallAddParticipants(callID string, participants []CallParticipant) error { + return api.CallAddParticipantsContext(context.Background(), callID, participants) +} + +// CallAddParticipantsContext adds users to a Call. +func (api *Client) CallAddParticipantsContext(ctx context.Context, callID string, participants []CallParticipant) error { + return api.setCallParticipants(ctx, "calls.participants.add", callID, participants) +} + +// CallRemoveParticipants removes users from a Call. +func (api *Client) CallRemoveParticipants(callID string, participants []CallParticipant) error { + return api.CallRemoveParticipantsContext(context.Background(), callID, participants) +} + +// CallRemoveParticipantsContext removes users from a Call. +func (api *Client) CallRemoveParticipantsContext(ctx context.Context, callID string, participants []CallParticipant) error { + return api.setCallParticipants(ctx, "calls.participants.remove", callID, participants) +} + +func (api *Client) setCallParticipants(ctx context.Context, method, callID string, participants []CallParticipant) error { + values := url.Values{ + "token": {api.token}, + "id": {callID}, + } + + data, err := json.Marshal(participants) + if err != nil { + return err + } + values.Set("users", string(data)) + + response := &SlackResponse{} + if err := api.postMethod(ctx, method, values, response); err != nil { + return err + } + return response.Err() +} diff --git a/calls_test.go b/calls_test.go new file mode 100644 index 000000000..0c225fb86 --- /dev/null +++ b/calls_test.go @@ -0,0 +1,189 @@ +package slack + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func getTestCall(callID string) Call { + return Call{ + ID: callID, + Title: "test call", + JoinURL: "https://example.com/example", + ExternalUniqueID: "123", + } +} + +func testClient(api string, f http.HandlerFunc) *Client { + http.HandleFunc(api, f) + once.Do(startServer) + return New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) +} + +var callTestId = 999 + +func addCallHandler(t *testing.T) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + if err := r.ParseForm(); err != nil { + httpTestErrReply(rw, true, fmt.Sprintf("err parsing form: %s", err.Error())) + return + } + call := Call{ + ID: fmt.Sprintf("R%d", callTestId), + Title: r.FormValue("title"), + JoinURL: r.FormValue("join_url"), + ExternalUniqueID: r.FormValue("external_unique_id"), + ExternalDisplayID: r.FormValue("external_display_id"), + DesktopAppJoinURL: r.FormValue("desktop_app_join_url"), + } + callTestId += 1 + json.Unmarshal([]byte(r.FormValue("users")), &call.Participants) + if start := r.FormValue("date_start"); start != "" { + dateStart, err := strconv.ParseInt(start, 10, 64) + require.NoError(t, err) + call.DateStart = JSONTime(dateStart) + } + resp, _ := json.Marshal(callResponse{Call: call, SlackResponse: SlackResponse{Ok: true}}) + rw.Write(resp) + } +} + +func TestAddCall(t *testing.T) { + api := testClient("/calls.add", addCallHandler(t)) + params := AddCallParameters{ + Title: "test call", + JoinURL: "https://example.com/example", + ExternalUniqueID: "123", + } + call, err := api.AddCall(params) + require.NoError(t, err) + assert.Equal(t, params.Title, call.Title) + assert.Equal(t, params.JoinURL, call.JoinURL) + assert.Equal(t, params.ExternalUniqueID, call.ExternalUniqueID) +} + +func getCallHandler(calls []Call) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + callID := r.FormValue("id") + + rw.Header().Set("Content-Type", "application/json") + for _, call := range calls { + if call.ID == callID { + resp, _ := json.Marshal(callResponse{Call: call, SlackResponse: SlackResponse{Ok: true}}) + rw.Write(resp) + return + } + } + // Fail if the call doesn't exist + rw.Write([]byte(`{ "ok": false, "error": "not_found" }`)) + } +} + +func TestGetCall(t *testing.T) { + calls := []Call{ + getTestCall("R1234567890"), + getTestCall("R1234567891"), + } + http.HandleFunc("/calls.info", getCallHandler(calls)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + for _, call := range calls { + resp, err := api.GetCall(call.ID) + require.NoError(t, err) + assert.Equal(t, call, resp) + } + // Test a call that doesn't exist + _, err := api.GetCall("R1234567892") + require.Error(t, err) +} + +func updateCallHandler(calls []Call) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + callID := r.FormValue("id") + + rw.Header().Set("Content-Type", "application/json") + if err := r.ParseForm(); err != nil { + httpTestErrReply(rw, true, fmt.Sprintf("err parsing form: %s", err.Error())) + return + } + + for _, call := range calls { + if call.ID == callID { + if title := r.FormValue("title"); title != "" { + call.Title = title + } + if joinURL := r.FormValue("join_url"); joinURL != "" { + call.JoinURL = joinURL + } + if desktopAppJoinURL := r.FormValue("desktop_app_join_url"); desktopAppJoinURL != "" { + call.DesktopAppJoinURL = desktopAppJoinURL + } + resp, _ := json.Marshal(callResponse{Call: call, SlackResponse: SlackResponse{Ok: true}}) + rw.Write(resp) + return + } + } + // Fail if the call doesn't exist + rw.Write([]byte(`{ "ok": false, "error": "not_found" }`)) + } +} + +func TestUpdateCall(t *testing.T) { + calls := []Call{ + getTestCall("R1234567890"), + getTestCall("R1234567891"), + getTestCall("R1234567892"), + getTestCall("R1234567893"), + getTestCall("R1234567894"), + } + http.HandleFunc("/calls.update", updateCallHandler(calls)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + changes := []struct { + callID string + params UpdateCallParameters + }{ + { + callID: "R1234567890", + params: UpdateCallParameters{Title: "test"}, + }, + { + callID: "R1234567891", + params: UpdateCallParameters{JoinURL: "https://example.com/join"}, + }, + { + callID: "R1234567892", + params: UpdateCallParameters{DesktopAppJoinURL: "https://example.com/join"}, + }, + { // Change multiple fields at once + callID: "R1234567893", + params: UpdateCallParameters{ + Title: "test", + JoinURL: "https://example.com/join", + }, + }, + } + + for _, change := range changes { + call, err := api.UpdateCall(change.callID, change.params) + require.NoError(t, err) + if change.params.Title != "" && call.Title != change.params.Title { + t.Fatalf("Expected title to be %s, got %s", change.params.Title, call.Title) + } + if change.params.JoinURL != "" && call.JoinURL != change.params.JoinURL { + t.Fatalf("Expected join_url to be %s, got %s", change.params.JoinURL, call.JoinURL) + } + if change.params.DesktopAppJoinURL != "" && call.DesktopAppJoinURL != change.params.DesktopAppJoinURL { + t.Fatalf("Expected desktop_app_join_url to be %s, got %s", change.params.DesktopAppJoinURL, call.DesktopAppJoinURL) + } + } +} From 132e0d11f7a8ea96717e5ea7b009db525e6067f0 Mon Sep 17 00:00:00 2001 From: "Obed S." <113571073+obed-sj@users.noreply.github.com> Date: Mon, 14 Oct 2024 12:46:19 -0300 Subject: [PATCH 273/606] feat: Add Convenience Methods to Block Elements (#1279) Adds some convenience methods to block elements to easily add functionality --------- Co-authored-by: Lorenzo Aiello --- block_element.go | 132 +++++++++++++++++++++++++++++++++++++++++++++++ block_input.go | 12 +++++ 2 files changed, 144 insertions(+) diff --git a/block_element.go b/block_element.go index 06bffe358..ad3b67006 100644 --- a/block_element.go +++ b/block_element.go @@ -258,6 +258,36 @@ func NewOptionsSelectBlockElement(optType string, placeholder *TextBlockObject, } } +// WithInitialOption sets the initial option for the select element +func (s *SelectBlockElement) WithInitialOption(option *OptionBlockObject) *SelectBlockElement { + s.InitialOption = option + return s +} + +// WithInitialUser sets the initial user for the select element +func (s *SelectBlockElement) WithInitialUser(user string) *SelectBlockElement { + s.InitialUser = user + return s +} + +// WithInitialConversation sets the initial conversation for the select element +func (s *SelectBlockElement) WithInitialConversation(conversation string) *SelectBlockElement { + s.InitialConversation = conversation + return s +} + +// WithInitialChannel sets the initial channel for the select element +func (s *SelectBlockElement) WithInitialChannel(channel string) *SelectBlockElement { + s.InitialChannel = channel + return s +} + +// WithConfirm adds a confirmation dialogue to the select element +func (s *SelectBlockElement) WithConfirm(confirm *ConfirmationBlockObject) *SelectBlockElement { + s.Confirm = confirm + return s +} + // NewOptionsGroupSelectBlockElement returns a new instance of SelectBlockElement for use with // the Options object only. func NewOptionsGroupSelectBlockElement( @@ -309,6 +339,48 @@ func NewOptionsMultiSelectBlockElement(optType string, placeholder *TextBlockObj } } +// WithInitialOptions sets the initial options for the multi-select element +func (s *MultiSelectBlockElement) WithInitialOptions(options ...*OptionBlockObject) *MultiSelectBlockElement { + s.InitialOptions = options + return s +} + +// WithInitialUsers sets the initial users for the multi-select element +func (s *MultiSelectBlockElement) WithInitialUsers(users ...string) *MultiSelectBlockElement { + s.InitialUsers = users + return s +} + +// WithInitialConversations sets the initial conversations for the multi-select element +func (s *MultiSelectBlockElement) WithInitialConversations(conversations ...string) *MultiSelectBlockElement { + s.InitialConversations = conversations + return s +} + +// WithInitialChannels sets the initial channels for the multi-select element +func (s *MultiSelectBlockElement) WithInitialChannels(channels ...string) *MultiSelectBlockElement { + s.InitialChannels = channels + return s +} + +// WithConfirm adds a confirmation dialogue to the multi-select element +func (s *MultiSelectBlockElement) WithConfirm(confirm *ConfirmationBlockObject) *MultiSelectBlockElement { + s.Confirm = confirm + return s +} + +// WithMaxSelectedItems sets the maximum number of items that can be selected +func (s *MultiSelectBlockElement) WithMaxSelectedItems(maxSelectedItems int) *MultiSelectBlockElement { + s.MaxSelectedItems = &maxSelectedItems + return s +} + +// WithMinQueryLength sets the minimum query length for the multi-select element +func (s *MultiSelectBlockElement) WithMinQueryLength(minQueryLength int) *MultiSelectBlockElement { + s.MinQueryLength = &minQueryLength + return s +} + // NewOptionsGroupMultiSelectBlockElement returns a new instance of MultiSelectBlockElement for use with // the Options object only. func NewOptionsGroupMultiSelectBlockElement( @@ -352,6 +424,12 @@ func NewOverflowBlockElement(actionID string, options ...*OptionBlockObject) *Ov } } +// WithConfirm adds a confirmation dialogue to the overflow element +func (s *OverflowBlockElement) WithConfirm(confirm *ConfirmationBlockObject) *OverflowBlockElement { + s.Confirm = confirm + return s +} + // DatePickerBlockElement defines an element which lets users easily select a // date from a calendar style UI. Date picker elements can be used inside of // section and actions blocks. @@ -520,6 +598,36 @@ func NewPlainTextInputBlockElement(placeholder *TextBlockObject, actionID string } } +// WithInitialValue sets the initial value for the plain-text input element +func (s *PlainTextInputBlockElement) WithInitialValue(initialValue string) *PlainTextInputBlockElement { + s.InitialValue = initialValue + return s +} + +// WithMinLength sets the minimum length for the plain-text input element +func (s *PlainTextInputBlockElement) WithMinLength(minLength int) *PlainTextInputBlockElement { + s.MinLength = minLength + return s +} + +// WithMaxLength sets the maximum length for the plain-text input element +func (s *PlainTextInputBlockElement) WithMaxLength(maxLength int) *PlainTextInputBlockElement { + s.MaxLength = maxLength + return s +} + +// WithMultiline sets the multiline property for the plain-text input element +func (s *PlainTextInputBlockElement) WithMultiline(multiline bool) *PlainTextInputBlockElement { + s.Multiline = multiline + return s +} + +// WithDispatchActionConfig sets the dispatch action config for the plain-text input element +func (s *PlainTextInputBlockElement) WithDispatchActionConfig(config *DispatchActionConfig) *PlainTextInputBlockElement { + s.DispatchActionConfig = config + return s +} + // RichTextInputBlockElement creates a field where allows users to enter formatted text // in a WYSIWYG composer, offering the same messaging writing experience as in Slack // More Information: https://api.slack.com/reference/block-kit/block-elements#rich_text_input @@ -629,6 +737,30 @@ func NewNumberInputBlockElement(placeholder *TextBlockObject, actionID string, i } } +// WithInitialValue sets the initial value for the number input element +func (s *NumberInputBlockElement) WithInitialValue(initialValue string) *NumberInputBlockElement { + s.InitialValue = initialValue + return s +} + +// WithMinValue sets the minimum value for the number input element +func (s *NumberInputBlockElement) WithMinValue(minValue string) *NumberInputBlockElement { + s.MinValue = minValue + return s +} + +// WithMaxValue sets the maximum value for the number input element +func (s *NumberInputBlockElement) WithMaxValue(maxValue string) *NumberInputBlockElement { + s.MaxValue = maxValue + return s +} + +// WithDispatchActionConfig sets the dispatch action config for the number input element +func (s *NumberInputBlockElement) WithDispatchActionConfig(config *DispatchActionConfig) *NumberInputBlockElement { + s.DispatchActionConfig = config + return s +} + // FileInputBlockElement creates a field where a user can upload a file. // // File input elements are currently only available in modals. diff --git a/block_input.go b/block_input.go index 78ffcdb81..7c1272a64 100644 --- a/block_input.go +++ b/block_input.go @@ -28,3 +28,15 @@ func NewInputBlock(blockID string, label, hint *TextBlockObject, element BlockEl Hint: hint, } } + +// WithOptional sets the optional flag on the input block +func (s *InputBlock) WithOptional(optional bool) *InputBlock { + s.Optional = optional + return s +} + +// WithDispatchAction sets the dispatch action flag on the input block +func (s *InputBlock) WithDispatchAction(dispatchAction bool) *InputBlock { + s.DispatchAction = dispatchAction + return s +} From 21e61c5b39be2dc0e2edef68db4bedcb2598439e Mon Sep 17 00:00:00 2001 From: Gideon Williams Date: Mon, 14 Oct 2024 08:51:25 -0700 Subject: [PATCH 274/606] feat: Add functions.completeError and functions.completeSuccess (#1328) Completion of https://github.com/slack-go/slack/pull/1301 - Adds the new complete functions for the Function Execution Event - Adds the context version of those methods --- > this PR to handle event [function_executed](https://api.slack.com/events/function_executed) and response the function with [functions.completeSuccess](https://api.slack.com/methods/functions.completeSuccess) and [functions.completeError](https://api.slack.com/methods/functions.completeError) --------- Co-authored-by: Yoga Setiawan --- examples/function/function.go | 60 +++++++++++++++++++++ examples/function/manifest.json | 56 ++++++++++++++++++++ function_execute.go | 93 +++++++++++++++++++++++++++++++++ function_execute_test.go | 80 ++++++++++++++++++++++++++++ 4 files changed, 289 insertions(+) create mode 100644 examples/function/function.go create mode 100644 examples/function/manifest.json create mode 100644 function_execute.go create mode 100644 function_execute_test.go diff --git a/examples/function/function.go b/examples/function/function.go new file mode 100644 index 000000000..d654890fe --- /dev/null +++ b/examples/function/function.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" + "os" +) + +func main() { + api := slack.New( + os.Getenv("SLACK_BOT_TOKEN"), + slack.OptionDebug(true), + slack.OptionAppLevelToken(os.Getenv("SLACK_APP_TOKEN")), + ) + client := socketmode.New(api, socketmode.OptionDebug(true)) + + go func() { + for evt := range client.Events { + switch evt.Type { + case socketmode.EventTypeEventsAPI: + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + continue + } + + fmt.Printf("Event received: %+v\n", eventsAPIEvent) + client.Ack(*evt.Request) + + switch eventsAPIEvent.Type { + case slackevents.CallbackEvent: + innerEvent := eventsAPIEvent.InnerEvent + switch ev := innerEvent.Data.(type) { + case *slackevents.FunctionExecutedEvent: + callbackID := ev.Function.CallbackID + if callbackID == "sample_function" { + userId := ev.Inputs["user_id"] + payload := map[string]string{ + "user_id": userId, + } + + err := api.FunctionCompleteSuccess(ev.FunctionExecutionID, slack.FunctionCompleteSuccessRequestOptionOutput(payload)) + if err != nil { + fmt.Printf("failed posting message: %v \n", err) + } + } + } + default: + client.Debugf("unsupported Events API event received\n") + } + + default: + fmt.Fprintf(os.Stderr, "Unexpected event type received: %s\n", evt.Type) + } + } + }() + client.Run() +} diff --git a/examples/function/manifest.json b/examples/function/manifest.json new file mode 100644 index 000000000..5f673f96d --- /dev/null +++ b/examples/function/manifest.json @@ -0,0 +1,56 @@ +{ + "display_information": { + "name": "Function Example" + }, + "features": { + "app_home": { + "home_tab_enabled": false, + "messages_tab_enabled": true, + "messages_tab_read_only_enabled": true + }, + "bot_user": { + "display_name": "Function Example", + "always_online": true + } + }, + "oauth_config": { + "scopes": { + "bot": [ + "chat:write" + ] + } + }, + "settings": { + "interactivity": { + "is_enabled": true + }, + "org_deploy_enabled": true, + "socket_mode_enabled": true, + "token_rotation_enabled": false + }, + "functions": { + "sample_function": { + "title": "Sample function", + "description": "Runs sample function", + "input_parameters": { + "user_id": { + "type": "slack#/types/user_id", + "title": "User", + "description": "Message recipient", + "is_required": true, + "hint": "Select a user in the workspace", + "name": "user_id" + } + }, + "output_parameters": { + "user_id": { + "type": "slack#/types/user_id", + "title": "User", + "description": "User that completed the function", + "is_required": true, + "name": "user_id" + } + } + } + } +} diff --git a/function_execute.go b/function_execute.go new file mode 100644 index 000000000..4ec8f9f4c --- /dev/null +++ b/function_execute.go @@ -0,0 +1,93 @@ +package slack + +import ( + "context" + "encoding/json" +) + +type ( + FunctionCompleteSuccessRequest struct { + FunctionExecutionID string `json:"function_execution_id"` + Outputs map[string]string `json:"outputs"` + } + + FunctionCompleteErrorRequest struct { + FunctionExecutionID string `json:"function_execution_id"` + Error string `json:"error"` + } +) + +type FunctionCompleteSuccessRequestOption func(opt *FunctionCompleteSuccessRequest) error + +func FunctionCompleteSuccessRequestOptionOutput(outputs map[string]string) FunctionCompleteSuccessRequestOption { + return func(opt *FunctionCompleteSuccessRequest) error { + if len(outputs) > 0 { + opt.Outputs = outputs + } + return nil + } +} + +// FunctionCompleteSuccess indicates function is completed +func (api *Client) FunctionCompleteSuccess(functionExecutionId string, options ...FunctionCompleteSuccessRequestOption) error { + return api.FunctionCompleteSuccessContext(context.Background(), functionExecutionId, options...) +} + +// FunctionCompleteSuccess indicates function is completed +func (api *Client) FunctionCompleteSuccessContext(ctx context.Context, functionExecutionId string, options ...FunctionCompleteSuccessRequestOption) error { + // More information: https://api.slack.com/methods/functions.completeSuccess + r := &FunctionCompleteSuccessRequest{ + FunctionExecutionID: functionExecutionId, + } + for _, option := range options { + option(r) + } + + endpoint := api.endpoint + "functions.completeSuccess" + jsonData, err := json.Marshal(r) + if err != nil { + return err + } + + response := &SlackResponse{} + if err := postJSON(ctx, api.httpclient, endpoint, api.token, jsonData, response, api); err != nil { + return err + } + + if !response.Ok { + return response.Err() + } + + return nil +} + +// FunctionCompleteError indicates function is completed with error +func (api *Client) FunctionCompleteError(functionExecutionID string, errorMessage string) error { + return api.FunctionCompleteErrorContext(context.Background(), functionExecutionID, errorMessage) +} + +// FunctionCompleteErrorContext indicates function is completed with error +func (api *Client) FunctionCompleteErrorContext(ctx context.Context, functionExecutionID string, errorMessage string) error { + // More information: https://api.slack.com/methods/functions.completeError + r := FunctionCompleteErrorRequest{ + FunctionExecutionID: functionExecutionID, + } + r.Error = errorMessage + + endpoint := api.endpoint + "functions.completeError" + jsonData, err := json.Marshal(r) + if err != nil { + return err + } + + response := &SlackResponse{} + if err := postJSON(ctx, api.httpclient, endpoint, api.token, jsonData, response, api); err != nil { + return err + } + + if !response.Ok { + return response.Err() + } + + return nil +} diff --git a/function_execute_test.go b/function_execute_test.go new file mode 100644 index 000000000..356e22328 --- /dev/null +++ b/function_execute_test.go @@ -0,0 +1,80 @@ +package slack + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" +) + +func postHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + defer r.Body.Close() + if err != nil { + t.Error(err) + return + } + + var req FunctionCompleteSuccessRequest + err = json.Unmarshal(body, &req) + if err != nil { + t.Error(err) + return + } + + switch req.FunctionExecutionID { + case "function-success": + postSuccess(rw, r) + case "function-failure": + postFailure(rw, r) + } + } +} + +func postSuccess(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response := []byte(`{ + "ok": true + }`) + rw.Write(response) +} + +func postFailure(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response := []byte(`{ + "ok": false, + "error": "function_execution_not_found" + }`) + rw.Write(response) + rw.WriteHeader(500) +} + +func TestFunctionComplete(t *testing.T) { + http.HandleFunc("/functions.completeSuccess", postHandler(t)) + + once.Do(startServer) + + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.FunctionCompleteSuccess("function-success") + if err != nil { + t.Error(err) + } + + err = api.FunctionCompleteSuccess("function-failure") + if err == nil { + t.Fail() + } + + err = api.FunctionCompleteSuccessContext(context.Background(), "function-success") + if err != nil { + t.Error(err) + } + + err = api.FunctionCompleteSuccessContext(context.Background(), "function-failure") + if err == nil { + t.Fail() + } +} From c5ef90eb5811bf3dee32387dc0154d5a86b63a9b Mon Sep 17 00:00:00 2001 From: Bennett Amodio Date: Mon, 14 Oct 2024 08:58:49 -0700 Subject: [PATCH 275/606] feat: Add support for external_limited option of inviteShared (#1330) Expose the ability to override the [external_limited option](https://api.slack.com/methods/conversations.inviteShared#arg_external_limited) for inviteShared. Adding the param to all the InviteSharedEmailsToConversation, etc. methods would be a breaking change to those callers, so I opted instead to expose the underlying helper (renamed to InviteSharedToConversation). I feel like the convenience methods (InviteSharedEmailsToConversation/InviteSharedUserIDsToConversation) are not actually that much more convenient than just using the helper, and I think we can eventually remove them in favor of having people call InviteSharedToConversation directly. But that's a future thing. Although it's slightly inconvenient for the caller to use *bool for ExternalLimited, the two alternatives I considered are, I think worse: - Include ExternalLimited as a bool in the InviteSharedParams. I dislike this way because it gives the SDK user of InviteSharedToConversation a different default behavior from inviteShared, since the default value in the API is true. - Add a bool like NonExternalLimited to InviteSharedParams. This way the defaulting is consistent with the API if it's not specified; however, the InviteSharedParams no longer mirror the API args, which I think is confusing. --- conversation.go | 59 +++++++++++++++++++++++++++++++++----------- conversation_test.go | 21 ++++++++++++++++ 2 files changed, 65 insertions(+), 15 deletions(-) diff --git a/conversation.go b/conversation.go index bfc17fd3b..6b941b5ee 100644 --- a/conversation.go +++ b/conversation.go @@ -329,42 +329,71 @@ func (api *Client) InviteUsersToConversationContext(ctx context.Context, channel } // InviteSharedEmailsToConversation invites users to a shared channels by email. -// For more details, see InviteSharedEmailsToConversationContext documentation. +// For more details, see InviteSharedToConversationContext documentation. func (api *Client) InviteSharedEmailsToConversation(channelID string, emails ...string) (string, bool, error) { - return api.inviteSharedToConversationHelper(context.Background(), channelID, emails, nil) + return api.InviteSharedToConversationContext(context.Background(), InviteSharedToConversationParams{ + ChannelID: channelID, + Emails: emails, + }) } // InviteSharedEmailsToConversationContext invites users to a shared channels by email using context. -// For more details, see inviteSharedToConversationHelper documentation. +// For more details, see InviteSharedToConversationContext documentation. func (api *Client) InviteSharedEmailsToConversationContext(ctx context.Context, channelID string, emails ...string) (string, bool, error) { - return api.inviteSharedToConversationHelper(ctx, channelID, emails, nil) + return api.InviteSharedToConversationContext(ctx, InviteSharedToConversationParams{ + ChannelID: channelID, + Emails: emails, + }) } // InviteSharedUserIDsToConversation invites users to a shared channels by user id. -// For more details, see InviteSharedUserIDsToConversationContext documentation. +// For more details, see InviteSharedToConversationContext documentation. func (api *Client) InviteSharedUserIDsToConversation(channelID string, userIDs ...string) (string, bool, error) { - return api.inviteSharedToConversationHelper(context.Background(), channelID, nil, userIDs) + return api.InviteSharedToConversationContext(context.Background(), InviteSharedToConversationParams{ + ChannelID: channelID, + UserIDs: userIDs, + }) } // InviteSharedUserIDsToConversationContext invites users to a shared channels by user id with context. -// For more details, see inviteSharedToConversationHelper documentation. +// For more details, see InviteSharedToConversationContext documentation. func (api *Client) InviteSharedUserIDsToConversationContext(ctx context.Context, channelID string, userIDs ...string) (string, bool, error) { - return api.inviteSharedToConversationHelper(ctx, channelID, nil, userIDs) + return api.InviteSharedToConversationContext(ctx, InviteSharedToConversationParams{ + ChannelID: channelID, + UserIDs: userIDs, + }) } -// inviteSharedToConversationHelper invites emails or userIDs to a channel with a custom context. +// InviteSharedToConversationParams defines the parameters for the InviteSharedToConversation and InviteSharedToConversationContext functions. +type InviteSharedToConversationParams struct { + ChannelID string + Emails []string + UserIDs []string + ExternalLimited *bool +} + +// InviteSharedToConversation invites emails or userIDs to a channel. +// For more details, see InviteSharedToConversationContext documentation. +func (api *Client) InviteSharedToConversation(params InviteSharedToConversationParams) (string, bool, error) { + return api.InviteSharedToConversationContext(context.Background(), params) +} + +// InviteSharedToConversationContext invites emails or userIDs to a channel with a custom context. // This is a helper function for InviteSharedEmailsToConversation and InviteSharedUserIDsToConversation. // It accepts either emails or userIDs, but not both. // Slack API docs: https://api.slack.com/methods/conversations.inviteShared -func (api *Client) inviteSharedToConversationHelper(ctx context.Context, channelID string, emails []string, userIDs []string) (string, bool, error) { +func (api *Client) InviteSharedToConversationContext(ctx context.Context, params InviteSharedToConversationParams) (string, bool, error) { values := url.Values{ "token": {api.token}, - "channel": {channelID}, + "channel": {params.ChannelID}, + } + if len(params.Emails) > 0 { + values.Add("emails", strings.Join(params.Emails, ",")) + } else if len(params.UserIDs) > 0 { + values.Add("user_ids", strings.Join(params.UserIDs, ",")) } - if len(emails) > 0 { - values.Add("emails", strings.Join(emails, ",")) - } else if len(userIDs) > 0 { - values.Add("user_ids", strings.Join(userIDs, ",")) + if params.ExternalLimited != nil { + values.Add("external_limited", strconv.FormatBool(*params.ExternalLimited)) } response := struct { SlackResponse diff --git a/conversation_test.go b/conversation_test.go index 77dfd0191..0ee6f4d4b 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -477,6 +477,27 @@ func TestInviteSharedToConversation(t *testing.T) { t.Error("is legacy shared channel should be false") } }) + + t.Run("external_limited", func(t *testing.T) { + userIDs := []string{"UXXXXXXX1", "UXXXXXXX2"} + externalLimited := true + inviteID, isLegacySharedChannel, err := api.InviteSharedToConversation(InviteSharedToConversationParams{ + ChannelID: "CXXXXXXXX", + UserIDs: userIDs, + ExternalLimited: &externalLimited, + }) + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + if inviteID == "" { + t.Error("invite id should have a value") + return + } + if isLegacySharedChannel { + t.Error("is legacy shared channel should be false") + } + }) } func TestKickUserFromConversation(t *testing.T) { From 203cdb23051138817874315b0fe2c01f7ec96fd1 Mon Sep 17 00:00:00 2001 From: Jaro Spisak <61154065+jarospisak-unity@users.noreply.github.com> Date: Mon, 14 Oct 2024 20:01:37 +0300 Subject: [PATCH 276/606] feat: Add support for Canvas API methods (#1334) This PR introduces new functionalities for managing canvases and creating channel-specific canvases. - CreateCanvas - DeleteCanvas - EditCanvas - SetCanvasAccess - DeleteCanvasAccess - LookupCanvasSections - CreateChannelCanvas Closes #1333 --- canvas.go | 264 +++++++++++++++++++++++++++++++++++++++++++ canvas_test.go | 216 +++++++++++++++++++++++++++++++++++ conversation.go | 34 ++++++ conversation_test.go | 31 +++++ 4 files changed, 545 insertions(+) create mode 100644 canvas.go create mode 100644 canvas_test.go diff --git a/canvas.go b/canvas.go new file mode 100644 index 000000000..5225afa35 --- /dev/null +++ b/canvas.go @@ -0,0 +1,264 @@ +package slack + +import ( + "context" + "encoding/json" + "net/url" +) + +type CanvasDetails struct { + CanvasID string `json:"canvas_id"` +} + +type DocumentContent struct { + Type string `json:"type"` + Markdown string `json:"markdown,omitempty"` +} + +type CanvasChange struct { + Operation string `json:"operation"` + SectionID string `json:"section_id,omitempty"` + DocumentContent DocumentContent `json:"document_content"` +} + +type EditCanvasParams struct { + CanvasID string `json:"canvas_id"` + Changes []CanvasChange `json:"changes"` +} + +type SetCanvasAccessParams struct { + CanvasID string `json:"canvas_id"` + AccessLevel string `json:"access_level"` + ChannelIDs []string `json:"channel_ids,omitempty"` + UserIDs []string `json:"user_ids,omitempty"` +} + +type DeleteCanvasAccessParams struct { + CanvasID string `json:"canvas_id"` + ChannelIDs []string `json:"channel_ids,omitempty"` + UserIDs []string `json:"user_ids,omitempty"` +} + +type LookupCanvasSectionsCriteria struct { + SectionTypes []string `json:"section_types,omitempty"` + ContainsText string `json:"contains_text,omitempty"` +} + +type LookupCanvasSectionsParams struct { + CanvasID string `json:"canvas_id"` + Criteria LookupCanvasSectionsCriteria `json:"criteria"` +} + +type CanvasSection struct { + ID string `json:"id"` +} + +type LookupCanvasSectionsResponse struct { + SlackResponse + Sections []CanvasSection `json:"sections"` +} + +// CreateCanvas creates a new canvas. +// For more details, see CreateCanvasContext documentation. +func (api *Client) CreateCanvas(title string, documentContent DocumentContent) (string, error) { + return api.CreateCanvasContext(context.Background(), title, documentContent) +} + +// CreateCanvasContext creates a new canvas with a custom context. +// Slack API docs: https://api.slack.com/methods/canvases.create +func (api *Client) CreateCanvasContext(ctx context.Context, title string, documentContent DocumentContent) (string, error) { + values := url.Values{ + "token": {api.token}, + } + if title != "" { + values.Add("title", title) + } + if documentContent.Type != "" { + documentContentJSON, err := json.Marshal(documentContent) + if err != nil { + return "", err + } + values.Add("document_content", string(documentContentJSON)) + } + + response := struct { + SlackResponse + CanvasID string `json:"canvas_id"` + }{} + + err := api.postMethod(ctx, "canvases.create", values, &response) + if err != nil { + return "", err + } + + return response.CanvasID, response.Err() +} + +// DeleteCanvas deletes an existing canvas. +// For more details, see DeleteCanvasContext documentation. +func (api *Client) DeleteCanvas(canvasID string) error { + return api.DeleteCanvasContext(context.Background(), canvasID) +} + +// DeleteCanvasContext deletes an existing canvas with a custom context. +// Slack API docs: https://api.slack.com/methods/canvases.delete +func (api *Client) DeleteCanvasContext(ctx context.Context, canvasID string) error { + values := url.Values{ + "token": {api.token}, + "canvas_id": {canvasID}, + } + + response := struct { + SlackResponse + }{} + + err := api.postMethod(ctx, "canvases.delete", values, &response) + if err != nil { + return err + } + + return response.Err() +} + +// EditCanvas edits an existing canvas. +// For more details, see EditCanvasContext documentation. +func (api *Client) EditCanvas(params EditCanvasParams) error { + return api.EditCanvasContext(context.Background(), params) +} + +// EditCanvasContext edits an existing canvas with a custom context. +// Slack API docs: https://api.slack.com/methods/canvases.edit +func (api *Client) EditCanvasContext(ctx context.Context, params EditCanvasParams) error { + values := url.Values{ + "token": {api.token}, + "canvas_id": {params.CanvasID}, + } + + changesJSON, err := json.Marshal(params.Changes) + if err != nil { + return err + } + values.Add("changes", string(changesJSON)) + + response := struct { + SlackResponse + }{} + + err = api.postMethod(ctx, "canvases.edit", values, &response) + if err != nil { + return err + } + + return response.Err() +} + +// SetCanvasAccess sets the access level to a canvas for specified entities. +// For more details, see SetCanvasAccessContext documentation. +func (api *Client) SetCanvasAccess(params SetCanvasAccessParams) error { + return api.SetCanvasAccessContext(context.Background(), params) +} + +// SetCanvasAccessContext sets the access level to a canvas for specified entities with a custom context. +// Slack API docs: https://api.slack.com/methods/canvases.access.set +func (api *Client) SetCanvasAccessContext(ctx context.Context, params SetCanvasAccessParams) error { + values := url.Values{ + "token": {api.token}, + "canvas_id": {params.CanvasID}, + "access_level": {params.AccessLevel}, + } + if len(params.ChannelIDs) > 0 { + channelIDsJSON, err := json.Marshal(params.ChannelIDs) + if err != nil { + return err + } + values.Add("channel_ids", string(channelIDsJSON)) + } + if len(params.UserIDs) > 0 { + userIDsJSON, err := json.Marshal(params.UserIDs) + if err != nil { + return err + } + values.Add("user_ids", string(userIDsJSON)) + } + + response := struct { + SlackResponse + }{} + + err := api.postMethod(ctx, "canvases.access.set", values, &response) + if err != nil { + return err + } + + return response.Err() +} + +// DeleteCanvasAccess removes access to a canvas for specified entities. +// For more details, see DeleteCanvasAccessContext documentation. +func (api *Client) DeleteCanvasAccess(params DeleteCanvasAccessParams) error { + return api.DeleteCanvasAccessContext(context.Background(), params) +} + +// DeleteCanvasAccessContext removes access to a canvas for specified entities with a custom context. +// Slack API docs: https://api.slack.com/methods/canvases.access.delete +func (api *Client) DeleteCanvasAccessContext(ctx context.Context, params DeleteCanvasAccessParams) error { + values := url.Values{ + "token": {api.token}, + "canvas_id": {params.CanvasID}, + } + if len(params.ChannelIDs) > 0 { + channelIDsJSON, err := json.Marshal(params.ChannelIDs) + if err != nil { + return err + } + values.Add("channel_ids", string(channelIDsJSON)) + } + if len(params.UserIDs) > 0 { + userIDsJSON, err := json.Marshal(params.UserIDs) + if err != nil { + return err + } + values.Add("user_ids", string(userIDsJSON)) + } + + response := struct { + SlackResponse + }{} + + err := api.postMethod(ctx, "canvases.access.delete", values, &response) + if err != nil { + return err + } + + return response.Err() +} + +// LookupCanvasSections finds sections matching the provided criteria. +// For more details, see LookupCanvasSectionsContext documentation. +func (api *Client) LookupCanvasSections(params LookupCanvasSectionsParams) ([]CanvasSection, error) { + return api.LookupCanvasSectionsContext(context.Background(), params) +} + +// LookupCanvasSectionsContext finds sections matching the provided criteria with a custom context. +// Slack API docs: https://api.slack.com/methods/canvases.sections.lookup +func (api *Client) LookupCanvasSectionsContext(ctx context.Context, params LookupCanvasSectionsParams) ([]CanvasSection, error) { + values := url.Values{ + "token": {api.token}, + "canvas_id": {params.CanvasID}, + } + + criteriaJSON, err := json.Marshal(params.Criteria) + if err != nil { + return nil, err + } + values.Add("criteria", string(criteriaJSON)) + + response := LookupCanvasSectionsResponse{} + + err = api.postMethod(ctx, "canvases.sections.lookup", values, &response) + if err != nil { + return nil, err + } + + return response.Sections, response.Err() +} diff --git a/canvas_test.go b/canvas_test.go new file mode 100644 index 000000000..c0e301039 --- /dev/null +++ b/canvas_test.go @@ -0,0 +1,216 @@ +package slack + +import ( + "encoding/json" + "net/http" + "reflect" + "testing" +) + +func createCanvasHandler(rw http.ResponseWriter, r *http.Request) { + title := r.FormValue("title") + documentContent := r.FormValue("document_content") + + rw.Header().Set("Content-Type", "application/json") + + if title != "" && documentContent != "" { + resp, _ := json.Marshal(&struct { + SlackResponse + CanvasID string `json:"canvas_id"` + }{ + SlackResponse: SlackResponse{Ok: true}, + CanvasID: "F1234ABCD", + }) + rw.Write(resp) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestCreateCanvas(t *testing.T) { + http.HandleFunc("/canvases.create", createCanvasHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + documentContent := DocumentContent{ + Type: "markdown", + Markdown: "Test Content", + } + + canvasID, err := api.CreateCanvas("Test Canvas", documentContent) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if canvasID != "F1234ABCD" { + t.Fatalf("Expected canvas ID to be F1234ABCD, got %s", canvasID) + } +} + +func deleteCanvasHandler(rw http.ResponseWriter, r *http.Request) { + canvasID := r.FormValue("canvas_id") + + rw.Header().Set("Content-Type", "application/json") + + if canvasID == "F1234ABCD" { + rw.Write([]byte(`{ "ok": true }`)) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestDeleteCanvas(t *testing.T) { + http.HandleFunc("/canvases.delete", deleteCanvasHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.DeleteCanvas("F1234ABCD") + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } +} + +func editCanvasHandler(rw http.ResponseWriter, r *http.Request) { + canvasID := r.FormValue("canvas_id") + + rw.Header().Set("Content-Type", "application/json") + + if canvasID == "F1234ABCD" { + rw.Write([]byte(`{ "ok": true }`)) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestEditCanvas(t *testing.T) { + http.HandleFunc("/canvases.edit", editCanvasHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := EditCanvasParams{ + CanvasID: "F1234ABCD", + Changes: []CanvasChange{ + { + Operation: "update", + SectionID: "S1234", + DocumentContent: DocumentContent{ + Type: "markdown", + Markdown: "Updated Content", + }, + }, + }, + } + + err := api.EditCanvas(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } +} + +func setCanvasAccessHandler(rw http.ResponseWriter, r *http.Request) { + canvasID := r.FormValue("canvas_id") + + rw.Header().Set("Content-Type", "application/json") + + if canvasID == "F1234ABCD" { + rw.Write([]byte(`{ "ok": true }`)) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestSetCanvasAccess(t *testing.T) { + http.HandleFunc("/canvases.access.set", setCanvasAccessHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := SetCanvasAccessParams{ + CanvasID: "F1234ABCD", + AccessLevel: "read", + ChannelIDs: []string{"C1234ABCD"}, + UserIDs: []string{"U1234ABCD"}, + } + + err := api.SetCanvasAccess(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } +} + +func deleteCanvasAccessHandler(rw http.ResponseWriter, r *http.Request) { + canvasID := r.FormValue("canvas_id") + + rw.Header().Set("Content-Type", "application/json") + + if canvasID == "F1234ABCD" { + rw.Write([]byte(`{ "ok": true }`)) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestDeleteCanvasAccess(t *testing.T) { + http.HandleFunc("/canvases.access.delete", deleteCanvasAccessHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := DeleteCanvasAccessParams{ + CanvasID: "F1234ABCD", + ChannelIDs: []string{"C1234ABCD"}, + UserIDs: []string{"U1234ABCD"}, + } + + err := api.DeleteCanvasAccess(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } +} + +func lookupCanvasSectionsHandler(rw http.ResponseWriter, r *http.Request) { + canvasID := r.FormValue("canvas_id") + + rw.Header().Set("Content-Type", "application/json") + + if canvasID == "F1234ABCD" { + sections := []CanvasSection{ + {ID: "S1234"}, + {ID: "S5678"}, + } + + resp, _ := json.Marshal(&LookupCanvasSectionsResponse{ + SlackResponse: SlackResponse{Ok: true}, + Sections: sections, + }) + rw.Write(resp) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestLookupCanvasSections(t *testing.T) { + http.HandleFunc("/canvases.sections.lookup", lookupCanvasSectionsHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := LookupCanvasSectionsParams{ + CanvasID: "F1234ABCD", + Criteria: LookupCanvasSectionsCriteria{ + SectionTypes: []string{"h1", "h2"}, + ContainsText: "Test", + }, + } + + sections, err := api.LookupCanvasSections(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + expectedSections := []CanvasSection{ + {ID: "S1234"}, + {ID: "S5678"}, + } + + if !reflect.DeepEqual(expectedSections, sections) { + t.Fatalf("Expected sections %v, got %v", expectedSections, sections) + } +} diff --git a/conversation.go b/conversation.go index 6b941b5ee..67d28be3b 100644 --- a/conversation.go +++ b/conversation.go @@ -2,6 +2,7 @@ package slack import ( "context" + "encoding/json" "errors" "net/url" "strconv" @@ -825,3 +826,36 @@ func (api *Client) MarkConversationContext(ctx context.Context, channel, ts stri } return response.Err() } + +// CreateChannelCanvas creates a new canvas in a channel. +// For more details, see CreateChannelCanvasContext documentation. +func (api *Client) CreateChannelCanvas(channel string, documentContent DocumentContent) (string, error) { + return api.CreateChannelCanvasContext(context.Background(), channel, documentContent) +} + +// CreateChannelCanvasContext creates a new canvas in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.canvases.create +func (api *Client) CreateChannelCanvasContext(ctx context.Context, channel string, documentContent DocumentContent) (string, error) { + values := url.Values{ + "token": {api.token}, + "channel_id": {channel}, + } + if documentContent.Type != "" { + documentContentJSON, err := json.Marshal(documentContent) + if err != nil { + return "", err + } + values.Add("document_content", string(documentContentJSON)) + } + + response := struct { + SlackResponse + CanvasID string `json:"canvas_id"` + }{} + err := api.postMethod(ctx, "conversations.canvases.create", values, &response) + if err != nil { + return "", err + } + + return response.CanvasID, response.Err() +} diff --git a/conversation_test.go b/conversation_test.go index 0ee6f4d4b..6b3576c97 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -743,3 +743,34 @@ func TestMarkConversation(t *testing.T) { return } } + +func createChannelCanvasHandler(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(struct { + SlackResponse + CanvasID string `json:"canvas_id"` + }{ + SlackResponse: SlackResponse{Ok: true}, + CanvasID: "F05RQ01LJU0", + }) + rw.Write(response) +} + +func TestCreateChannelCanvas(t *testing.T) { + http.HandleFunc("/conversations.canvases.create", createChannelCanvasHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + documentContent := DocumentContent{ + Type: "markdown", + Markdown: "> channel canvas!", + } + + canvasID, err := api.CreateChannelCanvas("C1234567890", documentContent) + if err != nil { + t.Errorf("Failed to create channel canvas: %v", err) + return + } + + assert.Equal(t, "F05RQ01LJU0", canvasID) +} From 00d0e9a995c975504c6db72b35c9da491e493ae2 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Wed, 6 Nov 2024 12:08:12 -0500 Subject: [PATCH 277/606] feat: Slack AI & Assistants Compatibility (#1331) Added new function for Slack AI & Assistants, information can be found here: https://api.slack.com/docs/apps/ai New Events: https://api.slack.com/events/assistant_thread_started https://api.slack.com/events/assistant_thread_context_changed New API Calls: https://api.slack.com/methods/assistant.threads.setStatus https://api.slack.com/methods/assistant.threads.setSuggestedPrompts https://api.slack.com/methods/assistant.threads.setTitle Tests have been added and pass all checks via `make pr-prep`. --- assistant.go | 153 +++++++++++++++++++++++++ assistant_test.go | 86 ++++++++++++++ block_section.go | 10 ++ slackevents/inner_events.go | 189 ++++++++++++++++++------------- slackevents/inner_events_test.go | 53 +++++++++ 5 files changed, 414 insertions(+), 77 deletions(-) create mode 100644 assistant.go create mode 100644 assistant_test.go diff --git a/assistant.go b/assistant.go new file mode 100644 index 000000000..ef1425ff3 --- /dev/null +++ b/assistant.go @@ -0,0 +1,153 @@ +package slack + +import ( + "context" + "encoding/json" + "net/url" +) + +// AssistantThreadSetStatusParameters are the parameters for AssistantThreadSetStatus +type AssistantThreadsSetStatusParameters struct { + ChannelID string `json:"channel_id"` + Status string `json:"status"` + ThreadTS string `json:"thread_ts"` +} + +// AssistantThreadSetTitleParameters are the parameters for AssistantThreadSetTitle +type AssistantThreadsSetTitleParameters struct { + ChannelID string `json:"channel_id"` + ThreadTS string `json:"thread_ts"` + Title string `json:"title"` +} + +// AssistantThreadSetSuggestedPromptsParameters are the parameters for AssistantThreadSetSuggestedPrompts +type AssistantThreadsSetSuggestedPromptsParameters struct { + Title string `json:"title"` + ChannelID string `json:"channel_id"` + ThreadTS string `json:"thread_ts"` + Prompts []AssistantThreadsPrompt `json:"prompts"` +} + +// AssistantThreadPrompt is a suggested prompt for a thread +type AssistantThreadsPrompt struct { + Title string `json:"title"` + Message string `json:"message"` +} + +// AssistantThreadSetSuggestedPrompts sets the suggested prompts for a thread +func (p *AssistantThreadsSetSuggestedPromptsParameters) AddPrompt(title, message string) { + p.Prompts = append(p.Prompts, AssistantThreadsPrompt{ + Title: title, + Message: message, + }) +} + +// SetAssistantThreadsSugesstedPrompts sets the suggested prompts for a thread +// @see https://api.slack.com/methods/assistant.threads.setSuggestedPrompts +func (api *Client) SetAssistantThreadsSuggestedPrompts(params AssistantThreadsSetSuggestedPromptsParameters) (err error) { + return api.SetAssistantThreadsSuggestedPromptsContext(context.Background(), params) +} + +// SetAssistantThreadSuggestedPromptsContext sets the suggested prompts for a thread with a custom context +// @see https://api.slack.com/methods/assistant.threads.setSuggestedPrompts +func (api *Client) SetAssistantThreadsSuggestedPromptsContext(ctx context.Context, params AssistantThreadsSetSuggestedPromptsParameters) (err error) { + + values := url.Values{ + "token": {api.token}, + } + + if params.ThreadTS != "" { + values.Add("thread_ts", params.ThreadTS) + } + + // Send Prompts as JSON + prompts, err := json.Marshal(params.Prompts) + if err != nil { + return err + } + + values.Add("prompts", string(prompts)) + + response := struct { + SlackResponse + }{} + + err = api.postMethod(ctx, "assistant.threads.setSuggestedPrompts", values, &response) + if err != nil { + return + } + + return response.Err() +} + +// SetAssistantThreadStatus sets the status of a thread +// @see https://api.slack.com/methods/assistant.threads.setStatus +func (api *Client) SetAssistantThreadsStatus(params AssistantThreadsSetStatusParameters) (err error) { + return api.SetAssistantThreadsStatusContext(context.Background(), params) +} + +// SetAssistantThreadStatusContext sets the status of a thread with a custom context +// @see https://api.slack.com/methods/assistant.threads.setStatus +func (api *Client) SetAssistantThreadsStatusContext(ctx context.Context, params AssistantThreadsSetStatusParameters) (err error) { + + values := url.Values{ + "token": {api.token}, + } + + if params.ThreadTS != "" { + values.Add("thread_ts", params.ThreadTS) + } + + // Always send the status parameter, if empty, it will clear any existing status + values.Add("status", params.Status) + + response := struct { + SlackResponse + }{} + + err = api.postMethod(ctx, "assistant.threads.setStatus", values, &response) + if err != nil { + return + } + + return response.Err() +} + +// SetAssistantThreadsTitle sets the title of a thread +// @see https://api.slack.com/methods/assistant.threads.setTitle +func (api *Client) SetAssistantThreadsTitle(params AssistantThreadsSetTitleParameters) (err error) { + return api.SetAssistantThreadsTitleContext(context.Background(), params) +} + +// SetAssistantThreadsTitleContext sets the title of a thread with a custom context +// @see https://api.slack.com/methods/assistant.threads.setTitle +func (api *Client) SetAssistantThreadsTitleContext(ctx context.Context, params AssistantThreadsSetTitleParameters) (err error) { + + values := url.Values{ + "token": {api.token}, + } + + if params.ChannelID != "" { + values.Add("channel_id", params.ChannelID) + } + + if params.ThreadTS != "" { + values.Add("thread_ts", params.ThreadTS) + } + + if params.Title != "" { + values.Add("title", params.Title) + } + + response := struct { + SlackResponse + }{} + + err = api.postMethod(ctx, "assistant.threads.setTitle", values, &response) + if err != nil { + return + } + + return response.Err() + +} diff --git a/assistant_test.go b/assistant_test.go new file mode 100644 index 000000000..51c271611 --- /dev/null +++ b/assistant_test.go @@ -0,0 +1,86 @@ +package slack + +import ( + "encoding/json" + "net/http" + "testing" +) + +func TestAssistantThreadsSuggestedPrompts(t *testing.T) { + + http.HandleFunc("/assistant.threads.setSuggestedPrompts", okJSONHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := AssistantThreadsSetSuggestedPromptsParameters{ + ChannelID: "CXXXXXXXX", + ThreadTS: "1234567890.123456", + } + + params.AddPrompt("title1", "message1") + params.AddPrompt("title2", "message2") + + err := api.SetAssistantThreadsSuggestedPrompts(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + +} + +func TestSetAssistantThreadsStatus(t *testing.T) { + + http.HandleFunc("/assistant.threads.setStatus", okJSONHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := AssistantThreadsSetStatusParameters{ + ChannelID: "CXXXXXXXX", + ThreadTS: "1234567890.123456", + Status: "updated status", + } + + err := api.SetAssistantThreadsStatus(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + +} + +func assistantThreadsTitleHandler(rw http.ResponseWriter, r *http.Request) { + + channelID := r.FormValue("channel_id") + threadTS := r.FormValue("thread_ts") + title := r.FormValue("title") + + rw.Header().Set("Content-Type", "application/json") + + if channelID != "" && threadTS != "" && title != "" { + + resp, _ := json.Marshal(&addBookmarkResponse{ + SlackResponse: SlackResponse{Ok: true}, + }) + rw.Write(resp) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } + +} + +func TestSetAssistantThreadsTitle(t *testing.T) { + + http.HandleFunc("/assistant.threads.setTitle", assistantThreadsTitleHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := AssistantThreadsSetTitleParameters{ + ChannelID: "CXXXXXXXX", + ThreadTS: "1234567890.123456", + Title: "updated title", + } + + err := api.SetAssistantThreadsTitle(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + +} diff --git a/block_section.go b/block_section.go index 01ffd5a1d..1ffc17e82 100644 --- a/block_section.go +++ b/block_section.go @@ -9,6 +9,7 @@ type SectionBlock struct { BlockID string `json:"block_id,omitempty"` Fields []*TextBlockObject `json:"fields,omitempty"` Accessory *Accessory `json:"accessory,omitempty"` + Expand bool `json:"expand,omitempty"` } // BlockType returns the type of the block @@ -25,6 +26,15 @@ func SectionBlockOptionBlockID(blockID string) SectionBlockOption { } } +// SectionBlockOptionExpand allows long text to be auto-expanded when displaying +// +// @see https://api.slack.com/reference/block-kit/blocks#section +func SectionBlockOptionExpand(shouldExpand bool) SectionBlockOption { + return func(block *SectionBlock) { + block.Expand = shouldExpand + } +} + // NewSectionBlock returns a new instance of a section block to be rendered func NewSectionBlock(textObj *TextBlockObject, fields []*TextBlockObject, accessory *Accessory, options ...SectionBlockOption) *SectionBlock { block := SectionBlock{ diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 8f8effaae..720640b63 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -12,6 +12,35 @@ type EventsAPIInnerEvent struct { Data interface{} } +// AssistantThreadMessageEvent is an (inner) EventsAPI subscribable event. +type AssistantThreadStartedEvent struct { + Type string `json:"type"` + AssistantThread AssistantThread `json:"assistant_thread"` + EventTimestamp string `json:"event_ts"` +} + +// AssistantThreadChangedEvent is an (inner) EventsAPI subscribable event. +type AssistantThreadContextChangedEvent struct { + Type string `json:"type"` + AssistantThread AssistantThread `json:"assistant_thread"` + EventTimestamp string `json:"event_ts"` +} + +// AssistantThread is an object that represents a thread of messages between a user and an assistant. +type AssistantThread struct { + UserID string `json:"user_id"` + Context AssistantThreadContext `json:"context"` + ChannelID string `json:"channel_id"` + ThreadTimeStamp string `json:"thread_ts"` +} + +// AssistantThreadContext is an object that represents the context of an assistant thread. +type AssistantThreadContext struct { + ChannelID string `json:"channel_id"` + TeamID string `json:"team_id"` + EnterpriseID string `json:"enterprise_id"` +} + // AppMentionEvent is an (inner) EventsAPI subscribable event. type AppMentionEvent struct { Type string `json:"type"` @@ -1117,6 +1146,10 @@ const ( AppHomeOpened = EventsAPIType("app_home_opened") // AppUninstalled Your Slack app was uninstalled. AppUninstalled = EventsAPIType("app_uninstalled") + // AssistantThreadStarted Your Slack AI Assistant has started a new thread + AssistantThreadStarted = EventsAPIType("assistant_thread_started") + // AssistantThreadContextChanged Your Slack AI Assistant has changed the context of a thread + AssistantThreadContextChanged = EventsAPIType("assistant_thread_context_changed") // ChannelCreated is sent when a new channel is created. ChannelCreated = EventsAPIType("channel_created") // ChannelDeleted is sent when a channel is deleted. @@ -1273,81 +1306,83 @@ const ( // implementations. The structs should be instances of the unmarshalling // target for the matching event type. var EventsAPIInnerEventMapping = map[EventsAPIType]interface{}{ - AppMention: AppMentionEvent{}, - AppHomeOpened: AppHomeOpenedEvent{}, - AppUninstalled: AppUninstalledEvent{}, - ChannelCreated: ChannelCreatedEvent{}, - ChannelDeleted: ChannelDeletedEvent{}, - ChannelArchive: ChannelArchiveEvent{}, - ChannelUnarchive: ChannelUnarchiveEvent{}, - ChannelLeft: ChannelLeftEvent{}, - ChannelRename: ChannelRenameEvent{}, - ChannelIDChanged: ChannelIDChangedEvent{}, - FileChange: FileChangeEvent{}, - FileDeleted: FileDeletedEvent{}, - FileShared: FileSharedEvent{}, - FileUnshared: FileUnsharedEvent{}, - GroupDeleted: GroupDeletedEvent{}, - GroupArchive: GroupArchiveEvent{}, - GroupUnarchive: GroupUnarchiveEvent{}, - GroupLeft: GroupLeftEvent{}, - GroupRename: GroupRenameEvent{}, - GridMigrationFinished: GridMigrationFinishedEvent{}, - GridMigrationStarted: GridMigrationStartedEvent{}, - LinkShared: LinkSharedEvent{}, - Message: MessageEvent{}, - MemberJoinedChannel: MemberJoinedChannelEvent{}, - MemberLeftChannel: MemberLeftChannelEvent{}, - PinAdded: PinAddedEvent{}, - PinRemoved: PinRemovedEvent{}, - ReactionAdded: ReactionAddedEvent{}, - ReactionRemoved: ReactionRemovedEvent{}, - SharedChannelInviteApproved: SharedChannelInviteApprovedEvent{}, - SharedChannelInviteAccepted: SharedChannelInviteAcceptedEvent{}, - SharedChannelInviteDeclined: SharedChannelInviteDeclinedEvent{}, - SharedChannelInviteReceived: SharedChannelInviteReceivedEvent{}, - TeamJoin: TeamJoinEvent{}, - TokensRevoked: TokensRevokedEvent{}, - EmojiChanged: EmojiChangedEvent{}, - WorkflowStepExecute: WorkflowStepExecuteEvent{}, - MessageMetadataPosted: MessageMetadataPostedEvent{}, - MessageMetadataUpdated: MessageMetadataUpdatedEvent{}, - MessageMetadataDeleted: MessageMetadataDeletedEvent{}, - TeamAccessGranted: TeamAccessGrantedEvent{}, - TeamAccessRevoked: TeamAccessRevokedEvent{}, - UserProfileChanged: UserProfileChangedEvent{}, - ChannelHistoryChanged: ChannelHistoryChangedEvent{}, - DndUpdated: DndUpdatedEvent{}, - DndUpdatedUser: DndUpdatedUserEvent{}, - EmailDomainChanged: EmailDomainChangedEvent{}, - GroupClose: GroupCloseEvent{}, - GroupHistoryChanged: GroupHistoryChangedEvent{}, - GroupOpen: GroupOpenEvent{}, - ImClose: ImCloseEvent{}, - ImCreated: ImCreatedEvent{}, - ImHistoryChanged: ImHistoryChangedEvent{}, - ImOpen: ImOpenEvent{}, - SubteamCreated: SubteamCreatedEvent{}, - SubteamMembersChanged: SubteamMembersChangedEvent{}, - SubteamSelfAdded: SubteamSelfAddedEvent{}, - SubteamSelfRemoved: SubteamSelfRemovedEvent{}, - SubteamUpdated: SubteamUpdatedEvent{}, - TeamDomainChange: TeamDomainChangeEvent{}, - TeamRename: TeamRenameEvent{}, - UserChange: UserChangeEvent{}, - AppDeleted: AppDeletedEvent{}, - AppInstalled: AppInstalledEvent{}, - AppRequested: AppRequestedEvent{}, - AppUninstalledTeam: AppUninstalledTeamEvent{}, - CallRejected: CallRejectedEvent{}, - ChannelShared: ChannelSharedEvent{}, - FileCreated: FileCreatedEvent{}, - FilePublic: FilePublicEvent{}, - FunctionExecuted: FunctionExecutedEvent{}, - InviteRequested: InviteRequestedEvent{}, - SharedChannelInviteRequested: SharedChannelInviteRequestedEvent{}, - StarAdded: StarAddedEvent{}, - StarRemoved: StarRemovedEvent{}, - UserHuddleChanged: UserHuddleChangedEvent{}, - UserStatusChanged: UserStatusChangedEvent{}, + AppMention: AppMentionEvent{}, + AppHomeOpened: AppHomeOpenedEvent{}, + AppUninstalled: AppUninstalledEvent{}, + AssistantThreadStarted: AssistantThreadStartedEvent{}, + AssistantThreadContextChanged: AssistantThreadContextChangedEvent{}, + ChannelCreated: ChannelCreatedEvent{}, + ChannelDeleted: ChannelDeletedEvent{}, + ChannelArchive: ChannelArchiveEvent{}, + ChannelUnarchive: ChannelUnarchiveEvent{}, + ChannelLeft: ChannelLeftEvent{}, + ChannelRename: ChannelRenameEvent{}, + ChannelIDChanged: ChannelIDChangedEvent{}, + FileChange: FileChangeEvent{}, + FileDeleted: FileDeletedEvent{}, + FileShared: FileSharedEvent{}, + FileUnshared: FileUnsharedEvent{}, + GroupDeleted: GroupDeletedEvent{}, + GroupArchive: GroupArchiveEvent{}, + GroupUnarchive: GroupUnarchiveEvent{}, + GroupLeft: GroupLeftEvent{}, + GroupRename: GroupRenameEvent{}, + GridMigrationFinished: GridMigrationFinishedEvent{}, + GridMigrationStarted: GridMigrationStartedEvent{}, + LinkShared: LinkSharedEvent{}, + Message: MessageEvent{}, + MemberJoinedChannel: MemberJoinedChannelEvent{}, + MemberLeftChannel: MemberLeftChannelEvent{}, + PinAdded: PinAddedEvent{}, + PinRemoved: PinRemovedEvent{}, + ReactionAdded: ReactionAddedEvent{}, + ReactionRemoved: ReactionRemovedEvent{}, + SharedChannelInviteApproved: SharedChannelInviteApprovedEvent{}, + SharedChannelInviteAccepted: SharedChannelInviteAcceptedEvent{}, + SharedChannelInviteDeclined: SharedChannelInviteDeclinedEvent{}, + SharedChannelInviteReceived: SharedChannelInviteReceivedEvent{}, + TeamJoin: TeamJoinEvent{}, + TokensRevoked: TokensRevokedEvent{}, + EmojiChanged: EmojiChangedEvent{}, + WorkflowStepExecute: WorkflowStepExecuteEvent{}, + MessageMetadataPosted: MessageMetadataPostedEvent{}, + MessageMetadataUpdated: MessageMetadataUpdatedEvent{}, + MessageMetadataDeleted: MessageMetadataDeletedEvent{}, + TeamAccessGranted: TeamAccessGrantedEvent{}, + TeamAccessRevoked: TeamAccessRevokedEvent{}, + UserProfileChanged: UserProfileChangedEvent{}, + ChannelHistoryChanged: ChannelHistoryChangedEvent{}, + DndUpdated: DndUpdatedEvent{}, + DndUpdatedUser: DndUpdatedUserEvent{}, + EmailDomainChanged: EmailDomainChangedEvent{}, + GroupClose: GroupCloseEvent{}, + GroupHistoryChanged: GroupHistoryChangedEvent{}, + GroupOpen: GroupOpenEvent{}, + ImClose: ImCloseEvent{}, + ImCreated: ImCreatedEvent{}, + ImHistoryChanged: ImHistoryChangedEvent{}, + ImOpen: ImOpenEvent{}, + SubteamCreated: SubteamCreatedEvent{}, + SubteamMembersChanged: SubteamMembersChangedEvent{}, + SubteamSelfAdded: SubteamSelfAddedEvent{}, + SubteamSelfRemoved: SubteamSelfRemovedEvent{}, + SubteamUpdated: SubteamUpdatedEvent{}, + TeamDomainChange: TeamDomainChangeEvent{}, + TeamRename: TeamRenameEvent{}, + UserChange: UserChangeEvent{}, + AppDeleted: AppDeletedEvent{}, + AppInstalled: AppInstalledEvent{}, + AppRequested: AppRequestedEvent{}, + AppUninstalledTeam: AppUninstalledTeamEvent{}, + CallRejected: CallRejectedEvent{}, + ChannelShared: ChannelSharedEvent{}, + FileCreated: FileCreatedEvent{}, + FilePublic: FilePublicEvent{}, + FunctionExecuted: FunctionExecutedEvent{}, + InviteRequested: InviteRequestedEvent{}, + SharedChannelInviteRequested: SharedChannelInviteRequestedEvent{}, + StarAdded: StarAddedEvent{}, + StarRemoved: StarRemovedEvent{}, + UserHuddleChanged: UserHuddleChangedEvent{}, + UserStatusChanged: UserStatusChangedEvent{}, } diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index 4307e8ad6..baa4e2c08 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -8,6 +8,59 @@ import ( "github.com/stretchr/testify/assert" ) +func TestAssistantThreadStartedEvent(t *testing.T) { + + rawE := []byte(` + { + "type": "assistant_thread_started", + "assistant_thread": { + "user_id": "U123ABC456", + "context": { + "channel_id": "C123ABC456", + "team_id": "T07XY8FPJ5C", + "enterprise_id": "E480293PS82" + }, + "channel_id": "D123ABC456", + "thread_ts": "1729999327.187299" + + }, + "event_ts": "1715873754.429808" + } + `) + + err := json.Unmarshal(rawE, &AssistantThreadStartedEvent{}) + if err != nil { + t.Error(err) + } + +} + +func TestAssistantThreadContextChangedEvent(t *testing.T) { + + rawE := []byte(` + { + "type": "assistant_thread_context_changed", + "assistant_thread": { + "user_id": "U123ABC456", + "context": { + "channel_id": "C123ABC456", + "team_id": "T07XY8FPJ5C", + "enterprise_id": "E480293PS82" + }, + "channel_id": "D123ABC456", + "thread_ts": "1729999327.187299" + }, + "event_ts": "17298244.022142" + } + `) + + err := json.Unmarshal(rawE, &AssistantThreadContextChangedEvent{}) + if err != nil { + t.Error(err) + } + +} + func TestAppMention(t *testing.T) { rawE := []byte(` { From a99606f766e7d8c85067d311b606e6456a371b4b Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Tue, 12 Nov 2024 14:30:09 -0500 Subject: [PATCH 278/606] fix: Assistant Prompts and Status, send channel_id with request (#1344) This is a fix for [PR 1331](https://github.com/slack-go/slack/pull/1331), which I mistakenly removed the channel_id parameter from the API request during feedback. Without channel_id provided, the call always returns `invalid_arguments`. --- assistant.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/assistant.go b/assistant.go index ef1425ff3..8432f89b3 100644 --- a/assistant.go +++ b/assistant.go @@ -60,6 +60,8 @@ func (api *Client) SetAssistantThreadsSuggestedPromptsContext(ctx context.Contex values.Add("thread_ts", params.ThreadTS) } + values.Add("channel_id", params.ChannelID) + // Send Prompts as JSON prompts, err := json.Marshal(params.Prompts) if err != nil { @@ -98,6 +100,8 @@ func (api *Client) SetAssistantThreadsStatusContext(ctx context.Context, params values.Add("thread_ts", params.ThreadTS) } + values.Add("channel_id", params.ChannelID) + // Always send the status parameter, if empty, it will clear any existing status values.Add("status", params.Status) From 52fcd3dfb438132318fa07d697cafac23d1dbfd1 Mon Sep 17 00:00:00 2001 From: Caleb McKay <11079725+calebmckay@users.noreply.github.com> Date: Tue, 12 Nov 2024 14:31:20 -0500 Subject: [PATCH 279/606] fix: Don't require `skin_tone` for rich text emoji element (#1341) In a rich text `emoji` element, the `skin_tone` value is optional, and when not provided will use the default skin tone. When unmarshalling from JSON, this parameter defaults to an invalid value of 0 (`skin_tone` values range from 2 to 6), resulting in an "invalid_blocks" error when sending a message. This PR allows the `skin_tone` element to be omitted when unmarshalling from JSON. --- block_rich_text.go | 2 +- block_rich_text_test.go | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/block_rich_text.go b/block_rich_text.go index c6eb0b1ba..22020bebd 100644 --- a/block_rich_text.go +++ b/block_rich_text.go @@ -340,7 +340,7 @@ func NewRichTextSectionUserElement(userID string, style *RichTextSectionTextStyl type RichTextSectionEmojiElement struct { Type RichTextSectionElementType `json:"type"` Name string `json:"name"` - SkinTone int `json:"skin_tone"` + SkinTone int `json:"skin_tone,omitempty"` Unicode string `json:"unicode,omitempty"` Style *RichTextSectionTextStyle `json:"style,omitempty"` } diff --git a/block_rich_text_test.go b/block_rich_text_test.go index dc4a0cf5b..903f14389 100644 --- a/block_rich_text_test.go +++ b/block_rich_text_test.go @@ -187,6 +187,16 @@ func TestRichTextSection_UnmarshalJSON(t *testing.T) { }, nil, }, + { + []byte(`{"type": "rich_text_section","elements":[{"type": "emoji","name": "+1"}]}`), + RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionEmojiElement{Type: RTSEEmoji, Name: "+1"}, + }, + }, + nil, + }, { []byte(`{"type": "rich_text_section","elements":[{"type": "emoji","name": "+1","unicode": "1f44d-1f3fb","skin_tone": 2}]}`), RichTextSection{ @@ -299,7 +309,7 @@ func TestRichTextList_UnmarshalJSON(t *testing.T) { func TestRichTextQuote_Marshal(t *testing.T) { t.Run("rich_text_section", func(t *testing.T) { - const rawRSE = "{\"type\":\"rich_text_section\",\"elements\":[{\"type\":\"text\",\"text\":\"Some Text\"}]}" + const rawRSE = "{\"type\":\"rich_text_section\",\"elements\":[{\"type\":\"text\",\"text\":\"Some Text\"},{\"type\":\"emoji\",\"name\":\"+1\"},{\"type\":\"emoji\",\"name\":\"+1\",\"skin_tone\":2}]}" var got RichTextSection if err := json.Unmarshal([]byte(rawRSE), &got); err != nil { @@ -309,6 +319,8 @@ func TestRichTextQuote_Marshal(t *testing.T) { Type: RTESection, Elements: []RichTextSectionElement{ &RichTextSectionTextElement{Type: RTSEText, Text: "Some Text"}, + &RichTextSectionEmojiElement{Type: RTSEEmoji, Name: "+1"}, + &RichTextSectionEmojiElement{Type: RTSEEmoji, Name: "+1", SkinTone: 2}, }, } From dbb76ddd28b1c5612cbfbc1786c0ae75687a951a Mon Sep 17 00:00:00 2001 From: Hussachai Puripunpinyo Date: Thu, 14 Nov 2024 10:26:17 -0800 Subject: [PATCH 280/606] fix: ScheduleMessage not returning scheduled_message_id (#1153) I'd like to introduce the fix for `ScheduledMessage` API. Currently, it doesn't return scheduled_message_id where this ID is needed for cancelling the scheduled message. My change is the least intrusive I could think of. It doesn't introduce any new field or adding new API. Basically, this change should be backward compatible. It is based on the fact that the API [chat.postMessage](https://api.slack.com/methods/chat.postMessage#examples) doesn't return `scheduled_message_id` and the API [chat.scheduledMessage](https://api.slack.com/methods/chat.scheduleMessage#examples) doesn't return either `message.ts` or `ts`. We just need to check the presence of this field and determine the caller API based on that. Existing code shouldn't break because the `timestamp` is always an empty string for `ScheduledMessage` API. Now it will return `scheduled_message_id` in place of `timestamp`. `SendMessage` should remain the same. ##### Pull Request Guidelines These are recommendations for pull requests. They are strictly guidelines to help manage expectations. ##### PR preparation Run `make pr-prep` from the root of the repository to run formatting, linting and tests. ##### Should this be an issue instead - [ ] is it a convenience method? (no new functionality, streamlines some use case) - [ ] exposes a previously private type, const, method, etc. - [ ] is it application specific (caching, retry logic, rate limiting, etc) - [ ] is it performance related. ##### API changes Since API changes have to be maintained they undergo a more detailed review and are more likely to require changes. - no tests, if you're adding to the API include at least a single test of the happy case. - If you can accomplish your goal without changing the API, then do so. - dependency changes. updates are okay. adding/removing need justification. ###### Examples of API changes that do not meet guidelines: - in library cache for users. caches are use case specific. - Convenience methods for Sending Messages, update, post, ephemeral, etc. consider opening an issue instead. --- chat.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/chat.go b/chat.go index 96843d68d..385836a05 100644 --- a/chat.go +++ b/chat.go @@ -116,13 +116,13 @@ func (api *Client) ScheduleMessage(channelID, postAt string, options ...MsgOptio // ScheduleMessageContext sends a message to a channel with a custom context. // Slack API docs: https://api.slack.com/methods/chat.scheduleMessage func (api *Client) ScheduleMessageContext(ctx context.Context, channelID, postAt string, options ...MsgOption) (string, string, error) { - respChannel, respTimestamp, _, err := api.SendMessageContext( + respChannel, scheduledMessageId, _, err := api.SendMessageContext( ctx, channelID, MsgOptionSchedule(postAt), MsgOptionCompose(options...), ) - return respChannel, respTimestamp, err + return respChannel, scheduledMessageId, err } // PostMessage sends a message to a channel. @@ -214,7 +214,7 @@ func (api *Client) SendMessage(channel string, options ...MsgOption) (string, st // SendMessageContext more flexible method for configuring messages with a custom context. // Slack API docs: https://api.slack.com/methods/chat.postMessage -func (api *Client) SendMessageContext(ctx context.Context, channelID string, options ...MsgOption) (_channel string, _timestamp string, _text string, err error) { +func (api *Client) SendMessageContext(ctx context.Context, channelID string, options ...MsgOption) (_channel string, _timestampOrScheduledMessageId string, _text string, err error) { var ( req *http.Request parser func(*chatResponseFull) responseParser @@ -238,7 +238,11 @@ func (api *Client) SendMessageContext(ctx context.Context, channelID string, opt return "", "", "", err } - return response.Channel, response.getMessageTimestamp(), response.Text, response.Err() + if response.ScheduledMessageID != "" { + return response.Channel, response.ScheduledMessageID, response.Text, response.Err() + } else { + return response.Channel, response.getMessageTimestamp(), response.Text, response.Err() + } } func redactToken(b []byte) []byte { From e76401112d28edda69b2c25b99999962f95c7a7f Mon Sep 17 00:00:00 2001 From: Lorenzo Aiello Date: Thu, 14 Nov 2024 13:26:43 -0500 Subject: [PATCH 281/606] chore: Contributing Guide - Codification of Project Norms (#1340) This pull request introduces a Contributing Guide to start codifying best practices and norms in the project. It also eliminates two old files that can be safely removed and just retained in the git history. The notable items in the contributing guide are: - Go Comment Formatting (already largely in place) - Codifying a preference for Input/Output structs on functions to allow more non-breaking changes to function calls as parameters change/expand over time (things have already been trending in this direction, but it helps to commit to the direction) I'll leave this up for a bit in case anyone has any thoughts or feedback. --- CHANGELOG.md | 103 ------------------------------------------------ CONTRIBUTING.md | 40 +++++++++++++++++++ README.md | 2 +- TODO.txt | 3 -- 4 files changed, 41 insertions(+), 107 deletions(-) delete mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md delete mode 100644 TODO.txt diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 32da687bb..000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,103 +0,0 @@ -### v0.7.0 - October 2, 2020 -full differences can be viewed using `git log --oneline --decorate --color v0.6.6..v0.7.0` -Thank you for many contributions! - -#### Breaking Changes -- Add ScheduledMessage type ([#753]) -- Add description field to option block object ([#783]) -- Fix wrong conditional branch ([#782]) - - The behavior of the user's application may change.(The current behavior is incorrect) - -#### Highlights -- example: fix to start up a server ([#773]) -- example: Add explanation how the message could be sent in a proper way ([#787]) -- example: fix typo in error log ([#779]) -- refactor: Make GetConversationsParameters.ExcludeArchived optional ([#791]) -- refactor: Unify variables to "config" ([#800]) -- refactor: Rename wrong file name ([#810]) -- feature: Add SetUserRealName for change user's realName([#755]) -- feature: Add response metadata to slack response ([#772]) -- feature: Add response metadata to slack response ([#778]) -- feature: Add select block element conversations filter field ([#790]) -- feature: Add Root field to MessageEvent to support thread_broadcast subtype ([#793]) -- feature: Add bot_profile to messages ([#794]) -- doc: Add logo to README ([#813]) -- doc: Update current project status and Add changelog for v0.7.0 ([#814]) - -[#753]: https://github.com/slack-go/slack/pull/753 -[#755]: https://github.com/slack-go/slack/pull/755 -[#772]: https://github.com/slack-go/slack/pull/772 -[#773]: https://github.com/slack-go/slack/pull/773 -[#778]: https://github.com/slack-go/slack/pull/778 -[#779]: https://github.com/slack-go/slack/pull/779 -[#782]: https://github.com/slack-go/slack/pull/782 -[#783]: https://github.com/slack-go/slack/pull/783 -[#787]: https://github.com/slack-go/slack/pull/787 -[#790]: https://github.com/slack-go/slack/pull/790 -[#791]: https://github.com/slack-go/slack/pull/791 -[#793]: https://github.com/slack-go/slack/pull/793 -[#794]: https://github.com/slack-go/slack/pull/794 -[#800]: https://github.com/slack-go/slack/pull/800 -[#810]: https://github.com/slack-go/slack/pull/810 -[#813]: https://github.com/slack-go/slack/pull/813 -[#814]: https://github.com/slack-go/slack/pull/814 - -### v0.6.0 - August 31, 2019 -full differences can be viewed using `git log --oneline --decorate --color v0.5.0..v0.6.0` -thanks to everyone who has contributed since January! - - -#### Breaking Changes: -- Info struct has had fields removed related to deprecated functionality by slack. -- minor adjustments to some structs. -- some internal default values have changed, usually to be more inline with slack defaults or to correct inability to set a particular value. (Message Parse for example.) - -##### Highlights: -- new slacktest package easy mocking for slack client. use, enjoy, please submit PRs for improvements and default behaviours! shamelessly taken from the [slack-test repo](https://github.com/lusis/slack-test) thank you lusis for letting us use it and bring it into the slack repo. -- blocks, blocks, blocks. -- RTM ManagedConnection has undergone a significant cleanup. -in particular handles backoffs gracefully, removed many deadlocks, -and Disconnect is now much more responsive. - -### v0.5.0 - January 20, 2019 -full differences can be viewed using `git log --oneline --decorate --color v0.4.0..v0.5.0` -- Breaking changes: various old struct fields have been removed or updated to match slack's api. -- deadlock fix in RTM disconnect. - -### v0.4.0 - October 06, 2018 -full differences can be viewed using `git log --oneline --decorate --color v0.3.0..v0.4.0` -- Breaking Change: renamed ApplyMessageOption, to mark it as unsafe, -this means it may break without warning in the future. -- Breaking: Msg structure files field changed to an array. -- General: implementation for new security headers. -- RTM: deadlock fix between connect/disconnect. -- Events: various new fields added. -- Web: various fixes, new fields exposed, new methods added. -- Interactions: minor additions expect breaking changes in next release for dialogs/button clicks. -- Utils: new methods added. - -### v0.3.0 - July 30, 2018 -full differences can be viewed using `git log --oneline --decorate --color v0.2.0..v0.3.0` -- slack events initial support added. (still considered experimental and undergoing changes, stability not promised) -- vendored depedencies using dep, ensure using up to date tooling before filing issues. -- RTM has improved its ability to identify dead connections and reconnect automatically (worth calling out in case it has unintended side effects). -- bug fixes (various timestamp handling, error handling, RTM locking, etc). - -### v0.2.0 - Feb 10, 2018 - -Release adds a bunch of functionality and improvements, mainly to give people a recent version to vendor against. - -Please check [0.2.0](https://github.com/nlopes/slack/releases/tag/v0.2.0) - -### v0.1.0 - May 28, 2017 - -This is released before adding context support. -As the used context package is the one from Go 1.7 this will be the last -compatible with Go < 1.7. - -Please check [0.1.0](https://github.com/nlopes/slack/releases/tag/v0.1.0) - -### v0.0.1 - Jul 26, 2015 - -If you just updated from master and it broke your implementation, please -check [0.0.1](https://github.com/nlopes/slack/releases/tag/v0.0.1) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..8b92d3531 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,40 @@ +# Contributing Guide + +Welcome! We are glad that you want to contribute to our project! 💖 + +There are a just a few small guidelines you ask everyone to follow to make things a bit smoother and more consistent. + +## Opening Pull Requests + +1. It's generally best to start by opening a new issue describing the bug or feature you're intending to fix. Even if you think it's relatively minor, it's helpful to know what people are working on. Mention in the initial issue that you are planning to work on that bug or feature so that it can be assigned to you. + +2. Follow the normal process of [forking](https://help.github.com/articles/fork-a-repo) the project, and set up a new branch to work in. It's important that each group of changes be done in separate branches in order to ensure that a pull request only includes the commits related to that bug or feature. + +3. Any significant changes should almost always be accompanied by tests. The project already has some test coverage, so look at some of the existing tests if you're unsure how to go about it. + +4. Run `make pr-prep` to format your code and check that it passes all tests and linters. + +5. Do your best to have [well-formed commit messages](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) for each change. This provides consistency throughout the project, and ensures that commit messages are able to be formatted properly by various git tools. _Pull Request Titles_ should generally follow the [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/) format to ease the release note process when cutting releases. + +6. Finally, push the commits to your fork and submit a [pull request](https://help.github.com/articles/creating-a-pull-request). NOTE: Please do not use force-push on PRs in this repo, as it makes it more difficult for reviewers to see what has changed since the last code review. We always perform "squash and merge" actions on PRs in this repo, so it doesn't matter how many commits your PR has, as they will end up being a single commit after merging. This is done to make a much cleaner `git log` history and helps to find regressions in the code using existing tools such as `git bisect`. + +## Code Comments + +Every exported method needs to have code comments that follow [Go Doc Comments](https://go.dev/doc/comment). A typical method's comments will look like this: + +```go +// PostMessage sends a message to a channel. +// +// Slack API docs: https://api.dev.slack.com/methods/chat.postMessage +func (api *Client) PostMessage(ctx context.Context, input PostMesssageInput) (PostMesssageOutput, error) { +... +} +``` + +The first line is the name of the method followed by a short description. This could also be a longer description if needed, but there is no need to repeat any details that are documented in Slack's documentation because users are expected to follow the documentation links to learn more. + +After the description comes a link to the Slack API documentation. + +## Other notes on code organization + +Currently, everything is defined in the main `slack` package, with API methods group separate files by the [Slack API Method Groupings](https://api.dev.slack.com/methods). diff --git a/README.md b/README.md index 9618aebc3..7d9733e19 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ a fully managed way. There is currently no major version released. Therefore, minor version releases may include backward incompatible changes. -See [CHANGELOG.md](https://github.com/slack-go/slack/blob/master/CHANGELOG.md) or [Releases](https://github.com/slack-go/slack/releases) for more information about the changes. +See [Releases](https://github.com/slack-go/slack/releases) for more information about the changes. ## Installing diff --git a/TODO.txt b/TODO.txt deleted file mode 100644 index 8607960b8..000000000 --- a/TODO.txt +++ /dev/null @@ -1,3 +0,0 @@ -- Add more tests!!! -- Add support to have markdown hints - - See section Message Formatting at https://api.slack.com/docs/formatting From 4006d8a40b219e0c3a90c6dbb20d58d737d9d08f Mon Sep 17 00:00:00 2001 From: Lorenzo Aiello Date: Thu, 14 Nov 2024 11:49:13 -0800 Subject: [PATCH 282/606] chore: Adding A Deprecation Notice for rtm.start (no-op) --- rtm.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rtm.go b/rtm.go index ef6ba3434..9b30eb315 100644 --- a/rtm.go +++ b/rtm.go @@ -25,6 +25,9 @@ const ( // StartRTM calls the "rtm.start" endpoint and returns the provided URL and the full Info block. // // To have a fully managed Websocket connection, use `NewRTM`, and call `ManageConnection()` on it. +// +// Deprecated: Use [ConnectRTM] instead. +// For more details, see: https://api.slack.com/changelog/2021-10-rtm-start-to-stop func (api *Client) StartRTM() (info *Info, websocketURL string, err error) { ctx, cancel := context.WithTimeout(context.Background(), websocketDefaultTimeout) defer cancel() @@ -35,6 +38,9 @@ func (api *Client) StartRTM() (info *Info, websocketURL string, err error) { // StartRTMContext calls the "rtm.start" endpoint and returns the provided URL and the full Info block with a custom context. // // To have a fully managed Websocket connection, use `NewRTM`, and call `ManageConnection()` on it. +// +// Deprecated: Use [ConnectRTMContext] instead. +// For more details, see: https://api.slack.com/changelog/2021-10-rtm-start-to-stop func (api *Client) StartRTMContext(ctx context.Context) (info *Info, websocketURL string, err error) { response := &infoResponseFull{} err = api.postMethod(ctx, "rtm.start", url.Values{"token": {api.token}}, response) From a2280c9cebd90efa8befe90cd88c6a01c28a58b6 Mon Sep 17 00:00:00 2001 From: Fahmi Zulhasymi Date: Fri, 22 Nov 2024 14:19:29 +0700 Subject: [PATCH 283/606] add parse channel context and host for shared channel --- conversation.go | 2 ++ conversation_test.go | 86 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/conversation.go b/conversation.go index 67d28be3b..00f7d4e3b 100644 --- a/conversation.go +++ b/conversation.go @@ -36,6 +36,8 @@ type Conversation struct { ConnectedTeamIDs []string `json:"connected_team_ids,omitempty"` SharedTeamIDs []string `json:"shared_team_ids,omitempty"` InternalTeamIDs []string `json:"internal_team_ids,omitempty"` + ContextTeamID string `json:"context_team_id,omitempty"` + ConversationHostID string `json:"conversation_host_id,omitempty"` // TODO support pending_shared // TODO support previous_names diff --git a/conversation_test.go b/conversation_test.go index 6b3576c97..cd28f11c6 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -82,6 +82,92 @@ func TestCreateSimpleChannel(t *testing.T) { assertSimpleChannel(t, channel) } +// Shared Channel +var sharedChannel = `{ + "id": "C024BE91L", + "name": "fun", + "is_channel": true, + "created": 1360782804, + "creator": "U024BE7LH", + "is_archived": false, + "is_general": false, + "members": [ + "U024BE7LH" + ], + "is_shared": true, + "context_team_id": "T1ABCD2E12", + "is_ext_shared": true, + "shared_team_ids": [ + "T07XY8FPJ5C" + ], + "internal_team_ids": [], + "connected_team_ids": [ + "T07XY8FPJ5C", + "T1ABCD2E12" + ], + "connected_limited_team_ids": [], + "pending_connected_team_ids": [], + "conversation_host_id": "T07XY8FPJ5C", + "topic": { + "value": "Fun times", + "creator": "U024BE7LV", + "last_set": 1369677212 + }, + "purpose": { + "value": "This channel is for fun", + "creator": "U024BE7LH", + "last_set": 1360782804 + }, + "is_member": true, + "last_read": "1401383885.000061", + "unread_count": 0, + "unread_count_display": 0 +}` + +func unmarshalSharedChannel(j string) (*Channel, error) { + channel := &Channel{} + if err := json.Unmarshal([]byte(j), &channel); err != nil { + return nil, err + } + return channel, nil +} + +func TestSharedChannel(t *testing.T) { + channel, err := unmarshalSharedChannel(sharedChannel) + assert.Nil(t, err) + assertSharedChannel(t, channel) +} + +func assertSharedChannel(t *testing.T, channel *Channel) { + assertSimpleChannel(t, channel) + assert.Equal(t, true, channel.IsShared) + assert.Equal(t, true, channel.IsExtShared) + assert.Equal(t, "T1ABCD2E12", channel.ContextTeamID) + assert.Equal(t, "T07XY8FPJ5C", channel.ConversationHostID) + if !reflect.DeepEqual([]string{"T07XY8FPJ5C"}, channel.SharedTeamIDs) { + t.Fatal(ErrIncorrectResponse) + } + if !reflect.DeepEqual([]string{"T07XY8FPJ5C", "T1ABCD2E12"}, channel.ConnectedTeamIDs) { + t.Fatal(ErrIncorrectResponse) + } +} + +func TestCreateSharedChannel(t *testing.T) { + channel := &Channel{} + channel.ID = "C024BE91L" + channel.Name = "fun" + channel.IsChannel = true + channel.Created = JSONTime(1360782804) + channel.Creator = "U024BE7LH" + channel.IsArchived = false + channel.IsGeneral = false + channel.IsMember = true + channel.LastRead = "1401383885.000061" + channel.UnreadCount = 0 + channel.UnreadCountDisplay = 0 + assertSharedChannel(t, channel) +} + // Group var simpleGroup = `{ "id": "G024BE91L", From 380d30bba29edd805d9338cdbd8405c0f6d77ec8 Mon Sep 17 00:00:00 2001 From: Fahmi Zulhasymi Date: Tue, 3 Dec 2024 19:51:03 +0700 Subject: [PATCH 284/606] fix unit test by add more context on shared channel testcase --- conversation_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/conversation_test.go b/conversation_test.go index cd28f11c6..d49d92dc1 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -165,6 +165,12 @@ func TestCreateSharedChannel(t *testing.T) { channel.LastRead = "1401383885.000061" channel.UnreadCount = 0 channel.UnreadCountDisplay = 0 + channel.IsShared = true + channel.IsExtShared = true + channel.ContextTeamID = "T1ABCD2E12" + channel.ConversationHostID = "T07XY8FPJ5C" + channel.SharedTeamIDs = []string{"T07XY8FPJ5C"} + channel.ConnectedTeamIDs = []string{"T07XY8FPJ5C", "T1ABCD2E12"} assertSharedChannel(t, channel) } From 3bae55e27cca36da6206f699f7b6bd97fe97898f Mon Sep 17 00:00:00 2001 From: renat Date: Tue, 28 Jan 2025 10:27:36 +0100 Subject: [PATCH 285/606] add file fields to support email file user info --- files.go | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/files.go b/files.go index b26317145..8b2422c44 100644 --- a/files.go +++ b/files.go @@ -79,16 +79,24 @@ type File struct { Lines int `json:"lines"` LinesMore int `json:"lines_more"` - IsPublic bool `json:"is_public"` - PublicURLShared bool `json:"public_url_shared"` - Channels []string `json:"channels"` - Groups []string `json:"groups"` - IMs []string `json:"ims"` - InitialComment Comment `json:"initial_comment"` - CommentsCount int `json:"comments_count"` - NumStars int `json:"num_stars"` - IsStarred bool `json:"is_starred"` - Shares Share `json:"shares"` + IsPublic bool `json:"is_public"` + PublicURLShared bool `json:"public_url_shared"` + Channels []string `json:"channels"` + Groups []string `json:"groups"` + IMs []string `json:"ims"` + InitialComment Comment `json:"initial_comment"` + CommentsCount int `json:"comments_count"` + NumStars int `json:"num_stars"` + IsStarred bool `json:"is_starred"` + Shares Share `json:"shares"` + To []EmailFileUserInfo `json:"to"` + From []EmailFileUserInfo `json:"from"` +} + +type EmailFileUserInfo struct { + Address string `json:"address"` + Name string `json:"name"` + Original string `json:"original"` } type Share struct { From 90e2abd5041c9309f3a04edab3dff0f0f018d4e6 Mon Sep 17 00:00:00 2001 From: renat Date: Tue, 28 Jan 2025 20:08:51 +0100 Subject: [PATCH 286/606] add subject File field for email file --- files.go | 1 + 1 file changed, 1 insertion(+) diff --git a/files.go b/files.go index 8b2422c44..5eb6d1c7c 100644 --- a/files.go +++ b/files.go @@ -89,6 +89,7 @@ type File struct { NumStars int `json:"num_stars"` IsStarred bool `json:"is_starred"` Shares Share `json:"shares"` + Subject string `json:"subject"` To []EmailFileUserInfo `json:"to"` From []EmailFileUserInfo `json:"from"` } From 383179b78cec1addc145dd8ef04d60462ac37e53 Mon Sep 17 00:00:00 2001 From: renat Date: Thu, 30 Jan 2025 11:45:37 +0100 Subject: [PATCH 287/606] add CC to File struct for email file --- files.go | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/files.go b/files.go index 5eb6d1c7c..0682fb675 100644 --- a/files.go +++ b/files.go @@ -79,19 +79,21 @@ type File struct { Lines int `json:"lines"` LinesMore int `json:"lines_more"` - IsPublic bool `json:"is_public"` - PublicURLShared bool `json:"public_url_shared"` - Channels []string `json:"channels"` - Groups []string `json:"groups"` - IMs []string `json:"ims"` - InitialComment Comment `json:"initial_comment"` - CommentsCount int `json:"comments_count"` - NumStars int `json:"num_stars"` - IsStarred bool `json:"is_starred"` - Shares Share `json:"shares"` - Subject string `json:"subject"` - To []EmailFileUserInfo `json:"to"` - From []EmailFileUserInfo `json:"from"` + IsPublic bool `json:"is_public"` + PublicURLShared bool `json:"public_url_shared"` + Channels []string `json:"channels"` + Groups []string `json:"groups"` + IMs []string `json:"ims"` + InitialComment Comment `json:"initial_comment"` + CommentsCount int `json:"comments_count"` + NumStars int `json:"num_stars"` + IsStarred bool `json:"is_starred"` + Shares Share `json:"shares"` + + Subject string `json:"subject"` + To []EmailFileUserInfo `json:"to"` + From []EmailFileUserInfo `json:"from"` + Cc []EmailFileUserInfo `json:"cc"` } type EmailFileUserInfo struct { From 6615a26f89c4066cfecb4914fac33d5b3697ff19 Mon Sep 17 00:00:00 2001 From: sivchari Date: Mon, 6 Jan 2025 13:22:57 +0900 Subject: [PATCH 288/606] add enable_section option for UserGroup API Signed-off-by: sivchari --- usergroups.go | 52 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/usergroups.go b/usergroups.go index 0c2ec4c9a..a3eccb517 100644 --- a/usergroups.go +++ b/usergroups.go @@ -3,6 +3,7 @@ package slack import ( "context" "net/url" + "strconv" "strings" ) @@ -50,20 +51,45 @@ func (api *Client) userGroupRequest(ctx context.Context, path string, values url return response, response.Err() } +// CreateUserGroupParams contains arguments for CreateUserGroup method call +type CreateUserGroupParams struct { + EnableSection bool +} + +// CreateUserGroupOption options for the CreateUserGroup method call. +type CreateUserGroupOption func(*CreateUserGroupParams) + +// CreateUserGroupOptionEnableSection enable the section for the user group (default: false) +func CreateUserGroupOptionEnableSection(enableSection bool) CreateUserGroupOption { + return func(params *CreateUserGroupParams) { + params.EnableSection = enableSection + } +} + // CreateUserGroup creates a new user group. // For more information see the CreateUserGroupContext documentation. -func (api *Client) CreateUserGroup(userGroup UserGroup) (UserGroup, error) { - return api.CreateUserGroupContext(context.Background(), userGroup) +func (api *Client) CreateUserGroup(userGroup UserGroup, options ...CreateUserGroupOption) (UserGroup, error) { + return api.CreateUserGroupContext(context.Background(), userGroup, options...) } // CreateUserGroupContext creates a new user group with a custom context. // Slack API docs: https://api.slack.com/methods/usergroups.create -func (api *Client) CreateUserGroupContext(ctx context.Context, userGroup UserGroup) (UserGroup, error) { +func (api *Client) CreateUserGroupContext(ctx context.Context, userGroup UserGroup, options ...CreateUserGroupOption) (UserGroup, error) { + params := CreateUserGroupParams{} + + for _, opt := range options { + opt(¶ms) + } + values := url.Values{ "token": {api.token}, "name": {userGroup.Name}, } + if params.EnableSection { + values["enable_section"] = []string{strconv.FormatBool(params.EnableSection)} + } + if userGroup.TeamID != "" { values["team_id"] = []string{userGroup.TeamID} } @@ -236,12 +262,20 @@ func UpdateUserGroupsOptionChannels(channels []string) UpdateUserGroupsOption { } } +// UpdateUserGroupsOptionEnableSection enable the section for the user group (default: false) +func UpdateUserGroupsOptionEnableSection(enableSection bool) UpdateUserGroupsOption { + return func(params *UpdateUserGroupsParams) { + params.EnableSection = enableSection + } +} + // UpdateUserGroupsParams contains arguments for UpdateUserGroup method call type UpdateUserGroupsParams struct { - Name string - Handle string - Description *string - Channels *[]string + Name string + Handle string + Description *string + Channels *[]string + EnableSection bool } // UpdateUserGroup will update an existing user group. @@ -280,6 +314,10 @@ func (api *Client) UpdateUserGroupContext(ctx context.Context, userGroupID strin values["channels"] = []string{strings.Join(*params.Channels, ",")} } + if params.EnableSection { + values["enable_section"] = []string{strconv.FormatBool(params.EnableSection)} + } + response, err := api.userGroupRequest(ctx, "usergroups.update", values) if err != nil { return UserGroup{}, err From 9bca9dee876433597b03fee509965535b24fc885 Mon Sep 17 00:00:00 2001 From: sivchari Date: Wed, 29 Jan 2025 10:20:04 +0900 Subject: [PATCH 289/606] make CreateUserGroupParam private Signed-off-by: sivchari --- usergroups.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/usergroups.go b/usergroups.go index a3eccb517..256f7bd37 100644 --- a/usergroups.go +++ b/usergroups.go @@ -51,18 +51,18 @@ func (api *Client) userGroupRequest(ctx context.Context, path string, values url return response, response.Err() } -// CreateUserGroupParams contains arguments for CreateUserGroup method call -type CreateUserGroupParams struct { - EnableSection bool +// createUserGroupParams contains arguments for CreateUserGroup method call +type createUserGroupParams struct { + enableSection bool } // CreateUserGroupOption options for the CreateUserGroup method call. -type CreateUserGroupOption func(*CreateUserGroupParams) +type CreateUserGroupOption func(*createUserGroupParams) // CreateUserGroupOptionEnableSection enable the section for the user group (default: false) func CreateUserGroupOptionEnableSection(enableSection bool) CreateUserGroupOption { - return func(params *CreateUserGroupParams) { - params.EnableSection = enableSection + return func(params *createUserGroupParams) { + params.enableSection = enableSection } } @@ -75,7 +75,7 @@ func (api *Client) CreateUserGroup(userGroup UserGroup, options ...CreateUserGro // CreateUserGroupContext creates a new user group with a custom context. // Slack API docs: https://api.slack.com/methods/usergroups.create func (api *Client) CreateUserGroupContext(ctx context.Context, userGroup UserGroup, options ...CreateUserGroupOption) (UserGroup, error) { - params := CreateUserGroupParams{} + params := createUserGroupParams{} for _, opt := range options { opt(¶ms) @@ -86,8 +86,8 @@ func (api *Client) CreateUserGroupContext(ctx context.Context, userGroup UserGro "name": {userGroup.Name}, } - if params.EnableSection { - values["enable_section"] = []string{strconv.FormatBool(params.EnableSection)} + if params.enableSection { + values["enable_section"] = []string{strconv.FormatBool(params.enableSection)} } if userGroup.TeamID != "" { From 1992e48aab92e21b7221126c4e625f3542169a36 Mon Sep 17 00:00:00 2001 From: Sam Starling <42478+samstarling@users.noreply.github.com> Date: Sat, 7 Dec 2024 18:07:25 +0000 Subject: [PATCH 290/606] Add ID function to the Block interface --- block.go | 1 + block_action.go | 5 +++++ block_call.go | 7 ++++++- block_context.go | 5 +++++ block_divider.go | 6 +++++- block_file.go | 5 +++++ block_header.go | 5 +++++ block_image.go | 5 +++++ block_input.go | 5 +++++ block_rich_text.go | 5 +++++ block_section.go | 5 +++++ block_unknown.go | 5 +++++ block_video.go | 5 +++++ 13 files changed, 62 insertions(+), 2 deletions(-) diff --git a/block.go b/block.go index a3fb1a0a7..f0e288892 100644 --- a/block.go +++ b/block.go @@ -26,6 +26,7 @@ const ( // to ensure consistency between blocks. type Block interface { BlockType() MessageBlockType + ID() string } // Blocks is a convenience struct defined to allow dynamic unmarshalling of diff --git a/block_action.go b/block_action.go index c15e4a3f7..819c0ef0d 100644 --- a/block_action.go +++ b/block_action.go @@ -14,6 +14,11 @@ func (s ActionBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s ActionBlock) ID() string { + return s.BlockID +} + // NewActionBlock returns a new instance of an Action Block func NewActionBlock(blockID string, elements ...BlockElement) *ActionBlock { return &ActionBlock{ diff --git a/block_call.go b/block_call.go index 98f2c0255..c81dcb8be 100644 --- a/block_call.go +++ b/block_call.go @@ -14,7 +14,12 @@ func (s CallBlock) BlockType() MessageBlockType { return s.Type } -// NewFileBlock returns a new instance of a file block +// ID returns the ID of the block +func (s CallBlock) ID() string { + return s.BlockID +} + +// NewCallBlock returns a new instance of a call block func NewCallBlock(callID string) *CallBlock { return &CallBlock{ Type: MBTCall, diff --git a/block_context.go b/block_context.go index 384fee22b..879ee61ee 100644 --- a/block_context.go +++ b/block_context.go @@ -15,6 +15,11 @@ func (s ContextBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s ContextBlock) ID() string { + return s.BlockID +} + type ContextElements struct { Elements []MixedElement } diff --git a/block_divider.go b/block_divider.go index 2d442ba11..e10d7b053 100644 --- a/block_divider.go +++ b/block_divider.go @@ -13,10 +13,14 @@ func (s DividerBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s DividerBlock) ID() string { + return s.BlockID +} + // NewDividerBlock returns a new instance of a divider block func NewDividerBlock() *DividerBlock { return &DividerBlock{ Type: MBTDivider, } - } diff --git a/block_file.go b/block_file.go index ac4453f79..2f669b02a 100644 --- a/block_file.go +++ b/block_file.go @@ -15,6 +15,11 @@ func (s FileBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s FileBlock) ID() string { + return s.BlockID +} + // NewFileBlock returns a new instance of a file block func NewFileBlock(blockID string, externalID string, source string) *FileBlock { return &FileBlock{ diff --git a/block_header.go b/block_header.go index 6dff4b883..3afb4c95f 100644 --- a/block_header.go +++ b/block_header.go @@ -14,6 +14,11 @@ func (s HeaderBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s HeaderBlock) ID() string { + return s.BlockID +} + // HeaderBlockOption allows configuration of options for a new header block type HeaderBlockOption func(*HeaderBlock) diff --git a/block_image.go b/block_image.go index b3d2cb8cf..cdee5b4ab 100644 --- a/block_image.go +++ b/block_image.go @@ -12,6 +12,11 @@ type ImageBlock struct { SlackFile *SlackFileObject `json:"slack_file,omitempty"` } +// ID returns the ID of the block +func (s ImageBlock) ID() string { + return s.BlockID +} + // SlackFileObject Defines an object containing Slack file information to be used in an // image block or image element. // diff --git a/block_input.go b/block_input.go index 7c1272a64..f74eda6d2 100644 --- a/block_input.go +++ b/block_input.go @@ -18,6 +18,11 @@ func (s InputBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s InputBlock) ID() string { + return s.BlockID +} + // NewInputBlock returns a new instance of an input block func NewInputBlock(blockID string, label, hint *TextBlockObject, element BlockElement) *InputBlock { return &InputBlock{ diff --git a/block_rich_text.go b/block_rich_text.go index 22020bebd..18faf8cc8 100644 --- a/block_rich_text.go +++ b/block_rich_text.go @@ -16,6 +16,11 @@ func (b RichTextBlock) BlockType() MessageBlockType { return b.Type } +// ID returns the ID of the block +func (s RichTextBlock) ID() string { + return s.BlockID +} + func (e *RichTextBlock) UnmarshalJSON(b []byte) error { var raw struct { Type MessageBlockType `json:"type"` diff --git a/block_section.go b/block_section.go index 1ffc17e82..0d2352a03 100644 --- a/block_section.go +++ b/block_section.go @@ -17,6 +17,11 @@ func (s SectionBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s SectionBlock) ID() string { + return s.BlockID +} + // SectionBlockOption allows configuration of options for a new section block type SectionBlockOption func(*SectionBlock) diff --git a/block_unknown.go b/block_unknown.go index 97054c73e..7a49a2c8d 100644 --- a/block_unknown.go +++ b/block_unknown.go @@ -11,3 +11,8 @@ type UnknownBlock struct { func (b UnknownBlock) BlockType() MessageBlockType { return b.Type } + +// ID returns the ID of the block +func (s UnknownBlock) ID() string { + return s.BlockID +} diff --git a/block_video.go b/block_video.go index 322c614f9..4d6739c46 100644 --- a/block_video.go +++ b/block_video.go @@ -22,6 +22,11 @@ func (s VideoBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s VideoBlock) ID() string { + return s.BlockID +} + // NewVideoBlock returns an instance of a new Video Block type func NewVideoBlock(videoURL, thumbnailURL, altText, blockID string, title *TextBlockObject) *VideoBlock { return &VideoBlock{ From 7d458acdf091b07c722d41873ad37d3486696d1c Mon Sep 17 00:00:00 2001 From: Sam Starling <42478+samstarling@users.noreply.github.com> Date: Sat, 7 Dec 2024 18:44:36 +0000 Subject: [PATCH 291/606] Update unit tests to assert on ID() --- block_action_test.go | 6 +++--- block_call_test.go | 4 ++++ block_context_test.go | 7 +++---- block_divider_test.go | 6 ++++-- block_element_test.go | 14 -------------- block_file_test.go | 3 +++ block_header_test.go | 5 +++-- block_image_test.go | 4 ++-- block_input_test.go | 3 +++ block_object_test.go | 12 ------------ block_rich_text_test.go | 10 ++++++++++ block_section_test.go | 6 +++--- block_video_test.go | 4 ++-- 13 files changed, 40 insertions(+), 44 deletions(-) diff --git a/block_action_test.go b/block_action_test.go index 32bd0c2c8..bb22f45e0 100644 --- a/block_action_test.go +++ b/block_action_test.go @@ -7,13 +7,13 @@ import ( ) func TestNewActionBlock(t *testing.T) { - approveBtnTxt := NewTextBlockObject("plain_text", "Approve", false, false) approveBtn := NewButtonBlockElement("", "click_me_123", approveBtnTxt) - actionBlock := NewActionBlock("test", approveBtn) + + assert.Equal(t, actionBlock.BlockType(), MBTAction) assert.Equal(t, string(actionBlock.Type), "actions") assert.Equal(t, actionBlock.BlockID, "test") + assert.Equal(t, actionBlock.ID(), "test") assert.Equal(t, len(actionBlock.Elements.ElementSet), 1) - } diff --git a/block_call_test.go b/block_call_test.go index c118542a5..4966e3dc1 100644 --- a/block_call_test.go +++ b/block_call_test.go @@ -8,6 +8,10 @@ import ( func TestNewCallBlock(t *testing.T) { callBlock := NewCallBlock("ACallID") + + assert.Equal(t, callBlock.BlockType(), MBTCall) assert.Equal(t, string(callBlock.Type), "call") assert.Equal(t, callBlock.CallID, "ACallID") + assert.Equal(t, callBlock.BlockID, "") + assert.Equal(t, callBlock.ID(), "") } diff --git a/block_context_test.go b/block_context_test.go index 20fb39631..0ecfb8089 100644 --- a/block_context_test.go +++ b/block_context_test.go @@ -7,15 +7,14 @@ import ( ) func TestNewContextBlock(t *testing.T) { - locationPinImage := NewImageBlockElement("https://api.slack.com/img/blocks/bkb_template_images/tripAgentLocationMarker.png", "Location Pin Icon") textExample := NewTextBlockObject("plain_text", "Location: Central Business District", true, false) - elements := []MixedElement{locationPinImage, textExample} - contextBlock := NewContextBlock("test", elements...) + + assert.Equal(t, contextBlock.BlockType(), MBTContext) assert.Equal(t, string(contextBlock.Type), "context") assert.Equal(t, contextBlock.BlockID, "test") + assert.Equal(t, contextBlock.ID(), "test") assert.Equal(t, len(contextBlock.ContextElements.Elements), 2) - } diff --git a/block_divider_test.go b/block_divider_test.go index 35e3d48d5..27b81aad6 100644 --- a/block_divider_test.go +++ b/block_divider_test.go @@ -7,8 +7,10 @@ import ( ) func TestNewDividerBlock(t *testing.T) { - dividerBlock := NewDividerBlock() - assert.Equal(t, string(dividerBlock.Type), "divider") + assert.Equal(t, dividerBlock.BlockType(), MBTDivider) + assert.Equal(t, string(dividerBlock.Type), "divider") + assert.Equal(t, dividerBlock.BlockID, "") + assert.Equal(t, dividerBlock.ID(), "") } diff --git a/block_element_test.go b/block_element_test.go index d532fb7ef..fead400c7 100644 --- a/block_element_test.go +++ b/block_element_test.go @@ -7,17 +7,14 @@ import ( ) func TestNewImageBlockElement(t *testing.T) { - imageElement := NewImageBlockElement("https://api.slack.com/img/blocks/bkb_template_images/tripAgentLocationMarker.png", "Location Pin Icon") assert.Equal(t, string(imageElement.Type), "image") assert.Contains(t, imageElement.ImageURL, "tripAgentLocationMarker") assert.Equal(t, imageElement.AltText, "Location Pin Icon") - } func TestNewButtonBlockElement(t *testing.T) { - btnTxt := NewTextBlockObject("plain_text", "Next 2 Results", false, false) btnElement := NewButtonBlockElement("test", "click_me_123", btnTxt) @@ -25,11 +22,9 @@ func TestNewButtonBlockElement(t *testing.T) { assert.Equal(t, btnElement.ActionID, "test") assert.Equal(t, btnElement.Value, "click_me_123") assert.Equal(t, btnElement.Text.Text, "Next 2 Results") - } func TestWithStyleForButtonElement(t *testing.T) { - // these values are irrelevant in this test btnTxt := NewTextBlockObject("plain_text", "Next 2 Results", false, false) btnElement := NewButtonBlockElement("test", "click_me_123", btnTxt) @@ -40,21 +35,17 @@ func TestWithStyleForButtonElement(t *testing.T) { assert.Equal(t, btnElement.Style, Style("primary")) btnElement.WithStyle(StyleDanger) assert.Equal(t, btnElement.Style, Style("danger")) - } func TestWithURLForButtonElement(t *testing.T) { - btnTxt := NewTextBlockObject("plain_text", "Next 2 Results", false, false) btnElement := NewButtonBlockElement("test", "click_me_123", btnTxt) btnElement.WithURL("https://foo.bar") assert.Equal(t, btnElement.URL, "https://foo.bar") - } func TestNewOptionsSelectBlockElement(t *testing.T) { - testOptionText := NewTextBlockObject("plain_text", "Option One", false, false) testOption := NewOptionBlockObject("test", testOptionText, nil) @@ -62,11 +53,9 @@ func TestNewOptionsSelectBlockElement(t *testing.T) { assert.Equal(t, option.Type, "static_select") assert.Equal(t, len(option.Options), 1) assert.Nil(t, option.OptionGroups) - } func TestNewOptionsGroupSelectBlockElement(t *testing.T) { - testOptionText := NewTextBlockObject("plain_text", "Option One", false, false) testOption := NewOptionBlockObject("test", testOptionText, nil) testLabel := NewTextBlockObject("plain_text", "Test Label", false, false) @@ -77,11 +66,9 @@ func TestNewOptionsGroupSelectBlockElement(t *testing.T) { assert.Equal(t, optGroup.Type, "static_select") assert.Equal(t, optGroup.ActionID, "test") assert.Equal(t, len(optGroup.OptionGroups), 1) - } func TestNewOptionsMultiSelectBlockElement(t *testing.T) { - testOptionText := NewTextBlockObject("plain_text", "Option One", false, false) testDescriptionText := NewTextBlockObject("plain_text", "Description One", false, false) testOption := NewOptionBlockObject("test", testOptionText, testDescriptionText) @@ -90,7 +77,6 @@ func TestNewOptionsMultiSelectBlockElement(t *testing.T) { assert.Equal(t, option.Type, "static_select") assert.Equal(t, len(option.Options), 1) assert.Nil(t, option.OptionGroups) - } func TestNewOptionsGroupMultiSelectBlockElement(t *testing.T) { diff --git a/block_file_test.go b/block_file_test.go index c51a93ebe..f52270acb 100644 --- a/block_file_test.go +++ b/block_file_test.go @@ -8,8 +8,11 @@ import ( func TestNewFileBlock(t *testing.T) { fileBlock := NewFileBlock("test", "external_id", "source") + + assert.Equal(t, fileBlock.BlockType(), MBTFile) assert.Equal(t, string(fileBlock.Type), "file") assert.Equal(t, fileBlock.BlockID, "test") + assert.Equal(t, fileBlock.ID(), "test") assert.Equal(t, fileBlock.ExternalID, "external_id") assert.Equal(t, fileBlock.Source, "source") } diff --git a/block_header_test.go b/block_header_test.go index d8ed332cf..6e6df8adf 100644 --- a/block_header_test.go +++ b/block_header_test.go @@ -7,11 +7,12 @@ import ( ) func TestNewHeaderBlock(t *testing.T) { - textInfo := NewTextBlockObject("plain_text", "This is quite the header", false, false) - headerBlock := NewHeaderBlock(textInfo, HeaderBlockOptionBlockID("test_block")) + + assert.Equal(t, headerBlock.BlockType(), MBTHeader) assert.Equal(t, string(headerBlock.Type), "header") + assert.Equal(t, headerBlock.ID(), "test_block") assert.Equal(t, headerBlock.BlockID, "test_block") assert.Equal(t, headerBlock.Text.Type, "plain_text") assert.Contains(t, headerBlock.Text.Text, "quite the header") diff --git a/block_image_test.go b/block_image_test.go index 3cdd203c4..801a14217 100644 --- a/block_image_test.go +++ b/block_image_test.go @@ -7,14 +7,14 @@ import ( ) func TestNewImageBlock(t *testing.T) { - imageText := NewTextBlockObject("plain_text", "Location", false, false) imageBlock := NewImageBlock("https://api.slack.com/img/blocks/bkb_template_images/tripAgentLocationMarker.png", "Marker", "test", imageText) + assert.Equal(t, imageBlock.BlockType(), MBTImage) assert.Equal(t, string(imageBlock.Type), "image") assert.Equal(t, imageBlock.Title.Type, "plain_text") + assert.Equal(t, imageBlock.ID(), "test") assert.Equal(t, imageBlock.BlockID, "test") assert.Contains(t, imageBlock.Title.Text, "Location") assert.Contains(t, imageBlock.ImageURL, "tripAgentLocationMarker.png") - } diff --git a/block_input_test.go b/block_input_test.go index 694e57580..fa25f48ee 100644 --- a/block_input_test.go +++ b/block_input_test.go @@ -11,7 +11,10 @@ func TestNewInputBlock(t *testing.T) { element := NewDatePickerBlockElement("action_id") hint := NewTextBlockObject("plain_text", "hint", false, false) inputBlock := NewInputBlock("test", label, hint, element) + + assert.Equal(t, inputBlock.BlockType(), MBTInput) assert.Equal(t, string(inputBlock.Type), "input") + assert.Equal(t, inputBlock.ID(), "test") assert.Equal(t, inputBlock.BlockID, "test") assert.Equal(t, inputBlock.Label, label) assert.Equal(t, inputBlock.Element, element) diff --git a/block_object_test.go b/block_object_test.go index 1f4874a02..b8f55a4c1 100644 --- a/block_object_test.go +++ b/block_object_test.go @@ -9,28 +9,23 @@ import ( ) func TestNewImageBlockObject(t *testing.T) { - imageObject := NewImageBlockElement("https://api.slack.com/img/blocks/bkb_template_images/beagle.png", "Beagle") assert.Equal(t, string(imageObject.Type), "image") assert.Equal(t, imageObject.AltText, "Beagle") assert.Contains(t, imageObject.ImageURL, "beagle.png") - } func TestNewTextBlockObject(t *testing.T) { - textObject := NewTextBlockObject("plain_text", "test", true, false) assert.Equal(t, textObject.Type, "plain_text") assert.Equal(t, textObject.Text, "test") assert.True(t, textObject.Emoji, "Emoji property should be true") assert.False(t, textObject.Verbatim, "Verbatim should be false") - } func TestNewConfirmationBlockObject(t *testing.T) { - titleObj := NewTextBlockObject("plain_text", "testTitle", false, false) textObj := NewTextBlockObject("plain_text", "testText", false, false) confirmObj := NewTextBlockObject("plain_text", "testConfirm", false, false) @@ -41,11 +36,9 @@ func TestNewConfirmationBlockObject(t *testing.T) { assert.Equal(t, confirmation.Text.Text, "testText") assert.Equal(t, confirmation.Confirm.Text, "testConfirm") assert.Nil(t, confirmation.Deny, "Deny should be nil") - } func TestWithStyleForConfirmation(t *testing.T) { - // these values are irrelevant in this test titleObj := NewTextBlockObject("plain_text", "testTitle", false, false) textObj := NewTextBlockObject("plain_text", "testText", false, false) @@ -58,11 +51,9 @@ func TestWithStyleForConfirmation(t *testing.T) { assert.Equal(t, confirmation.Style, Style("primary")) confirmation.WithStyle(StyleDanger) assert.Equal(t, confirmation.Style, Style("danger")) - } func TestNewOptionBlockObject(t *testing.T) { - valTextObj := NewTextBlockObject("plain_text", "testText", false, false) valDescriptionObj := NewTextBlockObject("plain_text", "testDescription", false, false) optObj := NewOptionBlockObject("testOpt", valTextObj, valDescriptionObj) @@ -70,11 +61,9 @@ func TestNewOptionBlockObject(t *testing.T) { assert.Equal(t, optObj.Text.Text, "testText") assert.Equal(t, optObj.Description.Text, "testDescription") assert.Equal(t, optObj.Value, "testOpt") - } func TestNewOptionGroupBlockElement(t *testing.T) { - labelObj := NewTextBlockObject("plain_text", "testLabel", false, false) valTextObj := NewTextBlockObject("plain_text", "testText", false, false) optObj := NewOptionBlockObject("testOpt", valTextObj, nil) @@ -83,7 +72,6 @@ func TestNewOptionGroupBlockElement(t *testing.T) { assert.Equal(t, optGroup.Label.Text, "testLabel") assert.Len(t, optGroup.Options, 1, "Options should contain one element") - } func TestValidateTextBlockObject(t *testing.T) { diff --git a/block_rich_text_test.go b/block_rich_text_test.go index 903f14389..ce8019735 100644 --- a/block_rich_text_test.go +++ b/block_rich_text_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/go-test/deep" + "github.com/stretchr/testify/assert" ) const ( @@ -83,6 +84,15 @@ const ( }` ) +func TestNewRichTextBlock(t *testing.T) { + richTextBlock := NewRichTextBlock("test_block") + + assert.Equal(t, richTextBlock.BlockType(), MBTRichText) + assert.Equal(t, string(richTextBlock.Type), "rich_text") + assert.Equal(t, string(richTextBlock.BlockID), "test_block") + assert.Equal(t, string(richTextBlock.ID()), "test_block") +} + func TestRichTextBlock_UnmarshalJSON(t *testing.T) { cases := []struct { raw []byte diff --git a/block_section_test.go b/block_section_test.go index 68329679b..8f8374e04 100644 --- a/block_section_test.go +++ b/block_section_test.go @@ -7,17 +7,17 @@ import ( ) func TestNewSectionBlock(t *testing.T) { - textInfo := NewTextBlockObject("mrkdwn", "**\n★★★★★\n$340 per night\nRated: 9.1 - Excellent", false, false) - sectionBlock := NewSectionBlock(textInfo, nil, nil, SectionBlockOptionBlockID("test_block")) + + assert.Equal(t, sectionBlock.BlockType(), MBTSection) assert.Equal(t, string(sectionBlock.Type), "section") assert.Equal(t, sectionBlock.BlockID, "test_block") + assert.Equal(t, sectionBlock.ID(), "test_block") assert.Equal(t, len(sectionBlock.Fields), 0) assert.Nil(t, sectionBlock.Accessory) assert.Equal(t, sectionBlock.Text.Type, "mrkdwn") assert.Contains(t, sectionBlock.Text.Text, "New Orleans") - } func TestNewBlockSectionContainsAddedTextBlockAndAccessory(t *testing.T) { diff --git a/block_video_test.go b/block_video_test.go index d4b791fab..1ccece7f7 100644 --- a/block_video_test.go +++ b/block_video_test.go @@ -7,17 +7,17 @@ import ( ) func TestNewVideoBlock(t *testing.T) { - videoTitle := NewTextBlockObject("plain_text", "VideoTitle", false, false) videoBlock := NewVideoBlock( "https://example.com/example.mp4", "https://example.com/thumbnail.png", "alternative text", "blockID", videoTitle) + assert.Equal(t, videoBlock.Type, MBTVideo) assert.Equal(t, string(videoBlock.Type), "video") assert.Equal(t, videoBlock.Title.Type, "plain_text") assert.Equal(t, videoBlock.BlockID, "blockID") + assert.Equal(t, videoBlock.ID(), "blockID") assert.Contains(t, videoBlock.Title.Text, "VideoTitle") assert.Contains(t, videoBlock.VideoURL, "example.mp4") - } From 3d65d90376a8130c40ed65fcc74e00f179b02c83 Mon Sep 17 00:00:00 2001 From: Sam Starling <42478+samstarling@users.noreply.github.com> Date: Thu, 6 Feb 2025 17:31:53 +0000 Subject: [PATCH 292/606] Fix linting issues --- block_rich_text_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/block_rich_text_test.go b/block_rich_text_test.go index ce8019735..1e26f9e27 100644 --- a/block_rich_text_test.go +++ b/block_rich_text_test.go @@ -89,8 +89,8 @@ func TestNewRichTextBlock(t *testing.T) { assert.Equal(t, richTextBlock.BlockType(), MBTRichText) assert.Equal(t, string(richTextBlock.Type), "rich_text") - assert.Equal(t, string(richTextBlock.BlockID), "test_block") - assert.Equal(t, string(richTextBlock.ID()), "test_block") + assert.Equal(t, richTextBlock.BlockID, "test_block") + assert.Equal(t, richTextBlock.ID(), "test_block") } func TestRichTextBlock_UnmarshalJSON(t *testing.T) { From 209ec09999dd963928743f0950590eb35692f95f Mon Sep 17 00:00:00 2001 From: Dor Kauf Date: Thu, 6 Feb 2025 21:56:45 +0200 Subject: [PATCH 293/606] Add assistant app thread message subtype (#1368) --- messages.go | 1 + 1 file changed, 1 insertion(+) diff --git a/messages.go b/messages.go index 13a1ede98..90f8fc1ff 100644 --- a/messages.go +++ b/messages.go @@ -49,6 +49,7 @@ const ( MsgSubTypeUnpinnedItem = "unpinned_item" // [RTM] An item was unpinned from a channel MsgSubTypeEkmAccessDenied = "ekm_access_denied" // [Events API, RTM] Message content redacted due to Enterprise Key Management (EKM) MsgSubTypeChannelPostingPermissions = "channel_posting_permissions" // [Events API, RTM] The posting permissions for a channel changed + MsgSubTypeAssistantAppThread = "assistant_app_thread" // [Events API, RTM] The message is an app assistant thread ) // Msg contains information about a slack message From 98cc396d6e1a57eab431796d9c0744253d3a1e71 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 9 Feb 2025 19:22:06 +0000 Subject: [PATCH 294/606] feat: add markdown block There's a new block for markdown per changelog entry at https://api.slack.com/changelog#entry-february_2025_0. More information can be found in https://api.slack.com/reference/block-kit/blocks#markdown. --- block.go | 5 +---- block_markdown.go | 34 ++++++++++++++++++++++++++++++++++ block_markdown_test.go | 17 +++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 block_markdown.go create mode 100644 block_markdown_test.go diff --git a/block.go b/block.go index f0e288892..33a78751b 100644 --- a/block.go +++ b/block.go @@ -1,9 +1,5 @@ package slack -// @NOTE: Blocks are in beta and subject to change. - -// More Information: https://api.slack.com/block-kit - // MessageBlockType defines a named string type to define each block type // as a constant for use within the package. type MessageBlockType string @@ -20,6 +16,7 @@ const ( MBTRichText MessageBlockType = "rich_text" MBTCall MessageBlockType = "call" MBTVideo MessageBlockType = "video" + MBTMarkdown MessageBlockType = "markdown" ) // Block defines an interface all block types should implement diff --git a/block_markdown.go b/block_markdown.go new file mode 100644 index 000000000..e22a0d16d --- /dev/null +++ b/block_markdown.go @@ -0,0 +1,34 @@ +package slack + +// MarkdownBlock defines a block that lets you use markdown to format your text. +// +// This block can be used with AI apps when you expect a markdown response from an LLM +// that can get lost in translation rendering in Slack. Providing it in a markdown block +// leaves the translating to Slack to ensure your message appears as intended. Note that +// passing a single block may result in multiple blocks after translation. +// +// More Information: https://api.slack.com/reference/block-kit/blocks#markdown +type MarkdownBlock struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + Text string `json:"text"` +} + +// BlockType returns the type of the block +func (s MarkdownBlock) BlockType() MessageBlockType { + return s.Type +} + +// ID returns the ID of the block +func (s MarkdownBlock) ID() string { + return s.BlockID +} + +// NewMarkdownBlock returns an instance of a new Markdown Block type +func NewMarkdownBlock(blockID, text string) *MarkdownBlock { + return &MarkdownBlock{ + Type: MBTMarkdown, + BlockID: blockID, + Text: text, + } +} diff --git a/block_markdown_test.go b/block_markdown_test.go new file mode 100644 index 000000000..45fe3c5e8 --- /dev/null +++ b/block_markdown_test.go @@ -0,0 +1,17 @@ +package slack + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewMarkdownBlock(t *testing.T) { + markdownBlock := NewMarkdownBlock("test", "*asfd*") + + assert.Equal(t, markdownBlock.BlockType(), MBTMarkdown) + assert.Equal(t, string(markdownBlock.Type), "markdown") + assert.Equal(t, markdownBlock.ID(), "test") + assert.Equal(t, markdownBlock.BlockID, "test") + assert.Equal(t, markdownBlock.Text, "*asfd*") +} From b26c57a209ec41ce574b91f2141f885f649997c8 Mon Sep 17 00:00:00 2001 From: Brandon Barker Date: Wed, 4 Dec 2024 11:51:42 -0500 Subject: [PATCH 295/606] [fix] function execution events accept interfaces not strings --- examples/function/function.go | 5 +++-- slackevents/inner_events.go | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/examples/function/function.go b/examples/function/function.go index d654890fe..0e189c246 100644 --- a/examples/function/function.go +++ b/examples/function/function.go @@ -2,10 +2,11 @@ package main import ( "fmt" + "os" + "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" "github.com/slack-go/slack/socketmode" - "os" ) func main() { @@ -38,7 +39,7 @@ func main() { if callbackID == "sample_function" { userId := ev.Inputs["user_id"] payload := map[string]string{ - "user_id": userId, + "user_id": userId.(string), } err := api.FunctionCompleteSuccess(ev.FunctionExecutionID, slack.FunctionCompleteSuccessRequestOptionOutput(payload)) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 720640b63..c14373f3e 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -986,11 +986,11 @@ type FunctionExecutedEvent struct { DateUpdated int64 `json:"date_updated"` DateDeleted int64 `json:"date_deleted"` } `json:"function"` - Inputs map[string]string `json:"inputs"` - FunctionExecutionID string `json:"function_execution_id"` - WorkflowExecutionID string `json:"workflow_execution_id"` - EventTs string `json:"event_ts"` - BotAccessToken string `json:"bot_access_token"` + Inputs map[string]interface{} `json:"inputs"` + FunctionExecutionID string `json:"function_execution_id"` + WorkflowExecutionID string `json:"workflow_execution_id"` + EventTs string `json:"event_ts"` + BotAccessToken string `json:"bot_access_token"` } type InviteRequestedEvent struct { From d26493c9b4b1562cedcbfe25b0dda9d2f7a1bfea Mon Sep 17 00:00:00 2001 From: Brandon Barker Date: Wed, 4 Dec 2024 12:15:45 -0500 Subject: [PATCH 296/606] [fix] function execution events add unit test --- slackevents/inner_events_test.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index baa4e2c08..546d69552 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -2546,6 +2546,13 @@ func TestFunctionExecutedEvent(t *testing.T) { "description": "Runs sample function", "type": "app", "input_parameters": [ + { + "type": "slack#/types/message_context", + "name": "message_context", + "description": "", + "title": "Message Context", + "is_required": true + }, { "type": "slack#/types/user_id", "name": "user_id", @@ -2568,7 +2575,13 @@ func TestFunctionExecutedEvent(t *testing.T) { "date_updated": 1698947481, "date_deleted": 0 }, - "inputs": { "user_id": "USER12345678" }, + "inputs": { + "user_id": "USER12345678", + "message_context": { + "channel_id": "C07H1ER7U66", + "message_ts": "1733331835.871019" + } + }, "function_execution_id": "Fx1234567O9L", "workflow_execution_id": "WxABC123DEF0", "event_ts": "1698958075.998738", From 2ede2bfb82101193586520305efbf0ab8da0bc71 Mon Sep 17 00:00:00 2001 From: Brandon Barker Date: Thu, 12 Dec 2024 13:44:07 -0500 Subject: [PATCH 297/606] [tests] improve test cases for marshaled params --- slackevents/inner_events_test.go | 44 +++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index 546d69552..59552089d 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -2559,6 +2559,20 @@ func TestFunctionExecutedEvent(t *testing.T) { "description": "Message recipient", "title": "User", "is_required": true + }, + { + "type": "integer", + "name": "timestamp", + "description": "Timestamp of the event", + "title": "Timestamp", + "is_required": true + }, + { + "type": "boolean", + "name": "enabled", + "description": "Indicates if the feature is enabled", + "title": "Enabled", + "is_required": true } ], "output_parameters": [ @@ -2577,8 +2591,10 @@ func TestFunctionExecutedEvent(t *testing.T) { }, "inputs": { "user_id": "USER12345678", + "timestamp": 1698947481, + "enabled": true, "message_context": { - "channel_id": "C07H1ER7U66", + "channel_id": "C0123456789", "message_ts": "1733331835.871019" } }, @@ -2588,6 +2604,17 @@ func TestFunctionExecutedEvent(t *testing.T) { "bot_access_token": "abcd-1325532282098-1322446258629-6123648410839-527a1cab3979cad288c9e20330d212cf" }` + type MessageContext struct { + ChannelId string `json:"channel_id"` + MessageTs string `json:"message_ts"` + } + type TestInputs struct { + UserId string `json:"user_id"` + Timestamp int `json:"timestamp"` + Enabled bool `json:"enabled"` + Context MessageContext `json:"message_context"` + } + var event FunctionExecutedEvent if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { t.Errorf("Failed to unmarshal FunctionExecutedEvent: %v", err) @@ -2604,6 +2631,21 @@ func TestFunctionExecutedEvent(t *testing.T) { if event.FunctionExecutionID != "Fx1234567O9L" { t.Fail() } + + inputStr, err := json.Marshal(event.Inputs) + if err != nil { + t.Errorf("Failed to marshal Inputs of FunctionExecutedEvent: %v", err) + } + testInputs := new(TestInputs) + err = json.Unmarshal(inputStr, testInputs) + if err != nil { + t.Errorf("Failed to unmarshal Inputs of FunctionExecutedEvent: %v", err) + } + assert.Equal(t, "USER12345678", testInputs.UserId) + assert.Equal(t, 1698947481, testInputs.Timestamp) + assert.True(t, testInputs.Enabled) + assert.Equal(t, "C0123456789", testInputs.Context.ChannelId) + assert.Equal(t, "1733331835.871019", testInputs.Context.MessageTs) } func TestInviteRequestedEvent(t *testing.T) { From 41883d8d289531fbf272a5759be20caa325d4771 Mon Sep 17 00:00:00 2001 From: YutoKashiwagi Date: Wed, 19 Feb 2025 13:59:31 +0900 Subject: [PATCH 298/606] make completeUploadExternal public --- files.go | 49 +++++++++-------- files_test.go | 150 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 175 insertions(+), 24 deletions(-) diff --git a/files.go b/files.go index 0682fb675..3b9bc79c0 100644 --- a/files.go +++ b/files.go @@ -199,14 +199,14 @@ type FileSummary struct { Title string `json:"title"` } -type completeUploadExternalParameters struct { - title string - channel string - initialComment string - threadTimestamp string +type CompleteUploadExternalParameters struct { + Files []FileSummary + Channel string + InitialComment string + ThreadTimestamp string } -type completeUploadExternalResponse struct { +type CompleteUploadExternalResponse struct { SlackResponse Files []FileSummary `json:"files"` } @@ -542,28 +542,28 @@ func (api *Client) uploadToURL(ctx context.Context, params uploadToURLParameters return err } -// completeUploadExternal once files are uploaded, this completes the upload and shares it to the specified channel -func (api *Client) completeUploadExternal(ctx context.Context, fileID string, params completeUploadExternalParameters) (file *completeUploadExternalResponse, err error) { - request := []FileSummary{{ID: fileID, Title: params.title}} - requestBytes, err := json.Marshal(request) +// CompleteUploadExternalContext once files are uploaded, this completes the upload and shares it to the specified channel +// Slack API docs: https://api.slack.com/methods/files.completeUploadExternal +func (api *Client) CompleteUploadExternalContext(ctx context.Context, params CompleteUploadExternalParameters) (file *CompleteUploadExternalResponse, err error) { + filesBytes, err := json.Marshal(params.Files) if err != nil { return nil, err } values := url.Values{ "token": {api.token}, - "files": {string(requestBytes)}, + "files": {string(filesBytes)}, } - if params.channel != "" { - values.Add("channel_id", params.channel) + if params.Channel != "" { + values.Add("channel_id", params.Channel) } - if params.initialComment != "" { - values.Add("initial_comment", params.initialComment) + if params.InitialComment != "" { + values.Add("initial_comment", params.InitialComment) } - if params.threadTimestamp != "" { - values.Add("thread_ts", params.threadTimestamp) + if params.ThreadTimestamp != "" { + values.Add("thread_ts", params.ThreadTimestamp) } - response := &completeUploadExternalResponse{} + response := &CompleteUploadExternalResponse{} err = api.postMethod(ctx, "files.completeUploadExternal", values, response) if err != nil { return nil, err @@ -615,11 +615,14 @@ func (api *Client) UploadFileV2Context(ctx context.Context, params UploadFileV2P return nil, err } - c, err := api.completeUploadExternal(ctx, u.FileID, completeUploadExternalParameters{ - title: params.Title, - channel: params.Channel, - initialComment: params.InitialComment, - threadTimestamp: params.ThreadTimestamp, + c, err := api.CompleteUploadExternalContext(ctx, CompleteUploadExternalParameters{ + Files: []FileSummary{{ + ID: u.FileID, + Title: params.Title, + }}, + Channel: params.Channel, + InitialComment: params.InitialComment, + ThreadTimestamp: params.ThreadTimestamp, }) if err != nil { return nil, err diff --git a/files_test.go b/files_test.go index 1df46aa6e..d86b61fdd 100644 --- a/files_test.go +++ b/files_test.go @@ -2,7 +2,9 @@ package slack import ( "bytes" + "context" "encoding/json" + "fmt" "io" "log" "net/http" @@ -228,7 +230,7 @@ func urlFileUploadHandler(rw http.ResponseWriter, r *http.Request) { func completeURLUpload(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "application/json") - response, _ := json.Marshal(completeUploadExternalResponse{ + response, _ := json.Marshal(CompleteUploadExternalResponse{ Files: []FileSummary{ { ID: "RandomID", @@ -282,3 +284,149 @@ func TestUploadFileV2(t *testing.T) { t.Errorf("Unexpected error: %s", err) } } + +type mockCompleteUploadExternalHttpClient struct { + ResponseStatus int + ResponseBody []byte +} + +func (m *mockCompleteUploadExternalHttpClient) Do(req *http.Request) (*http.Response, error) { + if req.URL.Path != "files.completeUploadExternal" { + return nil, fmt.Errorf("invalid path: %s", req.URL.Path) + } + + return &http.Response{ + StatusCode: m.ResponseStatus, + Body: io.NopCloser(bytes.NewBuffer(m.ResponseBody)), + }, nil +} + +func TestCompleteUploadExternalContext(t *testing.T) { + type testCase struct { + title string + params CompleteUploadExternalParameters + wantResponse CompleteUploadExternalResponse + wantErr bool + } + testCases := []testCase{ + { + title: "Testing with required parameters", + params: CompleteUploadExternalParameters{ + Files: []FileSummary{ + { + ID: "ID1", + }, + { + ID: "ID2", + }, + }, + }, + wantResponse: CompleteUploadExternalResponse{ + Files: []FileSummary{ + { + ID: "ID1", + }, + { + ID: "ID2", + }, + }, + SlackResponse: SlackResponse{Ok: true}, + }, + }, + { + title: "Testing with optional parameters", + params: CompleteUploadExternalParameters{ + Files: []FileSummary{ + { + ID: "ID1", + }, + { + ID: "ID2", + Title: "Title2", + }, + }, + Channel: "test-channel", + InitialComment: "test-comment", + ThreadTimestamp: "1234567890.123456", + }, + wantResponse: CompleteUploadExternalResponse{ + Files: []FileSummary{ + { + ID: "ID1", + }, + { + ID: "ID2", + Title: "Title2", + }, + }, + SlackResponse: SlackResponse{Ok: true}, + }, + }, + { + title: "Testing with error", + params: CompleteUploadExternalParameters{ + Files: []FileSummary{ + { + ID: "ID1", + }, + }, + }, + wantErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.title, func(t *testing.T) { + var resBody map[string]interface{} + if !tc.wantErr { + resBody = map[string]interface{}{ + "ok": true, + } + files := make([]map[string]string, 0) + for _, file := range tc.params.Files { + m := map[string]string{ + "id": file.ID, + } + if file.Title != "" { + m["title"] = file.Title + } + files = append(files, m) + } + resBody["files"] = files + } else { + resBody = map[string]interface{}{ + "ok": false, + "error": "errored", + } + } + + resBodyBytes, err := json.Marshal(resBody) + if err != nil { + t.Fatalf("failed to marshal response body: %v", err) + } + + api := &Client{ + token: validToken, + httpclient: &mockCompleteUploadExternalHttpClient{ + ResponseStatus: 200, + ResponseBody: resBodyBytes, + }, + } + + gotResponse, err := api.CompleteUploadExternalContext(context.Background(), tc.params) + + if err != nil { + if !tc.wantErr { + t.Errorf("CompleteUploadExternalContext() error = %v, want nil", err) + } + } else { + if tc.wantErr { + t.Fatalf("CompleteUploadExternalContext() error = nil, want %v", tc.wantErr) + } + if !reflect.DeepEqual(gotResponse, &tc.wantResponse) { + t.Errorf("CompleteUploadExternalContext() = %v, want %v", gotResponse, tc.wantResponse) + } + } + }) + } +} From 53a00c2148a246ea6fd67ef3c7d0c42b9b885409 Mon Sep 17 00:00:00 2001 From: YutoKashiwagi Date: Wed, 19 Feb 2025 15:50:00 +0900 Subject: [PATCH 299/606] make getUploadURLExternal public --- files.go | 48 ++++++++++++--------- files_test.go | 117 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 144 insertions(+), 21 deletions(-) diff --git a/files.go b/files.go index 3b9bc79c0..c1471605f 100644 --- a/files.go +++ b/files.go @@ -173,14 +173,14 @@ type UploadFileV2Parameters struct { SnippetText string } -type getUploadURLExternalParameters struct { - altText string - fileSize int - fileName string - snippetText string +type GetUploadURLExternalParameters struct { + AltText string + FileSize int + FileName string + SnippetText string } -type getUploadURLExternalResponse struct { +type GetUploadURLExternalResponse struct { UploadURL string `json:"upload_url"` FileID string `json:"file_id"` SlackResponse @@ -506,20 +506,28 @@ func (api *Client) ShareFilePublicURLContext(ctx context.Context, fileID string) return &response.File, response.Comments, &response.Paging, nil } -// getUploadURLExternal gets a URL and fileID from slack which can later be used to upload a file. -func (api *Client) getUploadURLExternal(ctx context.Context, params getUploadURLExternalParameters) (*getUploadURLExternalResponse, error) { +// GetUploadURLExternalContext gets a URL and fileID from slack which can later be used to upload a file. +// Slack API docs: https://api.slack.com/methods/files.getUploadURLExternal +func (api *Client) GetUploadURLExternalContext(ctx context.Context, params GetUploadURLExternalParameters) (*GetUploadURLExternalResponse, error) { + if params.FileName == "" { + return nil, fmt.Errorf("GetUploadURLExternalContext: fileName cannot be empty") + } + if params.FileSize == 0 { + return nil, fmt.Errorf("GetUploadURLExternalContext: fileSize cannot be 0") + } + values := url.Values{ "token": {api.token}, - "filename": {params.fileName}, - "length": {strconv.Itoa(params.fileSize)}, + "filename": {params.FileName}, + "length": {strconv.Itoa(params.FileSize)}, } - if params.altText != "" { - values.Add("initial_comment", params.altText) + if params.AltText != "" { + values.Add("initial_comment", params.AltText) } - if params.snippetText != "" { - values.Add("thread_ts", params.snippetText) + if params.SnippetText != "" { + values.Add("thread_ts", params.SnippetText) } - response := &getUploadURLExternalResponse{} + response := &GetUploadURLExternalResponse{} err := api.postMethod(ctx, "files.getUploadURLExternal", values, response) if err != nil { return nil, err @@ -594,11 +602,11 @@ func (api *Client) UploadFileV2Context(ctx context.Context, params UploadFileV2P return nil, fmt.Errorf("file.upload.v2: file size cannot be 0") } - u, err := api.getUploadURLExternal(ctx, getUploadURLExternalParameters{ - altText: params.AltTxt, - fileName: params.Filename, - fileSize: params.FileSize, - snippetText: params.SnippetText, + u, err := api.GetUploadURLExternalContext(ctx, GetUploadURLExternalParameters{ + AltText: params.AltTxt, + FileName: params.Filename, + FileSize: params.FileSize, + SnippetText: params.SnippetText, }) if err != nil { return nil, err diff --git a/files_test.go b/files_test.go index d86b61fdd..de98cf352 100644 --- a/files_test.go +++ b/files_test.go @@ -216,7 +216,7 @@ func TestUploadFileWithoutFilename(t *testing.T) { func uploadURLHandler(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "application/json") - response, _ := json.Marshal(getUploadURLExternalResponse{ + response, _ := json.Marshal(GetUploadURLExternalResponse{ FileID: "RandomID", UploadURL: "http://" + serverAddr + "/abc", SlackResponse: SlackResponse{Ok: true}}) @@ -285,6 +285,121 @@ func TestUploadFileV2(t *testing.T) { } } +type mockGetUploadURLExternalHttpClient struct { + ResponseStatus int + ResponseBody []byte +} + +func (m *mockGetUploadURLExternalHttpClient) Do(req *http.Request) (*http.Response, error) { + if req.URL.Path != "files.getUploadURLExternal" { + return nil, fmt.Errorf("invalid path: %s", req.URL.Path) + } + + return &http.Response{ + StatusCode: m.ResponseStatus, + Body: io.NopCloser(bytes.NewBuffer(m.ResponseBody)), + }, nil +} + +func TestGetUploadURLExternalContext(t *testing.T) { + type testCase struct { + title string + params GetUploadURLExternalParameters + wantSlackResponse []byte + wantResponse GetUploadURLExternalResponse + wantErr error + } + testCases := []testCase{ + { + title: "Testing with required parameters", + params: GetUploadURLExternalParameters{ + FileName: "test.txt", + FileSize: 10, + }, + wantSlackResponse: []byte(`{"ok":true,"file_id":"RandomID","upload_url":"http://test-server/abc"}`), + wantResponse: GetUploadURLExternalResponse{ + FileID: "RandomID", + UploadURL: "http://test-server/abc", + SlackResponse: SlackResponse{ + Ok: true, + }, + }, + }, + { + title: "Testing with optional parameters", + params: GetUploadURLExternalParameters{ + FileSize: 10, + FileName: "test.txt", + AltText: "test-alt-text", + SnippetText: "test-snippet-text", + }, + wantSlackResponse: []byte(`{"ok":true,"file_id":"RandomID","upload_url":"http://test-server/abc"}`), + wantResponse: GetUploadURLExternalResponse{ + FileID: "RandomID", + UploadURL: "http://test-server/abc", + SlackResponse: SlackResponse{ + Ok: true, + }, + }, + }, + { + title: "Testing with request error", + params: GetUploadURLExternalParameters{ + FileName: "test.txt", + FileSize: 10, + }, + wantSlackResponse: []byte(`{"ok":false,"error":"errored"}`), + wantErr: fmt.Errorf("errored"), + }, + { + title: "Testing with invalid parameters: empty file name", + params: GetUploadURLExternalParameters{ + FileName: "", + FileSize: 10, + }, + wantErr: fmt.Errorf("GetUploadURLExternalContext: fileName cannot be empty"), + }, + { + title: "Testing with invalid parameters: file size 0", + params: GetUploadURLExternalParameters{ + FileName: "test.txt", + FileSize: 0, + }, + wantErr: fmt.Errorf("GetUploadURLExternalContext: fileSize cannot be 0"), + }, + } + + for _, tc := range testCases { + t.Run(tc.title, func(t *testing.T) { + api := &Client{ + token: validToken, + httpclient: &mockGetUploadURLExternalHttpClient{ + ResponseStatus: 200, + ResponseBody: tc.wantSlackResponse, + }, + } + + gotResponse, err := api.GetUploadURLExternalContext(context.Background(), tc.params) + + if err != nil { + if tc.wantErr == nil { + t.Fatalf("GetUploadURLExternalContext() error = %v, want nil", err) + } + if err.Error() != tc.wantErr.Error() { + t.Errorf("GetUploadURLExternalContext() error = %v, want %v", err, tc.wantErr) + } + } else { + if tc.wantErr != nil { + t.Fatalf("GetUploadURLExternalContext() error = nil, want %v", tc.wantErr) + } + if !reflect.DeepEqual(gotResponse, &tc.wantResponse) { + t.Errorf("GetUploadURLExternalContext() = %v, want %v", gotResponse, tc.wantResponse) + } + } + }) + } +} + type mockCompleteUploadExternalHttpClient struct { ResponseStatus int ResponseBody []byte From f3c8db1974495779040bab98046629050573fc1b Mon Sep 17 00:00:00 2001 From: Sasha Gerrand Date: Wed, 19 Feb 2025 14:13:23 +0000 Subject: [PATCH 300/606] Configure Dependabot to maintain GitHub Actions (#1374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💁 When browsing this repository earlier, I saw that external actions are referenced using commit SHAs. This is useful as a way of ensuring that the version of a specific workflow doesn't drift unexpectedly but it also means that they need to be maintained (whether actively or not). This change configures @dependabot to do the hard work of looking after those external action references and updating them as needed. --- .github/dependabot.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..4f2ea0fbb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" From a8c2a8f5d5aa289fe92a31e8f2402a31fb26d129 Mon Sep 17 00:00:00 2001 From: Nathanael Demacon <7271496+quantumsheep@users.noreply.github.com> Date: Wed, 19 Feb 2025 15:14:39 +0100 Subject: [PATCH 301/606] feat: add filter in `MultiSelectBlockElement` (#1373) Hi! This was an existing PR (#1191) that had breaking changes and was unmaintained. This one does not break anything and only add the missing field. This is specified at https://api.slack.com/reference/block-kit/block-elements#conversations_select, which is supported by multi-selects. Signed-off-by: Nathanael DEMACON Co-authored-by: Nathanael DEMACON --- block_element.go | 1 + 1 file changed, 1 insertion(+) diff --git a/block_element.go b/block_element.go index ad3b67006..a677e041b 100644 --- a/block_element.go +++ b/block_element.go @@ -318,6 +318,7 @@ type MultiSelectBlockElement struct { InitialUsers []string `json:"initial_users,omitempty"` InitialConversations []string `json:"initial_conversations,omitempty"` InitialChannels []string `json:"initial_channels,omitempty"` + Filter *SelectBlockElementFilter `json:"filter,omitempty"` MinQueryLength *int `json:"min_query_length,omitempty"` MaxSelectedItems *int `json:"max_selected_items,omitempty"` Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` From b81981fa6fef2ae53e8a378e9c330bb7a47e47fe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Feb 2025 20:04:19 +0000 Subject: [PATCH 302/606] chore(deps): bump actions/stale from 9.0.0 to 9.1.0 (#1377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/stale](https://github.com/actions/stale) from 9.0.0 to 9.1.0.

    Release notes

    Sourced from actions/stale's releases.

    v9.1.0

    What's Changed

    New Contributors

    Full Changelog: https://github.com/actions/stale/compare/v9...v9.1.0

    Commits
    • 5bef64f build(deps): bump @​actions/cache from 3.2.2 to 4.0.0 (#1194)
    • fa77dfd build(deps-dev): bump @​types/jest from 29.5.11 to 29.5.14 (#1193)
    • f04443d build(deps): bump @​actions/core from 1.10.1 to 1.11.1 (#1191)
    • 5c715b0 build(deps-dev): bump ts-jest from 29.1.1 to 29.2.5 (#1175)
    • f691222 build(deps): bump actions/publish-action from 0.2.2 to 0.3.0 (#1147)
    • df990c2 build(deps): bump actions/checkout from 3 to 4 (#1091)
    • 6e472ce Merge pull request #1179 from actions/Jcambass-patch-1
    • d10ba64 Merge pull request #1150 from actions/dependabot/npm_and_yarn/undici-5.28.4
    • bbf3da5 resolve check failures
    • 6a2e61d Add workflow file for publishing releases to immutable action package
    • Additional commits viewable in compare view

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/stale&package-manager=github_actions&previous-version=9.0.0&new-version=9.1.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml index e860f783b..8b9867c96 100644 --- a/.github/workflows/stale.yaml +++ b/.github/workflows/stale.yaml @@ -13,7 +13,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0 + - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 with: any-of-labels: 'feedback given' days-before-stale: 45 From 37a09a8f156023a726b399afcc003d8cbb3a72aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Feb 2025 20:05:03 +0000 Subject: [PATCH 303/606] chore(deps): bump golangci/golangci-lint-action from 6.1.0 to 6.5.0 (#1378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 6.1.0 to 6.5.0.
    Release notes

    Sourced from golangci/golangci-lint-action's releases.

    v6.5.0

    What's Changed

    Changes

    Dependencies

    Full Changelog: https://github.com/golangci/golangci-lint-action/compare/v6.4.1...v6.5.0

    v6.4.1

    What's Changed

    Changes

    Full Changelog: https://github.com/golangci/golangci-lint-action/compare/v6.4.0...v6.4.1

    v6.4.0

    What's Changed

    Changes

    Full Changelog: https://github.com/golangci/golangci-lint-action/compare/v6.3.3...v6.4.0

    v6.3.3

    What's Changed

    Changes

    Full Changelog: https://github.com/golangci/golangci-lint-action/compare/v6.3.2...v6.3.3

    v6.3.2

    What's Changed

    Changes

    ... (truncated)

    Commits

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=golangci/golangci-lint-action&package-manager=github_actions&previous-version=6.1.0&new-version=6.5.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 61eaa92dc..5c0550aff 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,6 +40,6 @@ jobs: with: fetch-depth: 0 - name: golangci-lint - uses: golangci/golangci-lint-action@aaa42aa0628b4ae2578232a66b541047968fac86 # v6.1.0 + uses: golangci/golangci-lint-action@2226d7cb06a077cd73e56eedd38eecad18e5d837 # v6.5.0 with: version: v1.52.2 From 8c0da32175d682be3477ee445e6b14cd6e0a8674 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carla=20Urrea=20Bl=C3=A1zquez?= Date: Thu, 27 Feb 2025 21:05:38 +0100 Subject: [PATCH 304/606] feat: support unmarshalling markdown blocks (#1381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hey 👋 This PR introduces support for unmarshalling the new [`markdown` Block type](https://api.slack.com/reference/block-kit/blocks#markdown). It complements the previously merged PR (https://github.com/slack-go/slack/pull/1372), which added support for the `markdown` Block type itself. Co-authored-by: Carla Urrea --- block_conv.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/block_conv.go b/block_conv.go index 26c57bbbb..7df9b107b 100644 --- a/block_conv.go +++ b/block_conv.go @@ -63,6 +63,8 @@ func (b *Blocks) UnmarshalJSON(data []byte) error { block = &ImageBlock{} case "input": block = &InputBlock{} + case "markdown": + block = &MarkdownBlock{} case "rich_text": block = &RichTextBlock{} case "rich_text_input": From 9a7fd29f8ccb6698f14e20a2f418d3e27012b439 Mon Sep 17 00:00:00 2001 From: YutoKashiwagi Date: Sun, 2 Mar 2025 23:31:46 +0900 Subject: [PATCH 305/606] fix GetUploadURLExternalContext error message --- files.go | 4 ++-- files_test.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/files.go b/files.go index c1471605f..bfdf0a589 100644 --- a/files.go +++ b/files.go @@ -510,10 +510,10 @@ func (api *Client) ShareFilePublicURLContext(ctx context.Context, fileID string) // Slack API docs: https://api.slack.com/methods/files.getUploadURLExternal func (api *Client) GetUploadURLExternalContext(ctx context.Context, params GetUploadURLExternalParameters) (*GetUploadURLExternalResponse, error) { if params.FileName == "" { - return nil, fmt.Errorf("GetUploadURLExternalContext: fileName cannot be empty") + return nil, fmt.Errorf("files.getUploadURLExternal: fileName cannot be empty") } if params.FileSize == 0 { - return nil, fmt.Errorf("GetUploadURLExternalContext: fileSize cannot be 0") + return nil, fmt.Errorf("files.getUploadURLExternal: fileSize cannot be 0") } values := url.Values{ diff --git a/files_test.go b/files_test.go index de98cf352..01cc46d0c 100644 --- a/files_test.go +++ b/files_test.go @@ -357,7 +357,7 @@ func TestGetUploadURLExternalContext(t *testing.T) { FileName: "", FileSize: 10, }, - wantErr: fmt.Errorf("GetUploadURLExternalContext: fileName cannot be empty"), + wantErr: fmt.Errorf("files.getUploadURLExternal: fileName cannot be empty"), }, { title: "Testing with invalid parameters: file size 0", @@ -365,7 +365,7 @@ func TestGetUploadURLExternalContext(t *testing.T) { FileName: "test.txt", FileSize: 0, }, - wantErr: fmt.Errorf("GetUploadURLExternalContext: fileSize cannot be 0"), + wantErr: fmt.Errorf("files.getUploadURLExternal: fileSize cannot be 0"), }, } From 11d6a3bc3614dc8c5512124b82c023d1ceedfa1d Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 2 Mar 2025 17:29:46 +0000 Subject: [PATCH 306/606] chore: remove unused GO111MODULE from the tests (#1382) Since Go 1.16 this variable defaults to "on" and we don't support anything below 1.18 in our test suite, so this is effectively a no-op. --- .github/workflows/test.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5c0550aff..219ff31a2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,8 +26,6 @@ jobs: go-version: ${{ matrix.go }} - name: run test run: go test -v -race ./... - env: - GO111MODULE: on lint: runs-on: ubuntu-22.04 name: lint From 7653d709b3a5d74ddf9739957da87da3a513f36d Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 2 Mar 2025 19:10:15 +0000 Subject: [PATCH 307/606] Simplify error messages in files. --- files.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/files.go b/files.go index bfdf0a589..0d6626bfe 100644 --- a/files.go +++ b/files.go @@ -510,10 +510,10 @@ func (api *Client) ShareFilePublicURLContext(ctx context.Context, fileID string) // Slack API docs: https://api.slack.com/methods/files.getUploadURLExternal func (api *Client) GetUploadURLExternalContext(ctx context.Context, params GetUploadURLExternalParameters) (*GetUploadURLExternalResponse, error) { if params.FileName == "" { - return nil, fmt.Errorf("files.getUploadURLExternal: fileName cannot be empty") + return nil, fmt.Errorf("FileName cannot be empty") } if params.FileSize == 0 { - return nil, fmt.Errorf("files.getUploadURLExternal: fileSize cannot be 0") + return nil, fmt.Errorf("FileSize cannot be 0") } values := url.Values{ From cf4c69c3bea4c89eb63344f8156a80f51d1323c9 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 2 Mar 2025 19:15:11 +0000 Subject: [PATCH 308/606] Fix tests message --- files_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/files_test.go b/files_test.go index 01cc46d0c..0fb9b2b84 100644 --- a/files_test.go +++ b/files_test.go @@ -357,7 +357,7 @@ func TestGetUploadURLExternalContext(t *testing.T) { FileName: "", FileSize: 10, }, - wantErr: fmt.Errorf("files.getUploadURLExternal: fileName cannot be empty"), + wantErr: fmt.Errorf("FileName cannot be empty"), }, { title: "Testing with invalid parameters: file size 0", @@ -365,7 +365,7 @@ func TestGetUploadURLExternalContext(t *testing.T) { FileName: "test.txt", FileSize: 0, }, - wantErr: fmt.Errorf("files.getUploadURLExternal: fileSize cannot be 0"), + wantErr: fmt.Errorf("FileSize cannot be 0"), }, } From edc2c7f43157e043234feca5637f6c6508af56d2 Mon Sep 17 00:00:00 2001 From: Kyle Smith Date: Fri, 13 Dec 2024 12:55:01 -0500 Subject: [PATCH 309/606] fix: slacktest GetSeenOutboundMessages race condition This patch addresses issue #1361. The storage backing GetSeenOutboundMessages is updated in a goroutine that may or may not be executed in time for assertions against this method. This patch updates the storage to be updated synchronously in the handler, while maintaining the asynchronous queue behavior for websockets handlers. The test case has been updated to remove the sleep statement that likely worked around this very issue. I opted to change the locking behavior to be more closely related with the messageCollection type. There is a smaller version of this fix that move the lock/update/unlock block into the callsite of each queue update, if that is preferable. --- slacktest/funcs.go | 10 +++----- slacktest/server.go | 53 ++++++++++++++++++++-------------------- slacktest/server_test.go | 24 +++++++++--------- slacktest/types.go | 22 ++++++++++++++--- 4 files changed, 60 insertions(+), 49 deletions(-) diff --git a/slacktest/funcs.go b/slacktest/funcs.go index d083af2b5..7be54fe8d 100644 --- a/slacktest/funcs.go +++ b/slacktest/funcs.go @@ -15,11 +15,9 @@ func (sts *Server) queueForWebsocket(s, hubname string) { channel, err := getHubForServer(hubname) if err != nil { log.Printf("Unable to get server's channels: %s", err.Error()) + } else { + channel.sent <- s } - sts.seenOutboundMessages.Lock() - sts.seenOutboundMessages.messages = append(sts.seenOutboundMessages.messages, s) - sts.seenOutboundMessages.Unlock() - channel.sent <- s } func handlePendingMessages(c *websocket.Conn, hubname string) { @@ -43,9 +41,7 @@ func (sts *Server) postProcessMessage(m, hubname string) { log.Printf("Unable to get server's channels: %s", err.Error()) return } - sts.seenInboundMessages.Lock() - sts.seenInboundMessages.messages = append(sts.seenInboundMessages.messages, m) - sts.seenInboundMessages.Unlock() + sts.seenInboundMessages.observe(m) // send to firehose channel.seen <- m } diff --git a/slacktest/server.go b/slacktest/server.go index 6d9849451..ba01939fa 100644 --- a/slacktest/server.go +++ b/slacktest/server.go @@ -106,28 +106,20 @@ func (sts *Server) GetGroups() []slack.Group { // GetSeenInboundMessages returns all messages seen via websocket excluding pings func (sts *Server) GetSeenInboundMessages() []string { - sts.seenInboundMessages.RLock() - m := sts.seenInboundMessages.messages - sts.seenInboundMessages.RUnlock() - return m + return sts.seenInboundMessages.get() } // GetSeenOutboundMessages returns all messages seen via websocket excluding pings func (sts *Server) GetSeenOutboundMessages() []string { - sts.seenOutboundMessages.RLock() - m := sts.seenOutboundMessages.messages - sts.seenOutboundMessages.RUnlock() - return m + return sts.seenOutboundMessages.get() } // SawOutgoingMessage checks if a message was sent to connected websocket clients func (sts *Server) SawOutgoingMessage(msg string) bool { - sts.seenOutboundMessages.RLock() - defer sts.seenOutboundMessages.RUnlock() - for _, m := range sts.seenOutboundMessages.messages { + for _, m := range sts.seenOutboundMessages.get() { evt := &slack.MessageEvent{} - jErr := json.Unmarshal([]byte(m), evt) - if jErr != nil { + err := json.Unmarshal([]byte(m), evt) + if err != nil { continue } @@ -135,17 +127,16 @@ func (sts *Server) SawOutgoingMessage(msg string) bool { return true } } + return false } // SawMessage checks if an incoming message was seen func (sts *Server) SawMessage(msg string) bool { - sts.seenInboundMessages.RLock() - defer sts.seenInboundMessages.RUnlock() - for _, m := range sts.seenInboundMessages.messages { + for _, m := range sts.seenInboundMessages.get() { evt := &slack.MessageEvent{} - jErr := json.Unmarshal([]byte(m), evt) - if jErr != nil { + err := json.Unmarshal([]byte(m), evt) + if err != nil { // This event isn't a message event so we'll skip it continue } @@ -153,6 +144,7 @@ func (sts *Server) SawMessage(msg string) bool { return true } } + return false } @@ -184,11 +176,14 @@ func (sts *Server) SendMessageToBot(channel, msg string) { m.User = defaultNonBotUserID m.Text = fmt.Sprintf("<@%s> %s", sts.BotID, msg) m.Timestamp = fmt.Sprintf("%d", time.Now().Unix()) - j, jErr := json.Marshal(m) - if jErr != nil { - log.Printf("Unable to marshal message for bot: %s", jErr.Error()) + + j, err := json.Marshal(m) + if err != nil { + log.Printf("Unable to marshal message for bot: %s", err.Error()) return } + + sts.seenOutboundMessages.observe(string(j)) go sts.queueForWebsocket(string(j), sts.ServerAddr) } @@ -200,11 +195,14 @@ func (sts *Server) SendDirectMessageToBot(msg string) { m.User = defaultNonBotUserID m.Text = msg m.Timestamp = fmt.Sprintf("%d", time.Now().Unix()) - j, jErr := json.Marshal(m) - if jErr != nil { - log.Printf("Unable to marshal private message for bot: %s", jErr.Error()) + + j, err := json.Marshal(m) + if err != nil { + log.Printf("Unable to marshal private message for bot: %s", err.Error()) return } + + sts.seenOutboundMessages.observe(string(j)) go sts.queueForWebsocket(string(j), sts.ServerAddr) } @@ -216,18 +214,21 @@ func (sts *Server) SendMessageToChannel(channel, msg string) { m.Text = msg m.User = defaultNonBotUserID m.Timestamp = fmt.Sprintf("%d", time.Now().Unix()) + j, jErr := json.Marshal(m) if jErr != nil { log.Printf("Unable to marshal message for channel: %s", jErr.Error()) return } - stringMsg := string(j) - go sts.queueForWebsocket(stringMsg, sts.ServerAddr) + + sts.seenOutboundMessages.observe(string(j)) + go sts.queueForWebsocket(string(j), sts.ServerAddr) } // SendToWebsocket send `s` as is to connected clients. // This is useful for sending your own custom json to the websocket func (sts *Server) SendToWebsocket(s string) { + sts.seenOutboundMessages.observe(s) go sts.queueForWebsocket(s, sts.ServerAddr) } diff --git a/slacktest/server_test.go b/slacktest/server_test.go index 45f620858..01a603a46 100644 --- a/slacktest/server_test.go +++ b/slacktest/server_test.go @@ -27,7 +27,7 @@ func TestCustomNewServer(t *testing.T) { func TestServerSendMessageToChannel(t *testing.T) { s := NewTestServer() - go s.Start() + s.Start() s.SendMessageToChannel("C123456789", "some text") time.Sleep(2 * time.Second) assert.True(t, s.SawOutgoingMessage("some text")) @@ -36,7 +36,7 @@ func TestServerSendMessageToChannel(t *testing.T) { func TestServerSendMessageToBot(t *testing.T) { s := NewTestServer() - go s.Start() + s.Start() s.SendMessageToBot("C123456789", "some text") expectedMsg := fmt.Sprintf("<@%s> %s", s.BotID, "some text") time.Sleep(2 * time.Second) @@ -46,7 +46,7 @@ func TestServerSendMessageToBot(t *testing.T) { func TestBotDirectMessageBotHandler(t *testing.T) { s := NewTestServer() - go s.Start() + s.Start() s.SendDirectMessageToBot("some text") expectedMsg := "some text" time.Sleep(2 * time.Second) @@ -55,14 +55,14 @@ func TestBotDirectMessageBotHandler(t *testing.T) { } func TestGetSeenOutboundMessages(t *testing.T) { - maxWait := 5 * time.Second s := NewTestServer() - go s.Start() + s.Start() s.SendMessageToChannel("foo", "should see this message") - time.Sleep(maxWait) + seenOutbound := s.GetSeenOutboundMessages() - assert.True(t, len(seenOutbound) > 0) + assert.Len(t, seenOutbound, 1) + hadMessage := false for _, msg := range seenOutbound { var m = slack.Message{} @@ -79,7 +79,7 @@ func TestGetSeenOutboundMessages(t *testing.T) { func TestGetSeenInboundMessages(t *testing.T) { maxWait := 5 * time.Second s := NewTestServer() - go s.Start() + s.Start() api := slack.New("ABCDEFG", slack.OptionAPIURL(s.GetAPIURL())) rtm := api.NewRTM() @@ -108,7 +108,7 @@ func TestGetSeenInboundMessages(t *testing.T) { func TestSendChannelInvite(t *testing.T) { maxWait := 5 * time.Second s := NewTestServer() - go s.Start() + s.Start() rtm := s.GetTestRTMInstance() go rtm.ManageConnection() evChan := make(chan (slack.Channel), 1) @@ -137,7 +137,7 @@ func TestSendChannelInvite(t *testing.T) { func TestSendGroupInvite(t *testing.T) { maxWait := 5 * time.Second s := NewTestServer() - go s.Start() + s.Start() rtm := s.GetTestRTMInstance() go rtm.ManageConnection() evChan := make(chan (slack.Channel), 1) @@ -165,12 +165,12 @@ func TestSendGroupInvite(t *testing.T) { func TestServerSawMessage(t *testing.T) { s := NewTestServer() - go s.Start() + s.Start() assert.False(t, s.SawMessage("foo"), "should not have seen any message") } func TestServerSawOutgoingMessage(t *testing.T) { s := NewTestServer() - go s.Start() + s.Start() assert.False(t, s.SawOutgoingMessage("foo"), "should not have seen any message") } diff --git a/slacktest/types.go b/slacktest/types.go index 4c90ed9ba..c6e776f72 100644 --- a/slacktest/types.go +++ b/slacktest/types.go @@ -40,15 +40,29 @@ type hub struct { } type messageChannels struct { - seen chan (string) - sent chan (string) - posted chan (slack.Message) + seen chan string + sent chan string + posted chan slack.Message } type messageCollection struct { sync.RWMutex messages []string } +func (mc *messageCollection) observe(msg string) { + mc.Lock() + defer mc.Unlock() + mc.messages = append(mc.messages, msg) +} + +func (mc *messageCollection) get() []string { + mc.RLock() + defer mc.RUnlock() + + m := mc.messages + return m +} + type serverChannels struct { sync.RWMutex channels []slack.Channel @@ -68,7 +82,7 @@ type Server struct { BotName string BotID string ServerAddr string - SeenFeed chan (string) + SeenFeed chan string channels *serverChannels groups *serverGroups seenInboundMessages *messageCollection From 585282af9db043e4f378d485aedc8e1caf476ec3 Mon Sep 17 00:00:00 2001 From: ishitamundhra <65913701+ishitamundhra@users.noreply.github.com> Date: Fri, 7 Mar 2025 18:43:55 +0530 Subject: [PATCH 310/606] Change PublishViewContext to not require a hash Closes #1321. --- views.go | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/views.go b/views.go index 0822d2cb2..5f55b537a 100644 --- a/views.go +++ b/views.go @@ -110,6 +110,12 @@ type ModalViewRequest struct { ExternalID string `json:"external_id,omitempty"` } +type PublishViewContextRequest struct { + UserID string `json:"user_id"` + View HomeTabViewRequest `json:"view"` + Hash *string `json:"hash,omitempty"` +} + func (v *ModalViewRequest) ViewType() ViewType { return v.Type } @@ -131,12 +137,6 @@ type openViewRequest struct { View ModalViewRequest `json:"view"` } -type publishViewRequest struct { - UserID string `json:"user_id"` - View HomeTabViewRequest `json:"view"` - Hash string `json:"hash,omitempty"` -} - type pushViewRequest struct { TriggerID string `json:"trigger_id"` View ModalViewRequest `json:"view"` @@ -212,25 +212,18 @@ func (api *Client) OpenViewContext( // PublishView publishes a static view for a user. // For more information see the PublishViewContext documentation. func (api *Client) PublishView(userID string, view HomeTabViewRequest, hash string) (*ViewResponse, error) { - return api.PublishViewContext(context.Background(), userID, view, hash) + return api.PublishViewContext(context.Background(), PublishViewContextRequest{UserID: userID, View: view, Hash: &hash}) } // PublishViewContext publishes a static view for a user with a custom context. // Slack API docs: https://api.slack.com/methods/views.publish func (api *Client) PublishViewContext( ctx context.Context, - userID string, - view HomeTabViewRequest, - hash string, + req PublishViewContextRequest, ) (*ViewResponse, error) { - if userID == "" { + if req.UserID == "" { return nil, ErrParametersMissing } - req := publishViewRequest{ - UserID: userID, - View: view, - Hash: hash, - } encoded, err := json.Marshal(req) if err != nil { return nil, err From a454f77660a6d5a889ebda5e9d3c9b06a7063fba Mon Sep 17 00:00:00 2001 From: Lorenzo Aiello Date: Fri, 7 Mar 2025 05:17:30 -0800 Subject: [PATCH 311/606] DEPRECATION!: Legacy Workflows Deprecation (#1350) In accordance with the [Legacy Workflow Deprecation Note](https://api.slack.com/changelog/2023-08-workflow-steps-from-apps-step-back), this pull request removes the following: WebAPI Methods: - [workflows.StepCompleted](https://api.slack.com/methods/workflows.stepCompleted) - [workflows.stepFailed](https://api.slack.com/methods/workflows.stepFailed) - [workflows.updateStep](https://api.slack.com/methods/workflows.stepCompleted) Events: - [workflow_step_execute](https://api.slack.com/events/workflow_step_execute) - [workflow_published](https://api.slack.com/events/workflow_published) - [workflow_unpublished](https://api.slack.com/events/workflow_unpublished) - [workflow_deleted](https://api.slack.com/events/workflow_deleted) - [workflow_step_deleted](https://api.slack.com/events/workflow_step_deleted) --- examples/workflow_step/README.md | 59 -------- examples/workflow_step/go.mod | 9 -- examples/workflow_step/go.sum | 11 -- examples/workflow_step/handler.go | 211 --------------------------- examples/workflow_step/main.go | 48 ------ examples/workflow_step/middleware.go | 41 ------ interactions.go | 55 +++---- slackevents/inner_events.go | 18 --- slackevents/inner_events_test.go | 61 -------- workflow_step.go | 101 ------------- workflow_step_execute.go | 85 ----------- workflow_step_execute_test.go | 35 ----- workflow_step_test.go | 190 ------------------------ 13 files changed, 23 insertions(+), 901 deletions(-) delete mode 100644 examples/workflow_step/README.md delete mode 100644 examples/workflow_step/go.mod delete mode 100644 examples/workflow_step/go.sum delete mode 100644 examples/workflow_step/handler.go delete mode 100644 examples/workflow_step/main.go delete mode 100644 examples/workflow_step/middleware.go delete mode 100644 workflow_step.go delete mode 100644 workflow_step_execute.go delete mode 100644 workflow_step_execute_test.go delete mode 100644 workflow_step_test.go diff --git a/examples/workflow_step/README.md b/examples/workflow_step/README.md deleted file mode 100644 index da378b894..000000000 --- a/examples/workflow_step/README.md +++ /dev/null @@ -1,59 +0,0 @@ -#WorkflowStep - -Have you ever wanted to run an app from a Slack workflow? This sample app shows you how it works. - -Slack describes some of the basics here: -https://api.slack.com/workflows/steps -https://api.slack.com/tutorials/workflow-builder-steps - - -1. Start the example app localy on port 8080 - - -2. Use ngrok to expose your app to the internet - -```shell - ./ngrok http 8080 -``` -Copy the https forwarding URL and paste it into the app manifest down below (event_subscription request_url and interactivity request_url) - - -3. Create a new Slack App at api.slack.com/apps from an app manifest - -The manifest of a sample Slack App looks like this: -```yaml -display_information: - name: Workflowstep-Example -features: - bot_user: - display_name: Workflowstep-Example - always_online: false - workflow_steps: - - name: Example Step - callback_id: example-step -oauth_config: - scopes: - bot: - - workflow.steps:execute -settings: - event_subscriptions: - request_url: https://*****.ngrok.io/api/v1/example-step - bot_events: - - workflow_step_execute - interactivity: - is_enabled: true - request_url: https://*****.ngrok.io/api/v1/interaction - org_deploy_enabled: false - socket_mode_enabled: false - token_rotation_enabled: false -``` - -("Interactivity" and "Enable Events" should be turned on) - -4. Slack Workflow (**paid plan required!**) - 1. Create a new Workflow at app.slack.com/workflow-builder - 2. give it a name - 3. select "Planned date & time" - 4. add another step and select "Example Step" from App Workflowstep-Example - 5. configure your app and hit save - 6. don't forget to publish your workflow \ No newline at end of file diff --git a/examples/workflow_step/go.mod b/examples/workflow_step/go.mod deleted file mode 100644 index 1df243ce5..000000000 --- a/examples/workflow_step/go.mod +++ /dev/null @@ -1,9 +0,0 @@ -module workflowstep-example - -go 1.17 - -require ( - github.com/gorilla/websocket v1.4.2 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/slack-go/slack v0.10.1 // indirect -) diff --git a/examples/workflow_step/go.sum b/examples/workflow_step/go.sum deleted file mode 100644 index e95148d32..000000000 --- a/examples/workflow_step/go.sum +++ /dev/null @@ -1,11 +0,0 @@ -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/slack-go/slack v0.10.1 h1:BGbxa0kMsGEvLOEoZmYs8T1wWfoZXwmQFBb6FgYCXUA= -github.com/slack-go/slack v0.10.1/go.mod h1:wWL//kk0ho+FcQXcBTmEafUI5dz4qz5f4mMk8oIkioQ= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= diff --git a/examples/workflow_step/handler.go b/examples/workflow_step/handler.go deleted file mode 100644 index d0eee101f..000000000 --- a/examples/workflow_step/handler.go +++ /dev/null @@ -1,211 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "net/url" - "time" - - "github.com/slack-go/slack" - "github.com/slack-go/slack/slackevents" -) - -const ( - IDSelectOptionBlock = "select-option-block" - IDExampleSelectInput = "example-select-input" -) - -func handleMyWorkflowStep(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - w.WriteHeader(http.StatusMethodNotAllowed) - return - } - - // see: https://github.com/slack-go/slack/blob/master/examples/eventsapi/events.go - body, err := io.ReadAll(r.Body) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - - eventsAPIEvent, err := slackevents.ParseEvent(json.RawMessage(body), slackevents.OptionNoVerifyToken()) - if err != nil { - log.Printf("[ERROR] Failed on parsing event: %s", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - return - } - - // see: https://api.slack.com/apis/connections/events-api#subscriptions - if eventsAPIEvent.Type == slackevents.URLVerification { - var r *slackevents.ChallengeResponse - err := json.Unmarshal([]byte(body), &r) - if err != nil { - log.Printf("[ERROR] Failed to decode json message on event url_verification: %s", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "text") - w.Write([]byte(r.Challenge)) - return - } - - // see: https://api.slack.com/apis/connections/events-api#receiving_events - if eventsAPIEvent.Type == slackevents.CallbackEvent { - innerEvent := eventsAPIEvent.InnerEvent - - switch ev := innerEvent.Data.(type) { - - // see: https://api.slack.com/events/workflow_step_execute - case *slackevents.WorkflowStepExecuteEvent: - if ev.CallbackID == MyExampleWorkflowStepCallbackID { - go doHeavyLoad(ev.WorkflowStep) - - w.WriteHeader(http.StatusOK) - return - } - w.WriteHeader(http.StatusBadRequest) - log.Printf("[WARN] unknown callbackID: %s", ev.CallbackID) - return - - default: - w.WriteHeader(http.StatusBadRequest) - log.Printf("[WARN] unknown inner event type: %s", eventsAPIEvent.InnerEvent.Type) - return - } - } - - w.WriteHeader(http.StatusBadRequest) - log.Printf("[WARN] unknown event type: %s", eventsAPIEvent.Type) -} - -func handleInteraction(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - w.WriteHeader(http.StatusMethodNotAllowed) - return - } - - body, err := io.ReadAll(r.Body) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - - jsonStr, err := url.QueryUnescape(string(body)[8:]) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - var message slack.InteractionCallback - if err := json.Unmarshal([]byte(jsonStr), &message); err != nil { - log.Printf("[ERROR] Failed to decode json message from slack: %s", jsonStr) - w.WriteHeader(http.StatusInternalServerError) - return - } - - switch message.Type { - case slack.InteractionTypeWorkflowStepEdit: - // https://api.slack.com/workflows/steps#handle_config_view - err := replyWithConfigurationView(message, "", "") - if err != nil { - log.Printf("[ERROR] Failed to open configuration modal in slack: %s", err.Error()) - } - - case slack.InteractionTypeViewSubmission: - // https://api.slack.com/workflows/steps#handle_view_submission - - // process user inputs - // this is just for demonstration, so we print it to console only - blockAction := message.View.State.Values - selectedOption := blockAction[IDSelectOptionBlock][IDExampleSelectInput].SelectedOption.Value - log.Println(fmt.Sprintf("user selected: %s", selectedOption)) - - in := &slack.WorkflowStepInputs{ - IDExampleSelectInput: slack.WorkflowStepInputElement{ - Value: selectedOption, - SkipVariableReplacement: false, - }, - } - - err := saveUserSettingsForWorkflowStep(message.WorkflowStep.WorkflowStepEditID, in, nil) - if err != nil { - log.Printf("[ERROR] Failed on doing a POST request to workflows.updateStep: %s", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - } - - default: - log.Printf("[WARN] unknown message type: %s", message.Type) - w.WriteHeader(http.StatusInternalServerError) - } -} - -func replyWithConfigurationView(message slack.InteractionCallback, privateMetaData string, externalID string) error { - headerText := slack.NewTextBlockObject("mrkdwn", "Hello World!\nThis is your workflow step app configuration view", false, false) - headerSection := slack.NewSectionBlock(headerText, nil, nil) - - options := []*slack.OptionBlockObject{} - options = append( - options, - slack.NewOptionBlockObject("one", slack.NewTextBlockObject("plain_text", "One", false, false), nil), - ) - - options = append( - options, - slack.NewOptionBlockObject("two", slack.NewTextBlockObject("plain_text", "Two", false, false), nil), - ) - - options = append( - options, - slack.NewOptionBlockObject("three", slack.NewTextBlockObject("plain_text", "Three", false, false), nil), - ) - - selection := slack.NewOptionsSelectBlockElement( - "static_select", - slack.NewTextBlockObject("plain_text", "your choice", false, false), - IDExampleSelectInput, - options..., - ) - - // preselect option, if workflow step input is defined - initialOption, ok := slack.GetInitialOptionFromWorkflowStepInput(selection, message.WorkflowStep.Inputs, options) - if ok { - selection.InitialOption = initialOption - } - - inputBlock := slack.NewInputBlock( - IDSelectOptionBlock, - slack.NewTextBlockObject("plain_text", "Select an option", false, false), - selection, - ) - - blocks := slack.Blocks{ - BlockSet: []slack.Block{ - headerSection, - inputBlock, - }, - } - - cmr := slack.NewConfigurationModalRequest(blocks, privateMetaData, externalID) - _, err := appCtx.slack.OpenView(message.TriggerID, cmr.ModalViewRequest) - return err -} - -func saveUserSettingsForWorkflowStep(workflowStepEditID string, inputs *slack.WorkflowStepInputs, outputs *[]slack.WorkflowStepOutput) error { - return appCtx.slack.SaveWorkflowStepConfiguration(workflowStepEditID, inputs, outputs) -} - -func doHeavyLoad(workflowStep slackevents.EventWorkflowStep) { - // process user configuration e.g. inputs - log.Printf("Inputs:") - for name, input := range *workflowStep.Inputs { - log.Printf(fmt.Sprintf("%s: %s", name, input.Value)) - } - - // do heavy load - time.Sleep(10 * time.Second) - log.Println("Done") -} diff --git a/examples/workflow_step/main.go b/examples/workflow_step/main.go deleted file mode 100644 index 45494d086..000000000 --- a/examples/workflow_step/main.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "fmt" - "log" - "net/http" - "os" - - "github.com/slack-go/slack" -) - -type ( - appContext struct { - slack *slack.Client - config configuration - } - configuration struct { - botToken string - signingSecret string - } - SecretsVerifierMiddleware struct { - handler http.Handler - } -) - -const ( - APIBaseURL = "/api/v1" - // MyExampleWorkflowStepCallbackID is configured in slack (api.slack.com/apps). - // Select your app or create a new one. Then choose menu "Workflow Steps"... - MyExampleWorkflowStepCallbackID = "example-step" -) - -var appCtx appContext - -func main() { - appCtx.config.botToken = os.Getenv("SLACK_BOT_TOKEN") - appCtx.config.signingSecret = os.Getenv("SLACK_SIGNING_SECRET") - - appCtx.slack = slack.New(appCtx.config.botToken) - - mux := http.NewServeMux() - mux.HandleFunc(fmt.Sprintf("%s/interaction", APIBaseURL), handleInteraction) - mux.HandleFunc(fmt.Sprintf("%s/%s", APIBaseURL, MyExampleWorkflowStepCallbackID), handleMyWorkflowStep) - middleware := NewSecretsVerifierMiddleware(mux) - - log.Printf("starting server on :8080") - log.Fatal(http.ListenAndServe(":8080", middleware)) -} diff --git a/examples/workflow_step/middleware.go b/examples/workflow_step/middleware.go deleted file mode 100644 index 6fe826910..000000000 --- a/examples/workflow_step/middleware.go +++ /dev/null @@ -1,41 +0,0 @@ -package main - -import ( - "bytes" - "io" - "net/http" - - "github.com/slack-go/slack" -) - -func (v *SecretsVerifierMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - r.Body.Close() - r.Body = io.NopCloser(bytes.NewBuffer(body)) - - sv, err := slack.NewSecretsVerifier(r.Header, appCtx.config.signingSecret) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - - if _, err := sv.Write(body); err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - if err := sv.Ensure(); err != nil { - w.WriteHeader(http.StatusUnauthorized) - return - } - - v.handler.ServeHTTP(w, r) -} - -func NewSecretsVerifierMiddleware(h http.Handler) *SecretsVerifierMiddleware { - return &SecretsVerifierMiddleware{h} -} diff --git a/interactions.go b/interactions.go index 8c6414707..abd0d55d3 100644 --- a/interactions.go +++ b/interactions.go @@ -33,30 +33,29 @@ const ( // InteractionCallback is sent from slack when a user interactions with a button or dialog. type InteractionCallback struct { - Type InteractionType `json:"type"` - Token string `json:"token"` - CallbackID string `json:"callback_id"` - ResponseURL string `json:"response_url"` - TriggerID string `json:"trigger_id"` - ActionTs string `json:"action_ts"` - Team Team `json:"team"` - Channel Channel `json:"channel"` - User User `json:"user"` - OriginalMessage Message `json:"original_message"` - Message Message `json:"message"` - Name string `json:"name"` - Value string `json:"value"` - MessageTs string `json:"message_ts"` - AttachmentID string `json:"attachment_id"` - ActionCallback ActionCallbacks `json:"actions"` - View View `json:"view"` - ActionID string `json:"action_id"` - APIAppID string `json:"api_app_id"` - BlockID string `json:"block_id"` - Container Container `json:"container"` - Enterprise Enterprise `json:"enterprise"` - IsEnterpriseInstall bool `json:"is_enterprise_install"` - WorkflowStep InteractionWorkflowStep `json:"workflow_step"` + Type InteractionType `json:"type"` + Token string `json:"token"` + CallbackID string `json:"callback_id"` + ResponseURL string `json:"response_url"` + TriggerID string `json:"trigger_id"` + ActionTs string `json:"action_ts"` + Team Team `json:"team"` + Channel Channel `json:"channel"` + User User `json:"user"` + OriginalMessage Message `json:"original_message"` + Message Message `json:"message"` + Name string `json:"name"` + Value string `json:"value"` + MessageTs string `json:"message_ts"` + AttachmentID string `json:"attachment_id"` + ActionCallback ActionCallbacks `json:"actions"` + View View `json:"view"` + ActionID string `json:"action_id"` + APIAppID string `json:"api_app_id"` + BlockID string `json:"block_id"` + Container Container `json:"container"` + Enterprise Enterprise `json:"enterprise"` + IsEnterpriseInstall bool `json:"is_enterprise_install"` DialogSubmissionCallback ViewSubmissionCallback ViewClosedCallback @@ -137,14 +136,6 @@ type Enterprise struct { Name string `json:"name"` } -type InteractionWorkflowStep struct { - WorkflowStepEditID string `json:"workflow_step_edit_id,omitempty"` - WorkflowID string `json:"workflow_id"` - StepID string `json:"step_id"` - Inputs *WorkflowStepInputs `json:"inputs,omitempty"` - Outputs *[]WorkflowStepOutput `json:"outputs,omitempty"` -} - // ActionCallback is a convenience struct defined to allow dynamic unmarshalling of // the "actions" value in Slack's JSON response, which varies depending on block type type ActionCallbacks struct { diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index c14373f3e..1c84e17a8 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -403,14 +403,6 @@ type EmojiChangedEvent struct { Value string `json:"value,omitempty"` } -// WorkflowStepExecuteEvent is fired, if a workflow step of your app is invoked -type WorkflowStepExecuteEvent struct { - Type string `json:"type"` - CallbackID string `json:"callback_id"` - WorkflowStep EventWorkflowStep `json:"workflow_step"` - EventTimestamp string `json:"event_ts"` -} - // MessageMetadataPostedEvent is sent, if a message with metadata is posted type MessageMetadataPostedEvent struct { Type string `json:"type"` @@ -452,15 +444,6 @@ type MessageMetadataDeletedEvent struct { DeletedTimestamp string `json:"deleted_ts"` } -type EventWorkflowStep struct { - WorkflowStepExecuteID string `json:"workflow_step_execute_id"` - WorkflowID string `json:"workflow_id"` - WorkflowInstanceID string `json:"workflow_instance_id"` - StepID string `json:"step_id"` - Inputs *slack.WorkflowStepInputs `json:"inputs,omitempty"` - Outputs *[]slack.WorkflowStepOutput `json:"outputs,omitempty"` -} - // JSONTime exists so that we can have a String method converting the date type JSONTime int64 @@ -1344,7 +1327,6 @@ var EventsAPIInnerEventMapping = map[EventsAPIType]interface{}{ TeamJoin: TeamJoinEvent{}, TokensRevoked: TokensRevokedEvent{}, EmojiChanged: EmojiChangedEvent{}, - WorkflowStepExecute: WorkflowStepExecuteEvent{}, MessageMetadataPosted: MessageMetadataPostedEvent{}, MessageMetadataUpdated: MessageMetadataUpdatedEvent{}, MessageMetadataDeleted: MessageMetadataDeletedEvent{}, diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index 59552089d..b2b7094a4 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -649,67 +649,6 @@ func TestEmojiChanged(t *testing.T) { } } -func TestWorkflowStepExecute(t *testing.T) { - // see: https://api.slack.com/events/workflow_step_execute - rawE := []byte(` - { - "type":"workflow_step_execute", - "callback_id":"open_ticket", - "workflow_step":{ - "workflow_step_execute_id":"1036669284371.19077474947.c94bcf942e047298d21f89faf24f1326", - "workflow_id":"123456789012345678", - "workflow_instance_id":"987654321098765432", - "step_id":"12a345bc-1a23-4567-8b90-1234a567b8c9", - "inputs":{ - "example-select-input":{ - "value": "value-two", - "skip_variable_replacement": false - } - }, - "outputs":[ - ] - }, - "event_ts":"1643290847.766536" - } - `) - - wse := WorkflowStepExecuteEvent{} - err := json.Unmarshal(rawE, &wse) - if err != nil { - t.Error(err) - } - - if wse.Type != "workflow_step_execute" { - t.Fail() - } - if wse.CallbackID != "open_ticket" { - t.Fail() - } - if wse.WorkflowStep.WorkflowStepExecuteID != "1036669284371.19077474947.c94bcf942e047298d21f89faf24f1326" { - t.Fail() - } - if wse.WorkflowStep.WorkflowID != "123456789012345678" { - t.Fail() - } - if wse.WorkflowStep.WorkflowInstanceID != "987654321098765432" { - t.Fail() - } - if wse.WorkflowStep.StepID != "12a345bc-1a23-4567-8b90-1234a567b8c9" { - t.Fail() - } - if len(*wse.WorkflowStep.Inputs) == 0 { - t.Fail() - } - if inputElement, ok := (*wse.WorkflowStep.Inputs)["example-select-input"]; ok { - if inputElement.Value != "value-two" { - t.Fail() - } - if inputElement.SkipVariableReplacement != false { - t.Fail() - } - } -} - func TestMessageMetadataPosted(t *testing.T) { rawE := []byte(` { diff --git a/workflow_step.go b/workflow_step.go deleted file mode 100644 index 747a5dc00..000000000 --- a/workflow_step.go +++ /dev/null @@ -1,101 +0,0 @@ -package slack - -import ( - "context" - "encoding/json" -) - -const VTWorkflowStep ViewType = "workflow_step" - -type ( - ConfigurationModalRequest struct { - ModalViewRequest - } - - WorkflowStepCompleteResponse struct { - WorkflowStepEditID string `json:"workflow_step_edit_id"` - Inputs *WorkflowStepInputs `json:"inputs,omitempty"` - Outputs *[]WorkflowStepOutput `json:"outputs,omitempty"` - } - - WorkflowStepInputElement struct { - Value string `json:"value"` - SkipVariableReplacement bool `json:"skip_variable_replacement"` - } - - WorkflowStepInputs map[string]WorkflowStepInputElement - - WorkflowStepOutput struct { - Name string `json:"name"` - Type string `json:"type"` - Label string `json:"label"` - } -) - -func NewConfigurationModalRequest(blocks Blocks, privateMetaData string, externalID string) *ConfigurationModalRequest { - return &ConfigurationModalRequest{ - ModalViewRequest{ - Type: VTWorkflowStep, - Title: nil, // slack configuration modal must not have a title! - Blocks: blocks, - PrivateMetadata: privateMetaData, - ExternalID: externalID, - }, - } -} - -// SaveWorkflowStepConfiguration opens a configuration modal for a workflow step. -// For more information see the SaveWorkflowStepConfigurationContext documentation. -func (api *Client) SaveWorkflowStepConfiguration(workflowStepEditID string, inputs *WorkflowStepInputs, outputs *[]WorkflowStepOutput) error { - return api.SaveWorkflowStepConfigurationContext(context.Background(), workflowStepEditID, inputs, outputs) -} - -// SaveWorkflowStepConfigurationContext saves the configuration of a workflow step with a custom context. -// Slack API docs: https://api.slack.com/methods/workflows.updateStep -func (api *Client) SaveWorkflowStepConfigurationContext(ctx context.Context, workflowStepEditID string, inputs *WorkflowStepInputs, outputs *[]WorkflowStepOutput) error { - wscr := WorkflowStepCompleteResponse{ - WorkflowStepEditID: workflowStepEditID, - Inputs: inputs, - Outputs: outputs, - } - - endpoint := api.endpoint + "workflows.updateStep" - jsonData, err := json.Marshal(wscr) - if err != nil { - return err - } - - response := &SlackResponse{} - if err := postJSON(ctx, api.httpclient, endpoint, api.token, jsonData, response, api); err != nil { - return err - } - - if !response.Ok { - return response.Err() - } - - return nil -} - -func GetInitialOptionFromWorkflowStepInput(selection *SelectBlockElement, inputs *WorkflowStepInputs, options []*OptionBlockObject) (*OptionBlockObject, bool) { - if len(*inputs) == 0 { - return &OptionBlockObject{}, false - } - if len(options) == 0 { - return &OptionBlockObject{}, false - } - - if val, ok := (*inputs)[selection.ActionID]; ok { - if val.SkipVariableReplacement { - return &OptionBlockObject{}, false - } - - for _, option := range options { - if option.Value == val.Value { - return option, true - } - } - } - - return &OptionBlockObject{}, false -} diff --git a/workflow_step_execute.go b/workflow_step_execute.go deleted file mode 100644 index 32db9ba0b..000000000 --- a/workflow_step_execute.go +++ /dev/null @@ -1,85 +0,0 @@ -package slack - -import ( - "context" - "encoding/json" -) - -type ( - WorkflowStepCompletedRequest struct { - WorkflowStepExecuteID string `json:"workflow_step_execute_id"` - Outputs map[string]string `json:"outputs"` - } - - WorkflowStepFailedRequest struct { - WorkflowStepExecuteID string `json:"workflow_step_execute_id"` - Error struct { - Message string `json:"message"` - } `json:"error"` - } -) - -type WorkflowStepCompletedRequestOption func(opt *WorkflowStepCompletedRequest) error - -func WorkflowStepCompletedRequestOptionOutput(outputs map[string]string) WorkflowStepCompletedRequestOption { - return func(opt *WorkflowStepCompletedRequest) error { - if len(outputs) > 0 { - opt.Outputs = outputs - } - return nil - } -} - -// WorkflowStepCompleted indicates step is completed -func (api *Client) WorkflowStepCompleted(workflowStepExecuteID string, options ...WorkflowStepCompletedRequestOption) error { - // More information: https://api.slack.com/methods/workflows.stepCompleted - r := &WorkflowStepCompletedRequest{ - WorkflowStepExecuteID: workflowStepExecuteID, - } - for _, option := range options { - option(r) - } - - endpoint := api.endpoint + "workflows.stepCompleted" - jsonData, err := json.Marshal(r) - if err != nil { - return err - } - - response := &SlackResponse{} - if err := postJSON(context.Background(), api.httpclient, endpoint, api.token, jsonData, response, api); err != nil { - return err - } - - if !response.Ok { - return response.Err() - } - - return nil -} - -// WorkflowStepFailed indicates step is failed -func (api *Client) WorkflowStepFailed(workflowStepExecuteID string, errorMessage string) error { - // More information: https://api.slack.com/methods/workflows.stepFailed - r := WorkflowStepFailedRequest{ - WorkflowStepExecuteID: workflowStepExecuteID, - } - r.Error.Message = errorMessage - - endpoint := api.endpoint + "workflows.stepFailed" - jsonData, err := json.Marshal(r) - if err != nil { - return err - } - - response := &SlackResponse{} - if err := postJSON(context.Background(), api.httpclient, endpoint, api.token, jsonData, response, api); err != nil { - return err - } - - if !response.Ok { - return response.Err() - } - - return nil -} diff --git a/workflow_step_execute_test.go b/workflow_step_execute_test.go deleted file mode 100644 index 60c21c8ec..000000000 --- a/workflow_step_execute_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package slack - -import ( - "encoding/json" - "net/http" - "testing" -) - -func workflowStepHandler(rw http.ResponseWriter, r *http.Request) { - rw.Header().Set("Content-Type", "application/json") - response, _ := json.Marshal(SlackResponse{ - Ok: true, - }) - rw.Write(response) -} - -func TestWorkflowStepCompleted(t *testing.T) { - http.HandleFunc("/workflows.stepCompleted", workflowStepHandler) - once.Do(startServer) - api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - - if err := api.WorkflowStepCompleted("executeID"); err != nil { - t.Errorf("Unexpected error: %s", err) - } -} - -func TestWorkflowStepFailed(t *testing.T) { - http.HandleFunc("/workflows.stepFailed", workflowStepHandler) - once.Do(startServer) - api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - - if err := api.WorkflowStepFailed("executeID", "error message"); err != nil { - t.Errorf("Unexpected error: %s", err) - } -} diff --git a/workflow_step_test.go b/workflow_step_test.go deleted file mode 100644 index f2d2eb733..000000000 --- a/workflow_step_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package slack - -import ( - "testing" - - "github.com/google/go-cmp/cmp" -) - -const ( - IDExampleSelectInput = "ae9642ae-a9ef-4394-904b-a5c7a83bf4a6" - IDSelectOptionBlock = "832bf7af-22ea-4acb-82e3-a0cc3722052b" -) - -func TestNewConfigurationModalRequest(t *testing.T) { - blocks := configModalBlocks() - privateMetaData := "An optional string that will be sent to your app in view_submission and block_actions events. Max length of 3000 characters." - externalID := "c4baf441-fbc1-4131-b349-7c8df0ae7df6" - - result := NewConfigurationModalRequest(blocks, privateMetaData, externalID) - - if result.ModalViewRequest.Title != nil { - t.Fail() - } - if result.PrivateMetadata != privateMetaData { - t.Fail() - } - if result.ExternalID != externalID { - t.Fail() - } -} - -func TestGetInitialOptionFromWorkflowStepInput(t *testing.T) { - options, testOption := createOptionBlockObjects() - selection := createSelection(options) - - scenarios := []struct { - options []*OptionBlockObject - inputs *WorkflowStepInputs - expectedResult *OptionBlockObject - expectedFlag bool - }{ - { - options: options, - inputs: createWorkflowStepInputs1(), - expectedResult: &OptionBlockObject{}, - expectedFlag: false, - }, - { - options: []*OptionBlockObject{}, - inputs: createWorkflowStepInputs4(testOption.Value), - expectedResult: &OptionBlockObject{}, - expectedFlag: false, - }, - { - options: options, - inputs: createWorkflowStepInputs2(), - expectedResult: &OptionBlockObject{}, - expectedFlag: false, - }, - { - options: options, - inputs: createWorkflowStepInputs3(), - expectedResult: &OptionBlockObject{}, - expectedFlag: false, - }, - { - options: options, - inputs: createWorkflowStepInputs4(testOption.Value), - expectedResult: testOption, - expectedFlag: true, - }, - } - - for _, scenario := range scenarios { - result, ok := GetInitialOptionFromWorkflowStepInput(selection, scenario.inputs, scenario.options) - if ok != scenario.expectedFlag { - t.Fail() - } - - if !cmp.Equal(result, scenario.expectedResult) { - t.Fail() - } - } -} - -func createOptionBlockObjects() ([]*OptionBlockObject, *OptionBlockObject) { - var options []*OptionBlockObject - options = append( - options, - NewOptionBlockObject("one", NewTextBlockObject("plain_text", "One", false, false), nil), - ) - - option2 := NewOptionBlockObject("two", NewTextBlockObject("plain_text", "Two", false, false), nil) - options = append( - options, - option2, - ) - - options = append( - options, - NewOptionBlockObject("three", NewTextBlockObject("plain_text", "Three", false, false), nil), - ) - - return options, option2 -} - -func createSelection(options []*OptionBlockObject) *SelectBlockElement { - return NewOptionsSelectBlockElement( - "static_select", - NewTextBlockObject("plain_text", "your choice", false, false), - IDExampleSelectInput, - options..., - ) -} - -func configModalBlocks() Blocks { - headerText := NewTextBlockObject("mrkdwn", "Hello World!\nThis is your workflow step app configuration view", false, false) - headerSection := NewSectionBlock(headerText, nil, nil) - - options, _ := createOptionBlockObjects() - - selection := createSelection(options) - - inputBlock := NewInputBlock( - IDSelectOptionBlock, - NewTextBlockObject("plain_text", "Select an option", false, false), - NewTextBlockObject("plain_text", "Hint", false, false), - selection, - ) - - blocks := Blocks{ - BlockSet: []Block{ - headerSection, - inputBlock, - }, - } - - return blocks -} - -func createWorkflowStepInputs1() *WorkflowStepInputs { - return &WorkflowStepInputs{} -} - -func createWorkflowStepInputs2() *WorkflowStepInputs { - return &WorkflowStepInputs{ - "test": WorkflowStepInputElement{ - Value: "random-string", - SkipVariableReplacement: false, - }, - "123-test": WorkflowStepInputElement{ - Value: "another-string", - SkipVariableReplacement: false, - }, - } -} - -func createWorkflowStepInputs3() *WorkflowStepInputs { - return &WorkflowStepInputs{ - "test": WorkflowStepInputElement{ - Value: "random-string", - SkipVariableReplacement: false, - }, - "123-test": WorkflowStepInputElement{ - Value: "another-string", - SkipVariableReplacement: false, - }, - IDExampleSelectInput: WorkflowStepInputElement{ - Value: "lorem-ipsum", - SkipVariableReplacement: true, - }, - } -} - -func createWorkflowStepInputs4(optionValue string) *WorkflowStepInputs { - return &WorkflowStepInputs{ - "test": WorkflowStepInputElement{ - Value: "random-string", - SkipVariableReplacement: false, - }, - "123-test": WorkflowStepInputElement{ - Value: "another-string", - SkipVariableReplacement: false, - }, - IDExampleSelectInput: WorkflowStepInputElement{ - Value: optionValue, - SkipVariableReplacement: false, - }, - } -} From 9d1c5121633192458de2898d529aefe436c2cce3 Mon Sep 17 00:00:00 2001 From: renat Date: Tue, 25 Feb 2025 15:03:56 +0100 Subject: [PATCH 312/606] add Headers field to support email as a file attachment --- files.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/files.go b/files.go index 0d6626bfe..d3cc97320 100644 --- a/files.go +++ b/files.go @@ -90,10 +90,11 @@ type File struct { IsStarred bool `json:"is_starred"` Shares Share `json:"shares"` - Subject string `json:"subject"` - To []EmailFileUserInfo `json:"to"` - From []EmailFileUserInfo `json:"from"` - Cc []EmailFileUserInfo `json:"cc"` + Subject string `json:"subject"` + To []EmailFileUserInfo `json:"to"` + From []EmailFileUserInfo `json:"from"` + Cc []EmailFileUserInfo `json:"cc"` + Headers map[string]interface{} `json:"headers"` } type EmailFileUserInfo struct { From 8e980f4be2daf4036661f8905d0a750eab1349f5 Mon Sep 17 00:00:00 2001 From: renat Date: Fri, 7 Mar 2025 17:12:05 +0100 Subject: [PATCH 313/606] email headers struct instead of map --- files.go | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/files.go b/files.go index d3cc97320..866b490b7 100644 --- a/files.go +++ b/files.go @@ -90,11 +90,11 @@ type File struct { IsStarred bool `json:"is_starred"` Shares Share `json:"shares"` - Subject string `json:"subject"` - To []EmailFileUserInfo `json:"to"` - From []EmailFileUserInfo `json:"from"` - Cc []EmailFileUserInfo `json:"cc"` - Headers map[string]interface{} `json:"headers"` + Subject string `json:"subject"` + To []EmailFileUserInfo `json:"to"` + From []EmailFileUserInfo `json:"from"` + Cc []EmailFileUserInfo `json:"cc"` + Headers EmailHeaders `json:"headers"` } type EmailFileUserInfo struct { @@ -103,6 +103,13 @@ type EmailFileUserInfo struct { Original string `json:"original"` } +type EmailHeaders struct { + Date string `json:"date"` + InReplyTo string `json:"in_reply_to"` + ReplyTo string `json:"reply_to"` + MessageID string `json:"message_id"` +} + type Share struct { Public map[string][]ShareFileInfo `json:"public"` Private map[string][]ShareFileInfo `json:"private"` From 8f006d10e5201175238367595da4b06c77c22036 Mon Sep 17 00:00:00 2001 From: Caleb McKay <11079725+calebmckay@users.noreply.github.com> Date: Mon, 10 Mar 2025 05:31:30 -0400 Subject: [PATCH 314/606] fix: Allow `emoji` value in plain text blocks to be null (#1354) Fixes #1332. This PR allows setting null values for the `emoji` field of plain text blocks, which was previously incorrectly marshalling to JSON due to a "false" value being omitted. I opted not to change the signature of `NewTextBlockObject`, since the null value is only expected when unmarshalling, and the user should know which value they need to use when creating the block. --- attachments_test.go | 1 + block_object.go | 8 +-- block_object_test.go | 128 +++++++++++++++++++++++++++++++++++++++--- block_section_test.go | 2 +- chat_test.go | 2 +- interactions_test.go | 18 ++++-- views_test.go | 9 ++- webhooks_test.go | 4 +- 8 files changed, 148 insertions(+), 24 deletions(-) diff --git a/attachments_test.go b/attachments_test.go index 4b951f110..cbb139c38 100644 --- a/attachments_test.go +++ b/attachments_test.go @@ -19,6 +19,7 @@ func TestAttachment_UnmarshalMarshalJSON_WithBlocks(t *testing.T) { "text": { "type": "mrkdwn", "text": "Pick something:", + "emoji": null, "verbatim": true }, "accessory": { diff --git a/block_object.go b/block_object.go index a3e78d4ea..899ac0db1 100644 --- a/block_object.go +++ b/block_object.go @@ -122,7 +122,7 @@ func unmarshalBlockObject(r json.RawMessage, object blockObject) (blockObject, e type TextBlockObject struct { Type string `json:"type"` Text string `json:"text"` - Emoji bool `json:"emoji,omitempty"` + Emoji *bool `json:"emoji"` Verbatim bool `json:"verbatim,omitempty"` } @@ -143,7 +143,7 @@ func (s TextBlockObject) Validate() error { } // https://github.com/slack-go/slack/issues/881 - if s.Type == "mrkdwn" && s.Emoji { + if s.Type == "mrkdwn" && s.Emoji != nil && *s.Emoji { return errors.New("emoji cannot be true in mrkdown") } @@ -161,11 +161,11 @@ func (s TextBlockObject) Validate() error { } // NewTextBlockObject returns an instance of a new Text Block Object -func NewTextBlockObject(elementType, text string, emoji, verbatim bool) *TextBlockObject { +func NewTextBlockObject(elementType, text string, emoji bool, verbatim bool) *TextBlockObject { return &TextBlockObject{ Type: elementType, Text: text, - Emoji: emoji, + Emoji: &emoji, Verbatim: verbatim, } } diff --git a/block_object_test.go b/block_object_test.go index b8f55a4c1..7311034dd 100644 --- a/block_object_test.go +++ b/block_object_test.go @@ -1,10 +1,12 @@ package slack import ( + "encoding/json" "errors" "strings" "testing" + "github.com/go-test/deep" "github.com/stretchr/testify/assert" ) @@ -21,7 +23,7 @@ func TestNewTextBlockObject(t *testing.T) { assert.Equal(t, textObject.Type, "plain_text") assert.Equal(t, textObject.Text, "test") - assert.True(t, textObject.Emoji, "Emoji property should be true") + assert.True(t, *textObject.Emoji, "Emoji property should be true") assert.False(t, textObject.Verbatim, "Verbatim should be false") } @@ -75,6 +77,11 @@ func TestNewOptionGroupBlockElement(t *testing.T) { } func TestValidateTextBlockObject(t *testing.T) { + emojiTrue := new(bool) + emojiFalse := new(bool) + *emojiTrue = true + *emojiFalse = false + tests := []struct { input TextBlockObject expected error @@ -83,7 +90,34 @@ func TestValidateTextBlockObject(t *testing.T) { input: TextBlockObject{ Type: "plain_text", Text: "testText", - Emoji: false, + Emoji: emojiFalse, + Verbatim: false, + }, + expected: nil, + }, + { + input: TextBlockObject{ + Type: "plain_text", + Text: "testText", + Emoji: emojiTrue, + Verbatim: false, + }, + expected: nil, + }, + { + input: TextBlockObject{ + Type: "plain_text", + Text: "testText", + Emoji: nil, + Verbatim: false, + }, + expected: nil, + }, + { + input: TextBlockObject{ + Type: "mrkdwn", + Text: "testText", + Emoji: emojiFalse, Verbatim: false, }, expected: nil, @@ -92,7 +126,7 @@ func TestValidateTextBlockObject(t *testing.T) { input: TextBlockObject{ Type: "mrkdwn", Text: "testText", - Emoji: false, + Emoji: nil, Verbatim: false, }, expected: nil, @@ -101,7 +135,7 @@ func TestValidateTextBlockObject(t *testing.T) { input: TextBlockObject{ Type: "invalid", Text: "testText", - Emoji: false, + Emoji: emojiFalse, Verbatim: false, }, expected: errors.New("type must be either of plain_text or mrkdwn"), @@ -110,7 +144,7 @@ func TestValidateTextBlockObject(t *testing.T) { input: TextBlockObject{ Type: "mrkdwn", Text: "testText", - Emoji: true, + Emoji: emojiTrue, Verbatim: false, }, expected: errors.New("emoji cannot be true in mrkdown"), @@ -119,7 +153,7 @@ func TestValidateTextBlockObject(t *testing.T) { input: TextBlockObject{ Type: "mrkdwn", Text: "", - Emoji: false, + Emoji: emojiFalse, Verbatim: false, }, expected: errors.New("text must have a minimum length of 1"), @@ -128,7 +162,7 @@ func TestValidateTextBlockObject(t *testing.T) { input: TextBlockObject{ Type: "mrkdwn", Text: strings.Repeat("a", 3001), - Emoji: false, + Emoji: emojiFalse, Verbatim: false, }, expected: errors.New("text cannot be longer than 3000 characters"), @@ -140,3 +174,83 @@ func TestValidateTextBlockObject(t *testing.T) { assert.Equal(t, err, test.expected) } } + +func TestTextBlockObject_UnmarshalJSON(t *testing.T) { + emojiTrue := new(bool) + emojiFalse := new(bool) + *emojiTrue = true + *emojiFalse = false + + cases := []struct { + raw []byte + expected TextBlockObject + err error + }{ + { + []byte(`{"type":"plain_text","text":"testText"}`), + TextBlockObject{ + Type: "plain_text", + Text: "testText", + Emoji: nil, + Verbatim: false, + }, + nil, + }, + { + []byte(`{"type":"plain_text","text":":+1:","emoji":true}`), + TextBlockObject{ + Type: "plain_text", + Text: ":+1:", + Emoji: emojiTrue, + Verbatim: false, + }, + nil, + }, + { + []byte(`{"type":"plain_text","text":"No emojis allowed :(","emoji":false}`), + TextBlockObject{ + Type: "plain_text", + Text: "No emojis allowed :(", + Emoji: emojiFalse, + Verbatim: false, + }, + nil, + }, + { + []byte(`{"type":"mrkdwn","text":"testText"}`), + TextBlockObject{ + Type: "mrkdwn", + Text: "testText", + Emoji: nil, + Verbatim: false, + }, + nil, + }, + { + []byte(`{"type":"mrkdwn","text":"No emojis allowed :(","emoji":false}`), + TextBlockObject{ + Type: "mrkdwn", + Text: "No emojis allowed :(", + Emoji: emojiFalse, + Verbatim: false, + }, + nil, + }, + } + for _, tc := range cases { + var actual TextBlockObject + err := json.Unmarshal(tc.raw, &actual) + if err != nil { + if tc.err == nil { + t.Errorf("unexpected error: %s", err) + } + t.Errorf("expected error is %v, but got %v", tc.err, err) + } + if tc.err != nil { + t.Errorf("expected to raise an error %v", tc.err) + } + if diff := deep.Equal(actual, tc.expected); diff != nil { + t.Errorf("actual value does not match expected one\n%s", diff) + } + } +} diff --git a/block_section_test.go b/block_section_test.go index 8f8374e04..c6bd209eb 100644 --- a/block_section_test.go +++ b/block_section_test.go @@ -30,7 +30,7 @@ func TestNewBlockSectionContainsAddedTextBlockAndAccessory(t *testing.T) { textBlockInSection := sectionBlock.Text assert.Equal(t, textBlockInSection.Text, textBlockObject.Text) assert.Equal(t, textBlockInSection.Type, textBlockObject.Type) - assert.True(t, textBlockInSection.Emoji) + assert.True(t, *textBlockInSection.Emoji) assert.False(t, textBlockInSection.Verbatim) assert.Equal(t, sectionBlock.Accessory.ImageElement, conflictImage) } diff --git a/chat_test.go b/chat_test.go index 917ff965e..ab326f2e4 100644 --- a/chat_test.go +++ b/chat_test.go @@ -81,7 +81,7 @@ func TestPostMessage(t *testing.T) { } blocks := []Block{NewContextBlock("context", NewTextBlockObject(PlainTextType, "hello", false, false))} - blockStr := `[{"type":"context","block_id":"context","elements":[{"type":"plain_text","text":"hello"}]}]` + blockStr := `[{"type":"context","block_id":"context","elements":[{"type":"plain_text","text":"hello","emoji":false}]}]` tests := map[string]messageTest{ "OnlyBasicProperties": { diff --git a/interactions_test.go b/interactions_test.go index 989690e27..faf533344 100644 --- a/interactions_test.go +++ b/interactions_test.go @@ -51,13 +51,15 @@ const ( "type": "modal", "title": { "type": "plain_text", - "text": "launch project" + "text": "launch project", + "emoji": false }, "blocks": [{ "type": "section", "text": { "text": "*Sally* has requested you set the deadline for the Nano launch project", - "type": "mrkdwn" + "type": "mrkdwn", + "emoji": false }, "accessory": { "type": "datepicker", @@ -65,7 +67,8 @@ const ( "initial_date": "1990-04-28", "placeholder": { "type": "plain_text", - "text": "Select a date" + "text": "Select a date", + "emoji": false } } }], @@ -92,7 +95,8 @@ const ( "type": "modal", "title": { "type": "plain_text", - "text": "meal choice" + "text": "meal choice", + "emoji": false }, "blocks": [ { @@ -100,7 +104,8 @@ const ( "block_id": "multi-line", "label": { "type": "plain_text", - "text": "dietary restrictions" + "text": "dietary restrictions", + "emoji": false }, "element": { "type": "plain_text_input", @@ -113,7 +118,8 @@ const ( "block_id": "target_channel", "label": { "type": "plain_text", - "text": "Select a channel to post the result on" + "text": "Select a channel to post the result on", + "emoji": false }, "element": { "type": "conversations_select", diff --git a/views_test.go b/views_test.go index 5908737f8..c86c0a085 100644 --- a/views_test.go +++ b/views_test.go @@ -767,7 +767,8 @@ func TestSlack_UpdateViewSubmissionResponse(t *testing.T) { "type": "modal", "title": { "type": "plain_text", - "text": "Test update view submission response" + "text": "Test update view submission response", + "emoji": false }, "blocks": [ { @@ -803,7 +804,8 @@ func TestSlack_PushViewSubmissionResponse(t *testing.T) { "type": "modal", "title": { "type": "plain_text", - "text": "Test update view submission response" + "text": "Test update view submission response", + "emoji": false }, "blocks": [ { @@ -812,7 +814,8 @@ func TestSlack_PushViewSubmissionResponse(t *testing.T) { "elements": [ { "type": "plain_text", - "text": "Context text" + "text": "Context text", + "emoji": false }, { "type": "image", diff --git a/webhooks_test.go b/webhooks_test.go index 90b7e285d..4c76a1dd0 100644 --- a/webhooks_test.go +++ b/webhooks_test.go @@ -77,13 +77,13 @@ func TestWebhookMessage_WithBlocks(t *testing.T) { assert.Equal(t, 1, len(msgSingleBlock.Blocks.BlockSet)) msgJsonSingleBlock, _ := json.Marshal(msgSingleBlock) - assert.Equal(t, `{"blocks":[{"type":"section","text":{"type":"plain_text","text":"text"}}],"replace_original":false,"delete_original":false}`, string(msgJsonSingleBlock)) + assert.Equal(t, `{"blocks":[{"type":"section","text":{"type":"plain_text","text":"text","emoji":false}}],"replace_original":false,"delete_original":false}`, string(msgJsonSingleBlock)) msgTwoBlocks := WebhookMessage{Blocks: twoBlocks} assert.Equal(t, 2, len(msgTwoBlocks.Blocks.BlockSet)) msgJsonTwoBlocks, _ := json.Marshal(msgTwoBlocks) - assert.Equal(t, `{"blocks":[{"type":"section","text":{"type":"plain_text","text":"text"}},{"type":"section","text":{"type":"plain_text","text":"text"}}],"replace_original":false,"delete_original":false}`, string(msgJsonTwoBlocks)) + assert.Equal(t, `{"blocks":[{"type":"section","text":{"type":"plain_text","text":"text","emoji":false}},{"type":"section","text":{"type":"plain_text","text":"text","emoji":false}}],"replace_original":false,"delete_original":false}`, string(msgJsonTwoBlocks)) msgNoBlocks := WebhookMessage{Text: "foo"} msgJsonNoBlocks, _ := json.Marshal(msgNoBlocks) From e3561928ec35597dea15245e99d685b87a65cefe Mon Sep 17 00:00:00 2001 From: Grey Baker Date: Sun, 9 Mar 2025 09:55:28 -0400 Subject: [PATCH 315/606] Return a StatusCodeError when a workspace's message limit is exceeded --- misc.go | 2 +- webhooks_test.go | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/misc.go b/misc.go index df779c1ad..b80e92b7e 100644 --- a/misc.go +++ b/misc.go @@ -306,7 +306,7 @@ func timerReset(t *time.Timer, d time.Duration) { } func checkStatusCode(resp *http.Response, d Debug) error { - if resp.StatusCode == http.StatusTooManyRequests { + if resp.StatusCode == http.StatusTooManyRequests && resp.Header.Get("Retry-After") != "" { retry, err := strconv.ParseInt(resp.Header.Get("Retry-After"), 10, 64) if err != nil { return err diff --git a/webhooks_test.go b/webhooks_test.go index 90b7e285d..f36923923 100644 --- a/webhooks_test.go +++ b/webhooks_test.go @@ -66,6 +66,25 @@ func TestPostWebhook_NotOK(t *testing.T) { } } +func TestPostWebhook_MessageLimitExceeded(t *testing.T) { + once.Do(startServer) + + http.HandleFunc("/message_limit_exceeded", func(rw http.ResponseWriter, r *http.Request) { + // When a workspace's message limit is exceeded we get a 429 without a Retry-After header + rw.WriteHeader(http.StatusTooManyRequests) + rw.Write([]byte("message_limit_exceeded")) + }) + + url := "http://" + serverAddr + "/message_limit_exceeded" + + err := PostWebhook(url, &WebhookMessage{}) + + if err == nil { + t.Errorf("Expected to receive error") + } + assert.IsType(t, StatusCodeError{}, err) +} + func TestWebhookMessage_WithBlocks(t *testing.T) { textBlockObject := NewTextBlockObject("plain_text", "text", false, false) sectionBlock := NewSectionBlock(textBlockObject, nil, nil) From d17a5a3be978e8560700603e0197af600f5f4d81 Mon Sep 17 00:00:00 2001 From: Boris Puterka Date: Sat, 15 Mar 2025 05:12:33 +0100 Subject: [PATCH 316/606] Add rich_text_value into BlockAction --- block.go | 1 + 1 file changed, 1 insertion(+) diff --git a/block.go b/block.go index 33a78751b..42ac8405c 100644 --- a/block.go +++ b/block.go @@ -58,6 +58,7 @@ type BlockAction struct { InitialConversation string `json:"initial_conversation"` InitialDate string `json:"initial_date"` InitialTime string `json:"initial_time"` + RichTextValue RichTextBlock `json:"rich_text_value"` } // actionType returns the type of the action From e14761e673814cef504cfbe186f68b0974f68e53 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Tue, 28 Jan 2025 11:06:16 +0000 Subject: [PATCH 317/606] chore: bump golangci to 1.63.4 As part of this change also removed the following linters which are deprecated: - `interfacer` - `structcheck` Added the following linters: - `gocritic` (I might regret this and roll it back, we'll see) --- .github/workflows/test.yml | 2 +- .golangci.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 219ff31a2..0f97057a3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,4 +40,4 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@2226d7cb06a077cd73e56eedd38eecad18e5d837 # v6.5.0 with: - version: v1.52.2 + version: v1.63.4 diff --git a/.golangci.yml b/.golangci.yml index c16f5389a..a6ec936c0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -4,11 +4,11 @@ run: linters: disable-all: true enable: + - gocritic - goimports - govet - - interfacer - misspell - - structcheck + - unused - unconvert issues: new: true From 01d26313508e616e846fe2b6389feeb7c0c31591 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Tue, 28 Jan 2025 11:09:04 +0000 Subject: [PATCH 318/606] chore: bump testing ubuntu runner to 24.04 --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0f97057a3..422dfd46d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ on: jobs: test: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: matrix: go: @@ -27,7 +27,7 @@ jobs: - name: run test run: go test -v -race ./... lint: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 name: lint steps: - uses: actions/setup-go@v5 From 9e95e14fda1222bc7dae5ac0b521ea9dcccdecec Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 15 Mar 2025 19:44:01 +0000 Subject: [PATCH 319/606] Create FUNDING.yml --- .github/FUNDING.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..d7ce766be --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: nlopes +buy_me_a_coffee: nlopes From f1b60b3383cfd1329a395a302fe33b08b5d3f338 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Mar 2025 06:52:40 +0000 Subject: [PATCH 320/606] chore(deps): bump golangci/golangci-lint-action from 6.5.0 to 6.5.1 Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 6.5.0 to 6.5.1. - [Release notes](https://github.com/golangci/golangci-lint-action/releases) - [Commits](https://github.com/golangci/golangci-lint-action/compare/2226d7cb06a077cd73e56eedd38eecad18e5d837...4696ba8babb6127d732c3c6dde519db15edab9ea) --- updated-dependencies: - dependency-name: golangci/golangci-lint-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 422dfd46d..de2880b40 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,6 +38,6 @@ jobs: with: fetch-depth: 0 - name: golangci-lint - uses: golangci/golangci-lint-action@2226d7cb06a077cd73e56eedd38eecad18e5d837 # v6.5.0 + uses: golangci/golangci-lint-action@4696ba8babb6127d732c3c6dde519db15edab9ea # v6.5.1 with: version: v1.63.4 From ca04703c765a310de1eb76a7ac30b7fd11c710e1 Mon Sep 17 00:00:00 2001 From: "K.Utsunomiya" Date: Mon, 17 Mar 2025 16:29:58 +0900 Subject: [PATCH 321/606] ci: add Go version 1.24 to test matrix --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 422dfd46d..776bb1f78 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,6 +18,7 @@ jobs: - '1.21' - '1.22' - '1.23' + - '1.24' name: test go-${{ matrix.go }} steps: - uses: actions/checkout@v4 From d7986b718574848d4dde016f10b57e1f34ed5035 Mon Sep 17 00:00:00 2001 From: Lisa Karlin Curtis Date: Thu, 13 Mar 2025 09:55:36 +0000 Subject: [PATCH 322/606] Fix message event struct This has been the source of many bugs. Slack's docs say that a 'message event' contains a 'message' which is the same type as other representations of a message in slack. However, the library has previously represented this as a nested MessageEvent which is recursively nested. That creates lots of bugs, as you can never know if you're looking at the outer MessageEvent (which contains the event information) or the inner MessageEvent (which contains the message itself). Instead, we can use our canonical Message instead. Note that we also have to add a `root` onto Message, as this is populated for thread_broadcast events (for legacy reasons). --- messages.go | 4 ++++ slackevents/inner_events.go | 18 ++---------------- slackevents/inner_events_test.go | 8 ++------ 3 files changed, 8 insertions(+), 22 deletions(-) diff --git a/messages.go b/messages.go index 90f8fc1ff..c53809fda 100644 --- a/messages.go +++ b/messages.go @@ -17,6 +17,10 @@ type Message struct { Msg SubMessage *Msg `json:"message,omitempty"` PreviousMessage *Msg `json:"previous_message,omitempty"` + // Root is the message that was broadcast to the channel when the SubType is + // thread_broadcast. If this is not a thread_broadcast message event, this + // value is nil. + Root *Msg `json:"root,omitempty"` } // Msg SubTypes (https://api.slack.com/events/message) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 1c84e17a8..0d487e6c0 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -284,9 +284,8 @@ type MessageEvent struct { SourceTeam string `json:"source_team,omitempty"` // Edited Message - Message *MessageEvent `json:"message,omitempty"` - PreviousMessage *MessageEvent `json:"previous_message,omitempty"` - Edited *Edited `json:"edited,omitempty"` + Message *slack.Message `json:"message,omitempty"` + PreviousMessage *slack.Message `json:"previous_message,omitempty"` // Deleted Message DeletedTimeStamp string `json:"deleted_ts,omitempty"` @@ -298,19 +297,6 @@ type MessageEvent struct { BotID string `json:"bot_id,omitempty"` Username string `json:"username,omitempty"` Icons *Icon `json:"icons,omitempty"` - - Upload bool `json:"upload"` - Files []File `json:"files"` - - Blocks slack.Blocks `json:"blocks,omitempty"` - Attachments []slack.Attachment `json:"attachments,omitempty"` - - Metadata slack.SlackMetadata `json:"metadata,omitempty"` - - // Root is the message that was broadcast to the channel when the SubType is - // thread_broadcast. If this is not a thread_broadcast message event, this - // value is nil. - Root *MessageEvent `json:"root"` } // MemberJoinedChannelEvent A member joined a public or private channel diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index b2b7094a4..feffaf58b 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -423,16 +423,12 @@ func TestThreadBroadcastEvent(t *testing.T) { t.Error(err) } - if me.Root != nil { - t.Error("me.Root should be nil") - } - if me.Message.Root == nil { t.Fatal("me.Message.Root is nil") } - if me.Message.Root.TimeStamp != "1355517523.000005" { - t.Errorf("me.Message.Root.TimeStamp = %q, want %q", me.Root.TimeStamp, "1355517523.000005") + if me.Message.Root.Timestamp != "1355517523.000005" { + t.Errorf("me.Message.Root.Timestamp = %q, want %q", me.Message.Root.Timestamp, "1355517523.000005") } } From 33a78e517e194fb7f5b082fd5943bd1d28d93178 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 22 Mar 2025 14:33:34 +0000 Subject: [PATCH 323/606] chore: add .env to .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 027f4de5f..040859518 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.test *~ .idea/ -/vendor/ \ No newline at end of file +/vendor/ +.env From 11c770707cf55941ec12ef56f2174290114576d4 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 22 Mar 2025 14:43:55 +0000 Subject: [PATCH 324/606] chore: fix typo in socketmode_handler.go --- examples/socketmode_handler/socketmode_handler.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/socketmode_handler/socketmode_handler.go b/examples/socketmode_handler/socketmode_handler.go index 221c3ea23..c78f4c2c9 100644 --- a/examples/socketmode_handler/socketmode_handler.go +++ b/examples/socketmode_handler/socketmode_handler.go @@ -102,7 +102,7 @@ func middlewareEventsAPI(evt *socketmode.Event, client *socketmode.Client) { innerEvent := eventsAPIEvent.InnerEvent switch ev := innerEvent.Data.(type) { case *slackevents.AppMentionEvent: - fmt.Printf("We have been mentionned in %v", ev.Channel) + fmt.Printf("We have been mentioned in %v", ev.Channel) _, _, err := client.Client.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) if err != nil { fmt.Printf("failed posting message: %v", err) @@ -131,7 +131,7 @@ func middlewareAppMentionEvent(evt *socketmode.Event, client *socketmode.Client) return } - fmt.Printf("We have been mentionned in %v\n", ev.Channel) + fmt.Printf("We have been mentioned in %v\n", ev.Channel) _, _, err := client.Client.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) if err != nil { fmt.Printf("failed posting message: %v", err) From 147d3f5891febb33e70137fdb9fb738820e32554 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 22 Mar 2025 15:19:13 +0000 Subject: [PATCH 325/606] feat: add '&debug_reconnects=true' to websocket url According to the documentation at https://api.slack.com/apis/socket-mode you can append &debug_reconnects=true to your WebSocket URL when you connect to it in order to make the connection time significantly shorter (360 seconds). That way, you can test and debug reconnects without waiting around. We're adding this whenever debug mode is enabled in the socketmode client. Closes #1379. --- examples/socketmode/socketmode.go | 2 ++ examples/socketmode_handler/socketmode_handler.go | 5 +++++ socket_mode.go | 6 ++++++ 3 files changed, 13 insertions(+) diff --git a/examples/socketmode/socketmode.go b/examples/socketmode/socketmode.go index 1490a932b..26c69d72f 100644 --- a/examples/socketmode/socketmode.go +++ b/examples/socketmode/socketmode.go @@ -141,6 +141,8 @@ func main() { } client.Ack(*evt.Request, payload) + case socketmode.EventTypeHello: + client.Debugf("Hello received!") default: fmt.Fprintf(os.Stderr, "Unexpected event type received: %s\n", evt.Type) } diff --git a/examples/socketmode_handler/socketmode_handler.go b/examples/socketmode_handler/socketmode_handler.go index c78f4c2c9..fddda490d 100644 --- a/examples/socketmode_handler/socketmode_handler.go +++ b/examples/socketmode_handler/socketmode_handler.go @@ -49,6 +49,7 @@ func main() { socketmodeHandler.Handle(socketmode.EventTypeConnecting, middlewareConnecting) socketmodeHandler.Handle(socketmode.EventTypeConnectionError, middlewareConnectionError) socketmodeHandler.Handle(socketmode.EventTypeConnected, middlewareConnected) + socketmodeHandler.Handle(socketmode.EventTypeHello, middlewareHello) //\\ EventTypeEventsAPI //\\ // Handle all EventsAPI @@ -85,6 +86,10 @@ func middlewareConnected(evt *socketmode.Event, client *socketmode.Client) { fmt.Println("Connected to Slack with Socket Mode.") } +func middlewareHello(evt *socketmode.Event, client *socketmode.Client) { + fmt.Println("Received a hello message. Howdy to you too.") +} + func middlewareEventsAPI(evt *socketmode.Event, client *socketmode.Client) { fmt.Println("middlewareEventsAPI") eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) diff --git a/socket_mode.go b/socket_mode.go index 69e40d99d..c741c5368 100644 --- a/socket_mode.go +++ b/socket_mode.go @@ -30,5 +30,11 @@ func (api *Client) StartSocketModeContext(ctx context.Context) (info *SocketMode api.Debugln("Using URL:", response.SocketModeConnection.URL) } + // According to the API documentation at https://api.slack.com/apis/socket-mode, we + // can add a query parameter `debug_reconnects=true` to the URL to make the connection + // time significantly shorter (360 seconds). + if api.debug { + response.SocketModeConnection.URL += "&debug_reconnects=true" + } return &response.SocketModeConnection, response.SocketModeConnection.URL, response.Err() } From ef6a05aff163574c79570f97111c0427bddd9f0d Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 22 Mar 2025 18:51:29 +0000 Subject: [PATCH 326/606] feat: add include_icon to auth.teams.list According to https://api.slack.com/methods/auth.teams.list, there's an optional argument named `include_icon`. --- auth.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/auth.go b/auth.go index 7fff1a168..972f59ea6 100644 --- a/auth.go +++ b/auth.go @@ -3,6 +3,7 @@ package slack import ( "context" "net/url" + "strconv" ) // AuthRevokeResponse contains our Auth response from the auth.revoke endpoint @@ -47,8 +48,9 @@ type listTeamsResponse struct { } type ListTeamsParameters struct { - Limit int - Cursor string + Limit int + Cursor string + IncludeIcon *bool } // ListTeams returns all workspaces a token can access. @@ -66,6 +68,9 @@ func (api *Client) ListTeamsContext(ctx context.Context, params ListTeamsParamet if params.Cursor != "" { values.Add("cursor", params.Cursor) } + if params.IncludeIcon != nil { + values.Add("include_icon", strconv.FormatBool(*params.IncludeIcon)) + } response := &listTeamsResponse{} err := api.postMethod(ctx, "auth.teams.list", values, response) From d83936c7b901c023783cfdc1f799e0d7f94abcc6 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 22 Mar 2025 19:43:41 +0000 Subject: [PATCH 327/606] feat: add a few missing fields to conversation Added: - `previous_names` - `pending_shared` Also added to `Properties`: - `posting_restricted_to` - `tabs` - `threads_restricted_to` As part of that, also added new types: `RestrictedTo` and `Tab`. --- conversation.go | 21 +++++++++++++---- examples/conversations/conversations.go | 30 +++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 examples/conversations/conversations.go diff --git a/conversation.go b/conversation.go index 00f7d4e3b..51ef0b05a 100644 --- a/conversation.go +++ b/conversation.go @@ -38,9 +38,8 @@ type Conversation struct { InternalTeamIDs []string `json:"internal_team_ids,omitempty"` ContextTeamID string `json:"context_team_id,omitempty"` ConversationHostID string `json:"conversation_host_id,omitempty"` - - // TODO support pending_shared - // TODO support previous_names + PreviousNames []string `json:"previous_names,omitempty"` + PendingShared []string `json:"pending_shared,omitempty"` } // GroupConversation is the foundation for Group and Channel @@ -70,7 +69,21 @@ type Purpose struct { // Properties contains the Canvas associated to the channel. type Properties struct { - Canvas Canvas `json:"canvas"` + Canvas Canvas `json:"canvas"` + PostingRestrictedTo RestrictedTo `json:"posting_restricted_to"` + Tabs []Tab `json:"tabs"` + ThreadsRestrictedTo RestrictedTo `json:"threads_restricted_to"` +} + +type RestrictedTo struct { + Type []string `json:"type"` + User []string `json:"user"` +} + +type Tab struct { + ID string `json:"id"` + Label string `json:"label"` + Type string `json:"type"` } type Canvas struct { diff --git a/examples/conversations/conversations.go b/examples/conversations/conversations.go new file mode 100644 index 000000000..4b83a11e7 --- /dev/null +++ b/examples/conversations/conversations.go @@ -0,0 +1,30 @@ +package main + +import ( + "fmt" + "os" + + "github.com/slack-go/slack" +) + +func main() { + botToken := os.Getenv("SLACK_BOT_TOKEN") + if botToken == "" { + fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN must be set.\n") + os.Exit(1) + } + + api := slack.New(botToken) + params := slack.GetConversationsParameters{ + ExcludeArchived: true, + Limit: 100, + } + channels, _, err := api.GetConversations(¶ms) + if err != nil { + fmt.Printf("%s\n", err) + return + } + for _, channel := range channels { + fmt.Printf("Channel: %v\n", channel) + } +} From 67ce282b837c39fef0b6b02e4063a2d197bf8035 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 22 Mar 2025 22:17:06 +0000 Subject: [PATCH 328/606] feat: add missing icons image_132 and image_230 to Team Icons --- info.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/info.go b/info.go index b06dffdbe..a882dfbc4 100644 --- a/info.go +++ b/info.go @@ -428,6 +428,7 @@ type Team struct { ID string `json:"id"` Name string `json:"name"` Domain string `json:"domain"` + Icons *Icons `json:"icon,omitempty"` } // Icons XXX: needs further investigation @@ -435,6 +436,8 @@ type Icons struct { Image36 string `json:"image_36,omitempty"` Image48 string `json:"image_48,omitempty"` Image72 string `json:"image_72,omitempty"` + Image132 string `json:"image_132,omitempty"` + Image230 string `json:"image_230,omitempty"` } // Info contains various details about the authenticated user and team. From 8e6d0768efd6941183bbc85c61937cd731bc4331 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 23 Mar 2025 09:26:02 +0000 Subject: [PATCH 329/606] feat: add `ForceInviteUsersToConversation` This is pretty much the same as `InviteUsersToConversation` but setting the param `force` to true when calling the API. I've done this to not break *even more* the API but this should have been a breaking change to `InviteUsersToConversation` to use a params struct. I'm leaving that for a future one. --- conversation.go | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/conversation.go b/conversation.go index 51ef0b05a..33eb0ff9b 100644 --- a/conversation.go +++ b/conversation.go @@ -344,6 +344,44 @@ func (api *Client) InviteUsersToConversationContext(ctx context.Context, channel return response.Channel, response.Err() } +// The following functions are for inviting users to a channel but setting the `force` +// parameter to true. We have added this so that we don't break the existing API. +// +// IMPORTANT: If we ever get here for _another_ parameter, we should consider refactoring +// this to be more flexible. +// +// ForceInviteUsersToConversation invites users to a channel but sets the `force` +// parameter to true. +// +// For more details, see ForceInviteUsersToConversationContext documentation. +func (api *Client) ForceInviteUsersToConversation(channelID string, users ...string) (*Channel, error) { + return api.ForceInviteUsersToConversationContext(context.Background(), channelID, users...) +} + +// ForceInviteUsersToConversationContext invites users to a channel with a custom context +// while setting the `force` argument to true. +// +// Slack API docs: https://api.slack.com/methods/conversations.invite +func (api *Client) ForceInviteUsersToConversationContext(ctx context.Context, channelID string, users ...string) (*Channel, error) { + values := url.Values{ + "token": {api.token}, + "channel": {channelID}, + "users": {strings.Join(users, ",")}, + "force": {"true"}, + } + response := struct { + SlackResponse + Channel *Channel `json:"channel"` + }{} + + err := api.postMethod(ctx, "conversations.invite", values, &response) + if err != nil { + return nil, err + } + + return response.Channel, response.Err() +} + // InviteSharedEmailsToConversation invites users to a shared channels by email. // For more details, see InviteSharedToConversationContext documentation. func (api *Client) InviteSharedEmailsToConversation(channelID string, emails ...string) (string, bool, error) { From dec7b1ad589849e83ce9dac9eeb989fc9f9b0c06 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 23 Mar 2025 10:58:52 +0000 Subject: [PATCH 330/606] feat: add a bunch of missing params to usergroups.* calls This was initially implemented in PR #1179 by @tenyo. I've pulled those changes into a new commit but all credit to @tenyo. This adds support for team_id in all of the usergroups functions, so they can work in Slack Enterprise Grid with multiple workspaces. For reference see https://api.slack.com/enterprise/apps/changes-apis#methods I tried to stick to the established pattern of using Options variadic parameters to pass the team_id (together with other optional arguments). I don't think the changes affect existing clients using a single workspace. I've tested all modified functions against the Slack API in an Enterprise Grid instance. --- usergroups.go | 225 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 213 insertions(+), 12 deletions(-) diff --git a/usergroups.go b/usergroups.go index 256f7bd37..616bbf158 100644 --- a/usergroups.go +++ b/usergroups.go @@ -54,6 +54,7 @@ func (api *Client) userGroupRequest(ctx context.Context, path string, values url // createUserGroupParams contains arguments for CreateUserGroup method call type createUserGroupParams struct { enableSection bool + includeCount bool } // CreateUserGroupOption options for the CreateUserGroup method call. @@ -66,6 +67,13 @@ func CreateUserGroupOptionEnableSection(enableSection bool) CreateUserGroupOptio } } +// CreateUserGroupOptionIncludeCount include the number of users in each User Group +func CreateUserGroupOptionIncludeCount(includeCount bool) CreateUserGroupOption { + return func(params *createUserGroupParams) { + params.includeCount = includeCount + } +} + // CreateUserGroup creates a new user group. // For more information see the CreateUserGroupContext documentation. func (api *Client) CreateUserGroup(userGroup UserGroup, options ...CreateUserGroupOption) (UserGroup, error) { @@ -90,6 +98,10 @@ func (api *Client) CreateUserGroupContext(ctx context.Context, userGroup UserGro values["enable_section"] = []string{strconv.FormatBool(params.enableSection)} } + if params.includeCount { + values["include_count"] = []string{strconv.FormatBool(params.includeCount)} + } + if userGroup.TeamID != "" { values["team_id"] = []string{userGroup.TeamID} } @@ -113,20 +125,57 @@ func (api *Client) CreateUserGroupContext(ctx context.Context, userGroup UserGro return response.UserGroup, nil } +// DisableUserGroupParams contains arguments for DisableUserGroup method calls. +type DisableUserGroupParams struct { + IncludeCount bool + TeamID string +} + +// DisableUserGroupOption options for the DisableUserGroup method calls. +type DisableUserGroupOption func(*DisableUserGroupParams) + +// DisableUserGroupOptionIncludeCount include the count of User Groups (default: false) +func DisableUserGroupOptionIncludeCount(b bool) DisableUserGroupOption { + return func(params *DisableUserGroupParams) { + params.IncludeCount = b + } +} + +// DisableUserGroupOptionTeamID include team Id +func DisableUserGroupOptionTeamID(teamID string) DisableUserGroupOption { + return func(params *DisableUserGroupParams) { + params.TeamID = teamID + } +} + // DisableUserGroup disables an existing user group. // For more information see the DisableUserGroupContext documentation. -func (api *Client) DisableUserGroup(userGroup string) (UserGroup, error) { - return api.DisableUserGroupContext(context.Background(), userGroup) +func (api *Client) DisableUserGroup(userGroup string, options ...DisableUserGroupOption) (UserGroup, error) { + return api.DisableUserGroupContext(context.Background(), userGroup, options...) } // DisableUserGroupContext disables an existing user group with a custom context. // Slack API docs: https://api.slack.com/methods/usergroups.disable -func (api *Client) DisableUserGroupContext(ctx context.Context, userGroup string) (UserGroup, error) { +func (api *Client) DisableUserGroupContext(ctx context.Context, userGroup string, options ...DisableUserGroupOption) (UserGroup, error) { + params := DisableUserGroupParams{} + + for _, opt := range options { + opt(¶ms) + } + values := url.Values{ "token": {api.token}, "usergroup": {userGroup}, } + if params.IncludeCount { + values.Add("include_count", "true") + } + + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } + response, err := api.userGroupRequest(ctx, "usergroups.disable", values) if err != nil { return UserGroup{}, err @@ -134,20 +183,57 @@ func (api *Client) DisableUserGroupContext(ctx context.Context, userGroup string return response.UserGroup, nil } +// EnableUserGroupParams contains arguments for EnableUserGroup method calls. +type EnableUserGroupParams struct { + IncludeCount bool + TeamID string +} + +// EnableUserGroupOption options for the EnableUserGroup method calls. +type EnableUserGroupOption func(*EnableUserGroupParams) + +// EnableUserGroupOptionIncludeCount include the count of User Groups (default: false) +func EnableUserGroupOptionIncludeCount(b bool) EnableUserGroupOption { + return func(params *EnableUserGroupParams) { + params.IncludeCount = b + } +} + +// EnableUserGroupOptionTeamID include team Id +func EnableUserGroupOptionTeamID(teamID string) EnableUserGroupOption { + return func(params *EnableUserGroupParams) { + params.TeamID = teamID + } +} + // EnableUserGroup enables an existing user group. // For more information see the EnableUserGroupContext documentation. -func (api *Client) EnableUserGroup(userGroup string) (UserGroup, error) { - return api.EnableUserGroupContext(context.Background(), userGroup) +func (api *Client) EnableUserGroup(userGroup string, options ...EnableUserGroupOption) (UserGroup, error) { + return api.EnableUserGroupContext(context.Background(), userGroup, options...) } // EnableUserGroupContext enables an existing user group with a custom context. // Slack API docs: https://api.slack.com/methods/usergroups.enable -func (api *Client) EnableUserGroupContext(ctx context.Context, userGroup string) (UserGroup, error) { +func (api *Client) EnableUserGroupContext(ctx context.Context, userGroup string, options ...EnableUserGroupOption) (UserGroup, error) { + params := EnableUserGroupParams{} + + for _, opt := range options { + opt(¶ms) + } + values := url.Values{ "token": {api.token}, "usergroup": {userGroup}, } + if params.IncludeCount { + values.Add("include_count", "true") + } + + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } + response, err := api.userGroupRequest(ctx, "usergroups.enable", values) if err != nil { return UserGroup{}, err @@ -158,7 +244,12 @@ func (api *Client) EnableUserGroupContext(ctx context.Context, userGroup string) // GetUserGroupsOption options for the GetUserGroups method call. type GetUserGroupsOption func(*GetUserGroupsParams) +// Deprecated: GetUserGroupsOptionWithTeamID is deprecated, use GetUserGroupsOptionTeamID instead func GetUserGroupsOptionWithTeamID(teamID string) GetUserGroupsOption { + return GetUserGroupsOptionTeamID(teamID) +} + +func GetUserGroupsOptionTeamID(teamID string) GetUserGroupsOption { return func(params *GetUserGroupsParams) { params.TeamID = teamID } @@ -269,6 +360,13 @@ func UpdateUserGroupsOptionEnableSection(enableSection bool) UpdateUserGroupsOpt } } +// UpdateUserGroupsOptionTeamID specify the team id for the User Group. (default: nil, so it's no-op) +func UpdateUserGroupsOptionTeamID(teamID string) UpdateUserGroupsOption { + return func(params *UpdateUserGroupsParams) { + params.TeamID = teamID + } +} + // UpdateUserGroupsParams contains arguments for UpdateUserGroup method call type UpdateUserGroupsParams struct { Name string @@ -276,6 +374,7 @@ type UpdateUserGroupsParams struct { Description *string Channels *[]string EnableSection bool + TeamID string } // UpdateUserGroup will update an existing user group. @@ -318,6 +417,10 @@ func (api *Client) UpdateUserGroupContext(ctx context.Context, userGroupID strin values["enable_section"] = []string{strconv.FormatBool(params.EnableSection)} } + if params.TeamID != "" { + values["team_id"] = []string{params.TeamID} + } + response, err := api.userGroupRequest(ctx, "usergroups.update", values) if err != nil { return UserGroup{}, err @@ -325,20 +428,57 @@ func (api *Client) UpdateUserGroupContext(ctx context.Context, userGroupID strin return response.UserGroup, nil } +// GetUserGroupMembersOption options for the GetUserGroupMembers method call. +type GetUserGroupMembersOption func(*GetUserGroupMembersParams) + +// GetUserGroupMembersParams contains arguments for GetUserGroupMembers method call +type GetUserGroupMembersParams struct { + IncludeDisabled bool + TeamID string +} + +// GetUserGroupMembersOptionIncludeDisabled include disabled User Groups (default: false) +func GetUserGroupMembersOptionIncludeDisabled(b bool) GetUserGroupMembersOption { + return func(params *GetUserGroupMembersParams) { + params.IncludeDisabled = b + } +} + +// GetUserGroupMembersOptionTeamID include team Id +func GetUserGroupMembersOptionTeamID(teamID string) GetUserGroupMembersOption { + return func(params *GetUserGroupMembersParams) { + params.TeamID = teamID + } +} + // GetUserGroupMembers will retrieve the current list of users in a group. // For more information see the GetUserGroupMembersContext documentation. -func (api *Client) GetUserGroupMembers(userGroup string) ([]string, error) { - return api.GetUserGroupMembersContext(context.Background(), userGroup) +func (api *Client) GetUserGroupMembers(userGroup string, options ...GetUserGroupMembersOption) ([]string, error) { + return api.GetUserGroupMembersContext(context.Background(), userGroup, options...) } // GetUserGroupMembersContext will retrieve the current list of users in a group with a custom context. // Slack API docs: https://api.slack.com/methods/usergroups.users.list -func (api *Client) GetUserGroupMembersContext(ctx context.Context, userGroup string) ([]string, error) { +func (api *Client) GetUserGroupMembersContext(ctx context.Context, userGroup string, options ...GetUserGroupMembersOption) ([]string, error) { + params := GetUserGroupMembersParams{} + + for _, opt := range options { + opt(¶ms) + } + values := url.Values{ "token": {api.token}, "usergroup": {userGroup}, } + if params.IncludeDisabled { + values.Add("include_disabled", "true") + } + + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } + response, err := api.userGroupRequest(ctx, "usergroups.users.list", values) if err != nil { return []string{}, err @@ -346,21 +486,82 @@ func (api *Client) GetUserGroupMembersContext(ctx context.Context, userGroup str return response.Users, nil } +// UpdateUserGroupMembersOption options for the UpdateUserGroupMembers method call. +type UpdateUserGroupMembersOption func(*UpdateUserGroupMembersParams) + +// UpdateUserGroupMembersParams contains arguments for UpdateUserGroupMembers method call +type UpdateUserGroupMembersParams struct { + AdditionalChannels []string + IncludeCount bool + IsShared bool + TeamID string +} + +// UpdateUserGroupMembersOptionAdditionalChannels include additional channels +func UpdateUserGroupMembersOptionAdditionalChannels(channels []string) UpdateUserGroupMembersOption { + return func(params *UpdateUserGroupMembersParams) { + params.AdditionalChannels = channels + } +} + +// UpdateUserGroupMembersOptionIsShared include the count of User Groups (default: false) +func UpdateUserGroupMembersOptionIsShared(b bool) UpdateUserGroupMembersOption { + return func(params *UpdateUserGroupMembersParams) { + params.IsShared = b + } +} + +// UpdateUserGroupMembersOptionIncludeCount include the count of User Groups (default: false) +func UpdateUserGroupMembersOptionIncludeCount(b bool) UpdateUserGroupMembersOption { + return func(params *UpdateUserGroupMembersParams) { + params.IncludeCount = b + } +} + +// UpdateUserGroupMembersOptionTeamID include team Id +func UpdateUserGroupMembersOptionTeamID(teamID string) UpdateUserGroupMembersOption { + return func(params *UpdateUserGroupMembersParams) { + params.TeamID = teamID + } +} + // UpdateUserGroupMembers will update the members of an existing user group. // For more information see the UpdateUserGroupMembersContext documentation. -func (api *Client) UpdateUserGroupMembers(userGroup string, members string) (UserGroup, error) { - return api.UpdateUserGroupMembersContext(context.Background(), userGroup, members) +func (api *Client) UpdateUserGroupMembers(userGroup string, members string, options ...UpdateUserGroupMembersOption) (UserGroup, error) { + return api.UpdateUserGroupMembersContext(context.Background(), userGroup, members, options...) } // UpdateUserGroupMembersContext will update the members of an existing user group with a custom context. // Slack API docs: https://api.slack.com/methods/usergroups.update -func (api *Client) UpdateUserGroupMembersContext(ctx context.Context, userGroup string, members string) (UserGroup, error) { +func (api *Client) UpdateUserGroupMembersContext(ctx context.Context, userGroup string, members string, options ...UpdateUserGroupMembersOption) (UserGroup, error) { + params := UpdateUserGroupMembersParams{} + + for _, opt := range options { + opt(¶ms) + } + values := url.Values{ "token": {api.token}, "usergroup": {userGroup}, "users": {members}, } + if params.IncludeCount { + values.Add("include_count", "true") + } + + if params.IsShared { + values.Add("is_shared", "true") + } + + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } + + if len(params.AdditionalChannels) > 0 { + values["additional_channels"] = []string{strings.Join(params.AdditionalChannels, ",")} + } + response, err := api.userGroupRequest(ctx, "usergroups.users.update", values) if err != nil { return UserGroup{}, err From 666e0f2722ddfbe40343df97a6dee6a19584abdb Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 23 Mar 2025 11:34:51 +0000 Subject: [PATCH 331/606] feat: add FocusOnLoad to DateTimePickerBlockElement --- block_element.go | 1 + 1 file changed, 1 insertion(+) diff --git a/block_element.go b/block_element.go index a677e041b..c27dc6f2e 100644 --- a/block_element.go +++ b/block_element.go @@ -491,6 +491,7 @@ type DateTimePickerBlockElement struct { ActionID string `json:"action_id,omitempty"` InitialDateTime int64 `json:"initial_date_time,omitempty"` Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` } // ElementType returns the type of the Element From 11882b18157462b8c057317b37eb818ed4131798 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 23 Mar 2025 12:03:50 +0000 Subject: [PATCH 332/606] feat: add Border and Offset to RichTextList --- block_rich_text.go | 14 ++++++++++++-- info.go | 6 +++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/block_rich_text.go b/block_rich_text.go index 18faf8cc8..b757ad92e 100644 --- a/block_rich_text.go +++ b/block_rich_text.go @@ -115,6 +115,8 @@ type RichTextList struct { Elements []RichTextElement `json:"elements"` Style RichTextListElementType `json:"style"` Indent int `json:"indent"` + Border int `json:"border"` + Offset int `json:"offset"` } // NewRichTextList returns a new rich text list element. @@ -137,6 +139,8 @@ func (e *RichTextList) UnmarshalJSON(b []byte) error { RawElements []json.RawMessage `json:"elements"` Style RichTextListElementType `json:"style"` Indent int `json:"indent"` + Border int `json:"border"` + Offset int `json:"offset"` } if string(b) == "{}" { return nil @@ -179,6 +183,8 @@ func (e *RichTextList) UnmarshalJSON(b []byte) error { Elements: elems, Style: raw.Style, Indent: raw.Indent, + Border: raw.Border, + Offset: raw.Offset, } return nil } @@ -195,7 +201,8 @@ func (s RichTextSection) RichTextElementType() RichTextElementType { func (e *RichTextSection) UnmarshalJSON(b []byte) error { var raw struct { - RawElements []json.RawMessage `json:"elements"` + RawElements []json.RawMessage `json:"elements"` + Type RichTextElementType `json:"type"` } if string(b) == "{}" { return nil @@ -245,8 +252,11 @@ func (e *RichTextSection) UnmarshalJSON(b []byte) error { } elems = append(elems, elem) } + if raw.Type == "" { + raw.Type = RTESection + } *e = RichTextSection{ - Type: RTESection, + Type: raw.Type, Elements: elems, } return nil diff --git a/info.go b/info.go index a882dfbc4..a026ab494 100644 --- a/info.go +++ b/info.go @@ -433,9 +433,9 @@ type Team struct { // Icons XXX: needs further investigation type Icons struct { - Image36 string `json:"image_36,omitempty"` - Image48 string `json:"image_48,omitempty"` - Image72 string `json:"image_72,omitempty"` + Image36 string `json:"image_36,omitempty"` + Image48 string `json:"image_48,omitempty"` + Image72 string `json:"image_72,omitempty"` Image132 string `json:"image_132,omitempty"` Image230 string `json:"image_230,omitempty"` } From f56bbab3925d8e85f3609d51a0c7ff35fa26f53e Mon Sep 17 00:00:00 2001 From: Sam Starling <42478+samstarling@users.noreply.github.com> Date: Sun, 23 Mar 2025 15:04:25 +0000 Subject: [PATCH 333/606] Add some missing admin.conversations endpoints --- admin_conversations.go | 76 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 admin_conversations.go diff --git a/admin_conversations.go b/admin_conversations.go new file mode 100644 index 000000000..f3c4f060e --- /dev/null +++ b/admin_conversations.go @@ -0,0 +1,76 @@ +package slack + +import ( + "context" + "net/url" + "strconv" + "strings" +) + +// Set the workspaces in an Enterprise Grid organisation that connect to a public or +// private channel. +// See: https://api.slack.com/methods/admin.conversations.setTeams +func (api *Client) AdminConversationsSetTeams(ctx context.Context, channelID string, orgChannel *bool, targetTeamIDs *[]string, teamID *string) error { + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + } + + if orgChannel != nil { + values.Add("org_channel", strconv.FormatBool(*orgChannel)) + } + + if targetTeamIDs != nil { + values.Add("target_team_ids", strings.Join(*targetTeamIDs, ",")) // ["T123", "T456"] - > "T123,T456" + } + + if teamID != nil { + values.Add("team_id", *teamID) + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.setTeams", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// ConversationsConvertToPrivate converts a public channel to a private channel. To do +// this, you must have the admin.conversations:write scope. There are other requirements: +// you should read the Slack documentation for more details. +// See: https://api.slack.com/methods/admin.conversations.convertToPrivate +func (api *Client) AdminConversationsConvertToPrivate(ctx context.Context, channelID string) error { + values := url.Values{ + "token": []string{api.token}, + "channel_id": []string{channelID}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.convertToPrivate", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// ConversationsConvertToPublic converts a private channel to a public channel. To do +// this, you must have the admin.conversations:write scope. There are other requirements: +// you should read the Slack documentation for more details. +// See: https://api.slack.com/methods/admin.conversations.convertToPublic +func (api *Client) AdminConversationsConvertToPublic(ctx context.Context, channelID string) error { + values := url.Values{ + "token": []string{api.token}, + "channel_id": []string{channelID}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.convertToPublic", values, response) + if err != nil { + return err + } + + return response.Err() +} From b11f9252dfa050d83c46f733e17ca396616419f7 Mon Sep 17 00:00:00 2001 From: Sam Starling <42478+samstarling@users.noreply.github.com> Date: Sun, 23 Mar 2025 15:17:15 +0000 Subject: [PATCH 334/606] Switch to use a params struct --- admin_conversations.go | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/admin_conversations.go b/admin_conversations.go index f3c4f060e..6f76568ba 100644 --- a/admin_conversations.go +++ b/admin_conversations.go @@ -7,25 +7,34 @@ import ( "strings" ) +// AdminConversationsSetTeamsParams contains arguments for AdminConversationsSetTeams +// method calls. +type AdminConversationsSetTeamsParams struct { + ChannelID string + OrgChannel *bool + TargetTeamIDs []string + TeamID *string +} + // Set the workspaces in an Enterprise Grid organisation that connect to a public or // private channel. // See: https://api.slack.com/methods/admin.conversations.setTeams -func (api *Client) AdminConversationsSetTeams(ctx context.Context, channelID string, orgChannel *bool, targetTeamIDs *[]string, teamID *string) error { +func (api *Client) AdminConversationsSetTeams(ctx context.Context, params AdminConversationsSetTeamsParams) error { values := url.Values{ "token": {api.token}, - "channel_id": {channelID}, + "channel_id": {params.ChannelID}, } - if orgChannel != nil { - values.Add("org_channel", strconv.FormatBool(*orgChannel)) + if params.OrgChannel != nil { + values.Add("org_channel", strconv.FormatBool(*params.OrgChannel)) } - if targetTeamIDs != nil { - values.Add("target_team_ids", strings.Join(*targetTeamIDs, ",")) // ["T123", "T456"] - > "T123,T456" + if len(params.TargetTeamIDs) > 0 { + values.Add("target_team_ids", strings.Join(params.TargetTeamIDs, ",")) // ["T123", "T456"] - > "T123,T456" } - if teamID != nil { - values.Add("team_id", *teamID) + if params.TeamID != nil { + values.Add("team_id", *params.TeamID) } response := &SlackResponse{} From 85b2eac51fe0afa5c8fed1e603c05d63b2bb8230 Mon Sep 17 00:00:00 2001 From: Sam Starling <42478+samstarling@users.noreply.github.com> Date: Sun, 23 Mar 2025 15:21:58 +0000 Subject: [PATCH 335/606] Remove a number of unused functions and parameters --- chat.go | 2 +- function_execute_test.go | 4 ++-- interactions_test.go | 2 -- misc.go | 29 ++++------------------------- team.go | 4 ++-- 5 files changed, 9 insertions(+), 32 deletions(-) diff --git a/chat.go b/chat.go index 385836a05..11324c9e4 100644 --- a/chat.go +++ b/chat.go @@ -234,7 +234,7 @@ func (api *Client) SendMessageContext(ctx context.Context, channelID string, opt api.Debugf("Sending request: %s", redactToken(reqBody)) } - if err = doPost(ctx, api.httpclient, req, parser(&response), api); err != nil { + if err = doPost(api.httpclient, req, parser(&response), api); err != nil { return "", "", "", err } diff --git a/function_execute_test.go b/function_execute_test.go index 356e22328..e040c7fef 100644 --- a/function_execute_test.go +++ b/function_execute_test.go @@ -33,7 +33,7 @@ func postHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { } } -func postSuccess(rw http.ResponseWriter, r *http.Request) { +func postSuccess(rw http.ResponseWriter, _ *http.Request) { rw.Header().Set("Content-Type", "application/json") response := []byte(`{ "ok": true @@ -41,7 +41,7 @@ func postSuccess(rw http.ResponseWriter, r *http.Request) { rw.Write(response) } -func postFailure(rw http.ResponseWriter, r *http.Request) { +func postFailure(rw http.ResponseWriter, _ *http.Request) { rw.Header().Set("Content-Type", "application/json") response := []byte(`{ "ok": false, diff --git a/interactions_test.go b/interactions_test.go index faf533344..308280960 100644 --- a/interactions_test.go +++ b/interactions_test.go @@ -494,7 +494,6 @@ func TestInteractionCallback_Container_Marshal_And_Unmarshal(t *testing.T) { IsEphemeral: false, IsAppUnfurl: false, }, - RawState: json.RawMessage(`{}`), } actual := new(InteractionCallback) @@ -537,7 +536,6 @@ func TestInteractionCallback_In_Thread_Container_Marshal_And_Unmarshal(t *testin IsEphemeral: false, IsAppUnfurl: false, }, - RawState: json.RawMessage(`{}`), } actual := new(InteractionCallback) diff --git a/misc.go b/misc.go index b80e92b7e..d619145f0 100644 --- a/misc.go +++ b/misc.go @@ -124,19 +124,6 @@ func jsonReq(ctx context.Context, endpoint string, body interface{}) (req *http. return req, nil } -func parseResponseBody(body io.ReadCloser, intf interface{}, d Debug) error { - response, err := io.ReadAll(body) - if err != nil { - return err - } - - if d.Debug() { - d.Debugln("parseResponseBody", string(response)) - } - - return json.Unmarshal(response, intf) -} - func postLocalWithMultipartResponse(ctx context.Context, client httpClient, method, fpath, fieldname, token string, values url.Values, intf interface{}, d Debug) error { fullpath, err := filepath.Abs(fpath) if err != nil { @@ -220,7 +207,7 @@ func createFormFields(mw *multipart.Writer, values url.Values) error { return nil } -func doPost(ctx context.Context, client httpClient, req *http.Request, parser responseParser, d Debug) error { +func doPost(client httpClient, req *http.Request, parser responseParser, d Debug) error { resp, err := client.Do(req) if err != nil { return err @@ -245,7 +232,7 @@ func postJSON(ctx context.Context, client httpClient, endpoint, token string, js req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - return doPost(ctx, client, req, newJSONParser(intf), d) + return doPost(client, req, newJSONParser(intf), d) } // post a url encoded form. @@ -256,7 +243,7 @@ func postForm(ctx context.Context, client httpClient, endpoint string, values ur return err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - return doPost(ctx, client, req, newJSONParser(intf), d) + return doPost(client, req, newJSONParser(intf), d) } func getResource(ctx context.Context, client httpClient, endpoint, token string, values url.Values, intf interface{}, d Debug) error { @@ -269,7 +256,7 @@ func getResource(ctx context.Context, client httpClient, endpoint, token string, req.URL.RawQuery = values.Encode() - return doPost(ctx, client, req, newJSONParser(intf), d) + return doPost(client, req, newJSONParser(intf), d) } func parseAdminResponse(ctx context.Context, client httpClient, method string, teamName string, values url.Values, intf interface{}, d Debug) error { @@ -297,14 +284,6 @@ func okJSONHandler(rw http.ResponseWriter, r *http.Request) { rw.Write(response) } -// timerReset safely reset a timer, see time.Timer.Reset for details. -func timerReset(t *time.Timer, d time.Duration) { - if !t.Stop() { - <-t.C - } - t.Reset(d) -} - func checkStatusCode(resp *http.Response, d Debug) error { if resp.StatusCode == http.StatusTooManyRequests && resp.Header.Get("Retry-After") != "" { retry, err := strconv.ParseInt(resp.Header.Get("Retry-After"), 10, 64) diff --git a/team.go b/team.go index 4e890c2be..35b699271 100644 --- a/team.go +++ b/team.go @@ -116,7 +116,7 @@ func (api *Client) accessLogsRequest(ctx context.Context, path string, values ur return response, response.Err() } -func (api *Client) teamProfileRequest(ctx context.Context, client httpClient, path string, values url.Values) (*TeamProfileResponse, error) { +func (api *Client) teamProfileRequest(ctx context.Context, path string, values url.Values) (*TeamProfileResponse, error) { response := &TeamProfileResponse{} err := api.postMethod(ctx, path, values, response) if err != nil { @@ -184,7 +184,7 @@ func (api *Client) GetTeamProfileContext(ctx context.Context, teamID ...string) values["team_id"] = teamID } - response, err := api.teamProfileRequest(ctx, api.httpclient, "team.profile.get", values) + response, err := api.teamProfileRequest(ctx, "team.profile.get", values) if err != nil { return nil, err } From 65e7367f4adcb572652a57bc932a24f995864909 Mon Sep 17 00:00:00 2001 From: Sam Starling <42478+samstarling@users.noreply.github.com> Date: Sun, 23 Mar 2025 15:23:38 +0000 Subject: [PATCH 336/606] Tidy up postSuccess/postFailure --- function_execute_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/function_execute_test.go b/function_execute_test.go index e040c7fef..01f43e9cb 100644 --- a/function_execute_test.go +++ b/function_execute_test.go @@ -26,14 +26,14 @@ func postHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { switch req.FunctionExecutionID { case "function-success": - postSuccess(rw, r) + postSuccess(rw) case "function-failure": - postFailure(rw, r) + postFailure(rw) } } } -func postSuccess(rw http.ResponseWriter, _ *http.Request) { +func postSuccess(rw http.ResponseWriter) { rw.Header().Set("Content-Type", "application/json") response := []byte(`{ "ok": true @@ -41,7 +41,7 @@ func postSuccess(rw http.ResponseWriter, _ *http.Request) { rw.Write(response) } -func postFailure(rw http.ResponseWriter, _ *http.Request) { +func postFailure(rw http.ResponseWriter) { rw.Header().Set("Content-Type", "application/json") response := []byte(`{ "ok": false, From 3e9954293a601eb3ce7dc9a0c8a52861fe857763 Mon Sep 17 00:00:00 2001 From: Sam Starling <42478+samstarling@users.noreply.github.com> Date: Sun, 23 Mar 2025 15:24:38 +0000 Subject: [PATCH 337/606] Handle nil being passed in to newTextParser --- misc.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/misc.go b/misc.go index b80e92b7e..c1ce948aa 100644 --- a/misc.go +++ b/misc.go @@ -336,6 +336,10 @@ func newJSONParser(dst interface{}) responseParser { func newTextParser(dst interface{}) responseParser { return func(resp *http.Response) error { + if dst == nil { + return nil + } + b, err := io.ReadAll(resp.Body) if err != nil { return err From e04964848a6a478cdffe748f971704f4f1ffe210 Mon Sep 17 00:00:00 2001 From: Sam Starling <42478+samstarling@users.noreply.github.com> Date: Sun, 23 Mar 2025 15:28:55 +0000 Subject: [PATCH 338/606] Tidy up arguments to _search function --- search.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/search.go b/search.go index d27497aae..9df433501 100644 --- a/search.go +++ b/search.go @@ -89,7 +89,7 @@ func NewSearchParameters() SearchParameters { } } -func (api *Client) _search(ctx context.Context, path, query string, params SearchParameters, files, messages bool) (response *searchResponseFull, error error) { +func (api *Client) _search(ctx context.Context, path, query string, params SearchParameters) (response *searchResponseFull, error error) { values := url.Values{ "token": {api.token}, "query": {query}, @@ -103,7 +103,7 @@ func (api *Client) _search(ctx context.Context, path, query string, params Searc if params.SortDirection != DEFAULT_SEARCH_SORT_DIR { values.Add("sort_dir", params.SortDirection) } - if params.Highlight != DEFAULT_SEARCH_HIGHLIGHT { + if params.Highlight { values.Add("highlight", strconv.Itoa(1)) } if params.Count != DEFAULT_SEARCH_COUNT { @@ -128,7 +128,7 @@ func (api *Client) Search(query string, params SearchParameters) (*SearchMessage } func (api *Client) SearchContext(ctx context.Context, query string, params SearchParameters) (*SearchMessages, *SearchFiles, error) { - response, err := api._search(ctx, "search.all", query, params, true, true) + response, err := api._search(ctx, "search.all", query, params) if err != nil { return nil, nil, err } @@ -140,7 +140,7 @@ func (api *Client) SearchFiles(query string, params SearchParameters) (*SearchFi } func (api *Client) SearchFilesContext(ctx context.Context, query string, params SearchParameters) (*SearchFiles, error) { - response, err := api._search(ctx, "search.files", query, params, true, false) + response, err := api._search(ctx, "search.files", query, params) if err != nil { return nil, err } @@ -152,7 +152,7 @@ func (api *Client) SearchMessages(query string, params SearchParameters) (*Searc } func (api *Client) SearchMessagesContext(ctx context.Context, query string, params SearchParameters) (*SearchMessages, error) { - response, err := api._search(ctx, "search.messages", query, params, false, true) + response, err := api._search(ctx, "search.messages", query, params) if err != nil { return nil, err } From deb9856ed103d1c45f78f976416285847238374d Mon Sep 17 00:00:00 2001 From: Sam Starling <42478+samstarling@users.noreply.github.com> Date: Sun, 23 Mar 2025 15:35:46 +0000 Subject: [PATCH 339/606] Tidy up go.mod --- go.mod | 1 - go.sum | 4 ---- 2 files changed, 5 deletions(-) diff --git a/go.mod b/go.mod index 5cc8e1a7e..66d8bb9f9 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.16 require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-test/deep v1.0.4 - github.com/google/go-cmp v0.5.7 github.com/gorilla/websocket v1.4.2 github.com/pmezard/go-difflib v1.0.0 // indirect github.com/stretchr/testify v1.2.2 diff --git a/go.sum b/go.sum index 194956433..063f679df 100644 --- a/go.sum +++ b/go.sum @@ -2,13 +2,9 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= -github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= -github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From dbfeb502097956066d10eb7d59ae2d0272bd3220 Mon Sep 17 00:00:00 2001 From: Sam Starling <42478+samstarling@users.noreply.github.com> Date: Sun, 23 Mar 2025 15:39:51 +0000 Subject: [PATCH 340/606] Add a linting check for go mod tidy --- .github/workflows/test.yml | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 46e829d27..149747feb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,13 +12,13 @@ jobs: strategy: matrix: go: - - '1.18' - - '1.19' - - '1.20' - - '1.21' - - '1.22' - - '1.23' - - '1.24' + - "1.18" + - "1.19" + - "1.20" + - "1.21" + - "1.22" + - "1.23" + - "1.24" name: test go-${{ matrix.go }} steps: - uses: actions/checkout@v4 @@ -33,7 +33,7 @@ jobs: steps: - uses: actions/setup-go@v5 with: - go-version: '1.20' + go-version: "1.20" cache: false - uses: actions/checkout@v4 with: @@ -42,3 +42,10 @@ jobs: uses: golangci/golangci-lint-action@4696ba8babb6127d732c3c6dde519db15edab9ea # v6.5.1 with: version: v1.63.4 + - name: Check 'go mod tidy' makes no changes + run: | + go mod tidy + if ! git diff --exit-code go.mod go.sum; then + echo "❌ go.mod or go.sum files are not tidy. Please run 'go mod tidy' and commit the changes." + exit 1 + fi From fd0e5d3aed3898f22f624617a154102f376d16a1 Mon Sep 17 00:00:00 2001 From: Sam Starling <42478+samstarling@users.noreply.github.com> Date: Sun, 23 Mar 2025 18:18:06 +0000 Subject: [PATCH 341/606] Add unit tests for admin conversations --- admin_conversations_test.go | 82 +++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 admin_conversations_test.go diff --git a/admin_conversations_test.go b/admin_conversations_test.go new file mode 100644 index 000000000..66ad86207 --- /dev/null +++ b/admin_conversations_test.go @@ -0,0 +1,82 @@ +package slack + +import ( + "context" + "encoding/json" + "net/http" + "testing" +) + +func TestAdminConversationsSetTeams(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.setTeams", mockAdminChannelIDHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + orgChannel := true + teamID := "T789" + + err := api.AdminConversationsSetTeams(context.Background(), AdminConversationsSetTeamsParams{ + ChannelID: "C1234567890", + OrgChannel: &orgChannel, + TargetTeamIDs: []string{"T123", "T456"}, + TeamID: &teamID, + }) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsConvertToPrivate(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.convertToPrivate", mockAdminChannelIDHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsConvertToPrivate(context.Background(), "C1234567890") + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsConvertToPublic(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.convertToPublic", mockAdminChannelIDHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsConvertToPublic(context.Background(), "C1234567890") + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +// mockAdminChannelIDHandler returns a handler which expects a channel_id to be present +// in the request, and will fail the test if it isn't present. +func mockAdminChannelIDHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["channel_id"]) == 0 { + t.Error("missing channel_id in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(SlackResponse{ + Ok: true, + }) + + rw.Write(response) + } +} From 4682a371f2c47b4deac1e016b3f6a2ae7770fbda Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Mar 2025 06:32:49 +0000 Subject: [PATCH 342/606] chore(deps): bump golangci/golangci-lint-action from 6.5.1 to 6.5.2 Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 6.5.1 to 6.5.2. - [Release notes](https://github.com/golangci/golangci-lint-action/releases) - [Commits](https://github.com/golangci/golangci-lint-action/compare/4696ba8babb6127d732c3c6dde519db15edab9ea...55c2c1448f86e01eaae002a5a3a9624417608d84) --- updated-dependencies: - dependency-name: golangci/golangci-lint-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 149747feb..55211cd13 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,7 +39,7 @@ jobs: with: fetch-depth: 0 - name: golangci-lint - uses: golangci/golangci-lint-action@4696ba8babb6127d732c3c6dde519db15edab9ea # v6.5.1 + uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 # v6.5.2 with: version: v1.63.4 - name: Check 'go mod tidy' makes no changes From 462a182d8cb77c9a2cb4aff7304bd7e779844d59 Mon Sep 17 00:00:00 2001 From: Lisa Karlin Curtis Date: Tue, 25 Mar 2025 14:11:15 +0000 Subject: [PATCH 343/606] Unmarshal messages correctly for all subtypes When we get a 'message' event, the message details (blocks, attachments, etc.) are all included on the top level struct. When it's a 'message_changed' event, these details are instead nested under a `message` key. This is really really confusing! We can't just nest a `Msg` struct inside our MessageEvent, because the keys of other message events conflict with the `Msg` keys (e.g. both a `message` and a `message_changed` event will have a `user` key). To avoid this, we implement a custom unmarshaller which will: 1. If there's a `message` key in the payload, unmarshal it into the Message field 2. If there's no `message` key, populate the Message field with the event itself, assuming that it's an event with no subtype and so the message info is at the top level. This means some information is represented twice in the same struct, but that is less bad than it being unclear to a caller where they should go to access e.g. the message text. Also reworks the tests so they use examples pulled directly from Slack's documentation. --- slackevents/inner_events.go | 47 +++++++++- slackevents/inner_events_test.go | 153 +++++++++++++++++++++++-------- 2 files changed, 159 insertions(+), 41 deletions(-) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 0d487e6c0..1d9906db9 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -3,6 +3,8 @@ package slackevents import ( + "encoding/json" + "github.com/slack-go/slack" ) @@ -283,9 +285,16 @@ type MessageEvent struct { UserTeam string `json:"user_team,omitempty"` SourceTeam string `json:"source_team,omitempty"` + // When we get a 'message' event with no subtype, i.e. telling us about a new + // message, the message information is stored at the top level. But when we get + // a 'message_changed' event, the message information is stored in + // the Message property. This is really hard to represent nicely in Go, so we use + // a custom JSON unmarshaller to populate the Message field in both cases. + Message *slack.Msg `json:"message,omitempty"` + // Root is set if the SubType is `thread_broadcast`. + Root *slack.Msg `json:"root,omitempty"` // Edited Message - Message *slack.Message `json:"message,omitempty"` - PreviousMessage *slack.Message `json:"previous_message,omitempty"` + PreviousMessage *slack.Msg `json:"previous_message,omitempty"` // Deleted Message DeletedTimeStamp string `json:"deleted_ts,omitempty"` @@ -299,6 +308,40 @@ type MessageEvent struct { Icons *Icon `json:"icons,omitempty"` } +// UnmarshalJSON implements the json.Unmarshaler interface for MessageEvent. +// This custom unmarshaler handles both regular messages and message_changed events +// by normalizing the message data into the Message field. +func (e *MessageEvent) UnmarshalJSON(data []byte) error { + // First, unmarshal into an anonymous struct to avoid infinite recursion + // when calling json.Unmarshal on the MessageEvent type itself + type MessageEventAlias MessageEvent + alias := struct { + MessageEventAlias + }{} + + if err := json.Unmarshal(data, &alias.MessageEventAlias); err != nil { + return err + } + + // Copy all fields from alias to the original struct + *e = MessageEvent(alias.MessageEventAlias) + + // Now check if there's no Message field (which would happen for regular messages) + if e.Message == nil { + // For regular messages, the message content is at the top level, + // so we need to unmarshal the data again into a slack.Msg + msg := &slack.Msg{} + if err := json.Unmarshal(data, msg); err != nil { + return err + } + + // Set the Message field to the unmarshaled msg + e.Message = msg + } + + return nil +} + // MemberJoinedChannelEvent A member joined a public or private channel type MemberJoinedChannelEvent struct { Type string `json:"type"` diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index feffaf58b..5ec99ecde 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -346,28 +346,71 @@ func TestMessageEvent(t *testing.T) { "channel_type": "channel", "source_team": "T3MQV36V7", "user_team": "T3MQV36V7", - "message": { - "text": "To infinity and beyond.", - "edited": { - "user": "U2147483697", - "ts": "1355517524.000000" - } - }, "metadata": { "event_type": "example", "event_payload": { "key": "value" } - }, - "previous_message": { - "text": "Live long and prospect." } } `) - err := json.Unmarshal(rawE, &MessageEvent{}) + var e MessageEvent + err := json.Unmarshal(rawE, &e) if err != nil { t.Error(err) } + + if e.Channel != "G024BE91L" { + t.Error(fmt.Errorf("expected channel G024BE91L, got %s", e.Channel)) + } + if e.User != "U2147483697" { + t.Error(fmt.Errorf("expected user U2147483697, got %s", e.User)) + } + if e.Text != "Live long and prospect." { + t.Error(fmt.Errorf("expected e.Text Live long and prospect., got %s", e.Text)) + } + if e.Message.Text != "Live long and prospect." { + t.Error(fmt.Errorf("expected e.Message.Text Live long and prospect., got %s", e.Message.Text)) + } + +} + +func TestMessageChangedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "message", + "subtype": "message_changed", + "hidden": true, + "channel": "G024BE91L", + "ts": "1358878755.000001", + "message": { + "type": "message", + "user": "U123ABC456", + "text": "Live long and prospect.", + "ts": "1355517523.000005", + "edited": { + "user": "U123ABC456", + "ts": "1358878755.000001" + } + } + } + `) + + var e MessageEvent + err := json.Unmarshal(rawE, &e) + if err != nil { + t.Error(err) + } + + if e.Channel != "G024BE91L" { + t.Error(fmt.Errorf("expected channel G024BE91L, got %s", e.Channel)) + } + if e.Message.Text != "Live long and prospect." { + t.Error(fmt.Errorf("expected e.Message.Text Live long and prospect., got %s", e.Message.Text)) + } + if e.Message.Edited.User != "U123ABC456" { + t.Error(fmt.Errorf("expected e.Message.Edited.User U123ABC456, got %s", e.Message.Edited.User)) + } } func TestBotMessageEvent(t *testing.T) { @@ -393,29 +436,65 @@ func TestThreadBroadcastEvent(t *testing.T) { { "type": "message", "subtype": "thread_broadcast", - "channel": "G024BE91L", - "user": "U2147483697", - "text": "Live long and prospect.", - "ts": "1355517523.000005", - "event_ts": "1355517523.000005", - "channel_type": "channel", - "source_team": "T3MQV36V7", - "user_team": "T3MQV36V7", - "message": { - "text": "To infinity and beyond.", - "root": { - "text": "To infinity and beyond.", - "ts": "1355517523.000005" - }, - "edited": { - "user": "U2147483697", - "ts": "1355517524.000000" - } + "text": "broadcasting this reply", + "user": "U123ABC456", + "ts": "1673464745.620769", + "thread_ts": "1673464730.703009", + "root": { + "client_msg_id": "123abc456-...", + "type": "message", + "text": "This is the original message", + "user": "U123ABC456", + "ts": "1673464730.703009", + "blocks": [ + { + "type": "rich_text", + "block_id": "qTg", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "This is the original message" + } + ] + } + ] + } + ], + "team": "T123ABC456", + "thread_ts": "1673464730.703009", + "reply_count": 1, + "reply_users_count": 1, + "latest_reply": "1673464745.620769", + "reply_users": [ + "U123ABC456" + ], + "is_locked": false }, - "previous_message": { - "text": "Live long and prospect." - } - } + "blocks": [ + { + "type": "rich_text", + "block_id": "BVp", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "broadcasting this reply" + } + ] + } + ] + } + ], + "client_msg_id": "123abc456-...", + "channel": "C123ABC456", + "event_ts": "1673464745.620769", + "channel_type": "channel" + } `) var me MessageEvent @@ -423,12 +502,8 @@ func TestThreadBroadcastEvent(t *testing.T) { t.Error(err) } - if me.Message.Root == nil { - t.Fatal("me.Message.Root is nil") - } - - if me.Message.Root.Timestamp != "1355517523.000005" { - t.Errorf("me.Message.Root.Timestamp = %q, want %q", me.Message.Root.Timestamp, "1355517523.000005") + if me.Root.Timestamp != "1673464730.703009" { + t.Errorf("me.Root.Timestamp = %q, want %q", me.Root.Timestamp, "1673464730.703009") } } From 82ebbec3fa47fccc56f5d96acde8277b499028c7 Mon Sep 17 00:00:00 2001 From: Bryan Bassett Date: Tue, 22 Apr 2025 22:24:55 -0400 Subject: [PATCH 344/606] RichTextSectionLinkElement Text omitempty --- block_rich_text.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/block_rich_text.go b/block_rich_text.go index b757ad92e..73233d23a 100644 --- a/block_rich_text.go +++ b/block_rich_text.go @@ -376,7 +376,7 @@ func NewRichTextSectionEmojiElement(name string, skinTone int, style *RichTextSe type RichTextSectionLinkElement struct { Type RichTextSectionElementType `json:"type"` URL string `json:"url"` - Text string `json:"text"` + Text string `json:"text,omitempty"` Style *RichTextSectionTextStyle `json:"style,omitempty"` } From 7c13500dda7b0a07d0560e0b20ed9a393e787e63 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 May 2025 07:11:41 +0000 Subject: [PATCH 345/606] chore(deps): bump golangci/golangci-lint-action from 6.5.2 to 8.0.0 Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 6.5.2 to 8.0.0. - [Release notes](https://github.com/golangci/golangci-lint-action/releases) - [Commits](https://github.com/golangci/golangci-lint-action/compare/55c2c1448f86e01eaae002a5a3a9624417608d84...4afd733a84b1f43292c63897423277bb7f4313a9) --- updated-dependencies: - dependency-name: golangci/golangci-lint-action dependency-version: 8.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 55211cd13..33a6ae19b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,7 +39,7 @@ jobs: with: fetch-depth: 0 - name: golangci-lint - uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 # v6.5.2 + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 with: version: v1.63.4 - name: Check 'go mod tidy' makes no changes From af39387f230b78cf3ce2539a09fe7003a2c48e06 Mon Sep 17 00:00:00 2001 From: max Date: Wed, 23 Apr 2025 17:56:22 -0400 Subject: [PATCH 346/606] Add function to parse interaction callback HTTP request payload --- interactions.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/interactions.go b/interactions.go index abd0d55d3..f658a72c2 100644 --- a/interactions.go +++ b/interactions.go @@ -3,6 +3,8 @@ package slack import ( "bytes" "encoding/json" + "errors" + "net/http" ) // InteractionType type of interactions @@ -74,6 +76,24 @@ type BlockActionStates struct { Values map[string]map[string]BlockAction `json:"values"` } +// InteractionCallbackParse parses the HTTP form value "payload" from r, unmarshals +// it as JSON into an InteractionCallback, and returns the result. +// It returns an error if the payload is missing or cannot be decoded. +// +// See https://github.com/slack-go/slack/issues/660 for context. +func InteractionCallbackParse(r *http.Request) (InteractionCallback, error) { + payload := r.FormValue("payload") + if len(payload) == 0 { + return InteractionCallback{}, errors.New("payload is empty") + } + + var ic InteractionCallback + if err := json.Unmarshal([]byte(payload), &ic); err != nil { + return InteractionCallback{}, err + } + return ic, nil +} + func (ic *InteractionCallback) MarshalJSON() ([]byte, error) { type alias InteractionCallback tmp := alias(*ic) From 3d64e013360fa51b4324a3582c2436df17b44e6e Mon Sep 17 00:00:00 2001 From: max Date: Wed, 23 Apr 2025 17:57:29 -0400 Subject: [PATCH 347/606] Add interaction callback parser tests --- interactions_test.go | 74 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/interactions_test.go b/interactions_test.go index 308280960..50332062f 100644 --- a/interactions_test.go +++ b/interactions_test.go @@ -2,6 +2,9 @@ package slack import ( "encoding/json" + "net/http" + "net/url" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -548,3 +551,74 @@ func TestInteractionCallback_In_Thread_Container_Marshal_And_Unmarshal(t *testin assert.NoError(t, err) assert.Equal(t, expectedJSON, actualJSON) } + +func TestInteractionCallback_Parser(t *testing.T) { + payload := `{ + "type": "block_actions", + "actions": [ + { + "type": "multi_conversations_select", + "action_id": "multi_convos", + "block_id": "test123", + "selected_conversations": ["G12345"] + } + ], + "container": { + "type": "view", + "view_id": "V12345" + }, + "state": { + "values": { + "section_block_id": { + "multi_convos": { + "type": "multi_conversations_select", + "selected_conversations": ["G12345"] + } + }, + "other_block_id": { + "other_action_id": { + "type": "plain_text_input", + "value": "test123" + } + } + } + } + }` + + // create request body + body := url.Values{} + body.Set("payload", payload) + + // create new request + req, err := http.NewRequest(http.MethodPost, "http://slack.example.org/interactions", strings.NewReader(body.Encode())) + assert.NoError(t, err) + assert.NotNil(t, req) + + // without this header, the parser will not decode the payload + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // parse payload from request + ic, err := InteractionCallbackParse(req) + assert.NoError(t, err) + assert.NotNil(t, ic) + + // test parsed InteractionCallback payload + assert.Equal(t, ic.Type, InteractionTypeBlockActions) + + assert.Equal(t, len(ic.ActionCallback.BlockActions), 1) + assert.Equal(t, ic.ActionCallback.BlockActions[0].ActionID, "multi_convos") + assert.Equal(t, ic.ActionCallback.BlockActions[0].BlockID, "test123") + assert.Equal(t, ic.ActionCallback.BlockActions[0].Type, ActionType(MultiOptTypeConversations)) + assert.Equal(t, len(ic.ActionCallback.BlockActions[0].SelectedConversations), 1) + assert.Equal(t, ic.ActionCallback.BlockActions[0].SelectedConversations[0], "G12345") + + assert.Equal(t, ic.Container.Type, "view") + assert.Equal(t, ic.Container.ViewID, "V12345") + + assert.Equal(t, len(ic.BlockActionState.Values), 2) + assert.Equal(t, ic.BlockActionState.Values["section_block_id"]["multi_convos"].Type, ActionType(MultiOptTypeConversations)) + assert.Equal(t, len(ic.BlockActionState.Values["section_block_id"]["multi_convos"].SelectedConversations), 1) + assert.Equal(t, ic.BlockActionState.Values["section_block_id"]["multi_convos"].SelectedConversations[0], "G12345") + assert.Equal(t, ic.BlockActionState.Values["other_block_id"]["other_action_id"].Type, ActionType(METPlainTextInput)) + assert.Equal(t, ic.BlockActionState.Values["other_block_id"]["other_action_id"].Value, "test123") +} From a34c0bca6892ef8585398aa3f7d3069df120ea9d Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 10 May 2025 13:46:16 +0100 Subject: [PATCH 348/606] [chore] bump version of golangci-lint to 2.1.6 --- .github/workflows/test.yml | 2 +- .golangci.yml | 27 ++++++++++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 33a6ae19b..c6f17ef54 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,7 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 with: - version: v1.63.4 + version: v2.1.6 - name: Check 'go mod tidy' makes no changes run: | go mod tidy diff --git a/.golangci.yml b/.golangci.yml index a6ec936c0..0ef0f87ec 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,14 +1,35 @@ +version: "2" run: timeout: 6m issues-exit-code: 1 linters: - disable-all: true + default: none enable: - gocritic - - goimports - govet - misspell - - unused - unconvert + - unused + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ issues: new: true +formatters: + enable: + - goimports + exclusions: + generated: lax + warn-unused: true + paths: + - third_party$ + - builtin$ + - examples$ From 6bb6180bcc7ba03e488d9aa2998632dd3e6340d5 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 10 May 2025 16:55:12 +0100 Subject: [PATCH 349/606] [chore]: support only the last 3 versions of go --- .github/workflows/test.yml | 6 +----- go.mod | 9 ++++++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c6f17ef54..50887a1ed 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,10 +12,6 @@ jobs: strategy: matrix: go: - - "1.18" - - "1.19" - - "1.20" - - "1.21" - "1.22" - "1.23" - "1.24" @@ -33,7 +29,7 @@ jobs: steps: - uses: actions/setup-go@v5 with: - go-version: "1.20" + go-version: "1.22" cache: false - uses: actions/checkout@v4 with: diff --git a/go.mod b/go.mod index 66d8bb9f9..e14add7c3 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,14 @@ module github.com/slack-go/slack -go 1.16 +go 1.22 require ( - github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-test/deep v1.0.4 github.com/gorilla/websocket v1.4.2 - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/stretchr/testify v1.2.2 ) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect +) From acbfc9ae605ec81109617e1d6a5e41deabb130f9 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 10 May 2025 17:15:19 +0100 Subject: [PATCH 350/606] [chore]: ignore .env* rather than just .env --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 040859518..9b3903cd2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ *~ .idea/ /vendor/ -.env +.env* From a27355601fc8e5bd3ccd280a49c1abcd7a89aeb2 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 10 May 2025 17:40:11 +0100 Subject: [PATCH 351/606] [chore] tidy up imports --- examples/socketmode/socketmode.go | 3 +-- examples/socketmode_handler/socketmode_handler.go | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/socketmode/socketmode.go b/examples/socketmode/socketmode.go index 26c69d72f..cdad9981b 100644 --- a/examples/socketmode/socketmode.go +++ b/examples/socketmode/socketmode.go @@ -6,10 +6,9 @@ import ( "os" "strings" - "github.com/slack-go/slack/socketmode" - "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" ) func main() { diff --git a/examples/socketmode_handler/socketmode_handler.go b/examples/socketmode_handler/socketmode_handler.go index fddda490d..a12e6d194 100644 --- a/examples/socketmode_handler/socketmode_handler.go +++ b/examples/socketmode_handler/socketmode_handler.go @@ -6,10 +6,9 @@ import ( "os" "strings" + "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" "github.com/slack-go/slack/socketmode" - - "github.com/slack-go/slack" ) func main() { From d236bd9a07bfc6818a637adf78b0fab311a60bc4 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 10 May 2025 17:40:44 +0100 Subject: [PATCH 352/606] [fix]: 'Count' entries in Subteam events should be int not string The slack documentation gets it wrong: their example shows strings but looking at their [own implementation of the types in their node sdk](https://github.com/slackapi/node-slack-sdk/blob/31c60f863c36cd74837fc9ad5d5111532451ffcd/packages/types/src/events/subteam.ts#L44 it certainly seems it should be int. --- slackevents/inner_events.go | 4 ++-- slackevents/inner_events_test.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 1d9906db9..75b9e2432 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -814,9 +814,9 @@ type SubteamMembersChangedEvent struct { DatePreviousUpdate int `json:"date_previous_update"` DateUpdate int64 `json:"date_update"` AddedUsers []string `json:"added_users"` - AddedUsersCount string `json:"added_users_count"` + AddedUsersCount int `json:"added_users_count"` RemovedUsers []string `json:"removed_users"` - RemovedUsersCount string `json:"removed_users_count"` + RemovedUsersCount int `json:"removed_users_count"` } type SubteamSelfAddedEvent struct { diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index 5ec99ecde..96c2014b4 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -1968,9 +1968,9 @@ func TestSubteamMembersChangedEvent(t *testing.T) { "date_previous_update": 1446670362, "date_update": 1624473600, "added_users": ["U1234567890"], - "added_users_count": "3", + "added_users_count": 3, "removed_users": ["U0987654321"], - "removed_users_count": "1" + "removed_users_count": 1 } `) From 195c9bd264f30f96981dcc1168f338d7502cbd5c Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 10 May 2025 18:53:30 +0100 Subject: [PATCH 353/606] fix: don't allow emoji with `mrkdwn` People should always call `Validate()` which should call misuses if they build the structure manually. With this change, if they call `NewTextBlockObject`, we will do the right thing for them. --- attachments_test.go | 1 - block_object.go | 26 +++++++++++++++++++++----- block_object_test.go | 24 ++++++++++++------------ block_section_test.go | 2 +- examples/blocks/README.md | 4 ++-- interactions_test.go | 3 +-- 6 files changed, 37 insertions(+), 23 deletions(-) diff --git a/attachments_test.go b/attachments_test.go index cbb139c38..4b951f110 100644 --- a/attachments_test.go +++ b/attachments_test.go @@ -19,7 +19,6 @@ func TestAttachment_UnmarshalMarshalJSON_WithBlocks(t *testing.T) { "text": { "type": "mrkdwn", "text": "Pick something:", - "emoji": null, "verbatim": true }, "accessory": { diff --git a/block_object.go b/block_object.go index 899ac0db1..fd73b6c4a 100644 --- a/block_object.go +++ b/block_object.go @@ -122,7 +122,7 @@ func unmarshalBlockObject(r json.RawMessage, object blockObject) (blockObject, e type TextBlockObject struct { Type string `json:"type"` Text string `json:"text"` - Emoji *bool `json:"emoji"` + Emoji *bool `json:"emoji,omitempty"` Verbatim bool `json:"verbatim,omitempty"` } @@ -142,9 +142,8 @@ func (s TextBlockObject) Validate() error { return errors.New("type must be either of plain_text or mrkdwn") } - // https://github.com/slack-go/slack/issues/881 - if s.Type == "mrkdwn" && s.Emoji != nil && *s.Emoji { - return errors.New("emoji cannot be true in mrkdown") + if s.Type == "mrkdwn" && s.Emoji != nil { + return errors.New("emoji cannot be set for mrkdwn type") } // https://api.slack.com/reference/block-kit/composition-objects#text__fields @@ -161,11 +160,28 @@ func (s TextBlockObject) Validate() error { } // NewTextBlockObject returns an instance of a new Text Block Object +// +// If you want to create a mrkdwn object, you should set the emoji parameter to false. The +// reason is that Slack doesn't accept emoji in mrkdwn. func NewTextBlockObject(elementType, text string, emoji bool, verbatim bool) *TextBlockObject { + // If we're trying to build a mrkdwn object, we can't send emoji at all. I think the + // right approach here is to be a bit clever, and not break the function interface. + // + // So, here's the plan: + // 1. If the type is mrkdwn, set emoji to nil, regardless of what the user passed in + // 2. Else, set emoji to the value passed in + var emojiPtr *bool + + if elementType == "mrkdwn" { + emojiPtr = nil + } else { + emojiPtr = &emoji + } + return &TextBlockObject{ Type: elementType, Text: text, - Emoji: &emoji, + Emoji: emojiPtr, Verbatim: verbatim, } } diff --git a/block_object_test.go b/block_object_test.go index 7311034dd..61ea9dfa3 100644 --- a/block_object_test.go +++ b/block_object_test.go @@ -117,43 +117,43 @@ func TestValidateTextBlockObject(t *testing.T) { input: TextBlockObject{ Type: "mrkdwn", Text: "testText", - Emoji: emojiFalse, + Emoji: nil, Verbatim: false, }, expected: nil, }, { input: TextBlockObject{ - Type: "mrkdwn", + Type: "invalid", Text: "testText", - Emoji: nil, + Emoji: emojiFalse, Verbatim: false, }, - expected: nil, + expected: errors.New("type must be either of plain_text or mrkdwn"), }, { input: TextBlockObject{ - Type: "invalid", + Type: "mrkdwn", Text: "testText", - Emoji: emojiFalse, + Emoji: emojiTrue, Verbatim: false, }, - expected: errors.New("type must be either of plain_text or mrkdwn"), + expected: errors.New("emoji cannot be set for mrkdwn type"), }, { input: TextBlockObject{ Type: "mrkdwn", Text: "testText", - Emoji: emojiTrue, + Emoji: emojiFalse, Verbatim: false, }, - expected: errors.New("emoji cannot be true in mrkdown"), + expected: errors.New("emoji cannot be set for mrkdwn type"), }, { input: TextBlockObject{ Type: "mrkdwn", Text: "", - Emoji: emojiFalse, + Emoji: nil, Verbatim: false, }, expected: errors.New("text must have a minimum length of 1"), @@ -162,7 +162,7 @@ func TestValidateTextBlockObject(t *testing.T) { input: TextBlockObject{ Type: "mrkdwn", Text: strings.Repeat("a", 3001), - Emoji: emojiFalse, + Emoji: nil, Verbatim: false, }, expected: errors.New("text cannot be longer than 3000 characters"), @@ -171,7 +171,7 @@ func TestValidateTextBlockObject(t *testing.T) { for _, test := range tests { err := test.input.Validate() - assert.Equal(t, err, test.expected) + assert.Equal(t, test.expected, err) } } diff --git a/block_section_test.go b/block_section_test.go index c6bd209eb..749e55510 100644 --- a/block_section_test.go +++ b/block_section_test.go @@ -30,7 +30,7 @@ func TestNewBlockSectionContainsAddedTextBlockAndAccessory(t *testing.T) { textBlockInSection := sectionBlock.Text assert.Equal(t, textBlockInSection.Text, textBlockObject.Text) assert.Equal(t, textBlockInSection.Type, textBlockObject.Type) - assert.True(t, *textBlockInSection.Emoji) + assert.Nil(t, textBlockInSection.Emoji) assert.False(t, textBlockInSection.Verbatim) assert.Equal(t, sectionBlock.Accessory.ImageElement, conflictImage) } diff --git a/examples/blocks/README.md b/examples/blocks/README.md index 2e5d81fef..a3e3aad24 100644 --- a/examples/blocks/README.md +++ b/examples/blocks/README.md @@ -9,7 +9,7 @@ The examples below should cover implementing most supported block elements. For additional information on Blocks, see the [Block Kit website](https://api.slack.com/block-kit). ### Using examples with the Block Kit Builder website -When generating examples, they will be printed to the screen as a complete message that is meant to be sent back to slack as a direct response, or throuogh the ResponseURL provided. To test your examples in the Block Kit Builder, you must take the contents of the `blocks` property and paste the results into the builder. +When generating examples, they will be printed to the screen as a complete message that is meant to be sent back to slack as a direct response, or throuogh the ResponseURL provided. To test your examples in the Block Kit Builder, you must take the contents of the `blocks` property and paste the results into the builder. For example, when printing a simple header, the output will be @@ -66,4 +66,4 @@ This example introduces overflow elements, allowing you to populate a select sty #### Example 6 - Search Results with Options and Actions -Using a combination of overflow elements containing selectable options and actions, this examples allows you to prompt the user with multiple actions in a single response. You can view the rendered [Search Results with Actions](https://api.slack.com/tools/block-kit-builder?blocks=%5B%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22%3Amag%3A%20Search%20results%20for%20*Cata*%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22divider%22%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*%3CfakeLink.toYourApp.com%7CUse%20Case%20Catalogue%3E*%5CnUse%20Case%20Catalogue%20for%20the%20following%20departments%2Froles...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Edit%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*%3CfakeLink.toYourApp.com%7CCustomer%20Support%20-%20Workflow%20Diagram%20Catalogue%3E*%5CnThis%20resource%20was%20put%20together%20by%20members%20of...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Manage%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*%3CfakeLink.toYourApp.com%7CSelf-Serve%20Learning%20Options%20Catalogue%3E*%5CnSee%20the%20learning%20and%20development%20options%20we...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Manage%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*%3CfakeLink.toYourApp.com%7CUse%20Case%20Catalogue%20-%20CF%20Presentation%20-%20%5BJune%2012%2C%202018%5D%3E*%5CnThis%20is%20presentation%20will%20continue%20to%20be%20updated%20as...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Manage%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*%3CfakeLink.toYourApp.com%7CComprehensive%20Benefits%20Catalogue%20-%202019%3E*%5CnInformation%20about%20all%20the%20benfits%20we%20offer%20is...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Manage%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22divider%22%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22actions%22%2C%0A%09%09%22elements%22%3A%20%5B%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%22text%22%3A%20%22Next%205%20Results%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%09%7D%0A%09%09%5D%0A%09%7D%0A%5D) example on the block kit builder website. Refer to the function named `exampleSix` for more information on building this block. \ No newline at end of file +Using a combination of overflow elements containing selectable options and actions, this examples allows you to prompt the user with multiple actions in a single response. You can view the rendered [Search Results with Actions](https://api.slack.com/tools/block-kit-builder?blocks=%5B%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22%3Amag%3A%20Search%20results%20for%20*Cata*%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22divider%22%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*%3CfakeLink.toYourApp.com%7CUse%20Case%20Catalogue%3E*%5CnUse%20Case%20Catalogue%20for%20the%20following%20departments%2Froles...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Edit%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*%3CfakeLink.toYourApp.com%7CCustomer%20Support%20-%20Workflow%20Diagram%20Catalogue%3E*%5CnThis%20resource%20was%20put%20together%20by%20members%20of...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Manage%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*%3CfakeLink.toYourApp.com%7CSelf-Serve%20Learning%20Options%20Catalogue%3E*%5CnSee%20the%20learning%20and%20development%20options%20we...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Manage%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*%3CfakeLink.toYourApp.com%7CUse%20Case%20Catalogue%20-%20CF%20Presentation%20-%20%5BJune%2012%2C%202018%5D%3E*%5CnThis%20is%20presentation%20will%20continue%20to%20be%20updated%20as...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Manage%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*%3CfakeLink.toYourApp.com%7CComprehensive%20Benefits%20Catalogue%20-%202019%3E*%5CnInformation%20about%20all%20the%20benfits%20we%20offer%20is...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Manage%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22divider%22%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22actions%22%2C%0A%09%09%22elements%22%3A%20%5B%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%22text%22%3A%20%22Next%205%20Results%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%09%7D%0A%09%09%5D%0A%09%7D%0A%5D) example on the block kit builder website. Refer to the function named `exampleSix` for more information on building this block. diff --git a/interactions_test.go b/interactions_test.go index 50332062f..17b9e0672 100644 --- a/interactions_test.go +++ b/interactions_test.go @@ -61,8 +61,7 @@ const ( "type": "section", "text": { "text": "*Sally* has requested you set the deadline for the Nano launch project", - "type": "mrkdwn", - "emoji": false + "type": "mrkdwn" }, "accessory": { "type": "datepicker", From edb966bf13eb92bbb6699e381cc06f2e7b76b4bc Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 17 May 2025 21:16:15 +0100 Subject: [PATCH 354/606] feat!: expose `UploadToURL` (from `uploadToURL`) I've also added an example on how to do this. You can find it in [./examples/files/multiple_files](./examples/files/multiple_files). BREAKING CHANGE: renamed `AltText` to `AltTxt` and `SnippetText` to `SnippetType`. --- examples/files/files.go | 29 +++++-- examples/files/multiple_files/batman.txt | 1 + .../files/multiple_files/multiple_files.go | 82 +++++++++++++++++++ examples/files/multiple_files/superman.txt | 1 + files.go | 40 +++++---- files_test.go | 4 +- 6 files changed, 131 insertions(+), 26 deletions(-) create mode 100644 examples/files/multiple_files/batman.txt create mode 100644 examples/files/multiple_files/multiple_files.go create mode 100644 examples/files/multiple_files/superman.txt diff --git a/examples/files/files.go b/examples/files/files.go index 0eb136a50..1f622ecd0 100644 --- a/examples/files/files.go +++ b/examples/files/files.go @@ -1,30 +1,41 @@ package main import ( + "context" "fmt" + "os" "github.com/slack-go/slack" ) func main() { - api := slack.New("YOUR_TOKEN_HERE") - params := slack.FileUploadParameters{ - Title: "Batman Example", - //Filetype: "txt", - File: "example.txt", - //Content: "Nan Nan Nan Nan Nan Nan Nan Nan Batman", + token, ok := os.LookupEnv("SLACK_BOT_TOKEN") + if !ok { + fmt.Println("Missing SLACK_BOT_TOKEN in environment") + os.Exit(1) } - file, err := api.UploadFile(params) + api := slack.New(token, slack.OptionDebug(true)) + + ctx := context.Background() + + // Upload a file + params := slack.UploadFileV2Parameters{ + Title: "Batman Example", + Filename: "example.txt", + File: "example.txt", + FileSize: 38, + } + file, err := api.UploadFileV2Context(ctx, params) if err != nil { fmt.Printf("%s\n", err) return } - fmt.Printf("Name: %s, URL: %s\n", file.Name, file.URL) + fmt.Printf("ID: %s, title: %s\n", file.ID, file.Title) err = api.DeleteFile(file.ID) if err != nil { fmt.Printf("%s\n", err) return } - fmt.Printf("File %s deleted successfully.\n", file.Name) + fmt.Printf("File %s deleted successfully.\n", file.ID) } diff --git a/examples/files/multiple_files/batman.txt b/examples/files/multiple_files/batman.txt new file mode 100644 index 000000000..0e3504a57 --- /dev/null +++ b/examples/files/multiple_files/batman.txt @@ -0,0 +1 @@ +Nan Nan Nan Nan Nan Nan Nan Nan Batman diff --git a/examples/files/multiple_files/multiple_files.go b/examples/files/multiple_files/multiple_files.go new file mode 100644 index 000000000..87541f2f8 --- /dev/null +++ b/examples/files/multiple_files/multiple_files.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/slack-go/slack" +) + +func main() { + token, ok := os.LookupEnv("SLACK_BOT_TOKEN") + if !ok { + fmt.Println("Missing SLACK_BOT_TOKEN in environment") + os.Exit(1) + } + channelID := "CXXXXXXXX" // Replace with your channel ID + + api := slack.New(token, slack.OptionDebug(true)) + ctx := context.Background() + + files := []slack.UploadFileV2Parameters{ + { + Title: "Batman Example", + Filename: "batman.txt", + File: "batman.txt", + FileSize: 39, + }, + { + Title: "Superman Example", + Filename: "superman.txt", + File: "superman.txt", + FileSize: 37, + }, + } + + uploads := []*slack.GetUploadURLExternalResponse{} + filesToComplete := []slack.FileSummary{} + + for _, file := range files { + u, err := api.GetUploadURLExternalContext(ctx, slack.GetUploadURLExternalParameters{ + AltTxt: "An alt text for superheroes", + FileName: file.Filename, + FileSize: file.FileSize, + }) + if err != nil { + fmt.Printf("%s\n", err) + return + } + uploads = append(uploads, u) + } + + for i, file := range files { + fmt.Printf("Uploading file %s to %s\n", file.Filename, uploads[i].UploadURL) + + err := api.UploadToURL(ctx, slack.UploadToURLParameters{ + UploadURL: uploads[i].UploadURL, + Filename: file.Filename, + File: file.File, + }) + if err != nil { + fmt.Printf("%s\n", err) + return + } + filesToComplete = append(filesToComplete, slack.FileSummary{ + ID: uploads[i].FileID, + Title: file.Title, + }) + } + + c, err := api.CompleteUploadExternalContext(ctx, slack.CompleteUploadExternalParameters{ + Files: filesToComplete, + Channel: channelID, + }) + + if err != nil { + fmt.Printf("%s\n", err) + return + } + + fmt.Printf("Files uploaded successfully: %+v\n", c.Files) +} diff --git a/examples/files/multiple_files/superman.txt b/examples/files/multiple_files/superman.txt new file mode 100644 index 000000000..62bb7239a --- /dev/null +++ b/examples/files/multiple_files/superman.txt @@ -0,0 +1 @@ +Whoosh Whoosh Whoosh Whoosh Superman diff --git a/files.go b/files.go index 866b490b7..45a39f828 100644 --- a/files.go +++ b/files.go @@ -178,14 +178,14 @@ type UploadFileV2Parameters struct { Channel string ThreadTimestamp string AltTxt string - SnippetText string + SnippetType string } type GetUploadURLExternalParameters struct { - AltText string + AltTxt string FileSize int FileName string - SnippetText string + SnippetType string } type GetUploadURLExternalResponse struct { @@ -194,7 +194,7 @@ type GetUploadURLExternalResponse struct { SlackResponse } -type uploadToURLParameters struct { +type UploadToURLParameters struct { UploadURL string Reader io.Reader File string @@ -375,7 +375,11 @@ func (api *Client) ListFilesContext(ctx context.Context, params ListFilesParamet // UploadFile uploads a file. // -// Deprecated: Use [Client.UploadFileV2] instead. This will stop functioning on March 11, 2025. +// Deprecated: Use [Client.UploadFileV2] instead. +// +// Per Slack Changelog, specifically [https://api.slack.com/changelog#entry-march_2025_1](this entry), +// this will stop functioning on November 12, 2025. +// // For more details, see: https://api.slack.com/methods/files.upload#markdown func (api *Client) UploadFile(params FileUploadParameters) (file *File, err error) { return api.UploadFileContext(context.Background(), params) @@ -383,7 +387,11 @@ func (api *Client) UploadFile(params FileUploadParameters) (file *File, err erro // UploadFileContext uploads a file and setting a custom context. // -// Deprecated: Use [Client.UploadFileV2Context] instead. This will stop functioning on March 11, 2025. +// Deprecated: Use [Client.UploadFileV2Context] instead. +// +// Per Slack Changelog, specifically [https://api.slack.com/changelog#entry-march_2025_1](this entry), +// this will stop functioning on November 12, 2025. +// // For more details, see: https://api.slack.com/methods/files.upload#markdown func (api *Client) UploadFileContext(ctx context.Context, params FileUploadParameters) (file *File, err error) { // Test if user token is valid. This helps because client.Do doesn't like this for some reason. XXX: More @@ -529,11 +537,11 @@ func (api *Client) GetUploadURLExternalContext(ctx context.Context, params GetUp "filename": {params.FileName}, "length": {strconv.Itoa(params.FileSize)}, } - if params.AltText != "" { - values.Add("initial_comment", params.AltText) + if params.AltTxt != "" { + values.Add("alt_txt", params.AltTxt) } - if params.SnippetText != "" { - values.Add("thread_ts", params.SnippetText) + if params.SnippetType != "" { + values.Add("snippet_type", params.SnippetType) } response := &GetUploadURLExternalResponse{} err := api.postMethod(ctx, "files.getUploadURLExternal", values, response) @@ -544,8 +552,9 @@ func (api *Client) GetUploadURLExternalContext(ctx context.Context, params GetUp return response, response.Err() } -// uploadToURL uploads the file to the provided URL using post method -func (api *Client) uploadToURL(ctx context.Context, params uploadToURLParameters) (err error) { +// UploadToURL uploads the file to the provided URL using post method +// This is not a Slack API method, but a helper function to upload files to the URL +func (api *Client) UploadToURL(ctx context.Context, params UploadToURLParameters) (err error) { values := url.Values{} if params.Content != "" { contentReader := strings.NewReader(params.Content) @@ -565,6 +574,7 @@ func (api *Client) CompleteUploadExternalContext(ctx context.Context, params Com if err != nil { return nil, err } + values := url.Values{ "token": {api.token}, "files": {string(filesBytes)}, @@ -611,16 +621,16 @@ func (api *Client) UploadFileV2Context(ctx context.Context, params UploadFileV2P } u, err := api.GetUploadURLExternalContext(ctx, GetUploadURLExternalParameters{ - AltText: params.AltTxt, + AltTxt: params.AltTxt, FileName: params.Filename, FileSize: params.FileSize, - SnippetText: params.SnippetText, + SnippetType: params.SnippetType, }) if err != nil { return nil, err } - err = api.uploadToURL(ctx, uploadToURLParameters{ + err = api.UploadToURL(ctx, UploadToURLParameters{ UploadURL: u.UploadURL, Reader: params.Reader, File: params.File, diff --git a/files_test.go b/files_test.go index 0fb9b2b84..cf3a801a9 100644 --- a/files_test.go +++ b/files_test.go @@ -330,8 +330,8 @@ func TestGetUploadURLExternalContext(t *testing.T) { params: GetUploadURLExternalParameters{ FileSize: 10, FileName: "test.txt", - AltText: "test-alt-text", - SnippetText: "test-snippet-text", + AltTxt: "test-alt-text", + SnippetType: "test-snippet-type", }, wantSlackResponse: []byte(`{"ok":true,"file_id":"RandomID","upload_url":"http://test-server/abc"}`), wantResponse: GetUploadURLExternalResponse{ From c2d93125e2cd5975a8869049b37b34a3708994a7 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 24 May 2025 15:24:14 +0100 Subject: [PATCH 355/606] fix!: AppHomeOpenedEvent View is now a pointer According to #1421, if there was never an app home view published, the event `app_home_opened` won't have a view keyword in the inner event. BREAKING CHANGE: Moving `View` to a pointer with `omitempty`, should fix this but also makes this fix a breaking change. --- examples/conversations/conversations.go | 23 ++- examples/users/users.go | 9 +- slackevents/inner_events.go | 12 +- slackevents/inner_events_test.go | 218 ++++++++++++++++++++++-- 4 files changed, 232 insertions(+), 30 deletions(-) diff --git a/examples/conversations/conversations.go b/examples/conversations/conversations.go index 4b83a11e7..b47de7558 100644 --- a/examples/conversations/conversations.go +++ b/examples/conversations/conversations.go @@ -8,13 +8,13 @@ import ( ) func main() { - botToken := os.Getenv("SLACK_BOT_TOKEN") - if botToken == "" { - fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN must be set.\n") + userToken := os.Getenv("SLACK_USER_TOKEN") + if userToken == "" { + fmt.Fprintf(os.Stderr, "SLACK_USER_TOKEN must be set.\n") os.Exit(1) } - api := slack.New(botToken) + api := slack.New(userToken) params := slack.GetConversationsParameters{ ExcludeArchived: true, Limit: 100, @@ -25,6 +25,19 @@ func main() { return } for _, channel := range channels { - fmt.Printf("Channel: %v\n", channel) + info, err := api.GetConversationInfo(&slack.GetConversationInfoInput{ + ChannelID: channel.ID, + IncludeNumMembers: true, + IncludeLocale: true, + }) + if err != nil { + fmt.Printf("Error getting info for channel %s: %s\n", channel.ID, err) + continue + } + fmt.Printf("Channel: %s\n", channel.ID) + if info.Properties != nil { + fmt.Printf("Canvas: %+v\n", info.Properties.Canvas) + fmt.Printf("Tabs: %+v\n", info.Properties.Tabs) + } } } diff --git a/examples/users/users.go b/examples/users/users.go index d6669b18b..53022bf58 100644 --- a/examples/users/users.go +++ b/examples/users/users.go @@ -2,12 +2,19 @@ package main import ( "fmt" + "os" "github.com/slack-go/slack" ) func main() { - api := slack.New("YOUR_TOKEN_HERE") + userToken := os.Getenv("SLACK_USER_TOKEN") + if userToken == "" { + fmt.Fprintf(os.Stderr, "SLACK_USER_TOKEN must be set.\n") + os.Exit(1) + } + + api := slack.New(userToken) user, err := api.GetUserInfo("U023BECGF") if err != nil { fmt.Printf("%s\n", err) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 75b9e2432..e0e86202c 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -66,12 +66,12 @@ type AppMentionEvent struct { // AppHomeOpenedEvent Your Slack app home was opened. type AppHomeOpenedEvent struct { - Type string `json:"type"` - User string `json:"user"` - Channel string `json:"channel"` - EventTimeStamp string `json:"event_ts"` - Tab string `json:"tab"` - View slack.View `json:"view"` + Type string `json:"type"` + User string `json:"user"` + Channel string `json:"channel"` + EventTimeStamp string `json:"event_ts"` + Tab string `json:"tab"` + View *slack.View `json:"view,omitempty"` } // AppUninstalledEvent Your Slack app was uninstalled. diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index 96c2014b4..67e70b3f6 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -5,6 +5,7 @@ import ( "fmt" "testing" + "github.com/slack-go/slack" "github.com/stretchr/testify/assert" ) @@ -15,9 +16,9 @@ func TestAssistantThreadStartedEvent(t *testing.T) { "type": "assistant_thread_started", "assistant_thread": { "user_id": "U123ABC456", - "context": { - "channel_id": "C123ABC456", - "team_id": "T07XY8FPJ5C", + "context": { + "channel_id": "C123ABC456", + "team_id": "T07XY8FPJ5C", "enterprise_id": "E480293PS82" }, "channel_id": "D123ABC456", @@ -42,9 +43,9 @@ func TestAssistantThreadContextChangedEvent(t *testing.T) { "type": "assistant_thread_context_changed", "assistant_thread": { "user_id": "U123ABC456", - "context": { - "channel_id": "C123ABC456", - "team_id": "T07XY8FPJ5C", + "context": { + "channel_id": "C123ABC456", + "team_id": "T07XY8FPJ5C", "enterprise_id": "E480293PS82" }, "channel_id": "D123ABC456", @@ -725,10 +726,10 @@ func TestMessageMetadataPosted(t *testing.T) { { "type":"message_metadata_posted", "app_id":"APPXXX", - "bot_id":"BOTXXX", - "user_id":"USERXXX", - "team_id":"TEAMXXX", - "channel_id":"CHANNELXXX", + "bot_id":"BOTXXX", + "user_id":"USERXXX", + "team_id":"TEAMXXX", + "channel_id":"CHANNELXXX", "metadata":{ "event_type":"type", "event_payload":{"key": "value"} @@ -781,16 +782,16 @@ func TestMessageMetadataUpdated(t *testing.T) { rawE := []byte(` { "type":"message_metadata_updated", - "channel_id":"CHANNELXXX", + "channel_id":"CHANNELXXX", "event_ts":"1660398079.756349", "previous_metadata":{ "event_type":"type1", "event_payload":{"key1": "value1"} }, "app_id":"APPXXX", - "bot_id":"BOTXXX", - "user_id":"USERXXX", - "team_id":"TEAMXXX", + "bot_id":"BOTXXX", + "user_id":"USERXXX", + "team_id":"TEAMXXX", "message_ts":"1660398079.756349", "metadata":{ "event_type":"type2", @@ -849,16 +850,16 @@ func TestMessageMetadataDeleted(t *testing.T) { rawE := []byte(` { "type":"message_metadata_deleted", - "channel_id":"CHANNELXXX", + "channel_id":"CHANNELXXX", "event_ts":"1660398079.756349", "previous_metadata":{ "event_type":"type", "event_payload":{"key": "value"} }, "app_id":"APPXXX", - "bot_id":"BOTXXX", - "user_id":"USERXXX", - "team_id":"TEAMXXX", + "bot_id":"BOTXXX", + "user_id":"USERXXX", + "team_id":"TEAMXXX", "message_ts":"1660398079.756349", "deleted_ts":"1660398079.756349" } @@ -2771,3 +2772,184 @@ func TestSharedChannelInviteRequested_UnmarshalJSON(t *testing.T) { t.Errorf("Expected second team to have name 'another_enterprise', got '%v'", event.TeamsInChannel) } } + +func TestAppHomeOpenedEvent_WithView(t *testing.T) { + eventJSON := []byte(`{ + "type": "app_home_opened", + "user": "U12345678", + "channel": "D12345678", + "tab": "home", + "event_ts": "1747319568.267214", + "view": { + "id": "V12345678", + "team_id": "T12345678", + "type": "home", + "blocks": [], + "private_metadata": "", + "callback_id": "", + "state": { + "values": {} + }, + "hash": "1234567890.abcdef", + "title": { + "type": "plain_text", + "text": "App Home" + }, + "clear_on_close": false, + "notify_on_close": false, + "close": null, + "submit": null, + "previous_view_id": "", + "root_view_id": "V12345678", + "app_id": "A12345678", + "external_id": "", + "app_installed_team_id": "T12345678", + "bot_id": "B12345678" + } + }`) + + var event AppHomeOpenedEvent + err := json.Unmarshal(eventJSON, &event) + + assert.NoError(t, err) + assert.Equal(t, "app_home_opened", event.Type) + assert.Equal(t, "U12345678", event.User) + assert.Equal(t, "D12345678", event.Channel) + assert.Equal(t, "home", event.Tab) + assert.Equal(t, "1747319568.267214", event.EventTimeStamp) + assert.NotNil(t, event.View) + assert.Equal(t, "V12345678", event.View.ID) + assert.Equal(t, "T12345678", event.View.TeamID) + assert.Equal(t, slack.ViewType("home"), event.View.Type) +} + +func TestAppHomeOpenedEvent_WithoutView(t *testing.T) { + eventJSON := []byte(`{ + "type": "app_home_opened", + "user": "U12345678", + "channel": "D12345678", + "tab": "home", + "event_ts": "1747319568.267214" + }`) + + var event AppHomeOpenedEvent + err := json.Unmarshal(eventJSON, &event) + + assert.NoError(t, err) + assert.Equal(t, "app_home_opened", event.Type) + assert.Equal(t, "U12345678", event.User) + assert.Equal(t, "D12345678", event.Channel) + assert.Equal(t, "home", event.Tab) + assert.Equal(t, "1747319568.267214", event.EventTimeStamp) + assert.Nil(t, event.View) +} + +func TestAppHomeOpenedEvent_FullEventParsing_WithView(t *testing.T) { + fullEventJSON := []byte(`{ + "token": "verification-token", + "team_id": "T12345678", + "api_app_id": "A12345678", + "event": { + "type": "app_home_opened", + "user": "U12345678", + "channel": "D12345678", + "tab": "home", + "event_ts": "1747319568.267214", + "view": { + "id": "V12345678", + "team_id": "T12345678", + "type": "home", + "blocks": [], + "private_metadata": "", + "callback_id": "", + "state": { + "values": {} + }, + "hash": "1234567890.abcdef", + "title": { + "type": "plain_text", + "text": "App Home" + }, + "clear_on_close": false, + "notify_on_close": false, + "close": null, + "submit": null, + "previous_view_id": "", + "root_view_id": "V12345678", + "app_id": "A12345678", + "external_id": "", + "app_installed_team_id": "T12345678", + "bot_id": "B12345678" + } + }, + "type": "event_callback", + "event_id": "Ev12345678", + "event_time": 1747319568, + "authorizations": [{ + "enterprise_id": null, + "team_id": "T12345678", + "user_id": "U12345678", + "is_bot": true, + "is_enterprise_install": false + }], + "is_ext_shared_channel": false + }`) + + parsedEvent, err := ParseEvent(fullEventJSON, OptionNoVerifyToken()) + + assert.NoError(t, err) + assert.Equal(t, "T12345678", parsedEvent.TeamID) + assert.Equal(t, "A12345678", parsedEvent.APIAppID) + assert.Equal(t, "app_home_opened", parsedEvent.InnerEvent.Type) + appHomeEvent, ok := parsedEvent.InnerEvent.Data.(*AppHomeOpenedEvent) + assert.True(t, ok) + assert.Equal(t, "app_home_opened", appHomeEvent.Type) + assert.Equal(t, "U12345678", appHomeEvent.User) + assert.Equal(t, "D12345678", appHomeEvent.Channel) + assert.Equal(t, "home", appHomeEvent.Tab) + assert.Equal(t, "1747319568.267214", appHomeEvent.EventTimeStamp) + assert.NotNil(t, appHomeEvent.View) + assert.Equal(t, "V12345678", appHomeEvent.View.ID) + assert.Equal(t, "T12345678", appHomeEvent.View.TeamID) +} + +func TestAppHomeOpenedEvent_FullEventParsing_WithoutView(t *testing.T) { + fullEventJSON := []byte(`{ + "token": "verification-token", + "team_id": "T12345678", + "api_app_id": "A12345678", + "event": { + "type": "app_home_opened", + "user": "U12345678", + "channel": "D12345678", + "tab": "home", + "event_ts": "1747319568.267214" + }, + "type": "event_callback", + "event_id": "Ev12345678", + "event_time": 1747319568, + "authorizations": [{ + "enterprise_id": null, + "team_id": "T12345678", + "user_id": "U12345678", + "is_bot": true, + "is_enterprise_install": false + }], + "is_ext_shared_channel": false + }`) + + parsedEvent, err := ParseEvent(fullEventJSON, OptionNoVerifyToken()) + + assert.NoError(t, err) + assert.Equal(t, "T12345678", parsedEvent.TeamID) + assert.Equal(t, "A12345678", parsedEvent.APIAppID) + assert.Equal(t, "app_home_opened", parsedEvent.InnerEvent.Type) + appHomeEvent, ok := parsedEvent.InnerEvent.Data.(*AppHomeOpenedEvent) + assert.True(t, ok) + assert.Equal(t, "app_home_opened", appHomeEvent.Type) + assert.Equal(t, "U12345678", appHomeEvent.User) + assert.Equal(t, "D12345678", appHomeEvent.Channel) + assert.Equal(t, "home", appHomeEvent.Tab) + assert.Equal(t, "1747319568.267214", appHomeEvent.EventTimeStamp) + assert.Nil(t, appHomeEvent.View) +} From c0b57075a8e027180813d8eb8a5878ea2a7de047 Mon Sep 17 00:00:00 2001 From: Lorenzo Aiello Date: Thu, 14 Nov 2024 11:13:51 -0800 Subject: [PATCH 356/606] feat: Adding Missing Workflow Trigger WebAPI Methods --- misc.go | 6 + slack.go | 11 ++ workflows_triggers.go | 154 +++++++++++++++++++++ workflows_triggers_test.go | 274 +++++++++++++++++++++++++++++++++++++ 4 files changed, 445 insertions(+) create mode 100644 workflows_triggers.go create mode 100644 workflows_triggers_test.go diff --git a/misc.go b/misc.go index f06108e80..bcd12472f 100644 --- a/misc.go +++ b/misc.go @@ -26,6 +26,12 @@ type SlackResponse struct { ResponseMetadata ResponseMetadata `json:"response_metadata"` } +type ResponsePointer struct { + Ok *bool `json:"ok,omitempty"` + Error *string `json:"error,omitempty"` + ResponseMetadata ResponseMetadata `json:"response_metadata,omitempty"` +} + func (t SlackResponse) Err() error { if t.Ok { return nil diff --git a/slack.go b/slack.go index 756106fe4..505ab98d7 100644 --- a/slack.go +++ b/slack.go @@ -2,6 +2,7 @@ package slack import ( "context" + "encoding/json" "fmt" "log" "net/http" @@ -168,6 +169,16 @@ func (api *Client) postMethod(ctx context.Context, path string, values url.Value return postForm(ctx, api.httpclient, api.endpoint+path, values, intf, api) } +// post to a slack web method with json. +func (api *Client) postJSON(ctx context.Context, path string, request interface{}, response interface{}) error { + jsonPayload, err := json.Marshal(request) + if err != nil { + return err + } + + return postJSON(ctx, api.httpclient, api.endpoint+path, api.token, jsonPayload, response, api) +} + // get a slack web method. func (api *Client) getMethod(ctx context.Context, path string, token string, values url.Values, intf interface{}) error { return getResource(ctx, api.httpclient, api.endpoint+path, token, values, intf, api) diff --git a/workflows_triggers.go b/workflows_triggers.go new file mode 100644 index 000000000..71fdf078a --- /dev/null +++ b/workflows_triggers.go @@ -0,0 +1,154 @@ +package slack + +import ( + "context" + "errors" + "fmt" +) + +type ( + WorkflowsTriggersPermissionsAddInput struct { + TriggerId string `json:"trigger_id"` + ChannelIds []string `json:"channel_ids,omitempty"` + OrgIds []string `json:"org_ids,omitempty"` + TeamIds []string `json:"team_ids,omitempty"` + UserIds []string `json:"user_ids,omitempty"` + } + + WorkflowsTriggersPermissionsAddOutput struct { + PermissionType string `json:"permission_type"` + ChannelIds []string `json:"channel_ids,omitempty"` + OrgIds []string `json:"org_ids,omitempty"` + TeamIds []string `json:"team_ids,omitempty"` + UserIds []string `json:"user_ids,omitempty"` + } + + WorkflowsTriggersPermissionsListInput struct { + TriggerId string `json:"trigger_id"` + } + + WorkflowsTriggersPermissionsListOutput struct { + PermissionType string `json:"permission_type"` + ChannelIds []string `json:"channel_ids,omitempty"` + OrgIds []string `json:"org_ids,omitempty"` + TeamIds []string `json:"team_ids,omitempty"` + UserIds []string `json:"user_ids,omitempty"` + } + + WorkflowsTriggersPermissionsRemoveInput struct { + TriggerId string `json:"trigger_id"` + ChannelIds []string `json:"channel_ids,omitempty"` + OrgIds []string `json:"org_ids,omitempty"` + TeamIds []string `json:"team_ids,omitempty"` + UserIds []string `json:"user_ids,omitempty"` + } + + WorkflowsTriggersPermissionsRemoveOutput struct { + PermissionType string `json:"permission_type"` + ChannelIds []string `json:"channel_ids,omitempty"` + OrgIds []string `json:"org_ids,omitempty"` + TeamIds []string `json:"team_ids,omitempty"` + UserIds []string `json:"user_ids,omitempty"` + } + + WorkflowsTriggersPermissionsSetInput struct { + PermissionType string `json:"permission_type"` + TriggerId string `json:"trigger_id"` + ChannelIds []string `json:"channel_ids,omitempty"` + OrgIds []string `json:"org_ids,omitempty"` + TeamIds []string `json:"team_ids,omitempty"` + UserIds []string `json:"user_ids,omitempty"` + } + + WorkflowsTriggersPermissionsSetOutput struct { + PermissionType string `json:"permission_type"` + ChannelIds []string `json:"channel_ids,omitempty"` + OrgIds []string `json:"org_ids,omitempty"` + TeamIds []string `json:"team_ids,omitempty"` + UserIds []string `json:"user_ids,omitempty"` + } +) + +// WorkflowsTriggersPermissionsAdd allows users to run a trigger that has its permission type set to named_entities. +// +// Slack API Docs:https://api.dev.slack.com/methods/workflows.triggers.permissions.add +func (api *Client) WorkflowsTriggersPermissionsAdd(ctx context.Context, input *WorkflowsTriggersPermissionsAddInput) (*WorkflowsTriggersPermissionsAddOutput, error) { + response := struct { + *ResponsePointer + *WorkflowsTriggersPermissionsAddOutput + }{} + + err := api.postJSON(ctx, "workflows.triggers.permissions.add", input, &response) + if err != nil { + return nil, err + } + + if response.Error != nil { + return nil, errors.New(fmt.Sprintf("error: %s", *response.Error)) + } + + return response.WorkflowsTriggersPermissionsAddOutput, nil +} + +// WorkflowsTriggersPermissionsList returns the permission type of a trigger and if applicable, includes the entities that have been granted access. +// +// Slack API Docs:https://api.dev.slack.com/methods/workflows.triggers.permissions.list +func (api *Client) WorkflowsTriggersPermissionsList(ctx context.Context, input *WorkflowsTriggersPermissionsListInput) (*WorkflowsTriggersPermissionsListOutput, error) { + response := struct { + *ResponsePointer + *WorkflowsTriggersPermissionsListOutput + }{} + + err := api.postJSON(ctx, "workflows.triggers.permissions.list", input, &response) + if err != nil { + return nil, err + } + + if response.Error != nil { + return nil, errors.New(fmt.Sprintf("error: %s", *response.Error)) + } + + return response.WorkflowsTriggersPermissionsListOutput, nil +} + +// WorkflowsTriggersPermissionsRemove revoke an entity's access to a trigger that has its permission type set to named_entities. +// +// Slack API Docs:https://api.dev.slack.com/methods/workflows.triggers.permissions.remove +func (api *Client) WorkflowsTriggersPermissionsRemove(ctx context.Context, input *WorkflowsTriggersPermissionsRemoveInput) (*WorkflowsTriggersPermissionsRemoveOutput, error) { + response := struct { + *ResponsePointer + *WorkflowsTriggersPermissionsRemoveOutput + }{} + + err := api.postJSON(ctx, "workflows.triggers.permissions.remove", input, &response) + if err != nil { + return nil, err + } + + if response.Error != nil { + return nil, errors.New(fmt.Sprintf("error: %s", *response.Error)) + } + + return response.WorkflowsTriggersPermissionsRemoveOutput, nil +} + +// WorkflowsTriggersPermissionsSet sets the permission type for who can run a trigger. +// +// Slack API Docs:https://api.dev.slack.com/methods/workflows.triggers.permissions.set +func (api *Client) WorkflowsTriggersPermissionsSet(ctx context.Context, input *WorkflowsTriggersPermissionsSetInput) (*WorkflowsTriggersPermissionsSetOutput, error) { + response := struct { + *ResponsePointer + *WorkflowsTriggersPermissionsSetOutput + }{} + + err := api.postJSON(ctx, "workflows.triggers.permissions.set", input, &response) + if err != nil { + return nil, err + } + + if response.Error != nil { + return nil, errors.New(fmt.Sprintf("error: %s", *response.Error)) + } + + return response.WorkflowsTriggersPermissionsSetOutput, nil +} diff --git a/workflows_triggers_test.go b/workflows_triggers_test.go new file mode 100644 index 000000000..695ec4870 --- /dev/null +++ b/workflows_triggers_test.go @@ -0,0 +1,274 @@ +package slack + +import ( + "context" + "net/http" + "reflect" + "testing" +) + +type workflowsHandler struct { + rawResponse string +} + +func (h *workflowsHandler) handler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(h.rawResponse)) +} + +func TestSlack_WorkflowsTriggersPermissionsAdd(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + cases := []struct { + caseName string + input *WorkflowsTriggersPermissionsAddInput + rawResp string + expectedResp *WorkflowsTriggersPermissionsAddOutput + expectedErr error + }{ + { + caseName: "success", + input: &WorkflowsTriggersPermissionsAddInput{ + TriggerId: "Ft0000000001", + ChannelIds: []string{"C0000000001"}, + OrgIds: []string{"E00000001"}, + TeamIds: []string{"T0000000001"}, + UserIds: []string{"U0000000001", "U0000000002"}, + }, + rawResp: `{ + "ok": true, + "permission_type": "named_entities", + "user_ids": ["U0000000001", "U0000000002"], + "channel_ids": ["C0000000001"], + "org_ids": ["E00000001"], + "team_ids": ["T0000000001"] + }`, + expectedResp: &WorkflowsTriggersPermissionsAddOutput{ + PermissionType: "named_entities", + UserIds: []string{"U0000000001", "U0000000002"}, + ChannelIds: []string{"C0000000001"}, + OrgIds: []string{"E00000001"}, + TeamIds: []string{"T0000000001"}, + }, + expectedErr: nil, + }, + } + + h := &workflowsHandler{} + http.HandleFunc("/workflows.triggers.permissions.add", h.handler) + for _, c := range cases { + t.Run(c.caseName, func(t *testing.T) { + h.rawResponse = c.rawResp + + resp, err := api.WorkflowsTriggersPermissionsAdd(context.Background(), c.input) + if c.expectedErr == nil && err != nil { + t.Fatalf("unexpected error: %s\n", err) + } + if c.expectedErr != nil && err == nil { + t.Fatalf("expected %s, but did not raise an error", c.expectedErr) + } + if c.expectedErr != nil && err != nil && c.expectedErr.Error() != err.Error() { + t.Fatalf("expected %s as error but got %s\n", c.expectedErr, err) + } + if resp == nil || c.expectedResp == nil { + return + } + if !reflect.DeepEqual(resp, c.expectedResp) { + t.Fatalf("expected:\n\t%v\n but got:\n\t%v\n", c.expectedResp, resp) + } + }) + } +} + +func TestSlack_WorkflowsTriggersPermissionsList(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + cases := []struct { + caseName string + input *WorkflowsTriggersPermissionsListInput + rawResp string + expectedResp *WorkflowsTriggersPermissionsListOutput + expectedErr error + }{ + { + caseName: "success", + input: &WorkflowsTriggersPermissionsListInput{ + TriggerId: "Ft0000000001", + }, + rawResp: `{ + "ok": true, + "permission_type": "named_entities", + "user_ids": ["U0000000001", "U0000000002"], + "channel_ids": ["C0000000001"], + "org_ids": ["E00000001"], + "team_ids": ["T0000000001"] + }`, + expectedResp: &WorkflowsTriggersPermissionsListOutput{ + PermissionType: "named_entities", + UserIds: []string{"U0000000001", "U0000000002"}, + ChannelIds: []string{"C0000000001"}, + OrgIds: []string{"E00000001"}, + TeamIds: []string{"T0000000001"}, + }, + expectedErr: nil, + }, + } + + h := &workflowsHandler{} + http.HandleFunc("/workflows.triggers.permissions.list", h.handler) + for _, c := range cases { + t.Run(c.caseName, func(t *testing.T) { + h.rawResponse = c.rawResp + + resp, err := api.WorkflowsTriggersPermissionsList(context.Background(), c.input) + if c.expectedErr == nil && err != nil { + t.Fatalf("unexpected error: %s\n", err) + } + if c.expectedErr != nil && err == nil { + t.Fatalf("expected %s, but did not raise an error", c.expectedErr) + } + if c.expectedErr != nil && err != nil && c.expectedErr.Error() != err.Error() { + t.Fatalf("expected %s as error but got %s\n", c.expectedErr, err) + } + if resp == nil || c.expectedResp == nil { + return + } + if !reflect.DeepEqual(resp, c.expectedResp) { + t.Fatalf("expected:\n\t%v\n but got:\n\t%v\n", c.expectedResp, resp) + } + }) + } +} + +func TestSlack_WorkflowsTriggersPermissionsRemove(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + cases := []struct { + caseName string + input *WorkflowsTriggersPermissionsRemoveInput + rawResp string + expectedResp *WorkflowsTriggersPermissionsRemoveOutput + expectedErr error + }{ + { + caseName: "success", + input: &WorkflowsTriggersPermissionsRemoveInput{ + TriggerId: "Ft0000000001", + ChannelIds: []string{"C0000000001"}, + OrgIds: []string{"E00000001"}, + TeamIds: []string{"T0000000001"}, + UserIds: []string{"U0000000001", "U0000000002"}, + }, + rawResp: `{ + "ok": true, + "permission_type": "named_entities", + "user_ids": ["U0000000001", "U0000000002"], + "channel_ids": ["C0000000001"], + "org_ids": ["E00000001"], + "team_ids": ["T0000000001"] + }`, + expectedResp: &WorkflowsTriggersPermissionsRemoveOutput{ + PermissionType: "named_entities", + UserIds: []string{"U0000000001", "U0000000002"}, + ChannelIds: []string{"C0000000001"}, + OrgIds: []string{"E00000001"}, + TeamIds: []string{"T0000000001"}, + }, + expectedErr: nil, + }, + } + + h := &workflowsHandler{} + http.HandleFunc("/workflows.triggers.permissions.remove", h.handler) + for _, c := range cases { + t.Run(c.caseName, func(t *testing.T) { + h.rawResponse = c.rawResp + + resp, err := api.WorkflowsTriggersPermissionsRemove(context.Background(), c.input) + if c.expectedErr == nil && err != nil { + t.Fatalf("unexpected error: %s\n", err) + } + if c.expectedErr != nil && err == nil { + t.Fatalf("expected %s, but did not raise an error", c.expectedErr) + } + if c.expectedErr != nil && err != nil && c.expectedErr.Error() != err.Error() { + t.Fatalf("expected %s as error but got %s\n", c.expectedErr, err) + } + if resp == nil || c.expectedResp == nil { + return + } + if !reflect.DeepEqual(resp, c.expectedResp) { + t.Fatalf("expected:\n\t%v\n but got:\n\t%v\n", c.expectedResp, resp) + } + }) + } +} + +func TestSlack_WorkflowsTriggersPermissionsSet(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + cases := []struct { + caseName string + input *WorkflowsTriggersPermissionsSetInput + rawResp string + expectedResp *WorkflowsTriggersPermissionsSetOutput + expectedErr error + }{ + { + caseName: "success", + input: &WorkflowsTriggersPermissionsSetInput{ + PermissionType: "named_entities", + TriggerId: "Ft0000000001", + ChannelIds: []string{"C0000000001"}, + OrgIds: []string{"E00000001"}, + TeamIds: []string{"T0000000001"}, + UserIds: []string{"U0000000001", "U0000000002"}, + }, + rawResp: `{ + "ok": true, + "permission_type": "named_entities", + "user_ids": ["U0000000001", "U0000000002"], + "channel_ids": ["C0000000001"], + "org_ids": ["E00000001"], + "team_ids": ["T0000000001"] + }`, + expectedResp: &WorkflowsTriggersPermissionsSetOutput{ + PermissionType: "named_entities", + UserIds: []string{"U0000000001", "U0000000002"}, + ChannelIds: []string{"C0000000001"}, + OrgIds: []string{"E00000001"}, + TeamIds: []string{"T0000000001"}, + }, + expectedErr: nil, + }, + } + + h := &workflowsHandler{} + http.HandleFunc("/workflows.triggers.permissions.set", h.handler) + for _, c := range cases { + t.Run(c.caseName, func(t *testing.T) { + h.rawResponse = c.rawResp + + resp, err := api.WorkflowsTriggersPermissionsSet(context.Background(), c.input) + if c.expectedErr == nil && err != nil { + t.Fatalf("unexpected error: %s\n", err) + } + if c.expectedErr != nil && err == nil { + t.Fatalf("expected %s, but did not raise an error", c.expectedErr) + } + if c.expectedErr != nil && err != nil && c.expectedErr.Error() != err.Error() { + t.Fatalf("expected %s as error but got %s\n", c.expectedErr, err) + } + if resp == nil || c.expectedResp == nil { + return + } + if !reflect.DeepEqual(resp, c.expectedResp) { + t.Fatalf("expected:\n\t%v\n but got:\n\t%v\n", c.expectedResp, resp) + } + }) + } +} From 05f25da2320466a26f1e293eb8cc669f94f94d24 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 24 May 2025 20:59:03 +0100 Subject: [PATCH 357/606] fix: use `SlackResponse` to match the rest of the library Signed-off-by: Norberto Lopes --- misc.go | 6 ---- slack.go | 11 ------- workflows_triggers.go | 71 ++++++++++++++++++++++++++++--------------- 3 files changed, 47 insertions(+), 41 deletions(-) diff --git a/misc.go b/misc.go index bcd12472f..f06108e80 100644 --- a/misc.go +++ b/misc.go @@ -26,12 +26,6 @@ type SlackResponse struct { ResponseMetadata ResponseMetadata `json:"response_metadata"` } -type ResponsePointer struct { - Ok *bool `json:"ok,omitempty"` - Error *string `json:"error,omitempty"` - ResponseMetadata ResponseMetadata `json:"response_metadata,omitempty"` -} - func (t SlackResponse) Err() error { if t.Ok { return nil diff --git a/slack.go b/slack.go index 505ab98d7..756106fe4 100644 --- a/slack.go +++ b/slack.go @@ -2,7 +2,6 @@ package slack import ( "context" - "encoding/json" "fmt" "log" "net/http" @@ -169,16 +168,6 @@ func (api *Client) postMethod(ctx context.Context, path string, values url.Value return postForm(ctx, api.httpclient, api.endpoint+path, values, intf, api) } -// post to a slack web method with json. -func (api *Client) postJSON(ctx context.Context, path string, request interface{}, response interface{}) error { - jsonPayload, err := json.Marshal(request) - if err != nil { - return err - } - - return postJSON(ctx, api.httpclient, api.endpoint+path, api.token, jsonPayload, response, api) -} - // get a slack web method. func (api *Client) getMethod(ctx context.Context, path string, token string, values url.Values, intf interface{}) error { return getResource(ctx, api.httpclient, api.endpoint+path, token, values, intf, api) diff --git a/workflows_triggers.go b/workflows_triggers.go index 71fdf078a..f9a7cd90c 100644 --- a/workflows_triggers.go +++ b/workflows_triggers.go @@ -2,7 +2,7 @@ package slack import ( "context" - "errors" + "encoding/json" "fmt" ) @@ -69,64 +69,82 @@ type ( } ) -// WorkflowsTriggersPermissionsAdd allows users to run a trigger that has its permission type set to named_entities. +// WorkflowsTriggersPermissionsAdd allows users to run a trigger that has its permission +// type set to named_entities. // -// Slack API Docs:https://api.dev.slack.com/methods/workflows.triggers.permissions.add +// Slack API Docs:https://api.slack.com/methods/workflows.triggers.permissions.add func (api *Client) WorkflowsTriggersPermissionsAdd(ctx context.Context, input *WorkflowsTriggersPermissionsAddInput) (*WorkflowsTriggersPermissionsAddOutput, error) { response := struct { - *ResponsePointer + SlackResponse *WorkflowsTriggersPermissionsAddOutput }{} - err := api.postJSON(ctx, "workflows.triggers.permissions.add", input, &response) + jsonPayload, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("failed to marshal WorkflowsTriggersPermissionsAddInput: %w", err) + } + + err = postJSON(ctx, api.httpclient, api.endpoint+"workflows.triggers.permissions.add", api.token, jsonPayload, &response, api) if err != nil { return nil, err } - if response.Error != nil { - return nil, errors.New(fmt.Sprintf("error: %s", *response.Error)) + if err := response.Err(); err != nil { + return nil, err } return response.WorkflowsTriggersPermissionsAddOutput, nil } -// WorkflowsTriggersPermissionsList returns the permission type of a trigger and if applicable, includes the entities that have been granted access. +// WorkflowsTriggersPermissionsList returns the permission type of a trigger and if +// applicable, includes the entities that have been granted access. // -// Slack API Docs:https://api.dev.slack.com/methods/workflows.triggers.permissions.list +// Slack API Docs:https://api.slack.com/methods/workflows.triggers.permissions.list func (api *Client) WorkflowsTriggersPermissionsList(ctx context.Context, input *WorkflowsTriggersPermissionsListInput) (*WorkflowsTriggersPermissionsListOutput, error) { response := struct { - *ResponsePointer + SlackResponse *WorkflowsTriggersPermissionsListOutput }{} - err := api.postJSON(ctx, "workflows.triggers.permissions.list", input, &response) + jsonPayload, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("failed to marshal WorkflowsTriggersPermissionsListInput: %w", err) + } + + err = postJSON(ctx, api.httpclient, api.endpoint+"workflows.triggers.permissions.list", api.token, jsonPayload, &response, api) if err != nil { return nil, err } - if response.Error != nil { - return nil, errors.New(fmt.Sprintf("error: %s", *response.Error)) + if err := response.Err(); err != nil { + return nil, err } return response.WorkflowsTriggersPermissionsListOutput, nil } -// WorkflowsTriggersPermissionsRemove revoke an entity's access to a trigger that has its permission type set to named_entities. +// WorkflowsTriggersPermissionsRemove revoke an entity's access to a trigger that has its +// permission type set to named_entities. // -// Slack API Docs:https://api.dev.slack.com/methods/workflows.triggers.permissions.remove +// Slack API Docs:https://api.slack.com/methods/workflows.triggers.permissions.remove func (api *Client) WorkflowsTriggersPermissionsRemove(ctx context.Context, input *WorkflowsTriggersPermissionsRemoveInput) (*WorkflowsTriggersPermissionsRemoveOutput, error) { response := struct { - *ResponsePointer + SlackResponse *WorkflowsTriggersPermissionsRemoveOutput }{} - err := api.postJSON(ctx, "workflows.triggers.permissions.remove", input, &response) + jsonPayload, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("failed to marshal WorkflowsTriggersPermissionsRemoveInput: %w", err) + } + + err = postJSON(ctx, api.httpclient, api.endpoint+"workflows.triggers.permissions.remove", api.token, jsonPayload, &response, api) if err != nil { return nil, err } - if response.Error != nil { - return nil, errors.New(fmt.Sprintf("error: %s", *response.Error)) + if err := response.Err(); err != nil { + return nil, err } return response.WorkflowsTriggersPermissionsRemoveOutput, nil @@ -134,20 +152,25 @@ func (api *Client) WorkflowsTriggersPermissionsRemove(ctx context.Context, input // WorkflowsTriggersPermissionsSet sets the permission type for who can run a trigger. // -// Slack API Docs:https://api.dev.slack.com/methods/workflows.triggers.permissions.set +// Slack API Docs:https://api.slack.com/methods/workflows.triggers.permissions.set func (api *Client) WorkflowsTriggersPermissionsSet(ctx context.Context, input *WorkflowsTriggersPermissionsSetInput) (*WorkflowsTriggersPermissionsSetOutput, error) { response := struct { - *ResponsePointer + SlackResponse *WorkflowsTriggersPermissionsSetOutput }{} - err := api.postJSON(ctx, "workflows.triggers.permissions.set", input, &response) + jsonPayload, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("failed to marshal WorkflowsTriggersPermissionsSetInput: %w", err) + } + + err = postJSON(ctx, api.httpclient, api.endpoint+"workflows.triggers.permissions.set", api.token, jsonPayload, &response, api) if err != nil { return nil, err } - if response.Error != nil { - return nil, errors.New(fmt.Sprintf("error: %s", *response.Error)) + if err := response.Err(); err != nil { + return nil, err } return response.WorkflowsTriggersPermissionsSetOutput, nil From 7d5a96568fe68854e0c84c1a2f86645f52ae66e2 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 24 May 2025 22:02:46 +0100 Subject: [PATCH 358/606] fix: tighten permissions for the test workflow I've also renamed `stale.yaml` file to `stale.yml` for consistency in the extensions. --- .github/workflows/{stale.yaml => stale.yml} | 0 .github/workflows/test.yml | 3 +++ 2 files changed, 3 insertions(+) rename .github/workflows/{stale.yaml => stale.yml} (100%) diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yml similarity index 100% rename from .github/workflows/stale.yaml rename to .github/workflows/stale.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 50887a1ed..135a29038 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,9 @@ on: - master pull_request: +permissions: + contents: read + jobs: test: runs-on: ubuntu-24.04 From a901aac67febd055e9589a574efac5e94257ff29 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 24 May 2025 22:09:40 +0100 Subject: [PATCH 359/606] chore: add gomod to dependabot --- .github/dependabot.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4f2ea0fbb..2ff0cdc3b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,3 +6,8 @@ updates: directory: "/" schedule: interval: "weekly" + + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" From 58e4146714e74b511147c65c15d97bdf1a18bb25 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 24 May 2025 21:12:54 +0000 Subject: [PATCH 360/606] chore(deps): bump github.com/stretchr/testify from 1.2.2 to 1.10.0 Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.2.2 to 1.10.0. - [Release notes](https://github.com/stretchr/testify/releases) - [Commits](https://github.com/stretchr/testify/compare/v1.2.2...v1.10.0) --- updated-dependencies: - dependency-name: github.com/stretchr/testify dependency-version: 1.10.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 3 ++- go.sum | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e14add7c3..8855fc63f 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,11 @@ go 1.22 require ( github.com/go-test/deep v1.0.4 github.com/gorilla/websocket v1.4.2 - github.com/stretchr/testify v1.2.2 + github.com/stretchr/testify v1.10.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 063f679df..dec623f9a 100644 --- a/go.sum +++ b/go.sum @@ -6,5 +6,9 @@ github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0U github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 3fce6b7a872a930bf6ecd159199b2281e3ee55fe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 24 May 2025 21:23:12 +0000 Subject: [PATCH 361/606] chore(deps): bump github.com/go-test/deep from 1.0.4 to 1.1.1 Bumps [github.com/go-test/deep](https://github.com/go-test/deep) from 1.0.4 to 1.1.1. - [Release notes](https://github.com/go-test/deep/releases) - [Changelog](https://github.com/go-test/deep/blob/master/CHANGES.md) - [Commits](https://github.com/go-test/deep/compare/v1.0.4...v1.1.1) --- updated-dependencies: - dependency-name: github.com/go-test/deep dependency-version: 1.1.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 8855fc63f..3970d9fc8 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/slack-go/slack go 1.22 require ( - github.com/go-test/deep v1.0.4 + github.com/go-test/deep v1.1.1 github.com/gorilla/websocket v1.4.2 github.com/stretchr/testify v1.10.0 ) diff --git a/go.sum b/go.sum index dec623f9a..ca8b7831a 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= -github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= From 35c31945c9400af0e1adf4a1a405c7b66ca5ace0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 24 May 2025 21:27:10 +0000 Subject: [PATCH 362/606] chore(deps): bump github.com/gorilla/websocket from 1.4.2 to 1.5.3 Bumps [github.com/gorilla/websocket](https://github.com/gorilla/websocket) from 1.4.2 to 1.5.3. - [Release notes](https://github.com/gorilla/websocket/releases) - [Commits](https://github.com/gorilla/websocket/compare/v1.4.2...v1.5.3) --- updated-dependencies: - dependency-name: github.com/gorilla/websocket dependency-version: 1.5.3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3970d9fc8..52f68aba4 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.22 require ( github.com/go-test/deep v1.1.1 - github.com/gorilla/websocket v1.4.2 + github.com/gorilla/websocket v1.5.3 github.com/stretchr/testify v1.10.0 ) diff --git a/go.sum b/go.sum index ca8b7831a..7d8d8f8a8 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= From 8422f651e2f58d030e438bbd7ca6107807dc628f Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 25 May 2025 17:32:59 +0100 Subject: [PATCH 363/606] chore: add CI test status badge to README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7d9733e19..127ae253c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Slack API in Go [![Go Reference](https://pkg.go.dev/badge/github.com/slack-go/slack.svg)](https://pkg.go.dev/github.com/slack-go/slack) +Slack API in Go [![Go Reference](https://pkg.go.dev/badge/github.com/slack-go/slack.svg)](https://pkg.go.dev/github.com/slack-go/slack) [![CI](https://github.com/slack-go/slack/actions/workflows/test.yml/badge.svg)](https://github.com/slack-go/slack/actions/workflows/test.yml) =============== This is the original Slack library for Go created by Norberto Lopes, transferred to a GitHub organization. From f96293ca66d4075910cae2b73f48fc85ea17f3f8 Mon Sep 17 00:00:00 2001 From: Dalyn Dalton Date: Thu, 29 May 2025 09:48:14 -0600 Subject: [PATCH 364/606] feat: Add missing Blocks to UploadFileContextV2 --- files.go | 10 ++++++++++ files_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/files.go b/files.go index 45a39f828..810c476b7 100644 --- a/files.go +++ b/files.go @@ -175,6 +175,7 @@ type UploadFileV2Parameters struct { Filename string Title string InitialComment string + Blocks Blocks Channel string ThreadTimestamp string AltTxt string @@ -209,6 +210,7 @@ type FileSummary struct { type CompleteUploadExternalParameters struct { Files []FileSummary + Blocks Blocks Channel string InitialComment string ThreadTimestamp string @@ -586,6 +588,13 @@ func (api *Client) CompleteUploadExternalContext(ctx context.Context, params Com if params.InitialComment != "" { values.Add("initial_comment", params.InitialComment) } + if params.Blocks.BlockSet != nil && params.InitialComment == "" { + blocksBytes, err := json.Marshal(params.Blocks) + if err != nil { + return nil, err + } + values.Add("blocks", string(blocksBytes)) + } if params.ThreadTimestamp != "" { values.Add("thread_ts", params.ThreadTimestamp) } @@ -649,6 +658,7 @@ func (api *Client) UploadFileV2Context(ctx context.Context, params UploadFileV2P Channel: params.Channel, InitialComment: params.InitialComment, ThreadTimestamp: params.ThreadTimestamp, + Blocks: params.Blocks, }) if err != nil { return nil, err diff --git a/files_test.go b/files_test.go index cf3a801a9..1de9859fc 100644 --- a/files_test.go +++ b/files_test.go @@ -477,6 +477,39 @@ func TestCompleteUploadExternalContext(t *testing.T) { SlackResponse: SlackResponse{Ok: true}, }, }, + { + title: "Testing with blocks", + params: CompleteUploadExternalParameters{ + Files: []FileSummary{ + { + ID: "ID1", + }, + { + ID: "ID2", + Title: "Title2", + }, + }, + Channel: "test-channel", + ThreadTimestamp: "1234567890.123456", + Blocks: Blocks{BlockSet: []Block{ + NewSectionBlock( + NewTextBlockObject("plain_text", "This is a section block", false, false), nil, nil), + }, + }, + }, + wantResponse: CompleteUploadExternalResponse{ + Files: []FileSummary{ + { + ID: "ID1", + }, + { + ID: "ID2", + Title: "Title2", + }, + }, + SlackResponse: SlackResponse{Ok: true}, + }, + }, { title: "Testing with error", params: CompleteUploadExternalParameters{ From b0e214f154f0b799c09f489bf4773478de2c547b Mon Sep 17 00:00:00 2001 From: "takeshi.nakata" <7553415+nktks@users.noreply.github.com> Date: Wed, 4 Jun 2025 20:54:16 +0900 Subject: [PATCH 365/606] support Data Access API - added https://api.slack.com/methods/assistant.search.context api - added `assistant_thread` attribute to Event API `message`, `app_mention` envets https://api.slack.com/docs/apps/data-access-api#event-listener --- assistant.go | 95 ++++++++++ assistant_test.go | 188 ++++++++++++++++++++ slackevents/inner_events.go | 11 ++ slackevents/inner_events_test.go | 71 ++++++++ slackevents/parsers_test.go | 292 +++++++++++++++++++++++++++++++ 5 files changed, 657 insertions(+) diff --git a/assistant.go b/assistant.go index 8432f89b3..057e89cce 100644 --- a/assistant.go +++ b/assistant.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/url" + "strconv" ) // AssistantThreadSetStatusParameters are the parameters for AssistantThreadSetStatus @@ -34,6 +35,43 @@ type AssistantThreadsPrompt struct { Message string `json:"message"` } +// AssistantSearchContextParameters are the parameters for AssistantSearchContext +type AssistantSearchContextParameters struct { + Query string `json:"query"` + ActionToken string `json:"action_token,omitempty"` + ChannelTypes []string `json:"channel_types,omitempty"` + ContentTypes []string `json:"content_types,omitempty"` + ContextChannelID string `json:"context_channel_id,omitempty"` + Cursor string `json:"cursor,omitempty"` + IncludeBots bool `json:"include_bots,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// AssistantSearchContextMessage represents a search result message +type AssistantSearchContextMessage struct { + AuthorUserID string `json:"author_user_id"` + TeamID string `json:"team_id"` + ChannelID string `json:"channel_id"` + MessageTS string `json:"message_ts"` + Content string `json:"content"` + IsAuthorBot bool `json:"is_author_bot"` + Permalink string `json:"permalink"` +} + +// AssistantSearchContextResults contains the search results +type AssistantSearchContextResults struct { + Messages []AssistantSearchContextMessage `json:"messages"` +} + +// AssistantSearchContextResponse is the response from assistant.search.context +type AssistantSearchContextResponse struct { + SlackResponse + Results AssistantSearchContextResults `json:"results"` + ResponseMetadata struct { + NextCursor string `json:"next_cursor"` + } `json:"response_metadata"` +} + // AssistantThreadSetSuggestedPrompts sets the suggested prompts for a thread func (p *AssistantThreadsSetSuggestedPromptsParameters) AddPrompt(title, message string) { p.Prompts = append(p.Prompts, AssistantThreadsPrompt{ @@ -155,3 +193,60 @@ func (api *Client) SetAssistantThreadsTitleContext(ctx context.Context, params A return response.Err() } + +// SearchAssistantContext searches messages across the Slack organization +// @see https://api.slack.com/methods/assistant.search.context +func (api *Client) SearchAssistantContext(params AssistantSearchContextParameters) (*AssistantSearchContextResponse, error) { + return api.SearchAssistantContextContext(context.Background(), params) +} + +// SearchAssistantContextContext searches messages across the Slack organization with a custom context +// @see https://api.slack.com/methods/assistant.search.context +func (api *Client) SearchAssistantContextContext(ctx context.Context, params AssistantSearchContextParameters) (*AssistantSearchContextResponse, error) { + values := url.Values{ + "token": {api.token}, + } + + values.Add("query", params.Query) + + if params.ActionToken != "" { + values.Add("action_token", params.ActionToken) + } + + if len(params.ChannelTypes) > 0 { + for _, channelType := range params.ChannelTypes { + values.Add("channel_types", channelType) + } + } + + if len(params.ContentTypes) > 0 { + for _, contentType := range params.ContentTypes { + values.Add("content_types", contentType) + } + } + + if params.ContextChannelID != "" { + values.Add("context_channel_id", params.ContextChannelID) + } + + if params.Cursor != "" { + values.Add("cursor", params.Cursor) + } + + if params.IncludeBots { + values.Add("include_bots", "true") + } + + if params.Limit > 0 { + values.Add("limit", strconv.Itoa(params.Limit)) + } + + response := &AssistantSearchContextResponse{} + + err := api.postMethod(ctx, "assistant.search.context", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} diff --git a/assistant_test.go b/assistant_test.go index 51c271611..1b4f453b9 100644 --- a/assistant_test.go +++ b/assistant_test.go @@ -3,6 +3,7 @@ package slack import ( "encoding/json" "net/http" + "net/url" "testing" ) @@ -84,3 +85,190 @@ func TestSetAssistantThreadsTitle(t *testing.T) { } } + +func assistantSearchContextHandler(rw http.ResponseWriter, r *http.Request) { + query := r.FormValue("query") + rw.Header().Set("Content-Type", "application/json") + + if query != "" { + resp, _ := json.Marshal(&AssistantSearchContextResponse{ + SlackResponse: SlackResponse{Ok: true}, + Results: AssistantSearchContextResults{ + Messages: []AssistantSearchContextMessage{ + { + AuthorUserID: "U1234567890", + TeamID: "T1234567890", + ChannelID: "C1234567890", + MessageTS: "1234567890.123456", + Content: "This is a test message", + IsAuthorBot: false, + Permalink: "https://example.slack.com/archives/C1234567890/p1234567890123456", + }, + }, + }, + ResponseMetadata: struct { + NextCursor string `json:"next_cursor"` + }{ + NextCursor: "next_cursor_value", + }, + }) + rw.Write(resp) + } else { + rw.Write([]byte(`{ "ok": false, "error": "invalid_arguments" }`)) + } +} + +func TestSearchAssistantContext(t *testing.T) { + http.HandleFunc("/assistant.search.context", assistantSearchContextHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := AssistantSearchContextParameters{ + Query: "test query", + ActionToken: "test_action_token", + ChannelTypes: []string{"public_channel", "private_channel"}, + ContentTypes: []string{"messages"}, + ContextChannelID: "C1234567890", + Cursor: "cursor_value", + IncludeBots: true, + Limit: 10, + } + + response, err := api.SearchAssistantContext(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if !response.Ok { + t.Fatalf("Expected Ok to be true") + } + + if len(response.Results.Messages) != 1 { + t.Fatalf("Expected 1 message, got %d", len(response.Results.Messages)) + } + + message := response.Results.Messages[0] + if message.AuthorUserID != "U1234567890" { + t.Fatalf("Expected AuthorUserID to be U1234567890, got %s", message.AuthorUserID) + } + + if message.TeamID != "T1234567890" { + t.Fatalf("Expected TeamID to be T1234567890, got %s", message.TeamID) + } + + if message.ChannelID != "C1234567890" { + t.Fatalf("Expected ChannelID to be C1234567890, got %s", message.ChannelID) + } + + if message.MessageTS != "1234567890.123456" { + t.Fatalf("Expected MessageTS to be '1234567890.123456', got %s", message.MessageTS) + } + + if message.Content != "This is a test message" { + t.Fatalf("Expected Content to be 'This is a test message', got %s", message.Content) + } + + if message.IsAuthorBot != false { + t.Fatalf("Expected IsAuthorBot to be false, got %v", message.IsAuthorBot) + } + + if response.ResponseMetadata.NextCursor != "next_cursor_value" { + t.Fatalf("Expected NextCursor to be 'next_cursor_value', got %s", response.ResponseMetadata.NextCursor) + } +} + +func assistantSearchContextHandlerWithNewParams(rw http.ResponseWriter, r *http.Request) { + query := r.FormValue("query") + actionToken := r.FormValue("action_token") + contextChannelID := r.FormValue("context_channel_id") + includeBots := r.FormValue("include_bots") + + rw.Header().Set("Content-Type", "application/json") + + if query != "" && actionToken != "" && contextChannelID != "" && includeBots == "true" { + resp, _ := json.Marshal(&AssistantSearchContextResponse{ + SlackResponse: SlackResponse{Ok: true}, + Results: AssistantSearchContextResults{ + Messages: []AssistantSearchContextMessage{ + { + AuthorUserID: "U1234567890", + TeamID: "T0987654321", + ChannelID: contextChannelID, // Use the provided context channel ID + MessageTS: "1234567890.123456", + Content: "This is a test message with new parameters", + IsAuthorBot: true, // Test with bot message + Permalink: "https://example.slack.com/archives/" + contextChannelID + "/p1234567890123456", + }, + }, + }, + ResponseMetadata: struct { + NextCursor string `json:"next_cursor"` + }{ + NextCursor: "next_cursor_value_new", + }, + }) + rw.Write(resp) + } else { + rw.Write([]byte(`{ "ok": false, "error": "missing_required_parameters" }`)) + } +} + +func TestSearchAssistantContextWithNewParameters(t *testing.T) { + http.HandleFunc("/assistant.search.context.new", assistantSearchContextHandlerWithNewParams) + once.Do(startServer) + + // Test with new parameters + params := AssistantSearchContextParameters{ + Query: "test query with new params", + ActionToken: "action_token_123", + ChannelTypes: []string{"public_channel"}, + ContentTypes: []string{"messages"}, + ContextChannelID: "C0987654321", + IncludeBots: true, + Limit: 5, + } + + // We need to temporarily change the API method for this test + // Since we can't easily override the method, we'll test the parameter building logic + + // Create a custom client to test parameter handling + values := url.Values{"token": {"testing-token"}} + values.Add("query", params.Query) + + if params.ActionToken != "" { + values.Add("action_token", params.ActionToken) + } + if len(params.ChannelTypes) > 0 { + for _, channelType := range params.ChannelTypes { + values.Add("channel_types", channelType) + } + } + if len(params.ContentTypes) > 0 { + for _, contentType := range params.ContentTypes { + values.Add("content_types", contentType) + } + } + if params.ContextChannelID != "" { + values.Add("context_channel_id", params.ContextChannelID) + } + if params.IncludeBots { + values.Add("include_bots", "true") + } + if params.Limit > 0 { + values.Add("limit", "5") + } + + // Verify all parameters are set correctly + if values.Get("action_token") != "action_token_123" { + t.Errorf("Expected action_token to be 'action_token_123', got %s", values.Get("action_token")) + } + if values.Get("context_channel_id") != "C0987654321" { + t.Errorf("Expected context_channel_id to be 'C0987654321', got %s", values.Get("context_channel_id")) + } + if values.Get("include_bots") != "true" { + t.Errorf("Expected include_bots to be 'true', got %s", values.Get("include_bots")) + } + if values.Get("limit") != "5" { + t.Errorf("Expected limit to be '5', got %s", values.Get("limit")) + } +} diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index e0e86202c..4ff4344fb 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -36,6 +36,11 @@ type AssistantThread struct { ThreadTimeStamp string `json:"thread_ts"` } +// AssistantThreadActionToken contains the action token for Data Access API queries +type AssistantThreadActionToken struct { + ActionToken string `json:"action_token"` +} + // AssistantThreadContext is an object that represents the context of an assistant thread. type AssistantThreadContext struct { ChannelID string `json:"channel_id"` @@ -62,6 +67,9 @@ type AppMentionEvent struct { // When the app is mentioned in the edited message Edited *Edited `json:"edited,omitempty"` + + // AssistantThread contains action token for Data Access API queries when app is mentioned + AssistantThread *AssistantThreadActionToken `json:"assistant_thread,omitempty"` } // AppHomeOpenedEvent Your Slack app home was opened. @@ -306,6 +314,9 @@ type MessageEvent struct { BotID string `json:"bot_id,omitempty"` Username string `json:"username,omitempty"` Icons *Icon `json:"icons,omitempty"` + + // AssistantThread contains action token for Data Access API queries in message events + AssistantThread *AssistantThreadActionToken `json:"assistant_thread,omitempty"` } // UnmarshalJSON implements the json.Unmarshaler interface for MessageEvent. diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index 67e70b3f6..94f847cf8 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -83,6 +83,38 @@ func TestAppMention(t *testing.T) { } } +func TestAppMentionWithAssistantThread(t *testing.T) { + rawE := []byte(` + { + "type": "app_mention", + "user": "U061F7AUR", + "text": "<@U0LAN0Z89> is it everything a river should be?", + "ts": "1515449522.000016", + "thread_ts": "1515449522.000016", + "channel": "C0LAN2Q65", + "event_ts": "1515449522000016", + "source_team": "T3MQV36V7", + "user_team": "T3MQV36V7", + "assistant_thread": { + "action_token": "1234567.abcdefg" + } + } + `) + var event AppMentionEvent + err := json.Unmarshal(rawE, &event) + if err != nil { + t.Error(err) + } + + if event.AssistantThread == nil { + t.Error("Expected AssistantThread to be non-nil") + } + + if event.AssistantThread.ActionToken != "1234567.abcdefg" { + t.Errorf("Expected ActionToken to be '1234567.abcdefg', got %s", event.AssistantThread.ActionToken) + } +} + func TestAppUninstalled(t *testing.T) { rawE := []byte(` { @@ -376,6 +408,45 @@ func TestMessageEvent(t *testing.T) { } +func TestMessageEventWithAssistantThread(t *testing.T) { + rawE := []byte(` + { + "client_msg_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "type": "message", + "channel": "D024BE91L", + "user": "U2147483697", + "text": "Hello, I need help with something.", + "ts": "1355517523.000005", + "event_ts": "1355517523.000005", + "channel_type": "im", + "assistant_thread": { + "action_token": "9876543.hijklmnop" + } + } + `) + var e MessageEvent + err := json.Unmarshal(rawE, &e) + if err != nil { + t.Error(err) + } + + if e.Channel != "D024BE91L" { + t.Error(fmt.Errorf("expected channel D024BE91L, got %s", e.Channel)) + } + if e.User != "U2147483697" { + t.Error(fmt.Errorf("expected user U2147483697, got %s", e.User)) + } + if e.Text != "Hello, I need help with something." { + t.Error(fmt.Errorf("expected e.Text Hello, I need help with something., got %s", e.Text)) + } + if e.AssistantThread == nil { + t.Error("Expected AssistantThread to be non-nil") + } + if e.AssistantThread.ActionToken != "9876543.hijklmnop" { + t.Errorf("Expected ActionToken to be '9876543.hijklmnop', got %s", e.AssistantThread.ActionToken) + } +} + func TestMessageChangedEvent(t *testing.T) { rawE := []byte(` { diff --git a/slackevents/parsers_test.go b/slackevents/parsers_test.go index 2e2b63401..d8f4859f8 100644 --- a/slackevents/parsers_test.go +++ b/slackevents/parsers_test.go @@ -170,3 +170,295 @@ func TestNoTokenVerification(t *testing.T) { t.Fail() } } + +func TestParseEventAPIAppMentionWithAssistantThread(t *testing.T) { + eventsAPIRawCallbackEvent := ` + { + "token": "XXYYZZ", + "team_id": "TXXXXXXXX", + "api_app_id": "AXXXXXXXXX", + "event": { + "type": "app_mention", + "event_ts": "1234567890.123456", + "user": "UXXXXXXX1", + "text": "<@U0LAN0Z89> help me with something", + "ts": "1515449522.000016", + "channel": "C0LAN2Q65", + "assistant_thread": { + "action_token": "1234567.abcdefg" + } + }, + "type": "event_callback", + "authed_users": [ "UXXXXXXX1" ], + "event_id": "Ev08MFMKH6", + "event_time": 1234567890 + } + ` + msg, e := ParseEvent(json.RawMessage(eventsAPIRawCallbackEvent), OptionVerifyToken(&TokenComparator{"XXYYZZ"})) + if e != nil { + fmt.Println(e) + t.Fail() + } + + switch outerEvent := msg.Data.(type) { + case *EventsAPICallbackEvent: + { + switch innerEvent := msg.InnerEvent.Data.(type) { + case *AppMentionEvent: + { + if innerEvent.AssistantThread == nil { + t.Error("Expected AssistantThread to be non-nil") + } + if innerEvent.AssistantThread.ActionToken != "1234567.abcdefg" { + t.Errorf("Expected ActionToken to be '1234567.abcdefg', got %s", innerEvent.AssistantThread.ActionToken) + } + } + default: + fmt.Println(innerEvent) + t.Fail() + } + } + default: + { + fmt.Println(outerEvent) + t.Fail() + } + } +} + +func TestParseEventAPIMessageIMWithAssistantThread(t *testing.T) { + eventsAPIRawCallbackEvent := ` + { + "token": "XXYYZZ", + "team_id": "TXXXXXXXX", + "api_app_id": "AXXXXXXXXX", + "event": { + "type": "message", + "channel": "D024BE91L", + "user": "U2147483697", + "text": "Hello, I need help with something.", + "ts": "1355517523.000005", + "event_ts": "1355517523.000005", + "channel_type": "im", + "assistant_thread": { + "action_token": "9876543.hijklmnop" + } + }, + "type": "event_callback", + "authed_users": [ "U2147483697" ], + "event_id": "Ev08MFMKH7", + "event_time": 1234567890 + } + ` + msg, e := ParseEvent(json.RawMessage(eventsAPIRawCallbackEvent), OptionVerifyToken(&TokenComparator{"XXYYZZ"})) + if e != nil { + fmt.Println(e) + t.Fail() + } + + switch outerEvent := msg.Data.(type) { + case *EventsAPICallbackEvent: + { + switch innerEvent := msg.InnerEvent.Data.(type) { + case *MessageEvent: + { + if innerEvent.AssistantThread == nil { + t.Error("Expected AssistantThread to be non-nil") + } + if innerEvent.AssistantThread.ActionToken != "9876543.hijklmnop" { + t.Errorf("Expected ActionToken to be '9876543.hijklmnop', got %s", innerEvent.AssistantThread.ActionToken) + } + if innerEvent.ChannelType != "im" { + t.Errorf("Expected ChannelType to be 'im', got %s", innerEvent.ChannelType) + } + } + default: + fmt.Println(innerEvent) + t.Fail() + } + } + default: + { + fmt.Println(outerEvent) + t.Fail() + } + } +} + +func TestParseEventAPIMessageChannelWithAssistantThread(t *testing.T) { + eventsAPIRawCallbackEvent := ` + { + "token": "XXYYZZ", + "team_id": "TXXXXXXXX", + "api_app_id": "AXXXXXXXXX", + "event": { + "type": "message", + "channel": "C024BE91L", + "user": "U2147483697", + "text": "Hello everyone, I need help with something.", + "ts": "1355517523.000005", + "event_ts": "1355517523.000005", + "channel_type": "channel", + "assistant_thread": { + "action_token": "abcd1234.qwerty" + } + }, + "type": "event_callback", + "authed_users": [ "U2147483697" ], + "event_id": "Ev08MFMKH8", + "event_time": 1234567890 + } + ` + msg, e := ParseEvent(json.RawMessage(eventsAPIRawCallbackEvent), OptionVerifyToken(&TokenComparator{"XXYYZZ"})) + if e != nil { + fmt.Println(e) + t.Fail() + } + + switch outerEvent := msg.Data.(type) { + case *EventsAPICallbackEvent: + { + switch innerEvent := msg.InnerEvent.Data.(type) { + case *MessageEvent: + { + if innerEvent.AssistantThread == nil { + t.Error("Expected AssistantThread to be non-nil") + } + if innerEvent.AssistantThread.ActionToken != "abcd1234.qwerty" { + t.Errorf("Expected ActionToken to be 'abcd1234.qwerty', got %s", innerEvent.AssistantThread.ActionToken) + } + if innerEvent.ChannelType != "channel" { + t.Errorf("Expected ChannelType to be 'channel', got %s", innerEvent.ChannelType) + } + } + default: + fmt.Println(innerEvent) + t.Fail() + } + } + default: + { + fmt.Println(outerEvent) + t.Fail() + } + } +} + +func TestParseEventAPIMessageMPIMWithAssistantThread(t *testing.T) { + eventsAPIRawCallbackEvent := ` + { + "token": "XXYYZZ", + "team_id": "TXXXXXXXX", + "api_app_id": "AXXXXXXXXX", + "event": { + "type": "message", + "channel": "G024BE91L", + "user": "U2147483697", + "text": "Hey team, I need some assistance.", + "ts": "1355517523.000005", + "event_ts": "1355517523.000005", + "channel_type": "mpim", + "assistant_thread": { + "action_token": "xyz789.multiparty" + } + }, + "type": "event_callback", + "authed_users": [ "U2147483697" ], + "event_id": "Ev08MFMKH9", + "event_time": 1234567890 + } + ` + msg, e := ParseEvent(json.RawMessage(eventsAPIRawCallbackEvent), OptionVerifyToken(&TokenComparator{"XXYYZZ"})) + if e != nil { + fmt.Println(e) + t.Fail() + } + + switch outerEvent := msg.Data.(type) { + case *EventsAPICallbackEvent: + { + switch innerEvent := msg.InnerEvent.Data.(type) { + case *MessageEvent: + { + if innerEvent.AssistantThread == nil { + t.Error("Expected AssistantThread to be non-nil") + } + if innerEvent.AssistantThread.ActionToken != "xyz789.multiparty" { + t.Errorf("Expected ActionToken to be 'xyz789.multiparty', got %s", innerEvent.AssistantThread.ActionToken) + } + if innerEvent.ChannelType != "mpim" { + t.Errorf("Expected ChannelType to be 'mpim', got %s", innerEvent.ChannelType) + } + } + default: + fmt.Println(innerEvent) + t.Fail() + } + } + default: + { + fmt.Println(outerEvent) + t.Fail() + } + } +} + +func TestParseEventAPIMessageGroupWithAssistantThread(t *testing.T) { + eventsAPIRawCallbackEvent := ` + { + "token": "XXYYZZ", + "team_id": "TXXXXXXXX", + "api_app_id": "AXXXXXXXXX", + "event": { + "type": "message", + "channel": "G124BE91L", + "user": "U2147483697", + "text": "Private group message with assistant request.", + "ts": "1355517523.000005", + "event_ts": "1355517523.000005", + "channel_type": "group", + "assistant_thread": { + "action_token": "group123.private" + } + }, + "type": "event_callback", + "authed_users": [ "U2147483697" ], + "event_id": "Ev08MFMK10", + "event_time": 1234567890 + } + ` + msg, e := ParseEvent(json.RawMessage(eventsAPIRawCallbackEvent), OptionVerifyToken(&TokenComparator{"XXYYZZ"})) + if e != nil { + fmt.Println(e) + t.Fail() + } + + switch outerEvent := msg.Data.(type) { + case *EventsAPICallbackEvent: + { + switch innerEvent := msg.InnerEvent.Data.(type) { + case *MessageEvent: + { + if innerEvent.AssistantThread == nil { + t.Error("Expected AssistantThread to be non-nil") + } + if innerEvent.AssistantThread.ActionToken != "group123.private" { + t.Errorf("Expected ActionToken to be 'group123.private', got %s", innerEvent.AssistantThread.ActionToken) + } + if innerEvent.ChannelType != "group" { + t.Errorf("Expected ChannelType to be 'group', got %s", innerEvent.ChannelType) + } + } + default: + fmt.Println(innerEvent) + t.Fail() + } + } + default: + { + fmt.Println(outerEvent) + t.Fail() + } + } +} + From ef7dcb617e973c5797092d743c82a501fa4489e8 Mon Sep 17 00:00:00 2001 From: "takeshi.nakata" <7553415+nktks@users.noreply.github.com> Date: Fri, 6 Jun 2025 15:53:32 +0900 Subject: [PATCH 366/606] go fmt and goimports --- assistant_test.go | 6 +++--- slackevents/inner_events_test.go | 4 ++-- slackevents/parsers_test.go | 11 +++++------ 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/assistant_test.go b/assistant_test.go index 1b4f453b9..6c8960e4f 100644 --- a/assistant_test.go +++ b/assistant_test.go @@ -182,7 +182,7 @@ func assistantSearchContextHandlerWithNewParams(rw http.ResponseWriter, r *http. actionToken := r.FormValue("action_token") contextChannelID := r.FormValue("context_channel_id") includeBots := r.FormValue("include_bots") - + rw.Header().Set("Content-Type", "application/json") if query != "" && actionToken != "" && contextChannelID != "" && includeBots == "true" { @@ -230,11 +230,11 @@ func TestSearchAssistantContextWithNewParameters(t *testing.T) { // We need to temporarily change the API method for this test // Since we can't easily override the method, we'll test the parameter building logic - + // Create a custom client to test parameter handling values := url.Values{"token": {"testing-token"}} values.Add("query", params.Query) - + if params.ActionToken != "" { values.Add("action_token", params.ActionToken) } diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index 94f847cf8..c7875d5ed 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -105,11 +105,11 @@ func TestAppMentionWithAssistantThread(t *testing.T) { if err != nil { t.Error(err) } - + if event.AssistantThread == nil { t.Error("Expected AssistantThread to be non-nil") } - + if event.AssistantThread.ActionToken != "1234567.abcdefg" { t.Errorf("Expected ActionToken to be '1234567.abcdefg', got %s", event.AssistantThread.ActionToken) } diff --git a/slackevents/parsers_test.go b/slackevents/parsers_test.go index d8f4859f8..bacc703a2 100644 --- a/slackevents/parsers_test.go +++ b/slackevents/parsers_test.go @@ -199,7 +199,7 @@ func TestParseEventAPIAppMentionWithAssistantThread(t *testing.T) { fmt.Println(e) t.Fail() } - + switch outerEvent := msg.Data.(type) { case *EventsAPICallbackEvent: { @@ -255,7 +255,7 @@ func TestParseEventAPIMessageIMWithAssistantThread(t *testing.T) { fmt.Println(e) t.Fail() } - + switch outerEvent := msg.Data.(type) { case *EventsAPICallbackEvent: { @@ -314,7 +314,7 @@ func TestParseEventAPIMessageChannelWithAssistantThread(t *testing.T) { fmt.Println(e) t.Fail() } - + switch outerEvent := msg.Data.(type) { case *EventsAPICallbackEvent: { @@ -373,7 +373,7 @@ func TestParseEventAPIMessageMPIMWithAssistantThread(t *testing.T) { fmt.Println(e) t.Fail() } - + switch outerEvent := msg.Data.(type) { case *EventsAPICallbackEvent: { @@ -432,7 +432,7 @@ func TestParseEventAPIMessageGroupWithAssistantThread(t *testing.T) { fmt.Println(e) t.Fail() } - + switch outerEvent := msg.Data.(type) { case *EventsAPICallbackEvent: { @@ -461,4 +461,3 @@ func TestParseEventAPIMessageGroupWithAssistantThread(t *testing.T) { } } } - From c711d4793c52b769e92362caaf281d3e158faf3b Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 30 May 2025 22:04:28 +0100 Subject: [PATCH 367/606] feat: add channel_unshared event --- slackevents/inner_events.go | 12 ++++++++++++ slackevents/inner_events_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index e0e86202c..df61c7f08 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -147,6 +147,15 @@ type ChannelRenameInfo struct { Created int `json:"created"` } +// ChannelUnsharedEvent represents a channel has been unshared with an external workspace event +type ChannelUnsharedEvent struct { + Type string `json:"type"` + PreviouslyConnectedTeamID string `json:"previously_connected_team_id"` + Channel string `json:"channel"` + IsExtShared bool `json:"is_ext_shared"` + EventTimestamp string `json:"event_ts"` +} + // GroupDeletedEvent represents the Group deleted event type GroupDeletedEvent struct { Type string `json:"type"` @@ -1170,6 +1179,8 @@ const ( ChannelArchive = EventsAPIType("channel_archive") // ChannelUnarchive is sent when a channel is unarchived. ChannelUnarchive = EventsAPIType("channel_unarchive") + // ChannelUnshared is sent when a channel is unshared. + ChannelUnshared = EventsAPIType("channel_unshared") // ChannelLeft is sent when a channel is left. ChannelLeft = EventsAPIType("channel_left") // ChannelRename is sent when a channel is rename. @@ -1327,6 +1338,7 @@ var EventsAPIInnerEventMapping = map[EventsAPIType]interface{}{ ChannelDeleted: ChannelDeletedEvent{}, ChannelArchive: ChannelArchiveEvent{}, ChannelUnarchive: ChannelUnarchiveEvent{}, + ChannelUnshared: ChannelUnsharedEvent{}, ChannelLeft: ChannelLeftEvent{}, ChannelRename: ChannelRenameEvent{}, ChannelIDChanged: ChannelIDChangedEvent{}, diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index 67e70b3f6..96b5b0296 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -2502,6 +2502,37 @@ func TestChannelSharedEvent(t *testing.T) { } } +func TestChannelUnsharedEvent(t *testing.T) { + jsonStr := `{ + "type": "channel_unshared", + "previously_connected_team_id": "E163Q94DX", + "channel": "C123ABC456", + "is_ext_shared": false, + "event_ts": "1561064063.001100" + }` + + var event ChannelUnsharedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal ChannelUnsharedEvent: %v", err) + } + + if event.Type != "channel_unshared" { + t.Errorf("Expected type to be 'channel_unshared', got %s", event.Type) + } + + if event.PreviouslyConnectedTeamID != "E163Q94DX" { + t.Errorf("Expected previously_connected_team_id to be 'E163Q94DX', got %s", event.PreviouslyConnectedTeamID) + } + + if event.IsExtShared { + t.Errorf("Expected is_ext_shared to be false, got %t", event.IsExtShared) + } + + if event.Channel != "C123ABC456" { + t.Fail() + } +} + func TestFileCreatedEvent(t *testing.T) { jsonStr := `{ "type": "file_created", From bf3b3cf218e37f976acd62524525912c1e4152f7 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 6 Jun 2025 17:11:48 +0100 Subject: [PATCH 368/606] chore: better events example code --- examples/eventsapi/events.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/examples/eventsapi/events.go b/examples/eventsapi/events.go index b3bb70ab4..5a33b6586 100644 --- a/examples/eventsapi/events.go +++ b/examples/eventsapi/events.go @@ -11,12 +11,12 @@ import ( "github.com/slack-go/slack/slackevents" ) -// You more than likely want your "Bot User OAuth Access Token" which starts with "xoxb-" -var api = slack.New("TOKEN") - func main() { + botToken := os.Getenv("SLACK_BOT_TOKEN") signingSecret := os.Getenv("SLACK_SIGNING_SECRET") + api := slack.New(botToken, slack.OptionDebug(true), slack.OptionLog(nil)) + http.HandleFunc("/events-endpoint", func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { @@ -42,7 +42,9 @@ func main() { return } - if eventsAPIEvent.Type == slackevents.URLVerification { + fmt.Println("[INFO] Received event:", eventsAPIEvent.Type) + switch eventsAPIEvent.Type { + case slackevents.URLVerification: var r *slackevents.ChallengeResponse err := json.Unmarshal([]byte(body), &r) if err != nil { @@ -51,9 +53,9 @@ func main() { } w.Header().Set("Content-Type", "text") w.Write([]byte(r.Challenge)) - } - if eventsAPIEvent.Type == slackevents.CallbackEvent { + case slackevents.CallbackEvent: innerEvent := eventsAPIEvent.InnerEvent + fmt.Println("[INFO] Received inner event:", innerEvent.Type) switch ev := innerEvent.Data.(type) { case *slackevents.AppMentionEvent: api.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) From f3370d0adbcc0e66ab3e516650393b8d1f812d40 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 6 Jun 2025 12:36:05 +0100 Subject: [PATCH 369/606] chore: add test for section block expand option --- block_section_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/block_section_test.go b/block_section_test.go index 749e55510..373e1fe71 100644 --- a/block_section_test.go +++ b/block_section_test.go @@ -34,3 +34,19 @@ func TestNewBlockSectionContainsAddedTextBlockAndAccessory(t *testing.T) { assert.False(t, textBlockInSection.Verbatim) assert.Equal(t, sectionBlock.Accessory.ImageElement, conflictImage) } + +func TestSectionBlockOptionExpand(t *testing.T) { + textInfo := NewTextBlockObject("mrkdwn", "This is a long text that should be auto-expanded", false, false) + + // Create a section block with expand option set to true + sectionBlock := NewSectionBlock(textInfo, nil, nil, SectionBlockOptionExpand(true)) + + // Verify that the expand field is set correctly + assert.True(t, sectionBlock.Expand) + + // Create another section block with expand option set to false + sectionBlock = NewSectionBlock(textInfo, nil, nil, SectionBlockOptionExpand(false)) + + // Verify that the expand field is set correctly + assert.False(t, sectionBlock.Expand) +} From 1905f7efee6cebd53cc6ccb9b6ab55a5f3b8a881 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 6 Jun 2025 17:16:49 +0100 Subject: [PATCH 370/606] chore: order inner events vars alphabetically This makes it a lot easier to match against what the documentation has and to also figure out what we're missing. --- slackevents/inner_events.go | 291 ++++++++++++++++++------------------ 1 file changed, 146 insertions(+), 145 deletions(-) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index df61c7f08..f88648c9c 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -1161,62 +1161,114 @@ type SharedChannelInviteRequestedEvent struct { type EventsAPIType string const ( - // AppMention is an Events API subscribable event - AppMention = EventsAPIType("app_mention") + // AppDeleted is an event when an app is deleted from a workspace + AppDeleted = EventsAPIType("app_deleted") // AppHomeOpened Your Slack app home was opened AppHomeOpened = EventsAPIType("app_home_opened") + // AppInstalled is an event when an app is installed to a workspace + AppInstalled = EventsAPIType("app_installed") + // AppMention is an Events API subscribable event + AppMention = EventsAPIType("app_mention") + // AppRequested is an event when a user requests to install an app to a workspace + AppRequested = EventsAPIType("app_requested") // AppUninstalled Your Slack app was uninstalled. AppUninstalled = EventsAPIType("app_uninstalled") - // AssistantThreadStarted Your Slack AI Assistant has started a new thread - AssistantThreadStarted = EventsAPIType("assistant_thread_started") + // AppUninstalledTeam is an event when an app is uninstalled from a team + AppUninstalledTeam = EventsAPIType("app_uninstalled_team") // AssistantThreadContextChanged Your Slack AI Assistant has changed the context of a thread AssistantThreadContextChanged = EventsAPIType("assistant_thread_context_changed") + // AssistantThreadStarted Your Slack AI Assistant has started a new thread + AssistantThreadStarted = EventsAPIType("assistant_thread_started") + // CallRejected is an event when a Slack call is rejected + CallRejected = EventsAPIType("call_rejected") + // ChannelArchive is sent when a channel is archived. + ChannelArchive = EventsAPIType("channel_archive") // ChannelCreated is sent when a new channel is created. ChannelCreated = EventsAPIType("channel_created") // ChannelDeleted is sent when a channel is deleted. ChannelDeleted = EventsAPIType("channel_deleted") - // ChannelArchive is sent when a channel is archived. - ChannelArchive = EventsAPIType("channel_archive") - // ChannelUnarchive is sent when a channel is unarchived. - ChannelUnarchive = EventsAPIType("channel_unarchive") - // ChannelUnshared is sent when a channel is unshared. - ChannelUnshared = EventsAPIType("channel_unshared") + // ChannelHistoryChanged The history of a channel changed + ChannelHistoryChanged = EventsAPIType("channel_history_changed") + // ChannelIDChanged is sent when a channel identifier is changed. + ChannelIDChanged = EventsAPIType("channel_id_changed") // ChannelLeft is sent when a channel is left. ChannelLeft = EventsAPIType("channel_left") // ChannelRename is sent when a channel is rename. ChannelRename = EventsAPIType("channel_rename") - // ChannelIDChanged is sent when a channel identifier is changed. - ChannelIDChanged = EventsAPIType("channel_id_changed") - // GroupDeleted is sent when a group is deleted. - GroupDeleted = EventsAPIType("group_deleted") - // GroupArchive is sent when a group is archived. - GroupArchive = EventsAPIType("group_archive") - // GroupUnarchive is sent when a group is unarchived. - GroupUnarchive = EventsAPIType("group_unarchive") - // GroupLeft is sent when a group is left. - GroupLeft = EventsAPIType("group_left") - // GroupRename is sent when a group is renamed. - GroupRename = EventsAPIType("group_rename") + // ChannelShared is an event when a channel is shared with another workspace + ChannelShared = EventsAPIType("channel_shared") + // ChannelUnarchive is sent when a channel is unarchived. + ChannelUnarchive = EventsAPIType("channel_unarchive") + // ChannelUnshared is sent when a channel is unshared. + ChannelUnshared = EventsAPIType("channel_unshared") + // CommandsChanged A command was changed + CommandsChanged = EventsAPIType("commands_changed") + // DndUpdated Do Not Disturb settings were updated + DndUpdated = EventsAPIType("dnd_updated") + // DndUpdatedUser Do Not Disturb settings for a user were updated + DndUpdatedUser = EventsAPIType("dnd_updated_user") + // EmailDomainChanged The email domain changed + EmailDomainChanged = EventsAPIType("email_domain_changed") + // EmojiChanged A custom emoji has been added or changed + EmojiChanged = EventsAPIType("emoji_changed") // FileChange is sent when a file is changed. FileChange = EventsAPIType("file_change") + // FileCreated is an event when a file is created in a workspace + FileCreated = EventsAPIType("file_created") // FileDeleted is sent when a file is deleted. FileDeleted = EventsAPIType("file_deleted") + // FilePublic is an event when a file is made public in a workspace + FilePublic = EventsAPIType("file_public") // FileShared is sent when a file is shared. FileShared = EventsAPIType("file_shared") // FileUnshared is sent when a file is unshared. FileUnshared = EventsAPIType("file_unshared") + // FunctionExecuted is an event when a Slack function is executed + FunctionExecuted = EventsAPIType("function_executed") // GridMigrationFinished An enterprise grid migration has finished on this workspace. GridMigrationFinished = EventsAPIType("grid_migration_finished") // GridMigrationStarted An enterprise grid migration has started on this workspace. GridMigrationStarted = EventsAPIType("grid_migration_started") + // GroupArchive is sent when a group is archived. + GroupArchive = EventsAPIType("group_archive") + // GroupClose A group was closed + GroupClose = EventsAPIType("group_close") + // GroupDeleted is sent when a group is deleted. + GroupDeleted = EventsAPIType("group_deleted") + // GroupHistoryChanged The history of a group changed + GroupHistoryChanged = EventsAPIType("group_history_changed") + // GroupLeft is sent when a group is left. + GroupLeft = EventsAPIType("group_left") + // GroupOpen A group was opened + GroupOpen = EventsAPIType("group_open") + // GroupRename is sent when a group is renamed. + GroupRename = EventsAPIType("group_rename") + // GroupUnarchive is sent when a group is unarchived. + GroupUnarchive = EventsAPIType("group_unarchive") + // ImClose An instant message channel was closed + ImClose = EventsAPIType("im_close") + // ImCreated An instant message channel was created + ImCreated = EventsAPIType("im_created") + // ImHistoryChanged The history of an instant message channel changed + ImHistoryChanged = EventsAPIType("im_history_changed") + // ImOpen An instant message channel was opened + ImOpen = EventsAPIType("im_open") + // InviteRequested is an event when a user requests an invite to a workspace + InviteRequested = EventsAPIType("invite_requested") // LinkShared A message was posted containing one or more links relevant to your application LinkShared = EventsAPIType("link_shared") - // Message A message was posted to a channel, private channel (group), im, or mim - Message = EventsAPIType("message") // MemberJoinedChannel is sent if a member joined a channel. MemberJoinedChannel = EventsAPIType("member_joined_channel") // MemberLeftChannel is sent if a member left a channel. MemberLeftChannel = EventsAPIType("member_left_channel") + // Message A message was posted to a channel, private channel (group), im, or mim + Message = EventsAPIType("message") + // MessageMetadataDeleted A message with metadata was deleted + MessageMetadataDeleted = EventsAPIType("message_metadata_deleted") + // MessageMetadataPosted A message with metadata was posted + MessageMetadataPosted = EventsAPIType("message_metadata_posted") + // MessageMetadataUpdated A message with metadata was updated + MessageMetadataUpdated = EventsAPIType("message_metadata_updated") // PinAdded An item was pinned to a channel PinAdded = EventsAPIType("pin_added") // PinRemoved An item was unpinned from a channel @@ -1225,58 +1277,20 @@ const ( ReactionAdded = EventsAPIType("reaction_added") // ReactionRemoved An reaction was removed from a message ReactionRemoved = EventsAPIType("reaction_removed") - // TeamJoin A new user joined the workspace - TeamJoin = EventsAPIType("team_join") - // Slack connect app or bot invite received - SharedChannelInviteReceived = EventsAPIType("shared_channel_invite_received") - // Slack connect channel invite approved + // SharedChannelInviteAccepted Slack connect channel invite accepted by an end user + SharedChannelInviteAccepted = EventsAPIType("shared_channel_invite_accepted") + // SharedChannelInviteApproved Slack connect channel invite approved SharedChannelInviteApproved = EventsAPIType("shared_channel_invite_approved") - // Slack connect channel invite declined + // SharedChannelInviteDeclined Slack connect channel invite declined SharedChannelInviteDeclined = EventsAPIType("shared_channel_invite_declined") - // Slack connect channel invite accepted by an end user - SharedChannelInviteAccepted = EventsAPIType("shared_channel_invite_accepted") - // TokensRevoked APP's API tokes are revoked - TokensRevoked = EventsAPIType("tokens_revoked") - // EmojiChanged A custom emoji has been added or changed - EmojiChanged = EventsAPIType("emoji_changed") - // WorkflowStepExecute Happens, if a workflow step of your app is invoked - WorkflowStepExecute = EventsAPIType("workflow_step_execute") - // MessageMetadataPosted A message with metadata was posted - MessageMetadataPosted = EventsAPIType("message_metadata_posted") - // MessageMetadataUpdated A message with metadata was updated - MessageMetadataUpdated = EventsAPIType("message_metadata_updated") - // MessageMetadataDeleted A message with metadata was deleted - MessageMetadataDeleted = EventsAPIType("message_metadata_deleted") - // TeamAccessGranted is sent if access to teams was granted for your org-wide app. - TeamAccessGranted = EventsAPIType("team_access_granted") - // TeamAccessRevoked is sent if access to teams was revoked for your org-wide app. - TeamAccessRevoked = EventsAPIType("team_access_revoked") - // UserProfileChanged is sent if a user's profile information has changed. - UserProfileChanged = EventsAPIType("user_profile_changed") - // ChannelHistoryChanged The history of a channel changed - ChannelHistoryChanged = EventsAPIType("channel_history_changed") - // CommandsChanged A command was changed - CommandsChanged = EventsAPIType("commands_changed") - // DndUpdated Do Not Disturb settings were updated - DndUpdated = EventsAPIType("dnd_updated") - // DndUpdatedUser Do Not Disturb settings for a user were updated - DndUpdatedUser = EventsAPIType("dnd_updated_user") - // EmailDomainChanged The email domain changed - EmailDomainChanged = EventsAPIType("email_domain_changed") - // GroupClose A group was closed - GroupClose = EventsAPIType("group_close") - // GroupHistoryChanged The history of a group changed - GroupHistoryChanged = EventsAPIType("group_history_changed") - // GroupOpen A group was opened - GroupOpen = EventsAPIType("group_open") - // ImClose An instant message channel was closed - ImClose = EventsAPIType("im_close") - // ImCreated An instant message channel was created - ImCreated = EventsAPIType("im_created") - // ImHistoryChanged The history of an instant message channel changed - ImHistoryChanged = EventsAPIType("im_history_changed") - // ImOpen An instant message channel was opened - ImOpen = EventsAPIType("im_open") + // SharedChannelInviteReceived Slack connect app or bot invite received + SharedChannelInviteReceived = EventsAPIType("shared_channel_invite_received") + // SharedChannelInviteRequested is an event when an invitation to share a channel is requested + SharedChannelInviteRequested = EventsAPIType("shared_channel_invite_requested") + // StarAdded is an event when a star is added to a message or file + StarAdded = EventsAPIType("star_added") + // StarRemoved is an event when a star is removed from a message or file + StarRemoved = EventsAPIType("star_removed") // SubteamCreated A subteam was created SubteamCreated = EventsAPIType("subteam_created") // SubteamMembersChanged The members of a subteam changed @@ -1287,125 +1301,112 @@ const ( SubteamSelfRemoved = EventsAPIType("subteam_self_removed") // SubteamUpdated A subteam was updated SubteamUpdated = EventsAPIType("subteam_updated") + // TeamAccessGranted is sent if access to teams was granted for your org-wide app. + TeamAccessGranted = EventsAPIType("team_access_granted") + // TeamAccessRevoked is sent if access to teams was revoked for your org-wide app. + TeamAccessRevoked = EventsAPIType("team_access_revoked") // TeamDomainChange The team's domain changed TeamDomainChange = EventsAPIType("team_domain_change") + // TeamJoin A new user joined the workspace + TeamJoin = EventsAPIType("team_join") // TeamRename The team was renamed TeamRename = EventsAPIType("team_rename") + // TokensRevoked APP's API tokes are revoked + TokensRevoked = EventsAPIType("tokens_revoked") // UserChange A user object has changed UserChange = EventsAPIType("user_change") - // AppDeleted is an event when an app is deleted from a workspace - AppDeleted = EventsAPIType("app_deleted") - // AppInstalled is an event when an app is installed to a workspace - AppInstalled = EventsAPIType("app_installed") - // AppRequested is an event when a user requests to install an app to a workspace - AppRequested = EventsAPIType("app_requested") - // AppUninstalledTeam is an event when an app is uninstalled from a team - AppUninstalledTeam = EventsAPIType("app_uninstalled_team") - // CallRejected is an event when a Slack call is rejected - CallRejected = EventsAPIType("call_rejected") - // ChannelShared is an event when a channel is shared with another workspace - ChannelShared = EventsAPIType("channel_shared") - // FileCreated is an event when a file is created in a workspace - FileCreated = EventsAPIType("file_created") - // FilePublic is an event when a file is made public in a workspace - FilePublic = EventsAPIType("file_public") - // FunctionExecuted is an event when a Slack function is executed - FunctionExecuted = EventsAPIType("function_executed") - // InviteRequested is an event when a user requests an invite to a workspace - InviteRequested = EventsAPIType("invite_requested") - // SharedChannelInviteRequested is an event when an invitation to share a channel is requested - SharedChannelInviteRequested = EventsAPIType("shared_channel_invite_requested") - // StarAdded is an event when a star is added to a message or file - StarAdded = EventsAPIType("star_added") - // StarRemoved is an event when a star is removed from a message or file - StarRemoved = EventsAPIType("star_removed") // UserHuddleChanged is an event when a user's huddle status changes UserHuddleChanged = EventsAPIType("user_huddle_changed") + // UserProfileChanged is sent if a user's profile information has changed. + UserProfileChanged = EventsAPIType("user_profile_changed") // UserStatusChanged is an event when a user's status changes UserStatusChanged = EventsAPIType("user_status_changed") + // WorkflowStepExecute Happens, if a workflow step of your app is invoked + WorkflowStepExecute = EventsAPIType("workflow_step_execute") ) // EventsAPIInnerEventMapping maps INNER Event API events to their corresponding struct // implementations. The structs should be instances of the unmarshalling // target for the matching event type. var EventsAPIInnerEventMapping = map[EventsAPIType]interface{}{ - AppMention: AppMentionEvent{}, + AppDeleted: AppDeletedEvent{}, AppHomeOpened: AppHomeOpenedEvent{}, + AppInstalled: AppInstalledEvent{}, + AppMention: AppMentionEvent{}, + AppRequested: AppRequestedEvent{}, AppUninstalled: AppUninstalledEvent{}, - AssistantThreadStarted: AssistantThreadStartedEvent{}, + AppUninstalledTeam: AppUninstalledTeamEvent{}, AssistantThreadContextChanged: AssistantThreadContextChangedEvent{}, + AssistantThreadStarted: AssistantThreadStartedEvent{}, + CallRejected: CallRejectedEvent{}, + ChannelArchive: ChannelArchiveEvent{}, ChannelCreated: ChannelCreatedEvent{}, ChannelDeleted: ChannelDeletedEvent{}, - ChannelArchive: ChannelArchiveEvent{}, - ChannelUnarchive: ChannelUnarchiveEvent{}, - ChannelUnshared: ChannelUnsharedEvent{}, + ChannelHistoryChanged: ChannelHistoryChangedEvent{}, + ChannelIDChanged: ChannelIDChangedEvent{}, ChannelLeft: ChannelLeftEvent{}, ChannelRename: ChannelRenameEvent{}, - ChannelIDChanged: ChannelIDChangedEvent{}, + ChannelShared: ChannelSharedEvent{}, + ChannelUnarchive: ChannelUnarchiveEvent{}, + ChannelUnshared: ChannelUnsharedEvent{}, + CommandsChanged: CommandsChangedEvent{}, + DndUpdated: DndUpdatedEvent{}, + DndUpdatedUser: DndUpdatedUserEvent{}, + EmailDomainChanged: EmailDomainChangedEvent{}, + EmojiChanged: EmojiChangedEvent{}, FileChange: FileChangeEvent{}, + FileCreated: FileCreatedEvent{}, FileDeleted: FileDeletedEvent{}, + FilePublic: FilePublicEvent{}, FileShared: FileSharedEvent{}, FileUnshared: FileUnsharedEvent{}, - GroupDeleted: GroupDeletedEvent{}, + FunctionExecuted: FunctionExecutedEvent{}, + GridMigrationFinished: GridMigrationFinishedEvent{}, + GridMigrationStarted: GridMigrationStartedEvent{}, GroupArchive: GroupArchiveEvent{}, - GroupUnarchive: GroupUnarchiveEvent{}, + GroupClose: GroupCloseEvent{}, + GroupDeleted: GroupDeletedEvent{}, + GroupHistoryChanged: GroupHistoryChangedEvent{}, GroupLeft: GroupLeftEvent{}, + GroupOpen: GroupOpenEvent{}, GroupRename: GroupRenameEvent{}, - GridMigrationFinished: GridMigrationFinishedEvent{}, - GridMigrationStarted: GridMigrationStartedEvent{}, + GroupUnarchive: GroupUnarchiveEvent{}, + ImClose: ImCloseEvent{}, + ImCreated: ImCreatedEvent{}, + ImHistoryChanged: ImHistoryChangedEvent{}, + ImOpen: ImOpenEvent{}, + InviteRequested: InviteRequestedEvent{}, LinkShared: LinkSharedEvent{}, - Message: MessageEvent{}, MemberJoinedChannel: MemberJoinedChannelEvent{}, MemberLeftChannel: MemberLeftChannelEvent{}, + Message: MessageEvent{}, + MessageMetadataDeleted: MessageMetadataDeletedEvent{}, + MessageMetadataPosted: MessageMetadataPostedEvent{}, + MessageMetadataUpdated: MessageMetadataUpdatedEvent{}, PinAdded: PinAddedEvent{}, PinRemoved: PinRemovedEvent{}, ReactionAdded: ReactionAddedEvent{}, ReactionRemoved: ReactionRemovedEvent{}, - SharedChannelInviteApproved: SharedChannelInviteApprovedEvent{}, SharedChannelInviteAccepted: SharedChannelInviteAcceptedEvent{}, + SharedChannelInviteApproved: SharedChannelInviteApprovedEvent{}, SharedChannelInviteDeclined: SharedChannelInviteDeclinedEvent{}, SharedChannelInviteReceived: SharedChannelInviteReceivedEvent{}, - TeamJoin: TeamJoinEvent{}, - TokensRevoked: TokensRevokedEvent{}, - EmojiChanged: EmojiChangedEvent{}, - MessageMetadataPosted: MessageMetadataPostedEvent{}, - MessageMetadataUpdated: MessageMetadataUpdatedEvent{}, - MessageMetadataDeleted: MessageMetadataDeletedEvent{}, - TeamAccessGranted: TeamAccessGrantedEvent{}, - TeamAccessRevoked: TeamAccessRevokedEvent{}, - UserProfileChanged: UserProfileChangedEvent{}, - ChannelHistoryChanged: ChannelHistoryChangedEvent{}, - DndUpdated: DndUpdatedEvent{}, - DndUpdatedUser: DndUpdatedUserEvent{}, - EmailDomainChanged: EmailDomainChangedEvent{}, - GroupClose: GroupCloseEvent{}, - GroupHistoryChanged: GroupHistoryChangedEvent{}, - GroupOpen: GroupOpenEvent{}, - ImClose: ImCloseEvent{}, - ImCreated: ImCreatedEvent{}, - ImHistoryChanged: ImHistoryChangedEvent{}, - ImOpen: ImOpenEvent{}, + SharedChannelInviteRequested: SharedChannelInviteRequestedEvent{}, + StarAdded: StarAddedEvent{}, + StarRemoved: StarRemovedEvent{}, SubteamCreated: SubteamCreatedEvent{}, SubteamMembersChanged: SubteamMembersChangedEvent{}, SubteamSelfAdded: SubteamSelfAddedEvent{}, SubteamSelfRemoved: SubteamSelfRemovedEvent{}, SubteamUpdated: SubteamUpdatedEvent{}, + TeamAccessGranted: TeamAccessGrantedEvent{}, + TeamAccessRevoked: TeamAccessRevokedEvent{}, TeamDomainChange: TeamDomainChangeEvent{}, + TeamJoin: TeamJoinEvent{}, TeamRename: TeamRenameEvent{}, + TokensRevoked: TokensRevokedEvent{}, UserChange: UserChangeEvent{}, - AppDeleted: AppDeletedEvent{}, - AppInstalled: AppInstalledEvent{}, - AppRequested: AppRequestedEvent{}, - AppUninstalledTeam: AppUninstalledTeamEvent{}, - CallRejected: CallRejectedEvent{}, - ChannelShared: ChannelSharedEvent{}, - FileCreated: FileCreatedEvent{}, - FilePublic: FilePublicEvent{}, - FunctionExecuted: FunctionExecutedEvent{}, - InviteRequested: InviteRequestedEvent{}, - SharedChannelInviteRequested: SharedChannelInviteRequestedEvent{}, - StarAdded: StarAddedEvent{}, - StarRemoved: StarRemovedEvent{}, UserHuddleChanged: UserHuddleChangedEvent{}, + UserProfileChanged: UserProfileChangedEvent{}, UserStatusChanged: UserStatusChangedEvent{}, } From da9768248627cbeedabb83781f7f92579be1af0d Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 7 Jun 2025 23:52:22 +0100 Subject: [PATCH 371/606] feat: return Errors field in SlackErrorResponse --- .../conversations_invite.go | 37 ++++ misc.go | 88 ++++++++- misc_test.go | 185 ++++++++++++++++++ 3 files changed, 306 insertions(+), 4 deletions(-) create mode 100644 examples/conversations_invite/conversations_invite.go diff --git a/examples/conversations_invite/conversations_invite.go b/examples/conversations_invite/conversations_invite.go new file mode 100644 index 000000000..8b977ceee --- /dev/null +++ b/examples/conversations_invite/conversations_invite.go @@ -0,0 +1,37 @@ +package main + +import ( + "errors" + "fmt" + "os" + + "github.com/slack-go/slack" +) + +func main() { + userToken := os.Getenv("SLACK_USER_TOKEN") + if userToken == "" { + fmt.Fprintf(os.Stderr, "SLACK_USER_TOKEN must be set.\n") + os.Exit(1) + } + + api := slack.New(userToken) + _, err := api.InviteUsersToConversation( + "C1234567890", // Replace with the actual channel ID you want to invite the user to + "U1234567890", // Replace with the actual user ID you want to invite + ) + if err != nil { + var errorResponse slack.SlackErrorResponse + if errors.As(err, &errorResponse) { + for _, e := range errorResponse.Errors { + if e.ConversationsInviteResponseError != nil { + fmt.Fprintf(os.Stderr, "error inviting user (%s) to conversation: %s\n", e.ConversationsInviteResponseError.User, e.ConversationsInviteResponseError.Error) + } + } + } else { + fmt.Fprintf(os.Stderr, "error inviting user to conversation: %s\n", err.Error()) + } + os.Exit(1) + } + fmt.Println("User invited successfully to the conversation.") +} diff --git a/misc.go b/misc.go index f06108e80..7a0ab3545 100644 --- a/misc.go +++ b/misc.go @@ -19,11 +19,90 @@ import ( "time" ) +// Apps Manifest Create Response Errors ("/apps.manifest.create") +type AppsManifestCreateResponseError struct { + Code string `json:"code,omitempty"` + Message string `json:"message"` + Pointer string `json:"pointer"` + RelatedComponent string `json:"related_component,omitempty"` +} + +// Conversations Invite Response Errors ("/conversations.invite") +type ConversationsInviteResponseError struct { + Error string `json:"error"` + Ok bool `json:"ok"` + User string `json:"user"` +} + +func (t ConversationsInviteResponseError) Err() error { + if !t.Ok { + return fmt.Errorf("conversations invite error (user: %s): %s", t.User, t.Error) + } + return nil +} + +// SlackResponseErrors represents a union type for different error structures +type SlackResponseErrors struct { + AppsManifestCreateResponseError *AppsManifestCreateResponseError `json:"-"` + ConversationsInviteResponseError *ConversationsInviteResponseError `json:"-"` +} + +// MarshalJSON implements custom marshaling for SlackResponseErrors +func (e SlackResponseErrors) MarshalJSON() ([]byte, error) { + if e.AppsManifestCreateResponseError != nil { + return json.Marshal(e.AppsManifestCreateResponseError) + } + if e.ConversationsInviteResponseError != nil { + return json.Marshal(e.ConversationsInviteResponseError) + } + return json.Marshal(nil) +} + +// UnmarshalJSON implements custom unmarshaling for SlackResponseErrors +func (e *SlackResponseErrors) UnmarshalJSON(data []byte) error { + if bytes.Equal(data, []byte("null")) { + return nil + } + + // Try to determine the error type by checking for unique fields + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + if _, hasPointer := raw["pointer"]; hasPointer { + if _, hasMessage := raw["message"]; hasMessage { + var amc AppsManifestCreateResponseError + if err := json.Unmarshal(data, &amc); err != nil { + return err + } + e.AppsManifestCreateResponseError = &amc + return nil + } + } + + if _, hasUser := raw["user"]; hasUser { + if _, hasError := raw["error"]; hasError { + if _, hasOk := raw["ok"]; hasOk { + var ci ConversationsInviteResponseError + if err := json.Unmarshal(data, &ci); err != nil { + return err + } + e.ConversationsInviteResponseError = &ci + return nil + } + } + } + + return fmt.Errorf("unknown error structure: %s", string(data)) +} + // SlackResponse handles parsing out errors from the web api. type SlackResponse struct { - Ok bool `json:"ok"` - Error string `json:"error"` - ResponseMetadata ResponseMetadata `json:"response_metadata"` + Ok bool `json:"ok"` + Error string `json:"error"` + Errors []SlackResponseErrors `json:"errors,omitempty"` + ResponseMetadata ResponseMetadata `json:"response_metadata"` } func (t SlackResponse) Err() error { @@ -38,12 +117,13 @@ func (t SlackResponse) Err() error { return nil } - return SlackErrorResponse{Err: t.Error, ResponseMetadata: t.ResponseMetadata} + return SlackErrorResponse{Err: t.Error, Errors: t.Errors, ResponseMetadata: t.ResponseMetadata} } // SlackErrorResponse brings along the metadata of errors returned by the Slack API. type SlackErrorResponse struct { Err string + Errors []SlackResponseErrors ResponseMetadata ResponseMetadata } diff --git a/misc_test.go b/misc_test.go index 637cb77fb..06fab1eee 100644 --- a/misc_test.go +++ b/misc_test.go @@ -2,6 +2,7 @@ package slack import ( "context" + "encoding/json" "log" "net/http" "net/url" @@ -104,3 +105,187 @@ func TestRetryable(t *testing.T) { } } } + +func TestSlackResponseErrorsMarshaling(t *testing.T) { + tests := []struct { + name string + errors SlackResponseErrors + expected string + }{ + { + name: "AppsManifestCreateResponseError", + errors: SlackResponseErrors{ + AppsManifestCreateResponseError: &AppsManifestCreateResponseError{ + Message: "Interactivity requires Socket Mode enabled", + Pointer: "/settings/interactivity", + }, + }, + expected: `{"message":"Interactivity requires Socket Mode enabled","pointer":"/settings/interactivity"}`, + }, + { + name: "ConversationsInviteResponseError", + errors: SlackResponseErrors{ + ConversationsInviteResponseError: &ConversationsInviteResponseError{ + Error: "invalid_user", + Ok: false, + User: "U12345678", + }, + }, + expected: `{"error":"invalid_user","ok":false,"user":"U12345678"}`, + }, + { + name: "EmptyErrors", + errors: SlackResponseErrors{}, + expected: `null`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(tt.errors) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(data) != tt.expected { + t.Errorf("got %s; want %s", string(data), tt.expected) + } + }) + } +} + +func TestSlackResponseErrorsUnmarshaling(t *testing.T) { + tests := []struct { + name string + input string + expected SlackResponseErrors + }{ + { + name: "AppsManifestCreateResponseError", + input: `{"pointer":"/settings/interactivity","message":"Interactivity requires Socket Mode enabled"}`, + expected: SlackResponseErrors{ + AppsManifestCreateResponseError: &AppsManifestCreateResponseError{ + Pointer: "/settings/interactivity", + Message: "Interactivity requires Socket Mode enabled", + }, + }, + }, + { + name: "ConversationsInviteResponseError", + input: `{"error":"invalid_user","ok":false,"user":"U12345678"}`, + expected: SlackResponseErrors{ + ConversationsInviteResponseError: &ConversationsInviteResponseError{ + Error: "invalid_user", + Ok: false, + User: "U12345678", + }, + }, + }, + { + name: "NullInput", + input: `null`, + expected: SlackResponseErrors{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var errors SlackResponseErrors + err := json.Unmarshal([]byte(tt.input), &errors) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if tt.expected.AppsManifestCreateResponseError != nil { + if errors.AppsManifestCreateResponseError == nil { + t.Error("expected AppsManifestCreateResponseError, got nil") + } else if *errors.AppsManifestCreateResponseError != *tt.expected.AppsManifestCreateResponseError { + t.Errorf("got %+v; want %+v", *errors.AppsManifestCreateResponseError, *tt.expected.AppsManifestCreateResponseError) + } + } + + if tt.expected.ConversationsInviteResponseError != nil { + if errors.ConversationsInviteResponseError == nil { + t.Error("expected ConversationsInviteResponseError, got nil") + } else if *errors.ConversationsInviteResponseError != *tt.expected.ConversationsInviteResponseError { + t.Errorf("got %+v; want %+v", *errors.ConversationsInviteResponseError, *tt.expected.ConversationsInviteResponseError) + } + } + }) + } +} + +func TestSlackResponseErrorsUnmarshalingUnknownStructure(t *testing.T) { + input := `{"unknown_field":"value","other_field":123}` + var errors SlackResponseErrors + err := json.Unmarshal([]byte(input), &errors) + if err == nil { + t.Error("expected error for unknown structure, got nil") + } + expectedError := "unknown error structure: " + input + if err.Error() != expectedError { + t.Errorf("got error %q; want %q", err.Error(), expectedError) + } +} + +func TestSlackResponseWithErrors(t *testing.T) { + tests := []struct { + name string + input string + expected SlackResponse + }{ + { + name: "ResponseWithAppsManifestCreateResponseError", + input: `{"ok":false,"error":"invalid_manifest","errors":[{"pointer":"/settings/interactivity","message":"Interactivity requires Socket Mode enabled"}]}`, + expected: SlackResponse{ + Ok: false, + Error: "invalid_manifest", + Errors: []SlackResponseErrors{ + { + AppsManifestCreateResponseError: &AppsManifestCreateResponseError{ + Pointer: "/settings/interactivity", + Message: "Interactivity requires Socket Mode enabled", + }, + }, + }, + }, + }, + { + name: "ResponseWithoutErrors", + input: `{"ok":true}`, + expected: SlackResponse{ + Ok: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var response SlackResponse + err := json.Unmarshal([]byte(tt.input), &response) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if response.Ok != tt.expected.Ok { + t.Errorf("got Ok=%v; want Ok=%v", response.Ok, tt.expected.Ok) + } + if response.Error != tt.expected.Error { + t.Errorf("got Error=%q; want Error=%q", response.Error, tt.expected.Error) + } + + if tt.expected.Errors == nil { + if response.Errors != nil { + t.Error("expected nil Errors, got non-nil") + } + } else { + if response.Errors == nil { + t.Error("expected non-nil Errors, got nil") + return + } + if len(tt.expected.Errors) == 0 { + t.Errorf("got Errors=%v; want Errors=%v", response.Errors, tt.expected.Errors) + } + } + }) + } +} From 4620cceeb22f2f80b64f01ae7cb217a681f79920 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 8 Jun 2025 16:32:58 +0100 Subject: [PATCH 372/606] doc: add README.md to examples/eventsapi --- examples/eventsapi/README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 examples/eventsapi/README.md diff --git a/examples/eventsapi/README.md b/examples/eventsapi/README.md new file mode 100644 index 000000000..82e723402 --- /dev/null +++ b/examples/eventsapi/README.md @@ -0,0 +1,17 @@ +# Events Example + +This is a very simple example but should give you a glimpse of how to use the events API. + +## How to enable this + +1. Disable socket mode in the app if it's enabled: this will reveal the `Request URL`. +2. Set up the events `Request URL` in a way that matches the endpoint in + [events.go](./events.go). + + You can find this [here](https://api.slack.com/apps//event-subscriptions). +3. Set up the events you want to be subscribed to. +4. Copy the bot token and signing secret and set up the environment variables (per code). +5. Run the example: + ```bash + go run events.go + ``` From b8e2b9e498d4ff23c828aa2cc3c6f7deaa0fa1cc Mon Sep 17 00:00:00 2001 From: Jared Mosley Date: Mon, 9 Jun 2025 15:07:01 -0700 Subject: [PATCH 373/606] add slack file support for image block element; fix slack file support for image block --- block_element.go | 17 ++++++++++++++--- block_element_test.go | 9 +++++++++ block_image.go | 12 ++++++++++++ block_image_test.go | 16 +++++++++++++++- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/block_element.go b/block_element.go index c27dc6f2e..944d86493 100644 --- a/block_element.go +++ b/block_element.go @@ -117,9 +117,10 @@ func (s UnknownBlockElement) ElementType() MessageElementType { // // More Information: https://api.slack.com/reference/messaging/block-elements#image type ImageBlockElement struct { - Type MessageElementType `json:"type"` - ImageURL string `json:"image_url"` - AltText string `json:"alt_text"` + Type MessageElementType `json:"type"` + ImageURL string `json:"image_url"` + AltText string `json:"alt_text"` + SlackFile *SlackFileObject `json:"slack_file,omitempty"` } // ElementType returns the type of the Element @@ -140,6 +141,16 @@ func NewImageBlockElement(imageURL, altText string) *ImageBlockElement { } } +// NewImageBlockElementSlackFile returns a new instance of an image block element +// TODO: BREAKING CHANGE - This should be combined with the function above +func NewImageBlockElementSlackFile(slackFile *SlackFileObject, altText string) *ImageBlockElement { + return &ImageBlockElement{ + Type: METImage, + SlackFile: slackFile, + AltText: altText, + } +} + // Style is a style of Button element // https://api.slack.com/reference/block-kit/block-elements#button__fields type Style string diff --git a/block_element_test.go b/block_element_test.go index fead400c7..ed29868cb 100644 --- a/block_element_test.go +++ b/block_element_test.go @@ -14,6 +14,15 @@ func TestNewImageBlockElement(t *testing.T) { assert.Equal(t, imageElement.AltText, "Location Pin Icon") } +func TestNewImageBlockElementSlackFile(t *testing.T) { + slackFile := &SlackFileObject{URL: "https://api.slack.com/img/blocks/bkb_template_images/tripAgentLocationMarker.png"} + imageElement := NewImageBlockElementSlackFile(slackFile, "Location Pin Icon") + + assert.Equal(t, string(imageElement.Type), "image") + assert.Contains(t, imageElement.SlackFile.URL, "tripAgentLocationMarker") + assert.Equal(t, imageElement.AltText, "Location Pin Icon") +} + func TestNewButtonBlockElement(t *testing.T) { btnTxt := NewTextBlockObject("plain_text", "Next 2 Results", false, false) btnElement := NewButtonBlockElement("test", "click_me_123", btnTxt) diff --git a/block_image.go b/block_image.go index cdee5b4ab..2a914e5a0 100644 --- a/block_image.go +++ b/block_image.go @@ -41,3 +41,15 @@ func NewImageBlock(imageURL, altText, blockID string, title *TextBlockObject) *I Title: title, } } + +// NewImageBlockSlackFile returns an instance of a new Image Block type +// TODO: BREAKING CHANGE - This should be combined with the function above +func NewImageBlockSlackFile(slackFile *SlackFileObject, altText string, blockID string, title *TextBlockObject) *ImageBlock { + return &ImageBlock{ + Type: MBTImage, + SlackFile: slackFile, + AltText: altText, + BlockID: blockID, + Title: title, + } +} diff --git a/block_image_test.go b/block_image_test.go index 801a14217..0670bb6b1 100644 --- a/block_image_test.go +++ b/block_image_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestNewImageBlock(t *testing.T) { +func TestImageURLForNewImageBlock(t *testing.T) { imageText := NewTextBlockObject("plain_text", "Location", false, false) imageBlock := NewImageBlock("https://api.slack.com/img/blocks/bkb_template_images/tripAgentLocationMarker.png", "Marker", "test", imageText) @@ -18,3 +18,17 @@ func TestNewImageBlock(t *testing.T) { assert.Contains(t, imageBlock.Title.Text, "Location") assert.Contains(t, imageBlock.ImageURL, "tripAgentLocationMarker.png") } + +func TestSlackFileForNewImageBlock(t *testing.T) { + imageText := NewTextBlockObject("plain_text", "Location", false, false) + slackFile := &SlackFileObject{URL: "https://api.slack.com/img/blocks/bkb_template_images/tripAgentLocationMarker.png"} + imageBlock := NewImageBlockSlackFile(slackFile, "Marker", "test", imageText) + + assert.Equal(t, imageBlock.BlockType(), MBTImage) + assert.Equal(t, string(imageBlock.Type), "image") + assert.Equal(t, imageBlock.Title.Type, "plain_text") + assert.Equal(t, imageBlock.ID(), "test") + assert.Equal(t, imageBlock.BlockID, "test") + assert.Contains(t, imageBlock.Title.Text, "Location") + assert.Contains(t, imageBlock.SlackFile.URL, "tripAgentLocationMarker.png") +} From 1af0c3bd079ce470a1ce9ec5bebc1c7164170e03 Mon Sep 17 00:00:00 2001 From: James Mills <1290234+prologic@users.noreply.github.com> Date: Thu, 12 Jun 2025 17:41:10 +1000 Subject: [PATCH 374/606] [slacktest]: Fix missing /apps.connections.open handler for SocketMode Fixes #1244 Closes #1247 --- slacktest/funcs.go | 19 +++++++++++++++++++ slacktest/handlers.go | 5 +++++ slacktest/server.go | 1 + socket_mode.go | 5 ++++- 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/slacktest/funcs.go b/slacktest/funcs.go index 7be54fe8d..2b47f8794 100644 --- a/slacktest/funcs.go +++ b/slacktest/funcs.go @@ -94,6 +94,15 @@ func BotIDFromContext(ctx context.Context) string { return botname } +// ServerWSFromContext returns the server websocket endpoint from a provided context +func ServerWSFromContext(ctx context.Context) string { + url, ok := ctx.Value(ServerWSContextKey).(string) + if !ok { + return "ws://wtf?!" + } + return url +} + // generate a full rtminfo response for initial rtm connections func generateRTMInfo(ctx context.Context, wsurl string) *fullInfoSlackResponse { rtmInfo := slack.Info{ @@ -133,3 +142,13 @@ func defaultBotInfoJSON(ctx context.Context) string { } `, botid, botname) } + +func defaultAppsConnectionsJSON(ctx context.Context) string { + url := ServerWSFromContext(ctx) + return fmt.Sprintf(` + { + "ok":true, + "url": "%s" + } + `, url) +} diff --git a/slacktest/handlers.go b/slacktest/handlers.go index 22c1680f6..460977fa6 100644 --- a/slacktest/handlers.go +++ b/slacktest/handlers.go @@ -123,6 +123,11 @@ func reactionAddHandler(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(defaultOkJSON)) } +// handle apps.connections.open +func appsConnectionsOpenHandler(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(defaultAppsConnectionsJSON(r.Context()))) +} + // handle chat.postMessage func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) { serverAddr := r.Context().Value(ServerBotHubNameContextKey).(string) diff --git a/slacktest/server.go b/slacktest/server.go index ba01939fa..c7214505f 100644 --- a/slacktest/server.go +++ b/slacktest/server.go @@ -61,6 +61,7 @@ func NewTestServer(custom ...Binder) *Server { s.Handle("/bots.info", botsInfoHandler) s.Handle("/auth.test", authTestHandler) s.Handle("/reactions.add", reactionAddHandler) + s.Handle("/apps.connections.open", appsConnectionsOpenHandler) httpserver := httptest.NewUnstartedServer(s.mux) addr := httpserver.Listener.Addr().String() diff --git a/socket_mode.go b/socket_mode.go index c741c5368..ea9ea3b7a 100644 --- a/socket_mode.go +++ b/socket_mode.go @@ -2,6 +2,7 @@ package slack import ( "context" + "net/url" ) // SocketModeConnection contains various details about the SocketMode connection. @@ -34,7 +35,9 @@ func (api *Client) StartSocketModeContext(ctx context.Context) (info *SocketMode // can add a query parameter `debug_reconnects=true` to the URL to make the connection // time significantly shorter (360 seconds). if api.debug { - response.SocketModeConnection.URL += "&debug_reconnects=true" + u, _ := url.Parse(response.SocketModeConnection.URL) + u.Query().Add("debug_reconnects", "true") + response.SocketModeConnection.URL = u.String() } return &response.SocketModeConnection, response.SocketModeConnection.URL, response.Err() } From eb165d98a16dfcc0c5b08a4f4e4cb93aea45bf06 Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Thu, 12 Jun 2025 11:00:04 +0100 Subject: [PATCH 375/606] Add a Timezone parameter for the slack TimePickerBlockElement --- block_element.go | 1 + 1 file changed, 1 insertion(+) diff --git a/block_element.go b/block_element.go index c27dc6f2e..8fed87bce 100644 --- a/block_element.go +++ b/block_element.go @@ -468,6 +468,7 @@ type TimePickerBlockElement struct { Placeholder *TextBlockObject `json:"placeholder,omitempty"` InitialTime string `json:"initial_time,omitempty"` Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` + Timezone string `json:"timezone,omitempty"` } // ElementType returns the type of the Element From 7f276f54c369f43d3530925505ec4991f0064d21 Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Fri, 13 Jun 2025 14:42:14 +0100 Subject: [PATCH 376/606] timepicker, not datetimepicker, duh --- interactions_test.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/interactions_test.go b/interactions_test.go index 17b9e0672..a8f268722 100644 --- a/interactions_test.go +++ b/interactions_test.go @@ -144,6 +144,19 @@ const ( "type": "conversations_select", "value": "C1AB2C3DE" } + }, + "some_datetime": { + "value": { + "type": "datetimepicker", + "selected_date_time": null + } + }, + "some_timepicker": { + "value": { + "type": "timepicker", + "timezone": "Europe/Berlin", + "initial_time": "12:00" + } } } }, @@ -306,6 +319,19 @@ func TestViewSubmissionCallback(t *testing.T) { Value: "C1AB2C3DE", }, }, + "some_datetime": { + "value": BlockAction{ + Type: "datetimepicker", + // No selected datetime! + }, + }, + "some_timepicker": { + "value": BlockAction{ + Type: "timepicker", + InitialTime: "12:00", + Timezone: "Europe/Berlin", + }, + }, }, }, AppInstalledTeamID: "T1ABCD2E12", From f01c2cb7119d90c7e978190cc40bacbee235d7ed Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Fri, 13 Jun 2025 14:22:43 +0100 Subject: [PATCH 377/606] Update timezone unmarshaller --- block.go | 1 + 1 file changed, 1 insertion(+) diff --git a/block.go b/block.go index 42ac8405c..7c4f99308 100644 --- a/block.go +++ b/block.go @@ -52,6 +52,7 @@ type BlockAction struct { SelectedDate string `json:"selected_date"` SelectedTime string `json:"selected_time"` SelectedDateTime int64 `json:"selected_date_time"` + Timezone string `json:"timezone"` InitialOption OptionBlockObject `json:"initial_option"` InitialUser string `json:"initial_user"` InitialChannel string `json:"initial_channel"` From 91d325825ee7520f5bc16cd63a0e8dc08ef2600b Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 28 Jun 2025 18:40:50 +0100 Subject: [PATCH 378/606] feat: add ability to add file ids when updating a message Closes #1450. --- chat.go | 24 ++++++++++++++++++++++ chat_test.go | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/chat.go b/chat.go index 11324c9e4..85c4848ba 100644 --- a/chat.go +++ b/chat.go @@ -68,6 +68,9 @@ type PostMessageParameters struct { // chat metadata support MetaData SlackMetadata `json:"metadata"` + + // file_ids support + FileIDs []string `json:"file_ids,omitempty"` } // NewPostMessageParameters provides an instance of PostMessageParameters with all the sane default values set @@ -715,6 +718,23 @@ func MsgOptionLinkNames(linkName bool) MsgOption { } } +// MsgOptionFileIDs sets file IDs for the message +func MsgOptionFileIDs(fileIDs []string) MsgOption { + return func(config *sendConfig) error { + if len(fileIDs) == 0 { + return nil + } + + fileIDsBytes, err := json.Marshal(fileIDs) + if err != nil { + return err + } + + config.values.Set("file_ids", string(fileIDsBytes)) + return nil + } +} + // UnsafeMsgOptionEndpoint deliver the message to the specified endpoint. // NOTE: USE AT YOUR OWN RISK: No issues relating to the use of this Option // will be supported by the library, it is subject to change without notice that @@ -778,6 +798,10 @@ func MsgOptionPostMessageParameters(params PostMessageParameters) MsgOption { config.values.Set("reply_broadcast", "true") } + if len(params.FileIDs) > 0 { + return MsgOptionFileIDs(params.FileIDs)(config) + } + return nil } } diff --git a/chat_test.go b/chat_test.go index ab326f2e4..010389d6a 100644 --- a/chat_test.go +++ b/chat_test.go @@ -367,3 +367,61 @@ func TestSendMessageContextRedactsTokenInDebugLog(t *testing.T) { }) } } + +func TestUpdateMessage(t *testing.T) { + type messageTest struct { + endpoint string + opt []MsgOption + expected url.Values + } + tests := map[string]messageTest{ + "empty file_ids": { + endpoint: "/chat.update", + opt: []MsgOption{}, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "ts": []string{"1234567890.123456"}, + }, + }, + "with file_ids": { + endpoint: "/chat.update", + opt: []MsgOption{ + MsgOptionFileIDs([]string{"F123", "F456"}), + }, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "ts": []string{"1234567890.123456"}, + "file_ids": []string{`["F123","F456"]`}, + }, + }, + } + + once.Do(startServer) + api := New(validToken, OptionAPIURL("http://"+serverAddr+"/")) + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc(test.endpoint, func(rw http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + actual, err := url.ParseQuery(string(body)) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + if !reflect.DeepEqual(actual, test.expected) { + t.Errorf("\nexpected: %s\n actual: %s", test.expected, actual) + return + } + }) + + _, _, _, _ = api.UpdateMessage("CXXX", "1234567890.123456", test.opt...) + }) + } +} From a5e465d916204ebbef26433b53f608ea449e20a5 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 28 Jun 2025 19:22:36 +0100 Subject: [PATCH 379/606] fix: parse simple string based errors as part of the response A response can return a payload of the form: ``` { "ok": false, "error": "invalid_blocks", "errors": [ "failed to match all allowed schemas [json-pointer:/blocks/3/text]", "invalid additional property: emoji [json-pointer:/blocks/3/text]" ], "response_metadata": { "messages": [ "[ERROR] failed to match all allowed schemas [json-pointer:/blocks/3/text]", "[ERROR] invalid additional property: emoji [json-pointer:/blocks/3/text]" ] } } ``` This means that we need to fallback to parsing a simple string if we fail to parse the content of "errors" into a `map[string]interface{}`. --- misc.go | 13 +++++++++++++ misc_test.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/misc.go b/misc.go index 7a0ab3545..48411ee19 100644 --- a/misc.go +++ b/misc.go @@ -45,6 +45,7 @@ func (t ConversationsInviteResponseError) Err() error { type SlackResponseErrors struct { AppsManifestCreateResponseError *AppsManifestCreateResponseError `json:"-"` ConversationsInviteResponseError *ConversationsInviteResponseError `json:"-"` + Message *string `json:"-"` } // MarshalJSON implements custom marshaling for SlackResponseErrors @@ -55,6 +56,9 @@ func (e SlackResponseErrors) MarshalJSON() ([]byte, error) { if e.ConversationsInviteResponseError != nil { return json.Marshal(e.ConversationsInviteResponseError) } + if e.Message != nil { + return json.Marshal(*e.Message) + } return json.Marshal(nil) } @@ -67,6 +71,15 @@ func (e *SlackResponseErrors) UnmarshalJSON(data []byte) error { // Try to determine the error type by checking for unique fields var raw map[string]interface{} if err := json.Unmarshal(data, &raw); err != nil { + // If we can't unmarshal as object, try as string (fallback case) + // + // For more details on this specific problem look up issue + // https://github.com/slack-go/slack/issues/1446. + var stringError string + if stringErr := json.Unmarshal(data, &stringError); stringErr == nil { + e.Message = &stringError + return nil + } return err } diff --git a/misc_test.go b/misc_test.go index 06fab1eee..d872199aa 100644 --- a/misc_test.go +++ b/misc_test.go @@ -133,6 +133,13 @@ func TestSlackResponseErrorsMarshaling(t *testing.T) { }, expected: `{"error":"invalid_user","ok":false,"user":"U12345678"}`, }, + { + name: "StringError", + errors: SlackResponseErrors{ + Message: func() *string { s := "failed to match all allowed schemas"; return &s }(), + }, + expected: `"failed to match all allowed schemas"`, + }, { name: "EmptyErrors", errors: SlackResponseErrors{}, @@ -185,6 +192,16 @@ func TestSlackResponseErrorsUnmarshaling(t *testing.T) { input: `null`, expected: SlackResponseErrors{}, }, + { + name: "StringError", + input: `"failed to match all allowed schemas [json-pointer:\\/blocks\\/3\\/text]"`, + expected: SlackResponseErrors{ + Message: func() *string { + s := "failed to match all allowed schemas [json-pointer:\\/blocks\\/3\\/text]" + return &s + }(), + }, + }, } for _, tt := range tests { @@ -210,6 +227,14 @@ func TestSlackResponseErrorsUnmarshaling(t *testing.T) { t.Errorf("got %+v; want %+v", *errors.ConversationsInviteResponseError, *tt.expected.ConversationsInviteResponseError) } } + + if tt.expected.Message != nil { + if errors.Message == nil { + t.Error("expected Message, got nil") + } else if *errors.Message != *tt.expected.Message { + t.Errorf("got %+v; want %+v", *errors.Message, *tt.expected.Message) + } + } }) } } @@ -256,6 +281,28 @@ func TestSlackResponseWithErrors(t *testing.T) { Ok: true, }, }, + { + name: "ResponseWithStringErrors", + input: `{"ok":false,"error":"invalid_blocks","errors":["failed to match all allowed schemas [json-pointer:\\/blocks\\/3\\/text]","invalid additional property: emoji [json-pointer:\\/blocks\\/3\\/text]"]}`, + expected: SlackResponse{ + Ok: false, + Error: "invalid_blocks", + Errors: []SlackResponseErrors{ + { + Message: func() *string { + s := "failed to match all allowed schemas [json-pointer:\\/blocks\\/3\\/text]" + return &s + }(), + }, + { + Message: func() *string { + s := "invalid additional property: emoji [json-pointer:\\/blocks\\/3\\/text]" + return &s + }(), + }, + }, + }, + }, } for _, tt := range tests { From 07331b5fba440d165338919d6611942575077ce7 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 28 Jun 2025 21:42:29 +0100 Subject: [PATCH 380/606] feat: add API endpoint /migration.exchange to the library --- migration.go | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 migration.go diff --git a/migration.go b/migration.go new file mode 100644 index 000000000..fa6690ee3 --- /dev/null +++ b/migration.go @@ -0,0 +1,40 @@ +package slack + +import ( + "context" + "net/url" +) + +type migrationExchangeResponseFull struct { + TeamID string `json:"team_id"` + ToOld bool `json:"to_old"` + EnterpriseID string `json:"enterprise_id"` + UserIDMap map[string]string `json:"user_id_map"` + InvalidUserIDs []string `json:"invalid_user_ids"` + SlackResponse +} + +// MigrationExchange for Enterprise Grid workspaces, map local user IDs to global user IDs +func (api *Client) MigrationExchange(ctx context.Context, teamID string, toOld bool, users []string) (map[string]string, []string, error) { + values := url.Values{ + "users": users, + } + if teamID != "" { + values.Add("team_id", teamID) + } + if toOld { + values.Add("to_old", "true") + } + + response := &migrationExchangeResponseFull{} + err := api.getMethod(ctx, "migration.exchange", api.token, values, response) + if err != nil { + return nil, nil, err + } + + if err := response.Err(); err != nil { + return nil, nil, err + } + + return response.UserIDMap, response.InvalidUserIDs, nil +} From 9201c7a2e75f4a4ff1ec92238d9193dca53387e0 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 13:16:41 +0100 Subject: [PATCH 381/606] feat(examples): standardize authentication in buttons example - Use SLACK_BOT_TOKEN environment variable for token - Use -channel flag for channel ID - Follow hybrid approach: env vars for sensitive data, flags for config --- examples/buttons/buttons.go | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/examples/buttons/buttons.go b/examples/buttons/buttons.go index b5ef09c49..13ced658a 100644 --- a/examples/buttons/buttons.go +++ b/examples/buttons/buttons.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "flag" "fmt" "net/http" "os" @@ -9,17 +10,23 @@ import ( "github.com/slack-go/slack" ) +var ( + channelID = flag.String("channel", "", "Channel ID (required)") +) + func main() { - var token, channel string - var ok bool - token, ok = os.LookupEnv("SLACK_TOKEN") - if !ok { - fmt.Println("Missing SLACK_TOKEN in environment") + flag.Parse() + + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") os.Exit(1) } - channel, ok = os.LookupEnv("SLACK_CHANNEL") - if !ok { - fmt.Println("Missing SLACK_CHANNEL in environment") + + // Get channel ID from flag + if *channelID == "" { + fmt.Println("Channel ID is required: use -channel flag") os.Exit(1) } api := slack.New(token) @@ -46,11 +53,11 @@ func main() { } message := slack.MsgOptionAttachments(attachment) - channelID, timestamp, err := api.PostMessage(channel, slack.MsgOptionText("", false), message) + respChannelID, timestamp, err := api.PostMessage(*channelID, slack.MsgOptionText("", false), message) if err != nil { fmt.Printf("Could not send message: %v", err) } - fmt.Printf("Message with buttons successfully sent to channel %s at %s", channelID, timestamp) + fmt.Printf("Message with buttons successfully sent to channel %s at %s", respChannelID, timestamp) http.HandleFunc("/actions", actionHandler) http.ListenAndServe(":3000", nil) } From 48cada7e0275b48d47afa7b53becc46cf2813cc5 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 13:17:14 +0100 Subject: [PATCH 382/606] feat(examples): standardize authentication in connparams example - Use SLACK_BOT_TOKEN environment variable for token - Use -channel flag for channel ID - Use -users flag for comma-separated user IDs - Follow hybrid approach: env vars for sensitive data, flags for config --- examples/connparams/connparams.go | 46 ++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/examples/connparams/connparams.go b/examples/connparams/connparams.go index 2bf2dce1d..a114f8467 100644 --- a/examples/connparams/connparams.go +++ b/examples/connparams/connparams.go @@ -1,20 +1,49 @@ package main import ( + "flag" "fmt" "log" "net/url" "os" + "strings" "github.com/slack-go/slack" ) +var ( + channelID = flag.String("channel", "", "Channel ID (required)") + userIDs = flag.String("users", "", "Comma-separated user IDs for presence monitoring (required)") +) + func main() { - token, ok := os.LookupEnv("SLACK_TOKEN") - if !ok { - fmt.Println("Missing SLACK_TOKEN in environment") + flag.Parse() + + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + // Get channel ID from flag + if *channelID == "" { + fmt.Println("Channel ID is required: use -channel flag") os.Exit(1) } + + // Get user IDs from flag + if *userIDs == "" { + fmt.Println("User IDs are required: use -users flag (comma-separated)") + os.Exit(1) + } + + // Parse comma-separated user IDs + userIDList := strings.Split(*userIDs, ",") + for i, userID := range userIDList { + userIDList[i] = strings.TrimSpace(userID) + } + api := slack.New( token, slack.OptionDebug(true), @@ -31,17 +60,14 @@ func main() { fmt.Print("Event Received: ") switch ev := msg.Data.(type) { case *slack.HelloEvent: - // Replace USER-ID-N here with your User IDs - rtm.SendMessage(rtm.NewSubscribeUserPresence([]string{ - "USER-ID-1", - "USER-ID-2", - })) + // Subscribe to user presence using provided user IDs + rtm.SendMessage(rtm.NewSubscribeUserPresence(userIDList)) case *slack.ConnectedEvent: fmt.Println("Infos:", ev.Info) fmt.Println("Connection counter:", ev.ConnectionCount) - // Replace C2147483705 with your Channel ID - rtm.SendMessage(rtm.NewOutgoingMessage("Hello world", "C2147483705")) + // Send message to provided channel ID + rtm.SendMessage(rtm.NewOutgoingMessage("Hello world", *channelID)) case *slack.MessageEvent: fmt.Printf("Message: %v\n", ev) From 0e3b325a927c67dcbc6a73a4cdaea0751a0997f3 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 13:17:34 +0100 Subject: [PATCH 383/606] feat(examples): standardize authentication in conversation_history example - Use SLACK_BOT_TOKEN environment variable for token - Use -channel flag for channel ID - Add safety check for message attachments - Follow hybrid approach: env vars for sensitive data, flags for config --- .../conversation_history.go | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/examples/conversation_history/conversation_history.go b/examples/conversation_history/conversation_history.go index 569c12d68..07fed88e4 100644 --- a/examples/conversation_history/conversation_history.go +++ b/examples/conversation_history/conversation_history.go @@ -2,15 +2,36 @@ package main import ( "context" + "flag" "fmt" + "os" "github.com/slack-go/slack" ) +var ( + channelID = flag.String("channel", "", "Channel ID (required)") +) + func main() { - api := slack.New("YOUR_TOKEN") + flag.Parse() + + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + // Get channel ID from flag + if *channelID == "" { + fmt.Println("Channel ID is required: use -channel flag") + os.Exit(1) + } + + api := slack.New(token) params := slack.GetConversationHistoryParameters{ - ChannelID: "C0123456789", + ChannelID: *channelID, } messages, err := api.GetConversationHistoryContext(context.Background(), ¶ms) if err != nil { @@ -18,6 +39,10 @@ func main() { return } for _, message := range messages.Messages { - fmt.Printf("Message: %s\n", message.Attachments[0].Color) + if len(message.Attachments) > 0 { + fmt.Printf("Message: %s\n", message.Attachments[0].Color) + } else { + fmt.Printf("Message: %s\n", message.Text) + } } } From 78b252aa13758f68cb2475df97c26b8beb70bc8d Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 13:17:50 +0100 Subject: [PATCH 384/606] feat(examples): standardize error message in conversations example - Standardize error message format for consistency - Token name already follows convention (SLACK_USER_TOKEN) --- examples/conversations/conversations.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/conversations/conversations.go b/examples/conversations/conversations.go index b47de7558..556fbaacb 100644 --- a/examples/conversations/conversations.go +++ b/examples/conversations/conversations.go @@ -10,7 +10,7 @@ import ( func main() { userToken := os.Getenv("SLACK_USER_TOKEN") if userToken == "" { - fmt.Fprintf(os.Stderr, "SLACK_USER_TOKEN must be set.\n") + fmt.Fprintf(os.Stderr, "SLACK_USER_TOKEN environment variable is required\n") os.Exit(1) } From 0e8ffb9445960c3278d6c38a05e937aa645a9c85 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 13:18:11 +0100 Subject: [PATCH 385/606] feat(examples): standardize authentication in conversations_invite example - Use SLACK_USER_TOKEN environment variable for token - Use -channel flag for channel ID - Use -user flag for user ID to invite - Follow hybrid approach: env vars for sensitive data, flags for config --- .../conversations_invite.go | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/examples/conversations_invite/conversations_invite.go b/examples/conversations_invite/conversations_invite.go index 8b977ceee..a6be79159 100644 --- a/examples/conversations_invite/conversations_invite.go +++ b/examples/conversations_invite/conversations_invite.go @@ -2,24 +2,42 @@ package main import ( "errors" + "flag" "fmt" "os" "github.com/slack-go/slack" ) +var ( + channelID = flag.String("channel", "", "Channel ID (required)") + userID = flag.String("user", "", "User ID to invite (required)") +) + func main() { + flag.Parse() + + // Get token from environment variable userToken := os.Getenv("SLACK_USER_TOKEN") if userToken == "" { - fmt.Fprintf(os.Stderr, "SLACK_USER_TOKEN must be set.\n") + fmt.Fprintf(os.Stderr, "SLACK_USER_TOKEN environment variable is required\n") + os.Exit(1) + } + + // Get channel ID from flag + if *channelID == "" { + fmt.Println("Channel ID is required: use -channel flag") + os.Exit(1) + } + + // Get user ID from flag + if *userID == "" { + fmt.Println("User ID is required: use -user flag") os.Exit(1) } api := slack.New(userToken) - _, err := api.InviteUsersToConversation( - "C1234567890", // Replace with the actual channel ID you want to invite the user to - "U1234567890", // Replace with the actual user ID you want to invite - ) + _, err := api.InviteUsersToConversation(*channelID, *userID) if err != nil { var errorResponse slack.SlackErrorResponse if errors.As(err, &errorResponse) { From 9e43c76d2ee9d8408665489fd215c33d6e184db4 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 13:18:32 +0100 Subject: [PATCH 386/606] feat(examples): standardize authentication in dialog example - Use SLACK_BOT_TOKEN environment variable for token - Use SLACK_SIGNING_SECRET environment variable for signing secret - Initialize API client in main function - Follow hybrid approach: env vars for sensitive data --- examples/dialog/dialog.go | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/examples/dialog/dialog.go b/examples/dialog/dialog.go index dc0d431c3..9b83d0c1b 100644 --- a/examples/dialog/dialog.go +++ b/examples/dialog/dialog.go @@ -6,18 +6,33 @@ import ( "log" "net/http" "net/url" + "os" "strings" "github.com/slack-go/slack" ) -var api = slack.New("YOUR_TOKEN") -var signingSecret = "YOUR_SIGNING_SECRET" +var api *slack.Client +var signingSecret string // You can open a dialog with a user interaction. (like pushing buttons, slash commands ...) // https://api.slack.com/surfaces/modals // https://api.slack.com/interactivity/entry-points func main() { + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + log.Fatal("SLACK_BOT_TOKEN environment variable is required") + } + + // Get signing secret from environment variable + signingSecret = os.Getenv("SLACK_SIGNING_SECRET") + if signingSecret == "" { + log.Fatal("SLACK_SIGNING_SECRET environment variable is required") + } + + api = slack.New(token) + http.HandleFunc("/", handler) http.ListenAndServe(":3000", nil) } From 740cc462200ba0af4ea4d65fdd435a0ea3978aa5 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 13:18:47 +0100 Subject: [PATCH 387/606] feat(examples): standardize error message in files example - Standardize error message format for consistency - Use os.Getenv instead of os.LookupEnv for simplicity - Token name already follows convention (SLACK_BOT_TOKEN) --- examples/files/files.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/files/files.go b/examples/files/files.go index 1f622ecd0..55b3417fd 100644 --- a/examples/files/files.go +++ b/examples/files/files.go @@ -9,9 +9,9 @@ import ( ) func main() { - token, ok := os.LookupEnv("SLACK_BOT_TOKEN") - if !ok { - fmt.Println("Missing SLACK_BOT_TOKEN in environment") + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") os.Exit(1) } api := slack.New(token, slack.OptionDebug(true)) From 0eb46476eb81f1aa66c208587171ed63dc4c4d54 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 13:19:09 +0100 Subject: [PATCH 388/606] feat(examples): standardize authentication in messages example - Use SLACK_BOT_TOKEN environment variable for token - Use -channel flag for channel ID - Follow hybrid approach: env vars for sensitive data, flags for config --- examples/messages/messages.go | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/examples/messages/messages.go b/examples/messages/messages.go index dd7f02da0..8fd20536a 100644 --- a/examples/messages/messages.go +++ b/examples/messages/messages.go @@ -1,13 +1,34 @@ package main import ( + "flag" "fmt" + "os" "github.com/slack-go/slack" ) +var ( + channelID = flag.String("channel", "", "Channel ID (required)") +) + func main() { - api := slack.New("YOUR_TOKEN_HERE") + flag.Parse() + + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + // Get channel ID from flag + if *channelID == "" { + fmt.Println("Channel ID is required: use -channel flag") + os.Exit(1) + } + + api := slack.New(token) attachment := slack.Attachment{ Pretext: "some pretext", Text: "some text", @@ -22,8 +43,8 @@ func main() { */ } - channelID, timestamp, err := api.PostMessage( - "CHANNEL_ID", + respChannelID, timestamp, err := api.PostMessage( + *channelID, slack.MsgOptionText("Some text", false), slack.MsgOptionAttachments(attachment), slack.MsgOptionAsUser(true), // Add this if you want that the bot would post message as a user, otherwise it will send response using the default slackbot @@ -32,5 +53,5 @@ func main() { fmt.Printf("%s\n", err) return } - fmt.Printf("Message successfully sent to channel %s at %s", channelID, timestamp) + fmt.Printf("Message successfully sent to channel %s at %s", respChannelID, timestamp) } From 0e3e75fcf94eab6832df73cf80be56cdc802a29e Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 13:19:36 +0100 Subject: [PATCH 389/606] feat(examples): standardize error messages in socketmode example - Standardize error message format for consistency - Add missing os.Exit for token validation - Token names already follow convention (SLACK_APP_TOKEN, SLACK_BOT_TOKEN) --- examples/socketmode/socketmode.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/examples/socketmode/socketmode.go b/examples/socketmode/socketmode.go index cdad9981b..399c6f3ae 100644 --- a/examples/socketmode/socketmode.go +++ b/examples/socketmode/socketmode.go @@ -14,22 +14,24 @@ import ( func main() { appToken := os.Getenv("SLACK_APP_TOKEN") if appToken == "" { - fmt.Fprintf(os.Stderr, "SLACK_APP_TOKEN must be set.\n") + fmt.Fprintf(os.Stderr, "SLACK_APP_TOKEN environment variable is required\n") os.Exit(1) } if !strings.HasPrefix(appToken, "xapp-") { - fmt.Fprintf(os.Stderr, "SLACK_APP_TOKEN must have the prefix \"xapp-\".") + fmt.Fprintf(os.Stderr, "SLACK_APP_TOKEN must have the prefix \"xapp-\"\n") + os.Exit(1) } botToken := os.Getenv("SLACK_BOT_TOKEN") if botToken == "" { - fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN must be set.\n") + fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN environment variable is required\n") os.Exit(1) } if !strings.HasPrefix(botToken, "xoxb-") { - fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN must have the prefix \"xoxb-\".") + fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN must have the prefix \"xoxb-\"\n") + os.Exit(1) } api := slack.New( From 3c56d2c234ab6a351a6c2c14717508f685703e7c Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 13:19:58 +0100 Subject: [PATCH 390/606] feat(examples): standardize authentication in websocket example - Use SLACK_BOT_TOKEN environment variable for token - Use -channel flag for channel ID - Follow hybrid approach: env vars for sensitive data, flags for config --- examples/websocket/websocket.go | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/examples/websocket/websocket.go b/examples/websocket/websocket.go index 96e110e39..0b9f444c9 100644 --- a/examples/websocket/websocket.go +++ b/examples/websocket/websocket.go @@ -1,6 +1,7 @@ package main import ( + "flag" "fmt" "log" "os" @@ -8,10 +9,23 @@ import ( "github.com/slack-go/slack" ) +var ( + channelID = flag.String("channel", "", "Channel ID (required)") +) + func main() { - token, ok := os.LookupEnv("SLACK_TOKEN") - if !ok { - fmt.Println("Missing SLACK_TOKEN in environment") + flag.Parse() + + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + // Get channel ID from flag + if *channelID == "" { + fmt.Println("Channel ID is required: use -channel flag") os.Exit(1) } api := slack.New( @@ -32,8 +46,8 @@ func main() { case *slack.ConnectedEvent: fmt.Println("Infos:", ev.Info) fmt.Println("Connection counter:", ev.ConnectionCount) - // Replace C2147483705 with your Channel ID - rtm.SendMessage(rtm.NewOutgoingMessage("Hello world", "C2147483705")) + // Send message to provided channel ID + rtm.SendMessage(rtm.NewOutgoingMessage("Hello world", *channelID)) case *slack.MessageEvent: fmt.Printf("Message: %v\n", ev) From 158ef38d7a6884ca26ebceda65eeddb668d66b28 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 13:20:19 +0100 Subject: [PATCH 391/606] feat(examples): standardize authentication in pagination example - Use SLACK_BOT_TOKEN environment variable for token - Follow hybrid approach: env vars for sensitive data --- examples/pagination/pagination.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/examples/pagination/pagination.go b/examples/pagination/pagination.go index 265910137..1f9b232a0 100644 --- a/examples/pagination/pagination.go +++ b/examples/pagination/pagination.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "os" "time" "github.com/slack-go/slack" @@ -48,7 +49,14 @@ func getAllUserUIDs(ctx context.Context, client *slack.Client, pageSize int) ([] } func main() { - client := slack.New("YOUR_TOKEN_HERE") + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + client := slack.New(token) uids, err := getAllUserUIDs(context.Background(), client, 1000) if err != nil { From c2e545f7952ee3978232cb874967998cbd0ccd18 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 13:20:36 +0100 Subject: [PATCH 392/606] feat(examples): standardize authentication in pins example - Use SLACK_BOT_TOKEN environment variable for token - Remove hardcoded token from flag definition - Follow hybrid approach: env vars for sensitive data, flags for config --- examples/pins/pins.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/examples/pins/pins.go b/examples/pins/pins.go index 55158cb33..9000b9f12 100644 --- a/examples/pins/pins.go +++ b/examples/pins/pins.go @@ -3,6 +3,7 @@ package main import ( "flag" "fmt" + "os" "github.com/slack-go/slack" ) @@ -10,14 +11,19 @@ import ( // WARNING: This example is destructive in the sense that it create a channel called testpinning func main() { var ( - apiToken string - debug bool + debug bool ) - flag.StringVar(&apiToken, "token", "YOUR_TOKEN_HERE", "Your Slack API Token") flag.BoolVar(&debug, "debug", false, "Show JSON output") flag.Parse() + // Get token from environment variable + apiToken := os.Getenv("SLACK_BOT_TOKEN") + if apiToken == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + api := slack.New(apiToken, slack.OptionDebug(debug)) var ( From eb727d0517be3457f3163e8fd0b1fd72a4211f39 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 13:20:56 +0100 Subject: [PATCH 393/606] feat(examples): standardize authentication in reactions example - Use SLACK_BOT_TOKEN environment variable for token - Remove hardcoded token from flag definition - Follow hybrid approach: env vars for sensitive data, flags for config --- examples/reactions/reactions.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/examples/reactions/reactions.go b/examples/reactions/reactions.go index 4e19f508a..feff3ea09 100644 --- a/examples/reactions/reactions.go +++ b/examples/reactions/reactions.go @@ -3,20 +3,26 @@ package main import ( "flag" "fmt" + "os" "github.com/slack-go/slack" ) func main() { var ( - apiToken string - debug bool + debug bool ) - flag.StringVar(&apiToken, "token", "YOUR_TOKEN_HERE", "Your Slack API Token") flag.BoolVar(&debug, "debug", false, "Show JSON output") flag.Parse() + // Get token from environment variable + apiToken := os.Getenv("SLACK_BOT_TOKEN") + if apiToken == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + api := slack.New(apiToken, slack.OptionDebug(debug)) var ( From 585f900a07418a02a0e7c399ec57f26440b62156 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 13:21:26 +0100 Subject: [PATCH 394/606] feat(examples): standardize authentication in team example - Use SLACK_BOT_TOKEN environment variable for token - Use -user flag for optional user ID - Add proper error handling for team billing info - Follow hybrid approach: env vars for sensitive data, flags for config --- examples/team/team.go | 44 ++++++++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/examples/team/team.go b/examples/team/team.go index cba71aa74..260e74835 100644 --- a/examples/team/team.go +++ b/examples/team/team.go @@ -1,24 +1,46 @@ package main import ( + "flag" "fmt" + "os" "github.com/slack-go/slack" ) +var ( + userID = flag.String("user", "", "User ID for billing info (optional)") +) + func main() { - api := slack.New("YOUR_TOKEN_HERE") - //Example for single user - billingActive, err := api.GetBillableInfo(slack.GetBillableInfoParams{User: "U023BECGF"}) - if err != nil { - fmt.Printf("%s\n", err) - return + flag.Parse() + + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) } - fmt.Printf("ID: U023BECGF, BillingActive: %v\n\n\n", billingActive["U023BECGF"]) - //Example for team. Note: passing empty TeamID just uses the current user team. - billingActiveForTeam, _ := api.GetBillableInfo(slack.GetBillableInfoParams{}) - for id, value := range billingActiveForTeam { - fmt.Printf("ID: %v, BillingActive: %v\n", id, value) + api := slack.New(token) + + if *userID != "" { + //Example for single user + billingActive, err := api.GetBillableInfo(slack.GetBillableInfoParams{User: *userID}) + if err != nil { + fmt.Printf("%s\n", err) + return + } + fmt.Printf("ID: %s, BillingActive: %v\n\n\n", *userID, billingActive[*userID]) + } else { + //Example for team. Note: passing empty TeamID just uses the current user team. + billingActiveForTeam, err := api.GetBillableInfo(slack.GetBillableInfoParams{}) + if err != nil { + fmt.Printf("%s\n", err) + return + } + for id, value := range billingActiveForTeam { + fmt.Printf("ID: %v, BillingActive: %v\n", id, value) + } } } From 9262e3f41d8be3f2c44f34070ac644374f3ae7a4 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 13:21:48 +0100 Subject: [PATCH 395/606] feat(examples): standardize authentication in users example - Use SLACK_USER_TOKEN environment variable for token - Use -user flag for user ID - Standardize error message format - Follow hybrid approach: env vars for sensitive data, flags for config --- examples/users/users.go | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/examples/users/users.go b/examples/users/users.go index 53022bf58..a4fdb1071 100644 --- a/examples/users/users.go +++ b/examples/users/users.go @@ -1,21 +1,35 @@ package main import ( + "flag" "fmt" "os" "github.com/slack-go/slack" ) +var ( + userID = flag.String("user", "", "User ID (required)") +) + func main() { + flag.Parse() + + // Get token from environment variable userToken := os.Getenv("SLACK_USER_TOKEN") if userToken == "" { - fmt.Fprintf(os.Stderr, "SLACK_USER_TOKEN must be set.\n") + fmt.Fprintf(os.Stderr, "SLACK_USER_TOKEN environment variable is required\n") + os.Exit(1) + } + + // Get user ID from flag + if *userID == "" { + fmt.Println("User ID is required: use -user flag") os.Exit(1) } api := slack.New(userToken) - user, err := api.GetUserInfo("U023BECGF") + user, err := api.GetUserInfo(*userID) if err != nil { fmt.Printf("%s\n", err) return From dd2cd22e0eb488d8b5541cd47b54506ecbcc1f32 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 13:22:10 +0100 Subject: [PATCH 396/606] feat(examples): standardize authentication in websocket_respond example - Use SLACK_BOT_TOKEN environment variable for token - Follow hybrid approach: env vars for sensitive data --- examples/websocket_respond/respond.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/examples/websocket_respond/respond.go b/examples/websocket_respond/respond.go index eed3fa834..7c74da41a 100644 --- a/examples/websocket_respond/respond.go +++ b/examples/websocket_respond/respond.go @@ -2,15 +2,21 @@ package main import ( "fmt" + "os" "strings" "github.com/slack-go/slack" ) func main() { - api := slack.New( - "YOUR-TOKEN-HERE", - ) + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + api := slack.New(token) rtm := api.NewRTM() go rtm.ManageConnection() From df4cb0332c6fd7189df5fa9f75de3b53cbe9436e Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 13:23:02 +0100 Subject: [PATCH 397/606] feat(examples): standardize authentication in modal example - Use SLACK_BOT_TOKEN environment variable for token - Use SLACK_SIGNING_SECRET environment variable for signing secret - Initialize credentials in init() function for global access - Follow hybrid approach: env vars for sensitive data --- examples/modal/modal.go | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/examples/modal/modal.go b/examples/modal/modal.go index 13115a826..e1dcc7ab9 100644 --- a/examples/modal/modal.go +++ b/examples/modal/modal.go @@ -5,7 +5,7 @@ // 3. This will send a request to http://URL/modal and send a greeting message to the user // Note: Within your slack app you will need to enable and provide a URL for "Interactivity & Shortcuts" and "Slash Commands" -// Note: Be sure to update YOUR_SIGNING_SECRET_HERE and YOUR_TOKEN_HERE +// Note: Set SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET environment variables // You can use ngrok to test this example: https://api.slack.com/tutorials/tunneling-with-ngrok // Helpful slack documentation to learn more: https://api.slack.com/interactivity/handling @@ -17,11 +17,33 @@ import ( "fmt" "io" "net/http" + "os" "github.com/slack-go/slack" "time" ) +var ( + token string + signingSecret string +) + +func init() { + // Get token from environment variable + token = os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + // Get signing secret from environment variable + signingSecret = os.Getenv("SLACK_SIGNING_SECRET") + if signingSecret == "" { + fmt.Println("SLACK_SIGNING_SECRET environment variable is required") + os.Exit(1) + } +} + func generateModalRequest() slack.ModalViewRequest { // Create a ModalViewRequest with a header and two inputs titleText := slack.NewTextBlockObject("plain_text", "My App", false, false) @@ -88,7 +110,6 @@ func updateModal() slack.ModalViewRequest { // This was taken from the slash example // https://github.com/slack-go/slack/blob/master/examples/slash/slash.go func verifySigningSecret(r *http.Request) error { - signingSecret := "YOUR_SIGNING_SECRET_HERE" verifier, err := slack.NewSecretsVerifier(r.Header, signingSecret) if err != nil { fmt.Println(err.Error()) @@ -130,7 +151,7 @@ func handleSlash(w http.ResponseWriter, r *http.Request) { switch s.Command { case "/slash": - api := slack.New("YOUR_TOKEN_HERE") + api := slack.New(token) modalRequest := generateModalRequest() _, err = api.OpenView(s.TriggerID, modalRequest) if err != nil { @@ -159,12 +180,7 @@ func handleModal(w http.ResponseWriter, r *http.Request) { return } - api := slack.New("YOUR_TOKEN_HERE") - if err != nil { - fmt.Printf(err.Error()) - w.WriteHeader(http.StatusUnauthorized) - return - } + api := slack.New(token) // update modal sample switch i.Type { From faea44d3115974a3501d85822d3175e684a10d35 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 13:23:27 +0100 Subject: [PATCH 398/606] feat(examples): standardize authentication in files_remote example - Use SLACK_BOT_TOKEN environment variable for token - Follow hybrid approach: env vars for sensitive data --- examples/files_remote/files_remote.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/examples/files_remote/files_remote.go b/examples/files_remote/files_remote.go index 60bcfa967..9594d986f 100644 --- a/examples/files_remote/files_remote.go +++ b/examples/files_remote/files_remote.go @@ -3,12 +3,20 @@ package main import ( "context" "fmt" + "os" "github.com/slack-go/slack" ) func main() { - api := slack.New("YOUR_TOKEN") + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + api := slack.New(token) params := slack.RemoteFileParameters{ Title: "My File", ExternalID: "my-file-123", From 4b172c915956653a9f372eaed7e8de006175791f Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 13:23:52 +0100 Subject: [PATCH 399/606] feat(examples): standardize authentication in slash example - Use SLACK_SIGNING_SECRET environment variable for signing secret - Remove command-line flag for secret (security best practice) - Follow hybrid approach: env vars for sensitive data --- examples/slash/slash.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/slash/slash.go b/examples/slash/slash.go index e35ab10a8..9383aac4c 100644 --- a/examples/slash/slash.go +++ b/examples/slash/slash.go @@ -2,21 +2,21 @@ package main import ( "encoding/json" - "flag" "fmt" "io" "net/http" + "os" "github.com/slack-go/slack" ) func main() { - var ( - signingSecret string - ) - - flag.StringVar(&signingSecret, "secret", "YOUR_SIGNING_SECRET_HERE", "Your Slack app's signing secret") - flag.Parse() + // Get signing secret from environment variable + signingSecret := os.Getenv("SLACK_SIGNING_SECRET") + if signingSecret == "" { + fmt.Println("SLACK_SIGNING_SECRET environment variable is required") + os.Exit(1) + } http.HandleFunc("/slash", func(w http.ResponseWriter, r *http.Request) { From d65ebae89cf2ee7e6c9b08acb6e34b97745eaf46 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 13:24:14 +0100 Subject: [PATCH 400/606] feat(examples): add validation in function example - Add validation for SLACK_BOT_TOKEN and SLACK_APP_TOKEN environment variables - Token names already follow convention - Improve error handling for missing credentials --- examples/function/function.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/examples/function/function.go b/examples/function/function.go index 0e189c246..f9d45e100 100644 --- a/examples/function/function.go +++ b/examples/function/function.go @@ -10,10 +10,23 @@ import ( ) func main() { + // Get tokens from environment variables + botToken := os.Getenv("SLACK_BOT_TOKEN") + if botToken == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + appToken := os.Getenv("SLACK_APP_TOKEN") + if appToken == "" { + fmt.Println("SLACK_APP_TOKEN environment variable is required") + os.Exit(1) + } + api := slack.New( - os.Getenv("SLACK_BOT_TOKEN"), + botToken, slack.OptionDebug(true), - slack.OptionAppLevelToken(os.Getenv("SLACK_APP_TOKEN")), + slack.OptionAppLevelToken(appToken), ) client := socketmode.New(api, socketmode.OptionDebug(true)) From f89cda5ed3d116c0100289af1af615950a3280ff Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 16:23:42 +0100 Subject: [PATCH 401/606] chore: fix lint in team example --- examples/team/team.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/team/team.go b/examples/team/team.go index 260e74835..e907d9bf0 100644 --- a/examples/team/team.go +++ b/examples/team/team.go @@ -25,7 +25,7 @@ func main() { api := slack.New(token) if *userID != "" { - //Example for single user + // Example for single user billingActive, err := api.GetBillableInfo(slack.GetBillableInfoParams{User: *userID}) if err != nil { fmt.Printf("%s\n", err) @@ -33,7 +33,7 @@ func main() { } fmt.Printf("ID: %s, BillingActive: %v\n\n\n", *userID, billingActive[*userID]) } else { - //Example for team. Note: passing empty TeamID just uses the current user team. + // Example for team. Note: passing empty TeamID just uses the current user team. billingActiveForTeam, err := api.GetBillableInfo(slack.GetBillableInfoParams{}) if err != nil { fmt.Printf("%s\n", err) From 369013a7f899ed3480267b473b994f3f0f75a2fb Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 16:36:15 +0100 Subject: [PATCH 402/606] chore: better code --- examples/pins/pins.go | 5 +---- examples/reactions/reactions.go | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/examples/pins/pins.go b/examples/pins/pins.go index 9000b9f12..4a8e96d20 100644 --- a/examples/pins/pins.go +++ b/examples/pins/pins.go @@ -10,11 +10,8 @@ import ( // WARNING: This example is destructive in the sense that it create a channel called testpinning func main() { - var ( - debug bool - ) + debug := flag.Bool("debug", false, "Show JSON output") - flag.BoolVar(&debug, "debug", false, "Show JSON output") flag.Parse() // Get token from environment variable diff --git a/examples/reactions/reactions.go b/examples/reactions/reactions.go index feff3ea09..29258d59d 100644 --- a/examples/reactions/reactions.go +++ b/examples/reactions/reactions.go @@ -9,11 +9,8 @@ import ( ) func main() { - var ( - debug bool - ) + debug := flag.Bool("debug", false, "Show JSON output") - flag.BoolVar(&debug, "debug", false, "Show JSON output") flag.Parse() // Get token from environment variable From dccc5d825b8c4bcba736d5ef96c7bf6494e38e41 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 16:50:18 +0100 Subject: [PATCH 403/606] chore: remove a bunch of global vars --- examples/buttons/buttons.go | 6 +- examples/connparams/connparams.go | 7 +- .../conversation_history.go | 6 +- .../conversations_invite.go | 8 +- examples/messages/messages.go | 5 +- examples/modal/modal.go | 153 +++++++++--------- examples/pins/pins.go | 3 +- examples/reactions/reactions.go | 3 +- examples/slash/slash.go | 1 - examples/team/team.go | 5 +- examples/users/users.go | 5 +- examples/websocket/websocket.go | 5 +- 12 files changed, 88 insertions(+), 119 deletions(-) diff --git a/examples/buttons/buttons.go b/examples/buttons/buttons.go index 13ced658a..a66bd1bf0 100644 --- a/examples/buttons/buttons.go +++ b/examples/buttons/buttons.go @@ -10,11 +10,9 @@ import ( "github.com/slack-go/slack" ) -var ( - channelID = flag.String("channel", "", "Channel ID (required)") -) - func main() { + channelID := flag.String("channel", "", "Channel ID (required)") + flag.Parse() // Get token from environment variable diff --git a/examples/connparams/connparams.go b/examples/connparams/connparams.go index a114f8467..9811ecf37 100644 --- a/examples/connparams/connparams.go +++ b/examples/connparams/connparams.go @@ -11,12 +11,9 @@ import ( "github.com/slack-go/slack" ) -var ( - channelID = flag.String("channel", "", "Channel ID (required)") - userIDs = flag.String("users", "", "Comma-separated user IDs for presence monitoring (required)") -) - func main() { + channelID := flag.String("channel", "", "Channel ID (required)") + userIDs := flag.String("users", "", "Comma-separated user IDs for presence monitoring (required)") flag.Parse() // Get token from environment variable diff --git a/examples/conversation_history/conversation_history.go b/examples/conversation_history/conversation_history.go index 07fed88e4..fc96b8ec0 100644 --- a/examples/conversation_history/conversation_history.go +++ b/examples/conversation_history/conversation_history.go @@ -9,11 +9,9 @@ import ( "github.com/slack-go/slack" ) -var ( - channelID = flag.String("channel", "", "Channel ID (required)") -) - func main() { + channelID := flag.String("channel", "", "Channel ID (required)") + flag.Parse() // Get token from environment variable diff --git a/examples/conversations_invite/conversations_invite.go b/examples/conversations_invite/conversations_invite.go index a6be79159..7a4e04f5e 100644 --- a/examples/conversations_invite/conversations_invite.go +++ b/examples/conversations_invite/conversations_invite.go @@ -9,12 +9,10 @@ import ( "github.com/slack-go/slack" ) -var ( - channelID = flag.String("channel", "", "Channel ID (required)") - userID = flag.String("user", "", "User ID to invite (required)") -) - func main() { + channelID := flag.String("channel", "", "Channel ID (required)") + userID := flag.String("user", "", "User ID to invite (required)") + flag.Parse() // Get token from environment variable diff --git a/examples/messages/messages.go b/examples/messages/messages.go index 8fd20536a..7993dd710 100644 --- a/examples/messages/messages.go +++ b/examples/messages/messages.go @@ -8,11 +8,8 @@ import ( "github.com/slack-go/slack" ) -var ( - channelID = flag.String("channel", "", "Channel ID (required)") -) - func main() { + channelID := flag.String("channel", "", "Channel ID (required)") flag.Parse() // Get token from environment variable diff --git a/examples/modal/modal.go b/examples/modal/modal.go index e1dcc7ab9..c3cd97c20 100644 --- a/examples/modal/modal.go +++ b/examples/modal/modal.go @@ -23,27 +23,6 @@ import ( "time" ) -var ( - token string - signingSecret string -) - -func init() { - // Get token from environment variable - token = os.Getenv("SLACK_BOT_TOKEN") - if token == "" { - fmt.Println("SLACK_BOT_TOKEN environment variable is required") - os.Exit(1) - } - - // Get signing secret from environment variable - signingSecret = os.Getenv("SLACK_SIGNING_SECRET") - if signingSecret == "" { - fmt.Println("SLACK_SIGNING_SECRET environment variable is required") - os.Exit(1) - } -} - func generateModalRequest() slack.ModalViewRequest { // Create a ModalViewRequest with a header and two inputs titleText := slack.NewTextBlockObject("plain_text", "My App", false, false) @@ -107,9 +86,14 @@ func updateModal() slack.ModalViewRequest { return modalRequest } -// This was taken from the slash example -// https://github.com/slack-go/slack/blob/master/examples/slash/slash.go func verifySigningSecret(r *http.Request) error { + // Get signing secret from environment variable + signingSecret := os.Getenv("SLACK_SIGNING_SECRET") + if signingSecret == "" { + fmt.Println("SLACK_SIGNING_SECRET environment variable is required") + os.Exit(1) + } + verifier, err := slack.NewSecretsVerifier(r.Header, signingSecret) if err != nil { fmt.Println(err.Error()) @@ -133,76 +117,85 @@ func verifySigningSecret(r *http.Request) error { return nil } -func handleSlash(w http.ResponseWriter, r *http.Request) { - - err := verifySigningSecret(r) - if err != nil { - fmt.Printf(err.Error()) - w.WriteHeader(http.StatusUnauthorized) - return - } - - s, err := slack.SlashCommandParse(r) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - fmt.Println(err.Error()) - return - } - - switch s.Command { - case "/slash": - api := slack.New(token) - modalRequest := generateModalRequest() - _, err = api.OpenView(s.TriggerID, modalRequest) +func handleSlash(token string) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + err := verifySigningSecret(r) if err != nil { - fmt.Printf("Error opening view: %s", err) + fmt.Printf(err.Error()) + w.WriteHeader(http.StatusUnauthorized) + return } - default: - w.WriteHeader(http.StatusInternalServerError) - return - } -} -func handleModal(w http.ResponseWriter, r *http.Request) { + s, err := slack.SlashCommandParse(r) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Println(err.Error()) + return + } - err := verifySigningSecret(r) - if err != nil { - fmt.Printf(err.Error()) - w.WriteHeader(http.StatusUnauthorized) - return + switch s.Command { + case "/slash": + api := slack.New(token) + modalRequest := generateModalRequest() + _, err = api.OpenView(s.TriggerID, modalRequest) + if err != nil { + fmt.Printf("Error opening view: %s", err) + } + default: + w.WriteHeader(http.StatusInternalServerError) + return + } } +} - var i slack.InteractionCallback - err = json.Unmarshal([]byte(r.FormValue("payload")), &i) - if err != nil { - fmt.Printf(err.Error()) - w.WriteHeader(http.StatusUnauthorized) - return - } +func handleModal(token string) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + err := verifySigningSecret(r) + if err != nil { + fmt.Printf(err.Error()) + w.WriteHeader(http.StatusUnauthorized) + return + } - api := slack.New(token) - - // update modal sample - switch i.Type { - //update when interaction type is view_submission - case slack.InteractionTypeViewSubmission: - //you can use any modal you want to show to users just like creating modal. - updateModal := updateModal() - // You must set one of external_id or view_id and you can use hash for avoiding race condition. - // More details: https://api.slack.com/surfaces/modals/using#updating_apis - _, err := api.UpdateView(updateModal, "", i.View.Hash, i.View.ID) - // Wait for a few seconds to see result this code is necesarry due to slack server modal is going to be closed after the update - time.Sleep(time.Second * 2) + var i slack.InteractionCallback + err = json.Unmarshal([]byte(r.FormValue("payload")), &i) if err != nil { - fmt.Printf("Error updating view: %s", err) - w.WriteHeader(http.StatusInternalServerError) + fmt.Printf(err.Error()) + w.WriteHeader(http.StatusUnauthorized) return } + + api := slack.New(token) + + // update modal sample + switch i.Type { + // update when interaction type is view_submission + case slack.InteractionTypeViewSubmission: + // you can use any modal you want to show to users just like creating modal. + updateModal := updateModal() + // You must set one of external_id or view_id and you can use hash for avoiding race condition. + // More details: https://api.slack.com/surfaces/modals/using#updating_apis + _, err := api.UpdateView(updateModal, "", i.View.Hash, i.View.ID) + // Wait for a few seconds to see result this code is necesarry due to slack server modal is going to be closed after the update + time.Sleep(time.Second * 2) + if err != nil { + fmt.Printf("Error updating view: %s", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + } } } func main() { - http.HandleFunc("/slash", handleSlash) - http.HandleFunc("/modal", handleModal) + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + http.HandleFunc("/slash", handleSlash(token)) + http.HandleFunc("/modal", handleModal(token)) http.ListenAndServe(":4390", nil) } diff --git a/examples/pins/pins.go b/examples/pins/pins.go index 4a8e96d20..f4604dafc 100644 --- a/examples/pins/pins.go +++ b/examples/pins/pins.go @@ -11,7 +11,6 @@ import ( // WARNING: This example is destructive in the sense that it create a channel called testpinning func main() { debug := flag.Bool("debug", false, "Show JSON output") - flag.Parse() // Get token from environment variable @@ -21,7 +20,7 @@ func main() { os.Exit(1) } - api := slack.New(apiToken, slack.OptionDebug(debug)) + api := slack.New(apiToken, slack.OptionDebug(*debug)) var ( postAsUserName string diff --git a/examples/reactions/reactions.go b/examples/reactions/reactions.go index 29258d59d..122dc2d74 100644 --- a/examples/reactions/reactions.go +++ b/examples/reactions/reactions.go @@ -10,7 +10,6 @@ import ( func main() { debug := flag.Bool("debug", false, "Show JSON output") - flag.Parse() // Get token from environment variable @@ -20,7 +19,7 @@ func main() { os.Exit(1) } - api := slack.New(apiToken, slack.OptionDebug(debug)) + api := slack.New(apiToken, slack.OptionDebug(*debug)) var ( postAsUserName string diff --git a/examples/slash/slash.go b/examples/slash/slash.go index 9383aac4c..9f75841f0 100644 --- a/examples/slash/slash.go +++ b/examples/slash/slash.go @@ -19,7 +19,6 @@ func main() { } http.HandleFunc("/slash", func(w http.ResponseWriter, r *http.Request) { - verifier, err := slack.NewSecretsVerifier(r.Header, signingSecret) if err != nil { w.WriteHeader(http.StatusInternalServerError) diff --git a/examples/team/team.go b/examples/team/team.go index e907d9bf0..7bf14c488 100644 --- a/examples/team/team.go +++ b/examples/team/team.go @@ -8,11 +8,8 @@ import ( "github.com/slack-go/slack" ) -var ( - userID = flag.String("user", "", "User ID for billing info (optional)") -) - func main() { + userID := flag.String("user", "", "User ID for billing info (optional)") flag.Parse() // Get token from environment variable diff --git a/examples/users/users.go b/examples/users/users.go index a4fdb1071..d82bf86f3 100644 --- a/examples/users/users.go +++ b/examples/users/users.go @@ -8,11 +8,8 @@ import ( "github.com/slack-go/slack" ) -var ( - userID = flag.String("user", "", "User ID (required)") -) - func main() { + userID := flag.String("user", "", "User ID (required)") flag.Parse() // Get token from environment variable diff --git a/examples/websocket/websocket.go b/examples/websocket/websocket.go index 0b9f444c9..b2525f0de 100644 --- a/examples/websocket/websocket.go +++ b/examples/websocket/websocket.go @@ -9,11 +9,8 @@ import ( "github.com/slack-go/slack" ) -var ( - channelID = flag.String("channel", "", "Channel ID (required)") -) - func main() { + channelID := flag.String("channel", "", "Channel ID (required)") flag.Parse() // Get token from environment variable From dc611a30ff1a81c2c2998e0f80296b978c2600d4 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 16:55:56 +0100 Subject: [PATCH 404/606] fix(examples): quit if unable to send message --- examples/buttons/buttons.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/buttons/buttons.go b/examples/buttons/buttons.go index a66bd1bf0..6ccede3cc 100644 --- a/examples/buttons/buttons.go +++ b/examples/buttons/buttons.go @@ -12,7 +12,6 @@ import ( func main() { channelID := flag.String("channel", "", "Channel ID (required)") - flag.Parse() // Get token from environment variable @@ -53,7 +52,8 @@ func main() { message := slack.MsgOptionAttachments(attachment) respChannelID, timestamp, err := api.PostMessage(*channelID, slack.MsgOptionText("", false), message) if err != nil { - fmt.Printf("Could not send message: %v", err) + fmt.Printf("Could not send message: %v\n", err) + os.Exit(1) } fmt.Printf("Message with buttons successfully sent to channel %s at %s", respChannelID, timestamp) http.HandleFunc("/actions", actionHandler) From bc1f95f0a5b2ddbdc7448918287015deb123a574 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 17:08:34 +0100 Subject: [PATCH 405/606] chore: more linting --- examples/connparams/connparams.go | 2 +- examples/manifests/manifest.go | 1 + examples/modal/modal.go | 5 ++++- examples/tokens/tokens.go | 1 + socketmode/socketmode_handler.go | 1 + 5 files changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/connparams/connparams.go b/examples/connparams/connparams.go index 9811ecf37..c7437a1d6 100644 --- a/examples/connparams/connparams.go +++ b/examples/connparams/connparams.go @@ -13,7 +13,7 @@ import ( func main() { channelID := flag.String("channel", "", "Channel ID (required)") - userIDs := flag.String("users", "", "Comma-separated user IDs for presence monitoring (required)") + userIDs := flag.String("users", "", "Comma-separated user IDs for presence monitoring (required)") flag.Parse() // Get token from environment variable diff --git a/examples/manifests/manifest.go b/examples/manifests/manifest.go index bfcfa9cab..d1e0cd6d6 100644 --- a/examples/manifests/manifest.go +++ b/examples/manifests/manifest.go @@ -2,6 +2,7 @@ package manifests import ( "fmt" + "github.com/slack-go/slack" ) diff --git a/examples/modal/modal.go b/examples/modal/modal.go index c3cd97c20..8de65c529 100644 --- a/examples/modal/modal.go +++ b/examples/modal/modal.go @@ -19,8 +19,9 @@ import ( "net/http" "os" - "github.com/slack-go/slack" "time" + + "github.com/slack-go/slack" ) func generateModalRequest() slack.ModalViewRequest { @@ -183,6 +184,8 @@ func handleModal(token string) func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) return } + default: + fmt.Println("Fallback case") } } } diff --git a/examples/tokens/tokens.go b/examples/tokens/tokens.go index 63d46d25f..6ac97e67a 100644 --- a/examples/tokens/tokens.go +++ b/examples/tokens/tokens.go @@ -2,6 +2,7 @@ package tokens import ( "fmt" + "github.com/slack-go/slack" ) diff --git a/socketmode/socketmode_handler.go b/socketmode/socketmode_handler.go index 0cfe7555d..5b56f2954 100644 --- a/socketmode/socketmode_handler.go +++ b/socketmode/socketmode_handler.go @@ -2,6 +2,7 @@ package socketmode import ( "context" + "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" ) From 36472df66ab3dd292470a28ad72fe2fc8768f77b Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 17:08:56 +0100 Subject: [PATCH 406/606] chore: add generated README to examples/ --- examples/README.md | 95 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 examples/README.md diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 000000000..ead19a0bd --- /dev/null +++ b/examples/README.md @@ -0,0 +1,95 @@ +# Slack Examples + +This directory contains examples demonstrating how to use the slack-go library for various Slack API operations. + +## Development Guidelines + +### Environment Variables vs Command Line Arguments + +When developing examples, follow these patterns for handling different types of data: + +#### Environment Variables (Sensitive Data) + +Use environment variables for **sensitive information** that should not be exposed in command history or process lists: + +- **Tokens**: `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `SLACK_USER_TOKEN` +- **Secrets**: `SLACK_SIGNING_SECRET` +- **URLs with credentials**: webhook URLs, etc. + +**Pattern:** +```go +token := os.Getenv("SLACK_BOT_TOKEN") +if token == "" { + fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN environment variable is required\n") + os.Exit(1) +} + +// Optional: Validate token format +if !strings.HasPrefix(token, "xoxb-") { + fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN must be a bot token (xoxb-)\n") + os.Exit(1) +} +``` + +**Common environment variables:** +- `SLACK_BOT_TOKEN` - Bot user OAuth token (starts with `xoxb-`) +- `SLACK_APP_TOKEN` - App-level token (starts with `xapp-`) +- `SLACK_USER_TOKEN` - User OAuth token (starts with `xoxp-`) +- `SLACK_SIGNING_SECRET` - For webhook signature verification + +#### Command Line Arguments (Non-Sensitive Data) + +Use command line flags for **operational parameters** that are safe to expose: + +- **IDs**: channel IDs, user IDs, team IDs +- **Configuration**: timeouts, limits, modes +- **Options**: boolean flags, enum values + +**Pattern:** +```go +import "flag" + +var ( + channelID = flag.String("channel", "", "Channel ID (required)") + userID = flag.String("user", "", "User ID (required)") + verbose = flag.Bool("verbose", false, "Enable verbose logging") +) + +func main() { + flag.Parse() + + if *channelID == "" { + fmt.Fprintf(os.Stderr, "Error: -channel flag is required\n") + os.Exit(1) + } + + if *userID == "" { + fmt.Fprintf(os.Stderr, "Error: -user flag is required\n") + os.Exit(1) + } + + // Use the flags + fmt.Printf("Operating on channel %s with user %s\n", *channelID, *userID) +} +``` + +### Error Handling Standards + +**Environment variable errors:** +- Use `fmt.Fprintf(os.Stderr, ...)` for error output +- Include clear variable name in error message +- Exit with `os.Exit(1)` for missing required variables + +**Command line argument errors:** +- Use `fmt.Fprintf(os.Stderr, ...)` for error output +- Include flag name in error message +- Exit with `os.Exit(1)` for missing required flags + +### Security Considerations + +1. **Never hardcode sensitive values** in example code +2. **Think of validating required environment variables** before using them +3. **Use token format validation** when applicable (e.g., `xoxb-` prefix for bot tokens) +4. **Keep sensitive data out of command line arguments** to prevent exposure in process lists + +This pattern ensures consistent security practices across all examples and makes them easier to understand and use safely. From 4a2676d32e8c7577d9a35eb7f451a2c34edf7efc Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 4 Jul 2025 17:30:15 +0100 Subject: [PATCH 407/606] feat(users): add `IsEmailConfirmed` to `User` Closes #1457 --- users.go | 1 + 1 file changed, 1 insertion(+) diff --git a/users.go b/users.go index b51b1721f..91a9a4c7b 100644 --- a/users.go +++ b/users.go @@ -130,6 +130,7 @@ type User struct { IsStranger bool `json:"is_stranger"` IsAppUser bool `json:"is_app_user"` IsInvitedUser bool `json:"is_invited_user"` + IsEmailConfirmed bool `json:"is_email_confirmed"` Has2FA bool `json:"has_2fa"` TwoFactorType *string `json:"two_factor_type"` HasFiles bool `json:"has_files"` From fe4c0d1aee31ede0da2920ae3336978b9222ce4f Mon Sep 17 00:00:00 2001 From: Pete Markowsky Date: Tue, 8 Jul 2025 20:49:08 -0400 Subject: [PATCH 408/606] Make it possible to omit fields in the manifest struct. --- manifests.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/manifests.go b/manifests.go index 0a972a25d..ab6b1f2f6 100644 --- a/manifests.go +++ b/manifests.go @@ -187,11 +187,11 @@ type Display struct { // Settings is a group of settings corresponding to the Settings section of the app config pages. type Settings struct { - AllowedIPAddressRanges []string `json:"allowed_ip_address_ranges,omitempty" yaml:"allowed_ip_address_ranges,omitempty"` - EventSubscriptions EventSubscriptions `json:"event_subscriptions,omitempty" yaml:"event_subscriptions,omitempty"` - Interactivity Interactivity `json:"interactivity,omitempty" yaml:"interactivity,omitempty"` - OrgDeployEnabled bool `json:"org_deploy_enabled,omitempty" yaml:"org_deploy_enabled,omitempty"` - SocketModeEnabled bool `json:"socket_mode_enabled,omitempty" yaml:"socket_mode_enabled,omitempty"` + AllowedIPAddressRanges []string `json:"allowed_ip_address_ranges,omitempty" yaml:"allowed_ip_address_ranges,omitempty"` + EventSubscriptions *EventSubscriptions `json:"event_subscriptions,omitempty" yaml:"event_subscriptions,omitempty"` + Interactivity *Interactivity `json:"interactivity,omitempty" yaml:"interactivity,omitempty"` + OrgDeployEnabled bool `json:"org_deploy_enabled,omitempty" yaml:"org_deploy_enabled,omitempty"` + SocketModeEnabled bool `json:"socket_mode_enabled,omitempty" yaml:"socket_mode_enabled,omitempty"` } // EventSubscriptions is a group of settings that describe the Events API configuration From aa7a86872613f5dac6c2bb1a334800ed23eb1023 Mon Sep 17 00:00:00 2001 From: "takeshi.nakata" <7553415+nktks@users.noreply.github.com> Date: Wed, 9 Jul 2025 19:46:13 +0900 Subject: [PATCH 409/606] add example for assistant, data access api --- .../socketmode_handler/socketmode_handler.go | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/examples/socketmode_handler/socketmode_handler.go b/examples/socketmode_handler/socketmode_handler.go index a12e6d194..d8162fb14 100644 --- a/examples/socketmode_handler/socketmode_handler.go +++ b/examples/socketmode_handler/socketmode_handler.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "log" "os" @@ -56,6 +57,8 @@ func main() { // Handle a specific event from EventsAPI socketmodeHandler.HandleEvents(slackevents.AppMention, middlewareAppMentionEvent) + socketmodeHandler.HandleEvents(slackevents.Message, middlewareMessageEvent) + socketmodeHandler.HandleEvents(slackevents.AssistantThreadStarted, middlewareAssistantThreadStartedEvent) //\\ EventTypeInteractive //\\ // Handle all Interactive Events @@ -204,6 +207,92 @@ func middlewareSlashCommand(evt *socketmode.Event, client *socketmode.Client) { client.Ack(*evt.Request, payload) } +func middlewareAssistantThreadStartedEvent(evt *socketmode.Event, client *socketmode.Client) { + ctx := context.Background() + fmt.Printf("assistant thread started event: %+v\n", evt) + + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + fmt.Printf("Ignored: %+v\n", evt) + return + } + + innerEvent, ok := eventsAPIEvent.InnerEvent.Data.(*slackevents.AssistantThreadStartedEvent) + if !ok { + fmt.Printf("Can't get inner event: %+v\n", evt) + return + } + + fmt.Printf("Inner event: %+v\n", innerEvent) + + params := slack.AssistantThreadsSetSuggestedPromptsParameters{ + Title: "Welcome. What can I do for you?", + ChannelID: innerEvent.AssistantThread.ChannelID, + ThreadTS: innerEvent.AssistantThread.ThreadTimeStamp, + Prompts: []slack.AssistantThreadsPrompt{ + { + Title: "Generate ideas", + Message: "Pretend you are a marketing associate and you need new ideas for an enterprise productivity feature. Generate 10 ideas for a new feature launch.", + }, + { + Title: "Explain what SLACK stands for", + Message: "What does SLACK stand for?", + }, + }, + } + + if err := client.SetAssistantThreadsSuggestedPromptsContext(ctx, params); err != nil { + fmt.Printf("Can't SetAssistantThreadsSuggestedPromptsContext: %v\n", err) + return + } +} + +func middlewareMessageEvent(evt *socketmode.Event, client *socketmode.Client) { + ctx := context.Background() + fmt.Printf("message event: %+v\n", evt) + + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + fmt.Printf("Ignored: %+v\n", evt) + return + } + + messageEvent, ok := eventsAPIEvent.InnerEvent.Data.(*slackevents.MessageEvent) + if !ok { + fmt.Printf("Can't get message event: %+v\n", evt) + return + } + + // Assistant thread message handling + if messageEvent.AssistantThread != nil { + fmt.Printf("Assistant thread message received - user: %s, text: %s, channel: %s, assistantThread: %+v\n", + messageEvent.User, messageEvent.Text, messageEvent.Channel, messageEvent.AssistantThread) + + // Call Data Access API for context search + resp, err := client.SearchAssistantContextContext(ctx, slack.AssistantSearchContextParameters{ + Query: messageEvent.Text, + ActionToken: messageEvent.AssistantThread.ActionToken, + ChannelTypes: []string{"public_channel"}, + ContentTypes: []string{"messages"}, + Limit: 10, + }) + if err != nil { + fmt.Printf("SearchAssistantContextContext error: %v\n", err) + } else { + fmt.Printf("SearchAssistantContextContext response: %+v\n", resp) + } + + // Acknowledge the event + client.Ack(*evt.Request) + + // Simple response for demonstration + _, _, err = client.Client.PostMessage(messageEvent.Channel, slack.MsgOptionText("Hello! I received your message: "+messageEvent.Text, false), slack.MsgOptionTS(messageEvent.TimeStamp)) + if err != nil { + fmt.Printf("Failed to post message: %v\n", err) + } + } +} + func middlewareDefault(evt *socketmode.Event, client *socketmode.Client) { // fmt.Fprintf(os.Stderr, "Unexpected event type received: %s\n", evt.Type) } From d4221e7659559f291d92ac9321f7609c50223eb0 Mon Sep 17 00:00:00 2001 From: "takeshi.nakata" <7553415+nktks@users.noreply.github.com> Date: Sun, 13 Jul 2025 16:57:21 +0900 Subject: [PATCH 410/606] Revert "add example for assistant, data access api" This reverts commit aa7a86872613f5dac6c2bb1a334800ed23eb1023. --- .../socketmode_handler/socketmode_handler.go | 89 ------------------- 1 file changed, 89 deletions(-) diff --git a/examples/socketmode_handler/socketmode_handler.go b/examples/socketmode_handler/socketmode_handler.go index d8162fb14..a12e6d194 100644 --- a/examples/socketmode_handler/socketmode_handler.go +++ b/examples/socketmode_handler/socketmode_handler.go @@ -1,7 +1,6 @@ package main import ( - "context" "fmt" "log" "os" @@ -57,8 +56,6 @@ func main() { // Handle a specific event from EventsAPI socketmodeHandler.HandleEvents(slackevents.AppMention, middlewareAppMentionEvent) - socketmodeHandler.HandleEvents(slackevents.Message, middlewareMessageEvent) - socketmodeHandler.HandleEvents(slackevents.AssistantThreadStarted, middlewareAssistantThreadStartedEvent) //\\ EventTypeInteractive //\\ // Handle all Interactive Events @@ -207,92 +204,6 @@ func middlewareSlashCommand(evt *socketmode.Event, client *socketmode.Client) { client.Ack(*evt.Request, payload) } -func middlewareAssistantThreadStartedEvent(evt *socketmode.Event, client *socketmode.Client) { - ctx := context.Background() - fmt.Printf("assistant thread started event: %+v\n", evt) - - eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) - if !ok { - fmt.Printf("Ignored: %+v\n", evt) - return - } - - innerEvent, ok := eventsAPIEvent.InnerEvent.Data.(*slackevents.AssistantThreadStartedEvent) - if !ok { - fmt.Printf("Can't get inner event: %+v\n", evt) - return - } - - fmt.Printf("Inner event: %+v\n", innerEvent) - - params := slack.AssistantThreadsSetSuggestedPromptsParameters{ - Title: "Welcome. What can I do for you?", - ChannelID: innerEvent.AssistantThread.ChannelID, - ThreadTS: innerEvent.AssistantThread.ThreadTimeStamp, - Prompts: []slack.AssistantThreadsPrompt{ - { - Title: "Generate ideas", - Message: "Pretend you are a marketing associate and you need new ideas for an enterprise productivity feature. Generate 10 ideas for a new feature launch.", - }, - { - Title: "Explain what SLACK stands for", - Message: "What does SLACK stand for?", - }, - }, - } - - if err := client.SetAssistantThreadsSuggestedPromptsContext(ctx, params); err != nil { - fmt.Printf("Can't SetAssistantThreadsSuggestedPromptsContext: %v\n", err) - return - } -} - -func middlewareMessageEvent(evt *socketmode.Event, client *socketmode.Client) { - ctx := context.Background() - fmt.Printf("message event: %+v\n", evt) - - eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) - if !ok { - fmt.Printf("Ignored: %+v\n", evt) - return - } - - messageEvent, ok := eventsAPIEvent.InnerEvent.Data.(*slackevents.MessageEvent) - if !ok { - fmt.Printf("Can't get message event: %+v\n", evt) - return - } - - // Assistant thread message handling - if messageEvent.AssistantThread != nil { - fmt.Printf("Assistant thread message received - user: %s, text: %s, channel: %s, assistantThread: %+v\n", - messageEvent.User, messageEvent.Text, messageEvent.Channel, messageEvent.AssistantThread) - - // Call Data Access API for context search - resp, err := client.SearchAssistantContextContext(ctx, slack.AssistantSearchContextParameters{ - Query: messageEvent.Text, - ActionToken: messageEvent.AssistantThread.ActionToken, - ChannelTypes: []string{"public_channel"}, - ContentTypes: []string{"messages"}, - Limit: 10, - }) - if err != nil { - fmt.Printf("SearchAssistantContextContext error: %v\n", err) - } else { - fmt.Printf("SearchAssistantContextContext response: %+v\n", resp) - } - - // Acknowledge the event - client.Ack(*evt.Request) - - // Simple response for demonstration - _, _, err = client.Client.PostMessage(messageEvent.Channel, slack.MsgOptionText("Hello! I received your message: "+messageEvent.Text, false), slack.MsgOptionTS(messageEvent.TimeStamp)) - if err != nil { - fmt.Printf("Failed to post message: %v\n", err) - } - } -} - func middlewareDefault(evt *socketmode.Event, client *socketmode.Client) { // fmt.Fprintf(os.Stderr, "Unexpected event type received: %s\n", evt.Type) } From e044f7a80713e466893a0d2b4d44dd79660114bd Mon Sep 17 00:00:00 2001 From: "takeshi.nakata" <7553415+nktks@users.noreply.github.com> Date: Sun, 13 Jul 2025 17:48:03 +0900 Subject: [PATCH 411/606] add ai_apps_handler example --- .../socketmode_handler/ai_apps_handler.go | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 examples/socketmode_handler/ai_apps_handler.go diff --git a/examples/socketmode_handler/ai_apps_handler.go b/examples/socketmode_handler/ai_apps_handler.go new file mode 100644 index 000000000..458956bdd --- /dev/null +++ b/examples/socketmode_handler/ai_apps_handler.go @@ -0,0 +1,151 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "strings" + + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" +) + +// This is an example of AI apps https://api.slack.com/docs/apps/ai +// Developing and using AI apps requires a paid plan or joining the Developer Program and provision a sandbox with access to all Slack features for free. +// +// This example also have calling https://api.slack.com/docs/apps/data-access-api sample code. +// This API is currently in a limited access stage. You may be able to obtain a token and call the API, but to get a valid response, you must be enrolled in the program. Contact Customer Experience at feedback@slack.com to request to be added. +func main() { + appToken := os.Getenv("SLACK_APP_TOKEN") + if appToken == "" { + panic("SLACK_APP_TOKEN must be set.\n") + } + + if !strings.HasPrefix(appToken, "xapp-") { + panic("SLACK_APP_TOKEN must have the prefix \"xapp-\".") + } + + botToken := os.Getenv("SLACK_BOT_TOKEN") + if botToken == "" { + panic("SLACK_BOT_TOKEN must be set.\n") + } + + if !strings.HasPrefix(botToken, "xoxb-") { + panic("SLACK_BOT_TOKEN must have the prefix \"xoxb-\".") + } + + api := slack.New( + botToken, + slack.OptionDebug(true), + slack.OptionLog(log.New(os.Stdout, "api: ", log.Lshortfile|log.LstdFlags)), + slack.OptionAppLevelToken(appToken), + ) + + client := socketmode.New( + api, + socketmode.OptionDebug(true), + socketmode.OptionLog(log.New(os.Stdout, "socketmode: ", log.Lshortfile|log.LstdFlags)), + ) + + socketmodeHandler := socketmode.NewSocketmodeHandler(client) + + // Handle a specific event from EventsAPI + socketmodeHandler.HandleEvents(slackevents.AssistantThreadStarted, middlewareAssistantThreadStartedEvent) + socketmodeHandler.HandleEvents(slackevents.AppMention, middlewareAppMentionEvent) + + socketmodeHandler.RunEventLoop() +} + +func middlewareAssistantThreadStartedEvent(evt *socketmode.Event, client *socketmode.Client) { + ctx := context.Background() + fmt.Printf("assistant thread started event: %+v\n", evt) + + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + fmt.Printf("Ignored: %+v\n", evt) + return + } + + innerEvent, ok := eventsAPIEvent.InnerEvent.Data.(*slackevents.AssistantThreadStartedEvent) + if !ok { + fmt.Printf("Can't get inner event: %+v\n", evt) + return + } + + fmt.Printf("Inner event: %+v\n", innerEvent) + + params := slack.AssistantThreadsSetSuggestedPromptsParameters{ + Title: "Welcome. What can I do for you?", + ChannelID: innerEvent.AssistantThread.ChannelID, + ThreadTS: innerEvent.AssistantThread.ThreadTimeStamp, + Prompts: []slack.AssistantThreadsPrompt{ + { + Title: "Generate ideas", + Message: "Pretend you are a marketing associate and you need new ideas for an enterprise productivity feature. Generate 10 ideas for a new feature launch.", + }, + { + Title: "Explain what SLACK stands for", + Message: "What does SLACK stand for?", + }, + }, + } + + if err := client.SetAssistantThreadsSuggestedPromptsContext(ctx, params); err != nil { + fmt.Printf("Can't SetAssistantThreadsSuggestedPromptsContext: %v\n", err) + return + } +} + +func middlewareAppMentionEvent(evt *socketmode.Event, client *socketmode.Client) { + ctx := context.Background() + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + return + } + client.Ack(*evt.Request) + + ev, ok := eventsAPIEvent.InnerEvent.Data.(*slackevents.AppMentionEvent) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + return + } + fmt.Printf("We have been mentioned in %v\n", ev.Channel) + if err := searchAssistantContext(ctx, client, ev.AssistantThread, ev.Text, ev.Channel, ev.TimeStamp); err != nil { + fmt.Printf("Failed searchAssistantContext: %+v\n", err) + return + } +} + +// This is sample of calling https://api.slack.com/docs/apps/data-access-api +// This API is currently in a limited access stage. You may be able to obtain a token and call the API, but to get a valid response, you must be enrolled in the program. Contact Customer Experience at feedback@slack.com to request to be added. +func searchAssistantContext(ctx context.Context, client *socketmode.Client, at *slackevents.AssistantThreadActionToken, text, channel, ts string) error { + // Assistant thread message handling + if at != nil { + fmt.Printf("Assistant thread message received - text: %s, channel: %s", + text, channel, at) + + // Call Data Access API for context search + resp, err := client.SearchAssistantContextContext(ctx, slack.AssistantSearchContextParameters{ + Query: text, + ActionToken: at.ActionToken, + ChannelTypes: []string{"public_channel"}, + ContentTypes: []string{"messages"}, + Limit: 10, + }) + if err != nil { + return err + } + fmt.Printf("SearchAssistantContextContext response: %+v\n", resp) + + if len(resp.Results.Messages) > 0 { + _, _, err = client.Client.PostMessage(channel, slack.MsgOptionText("Hello! I searched your query.text:\n: "+text+"\nSearch first result:\n"+resp.Results.Messages[0].Content, false), slack.MsgOptionTS(ts)) + if err != nil { + return err + } + } + } + return nil +} From 4ae496d7aa1b3ec1e56ca77ab77dcea7d4f63991 Mon Sep 17 00:00:00 2001 From: "takeshi.nakata" <7553415+nktks@users.noreply.github.com> Date: Mon, 14 Jul 2025 11:27:15 +0900 Subject: [PATCH 412/606] fix ci --- examples/{socketmode_handler => ai_apps}/ai_apps_handler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename examples/{socketmode_handler => ai_apps}/ai_apps_handler.go (99%) diff --git a/examples/socketmode_handler/ai_apps_handler.go b/examples/ai_apps/ai_apps_handler.go similarity index 99% rename from examples/socketmode_handler/ai_apps_handler.go rename to examples/ai_apps/ai_apps_handler.go index 458956bdd..d9d61e2ba 100644 --- a/examples/socketmode_handler/ai_apps_handler.go +++ b/examples/ai_apps/ai_apps_handler.go @@ -125,7 +125,7 @@ func searchAssistantContext(ctx context.Context, client *socketmode.Client, at * // Assistant thread message handling if at != nil { fmt.Printf("Assistant thread message received - text: %s, channel: %s", - text, channel, at) + text, channel) // Call Data Access API for context search resp, err := client.SearchAssistantContextContext(ctx, slack.AssistantSearchContextParameters{ From 2057b59d87a3b6786a621ed0a83298c41e503815 Mon Sep 17 00:00:00 2001 From: Dor Kauf Date: Wed, 23 Jul 2025 17:00:52 +0300 Subject: [PATCH 413/606] get all conversations with pagination --- conversation.go | 145 +++++++++++++++++++++++++++++++++++++++++++ conversation_test.go | 40 +++++++++++- 2 files changed, 184 insertions(+), 1 deletion(-) diff --git a/conversation.go b/conversation.go index 33eb0ff9b..ae53f0762 100644 --- a/conversation.go +++ b/conversation.go @@ -7,6 +7,7 @@ import ( "net/url" "strconv" "strings" + "time" ) // Conversation is the foundation for IM and BaseGroupConversation @@ -677,6 +678,150 @@ type GetConversationsParameters struct { TeamID string } +// GetConversationsOption options for the GetAllConversationsContext method call. +type GetConversationsOption func(*ConversationPagination) + +// GetConversationsOptionLimit limit the number of conversations returned +func GetConversationsOptionLimit(n int) GetConversationsOption { + return func(p *ConversationPagination) { + p.limit = n + } +} + +// GetConversationsOptionExcludeArchived exclude archived conversations +func GetConversationsOptionExcludeArchived(exclude bool) GetConversationsOption { + return func(p *ConversationPagination) { + p.excludeArchived = exclude + } +} + +// GetConversationsOptionTypes filter conversations by type +func GetConversationsOptionTypes(types []string) GetConversationsOption { + return func(p *ConversationPagination) { + p.types = types + } +} + +// GetConversationsOptionTeamID include team Id +func GetConversationsOptionTeamID(teamId string) GetConversationsOption { + return func(p *ConversationPagination) { + p.teamId = teamId + } +} + +func newConversationPagination(c *Client, options ...GetConversationsOption) (cp ConversationPagination) { + cp = ConversationPagination{ + c: c, + limit: 200, // per slack api documentation. + } + + for _, opt := range options { + opt(&cp) + } + + return cp +} + +// ConversationPagination allows for paginating over the conversations +type ConversationPagination struct { + Conversations []Channel + limit int + excludeArchived bool + types []string + teamId string + previousResp *ResponseMetadata + c *Client +} + +// Done checks if the pagination has completed +func (ConversationPagination) Done(err error) bool { + return errors.Is(err, errPaginationComplete) +} + +// Failure checks if pagination failed. +func (t ConversationPagination) Failure(err error) error { + if t.Done(err) { + return nil + } + + return err +} + +func (t ConversationPagination) Next(ctx context.Context) (_ ConversationPagination, err error) { + if t.c == nil || (t.previousResp != nil && t.previousResp.Cursor == "") { + return t, errPaginationComplete + } + + t.previousResp = t.previousResp.initialize() + + values := url.Values{ + "token": {t.c.token}, + "limit": {strconv.Itoa(t.limit)}, + "cursor": {t.previousResp.Cursor}, + } + if t.excludeArchived { + values.Add("exclude_archived", strconv.FormatBool(t.excludeArchived)) + } + if t.types != nil { + values.Add("types", strings.Join(t.types, ",")) + } + if t.teamId != "" { + values.Add("team_id", t.teamId) + } + + response := struct { + Channels []Channel `json:"channels"` + ResponseMetaData responseMetaData `json:"response_metadata"` + SlackResponse + }{} + + err = t.c.postMethod(ctx, "conversations.list", values, &response) + if err != nil { + return t, err + } + + if err := response.Err(); err != nil { + return t, err + } + + t.c.Debugf("GetAllConversationsContext: got %d conversations; cursor %s", len(response.Channels), response.ResponseMetaData.NextCursor) + t.Conversations = response.Channels + t.previousResp = &ResponseMetadata{Cursor: response.ResponseMetaData.NextCursor} + + return t, nil +} + +// GetConversationsPaginated fetches conversations in a paginated fashion, see GetAllConversationsContext for usage. +func (api *Client) GetConversationsPaginated(options ...GetConversationsOption) ConversationPagination { + return newConversationPagination(api, options...) +} + +// GetAllConversations returns the list of all conversations, handling pagination and rate limiting +func (api *Client) GetAllConversations(options ...GetConversationsOption) (results []Channel, err error) { + return api.GetAllConversationsContext(context.Background(), options...) +} + +// GetAllConversationsContext returns the list of all conversations with a custom context, handling pagination and rate limiting +func (api *Client) GetAllConversationsContext(ctx context.Context, options ...GetConversationsOption) (results []Channel, err error) { + results = []Channel{} + p := api.GetConversationsPaginated(options...) + for err == nil { + p, err = p.Next(ctx) + if err == nil { + results = append(results, p.Conversations...) + } else if rateLimitedError, ok := err.(*RateLimitedError); ok { + select { + case <-ctx.Done(): + err = ctx.Err() + case <-time.After(rateLimitedError.RetryAfter): + err = nil + } + } + } + + return results, p.Failure(err) +} + // GetConversations returns the list of channels in a Slack team. // For more details, see GetConversationsContext documentation. func (api *Client) GetConversations(params *GetConversationsParameters) (channels []Channel, nextCursor string, err error) { diff --git a/conversation_test.go b/conversation_test.go index d49d92dc1..775842804 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -1,6 +1,7 @@ package slack import ( + "context" "encoding/json" "net/http" "reflect" @@ -734,7 +735,29 @@ func getConversationsHandler(rw http.ResponseWriter, r *http.Request) { Channels []Channel `json:"channels"` }{ SlackResponse: SlackResponse{Ok: true}, - Channels: []Channel{}}) + Channels: []Channel{ + { + GroupConversation: GroupConversation{ + Conversation: Conversation{ + ID: "CXXXXXXXX", + }, + }, + }, + { + GroupConversation: GroupConversation{ + Conversation: Conversation{ + ID: "CYYYYYYYY", + }, + }, + }, + { + GroupConversation: GroupConversation{ + Conversation: Conversation{ + ID: "CZZZZZZZZ", + }, + }, + }, + }}) rw.Write(response) } @@ -750,6 +773,21 @@ func TestGetConversations(t *testing.T) { } } +func TestGetAllConversationsContext(t *testing.T) { + http.HandleFunc("/conversations.list", getConversationsHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + conversations, err := api.GetAllConversationsContext(context.Background()) + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + if len(conversations) != 3 { + t.Errorf("Expected 3 conversations, got %d", len(conversations)) + return + } +} + func openConversationHandler(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "application/json") response, _ := json.Marshal(struct { From a2941be94e8d6cc4c46ba3585c082a8019c37c43 Mon Sep 17 00:00:00 2001 From: Dor Kauf Date: Wed, 23 Jul 2025 17:04:21 +0300 Subject: [PATCH 414/606] get all conversations with pagination --- conversation_test.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/conversation_test.go b/conversation_test.go index 775842804..bbc1fae40 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -771,12 +771,7 @@ func TestGetConversations(t *testing.T) { t.Errorf("Unexpected error: %s", err) return } -} -func TestGetAllConversationsContext(t *testing.T) { - http.HandleFunc("/conversations.list", getConversationsHandler) - once.Do(startServer) - api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) conversations, err := api.GetAllConversationsContext(context.Background()) if err != nil { t.Errorf("Unexpected error: %s", err) From 6bee7ebdfbc36aa04dc41eb2449329f881621f6c Mon Sep 17 00:00:00 2001 From: Dor Kauf Date: Wed, 23 Jul 2025 17:19:20 +0300 Subject: [PATCH 415/606] rate limit and server error tests --- conversation_test.go | 130 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/conversation_test.go b/conversation_test.go index bbc1fae40..069a7849a 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -3,9 +3,13 @@ package slack import ( "context" "encoding/json" + "fmt" "net/http" "reflect" + "strconv" + "sync/atomic" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -899,3 +903,129 @@ func TestCreateChannelCanvas(t *testing.T) { assert.Equal(t, "F05RQ01LJU0", canvasID) } + +func getTestChannelWithId(id string) Channel { + return Channel{ + GroupConversation: GroupConversation{ + Conversation: Conversation{ + ID: id, + }, + Name: "Test Channel", + Topic: Topic{ + Value: "Test topic", + }, + Purpose: Purpose{ + Value: "Test purpose", + }, + }, + IsChannel: true, + IsGeneral: false, + IsMember: true, + } +} + +// returns n pages of conversations and sends rate limited errors in between successful pages. +func getConversationPagesWithRateLimitErrors(max int64) func(rw http.ResponseWriter, r *http.Request) { + var n int64 + doRateLimit := false + return func(rw http.ResponseWriter, r *http.Request) { + defer func() { + doRateLimit = !doRateLimit + }() + if doRateLimit { + rw.Header().Set("Retry-After", "1") + rw.WriteHeader(http.StatusTooManyRequests) + return + } + var cpage int64 + sresp := SlackResponse{ + Ok: true, + } + channels := []Channel{ + getTestChannelWithId(fmt.Sprintf("C%03d", n)), + } + rw.Header().Set("Content-Type", "application/json") + if cpage = atomic.AddInt64(&n, 1); cpage == max { + response, _ := json.Marshal(struct { + SlackResponse + Channels []Channel `json:"channels"` + }{ + SlackResponse: sresp, + Channels: channels, + }) + rw.Write(response) + return + } + response, _ := json.Marshal(struct { + SlackResponse + Channels []Channel `json:"channels"` + ResponseMetaData responseMetaData `json:"response_metadata"` + }{ + SlackResponse: sresp, + Channels: channels, + ResponseMetaData: responseMetaData{ + NextCursor: strconv.Itoa(int(cpage)), + }, + }) + rw.Write(response) + } +} + +func TestGetAllConversationsHandlesRateLimit(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/conversations.list", getConversationPagesWithRateLimitErrors(3)) + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + start := time.Now() + conversations, err := api.GetAllConversations() + elapsed := time.Since(start) + + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + + // Should have 3 conversations (one per page) + if len(conversations) != 3 { + t.Errorf("Expected 3 conversations, got %d", len(conversations)) + return + } + + // Should have taken at least 2 seconds due to rate limiting (2 rate limit delays) + if elapsed < 2*time.Second { + t.Errorf("Expected at least 2 seconds due to rate limiting, took %v", elapsed) + return + } + + // Verify conversation IDs + expectedIDs := []string{"C000", "C001", "C002"} + for i, conv := range conversations { + if conv.ID != expectedIDs[i] { + t.Errorf("Expected conversation ID %s, got %s", expectedIDs[i], conv.ID) + } + } +} + +func TestGetAllConversationsReturnsServerError(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/conversations.list", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + _, err := api.GetAllConversations() + + if err == nil { + t.Errorf("Expected error but got nil") + return + } + + expectedErr := "slack server error: 500 Internal Server Error" + if err.Error() != expectedErr { + t.Errorf("Expected: %s. Got: %s", expectedErr, err.Error()) + } +} From 9f5517e4f4603d04c2e14cfb8b10f353af602803 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 10:04:07 +0000 Subject: [PATCH 416/606] chore(deps): bump actions/checkout from 4 to 5 Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 135a29038..7a7616123 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: - "1.24" name: test go-${{ matrix.go }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: actions/setup-go@v5 with: go-version: ${{ matrix.go }} @@ -34,7 +34,7 @@ jobs: with: go-version: "1.22" cache: false - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - name: golangci-lint From bbf9c4504e650d3cd640fa0dc8024cfc2109edd3 Mon Sep 17 00:00:00 2001 From: KamikazeZirou Date: Fri, 8 Aug 2025 00:16:09 +0900 Subject: [PATCH 417/606] Add cursor-based pagination for GetUsers API Replace ResponseMetadata-based pagination with direct cursor handling in UserPagination struct. This simplifies the pagination logic and provides more explicit control over cursor state. Changes: - Add GetUsersOptionCursor() to set pagination cursor - Replace previousResp field with Cursor and complete fields - Update Next() method to use cursor directly from API response - Add test coverage for cursor-based pagination scenarios --- users.go | 29 +++++++++++++-------- users_test.go | 72 +++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 73 insertions(+), 28 deletions(-) diff --git a/users.go b/users.go index 91a9a4c7b..7d7d61f6e 100644 --- a/users.go +++ b/users.go @@ -316,6 +316,13 @@ func GetUsersOptionTeamID(teamId string) GetUsersOption { } } +// GetUsersOptionCursor set the cursor to the next page of results +func GetUsersOptionCursor(cursor string) GetUsersOption { + return func(p *UserPagination) { + p.Cursor = cursor + } +} + func newUserPagination(c *Client, options ...GetUsersOption) (up UserPagination) { up = UserPagination{ c: c, @@ -331,12 +338,13 @@ func newUserPagination(c *Client, options ...GetUsersOption) (up UserPagination) // UserPagination allows for paginating over the users type UserPagination struct { - Users []User - limit int - presence bool - teamId string - previousResp *ResponseMetadata - c *Client + Users []User + Cursor string + limit int + presence bool + teamId string + complete bool + c *Client } // Done checks if the pagination has completed @@ -358,17 +366,15 @@ func (t UserPagination) Next(ctx context.Context) (_ UserPagination, err error) resp *userResponseFull ) - if t.c == nil || (t.previousResp != nil && t.previousResp.Cursor == "") { + if t.c == nil || t.complete { return t, errPaginationComplete } - t.previousResp = t.previousResp.initialize() - values := url.Values{ "limit": {strconv.Itoa(t.limit)}, "presence": {strconv.FormatBool(t.presence)}, "token": {t.c.token}, - "cursor": {t.previousResp.Cursor}, + "cursor": {t.Cursor}, "team_id": {t.teamId}, "include_locale": {strconv.FormatBool(true)}, } @@ -379,7 +385,8 @@ func (t UserPagination) Next(ctx context.Context) (_ UserPagination, err error) t.c.Debugf("GetUsersContext: got %d users; metadata %v", len(resp.Members), resp.Metadata) t.Users = resp.Members - t.previousResp = &resp.Metadata + t.Cursor = resp.Metadata.Cursor + t.complete = t.Cursor == "" return t, nil } diff --git a/users_test.go b/users_test.go index eedfd2d60..49cf31fa5 100644 --- a/users_test.go +++ b/users_test.go @@ -2,6 +2,7 @@ package slack import ( "bytes" + "context" "encoding/json" "fmt" "image" @@ -408,27 +409,61 @@ func TestGetUsers(t *testing.T) { once.Do(startServer) api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - users, err := api.GetUsers() - if err != nil { - t.Errorf("Unexpected error: %s", err) - return - } + t.Run("Get all users", func(t *testing.T) { - if !reflect.DeepEqual([]User{ - getTestUserWithId("U000"), - getTestUserWithId("U001"), - getTestUserWithId("U002"), - getTestUserWithId("U003"), - }, users) { - t.Fatal(ErrIncorrectResponse) - } + users, err := api.GetUsers() + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + + if !reflect.DeepEqual([]User{ + getTestUserWithId("U000"), + getTestUserWithId("U001"), + getTestUserWithId("U002"), + getTestUserWithId("U003"), + }, users) { + t.Fatal(ErrIncorrectResponse) + } + }) + + t.Run("Get users with cursor", func(t *testing.T) { + page := api.GetUsersPaginated(GetUsersOptionCursor("2"), GetUsersOptionLimit(1)) + nextPage, err := page.Next(context.TODO()) + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + + if !reflect.DeepEqual([]User{ + getTestUserWithId("U002"), + }, nextPage.Users) { + t.Fatal(ErrIncorrectResponse) + } + + if nextPage.Cursor != "3" { + t.Fatal(ErrIncorrectResponse) + } + }) } // returns n pages users. func getUserPage(max int64) func(rw http.ResponseWriter, r *http.Request) { - var n int64 return func(rw http.ResponseWriter, r *http.Request) { - var cpage int64 + var ( + n int64 + err error + ) + + _ = r.ParseForm() + if cursor := r.FormValue("cursor"); cursor != "" { + n, err = strconv.ParseInt(cursor, 10, 64) + } + if err != nil || n >= max { // invalid cursor + rw.WriteHeader(http.StatusBadRequest) + return + } + sresp := SlackResponse{ Ok: true, } @@ -436,7 +471,9 @@ func getUserPage(max int64) func(rw http.ResponseWriter, r *http.Request) { getTestUserWithId(fmt.Sprintf("U%03d", n)), } rw.Header().Set("Content-Type", "application/json") - if cpage = atomic.AddInt64(&n, 1); cpage == max { + + nextPage := n + 1 + if nextPage == max { response, _ := json.Marshal(userResponseFull{ SlackResponse: sresp, Members: members, @@ -444,10 +481,11 @@ func getUserPage(max int64) func(rw http.ResponseWriter, r *http.Request) { rw.Write(response) return } + response, _ := json.Marshal(userResponseFull{ SlackResponse: sresp, Members: members, - Metadata: ResponseMetadata{Cursor: strconv.Itoa(int(cpage))}, + Metadata: ResponseMetadata{Cursor: strconv.Itoa(int(nextPage))}, }) rw.Write(response) } From 2255bbde832a559510ac5bb7c6fa4ccfd3fec208 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 30 Aug 2025 15:59:57 +0100 Subject: [PATCH 418/606] fix: `KickUserFromConversation` errors as a map It seems `KickUserFromConversation` can return the field `errors` as a map, specifically `{}`. This adjusts the internals so that we don't have breaking changes but also capture this return. --- conversation.go | 2 +- misc.go | 62 +++++++++++++++++++++++++++++++++++++++++++++++ misc_test.go | 64 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) diff --git a/conversation.go b/conversation.go index ae53f0762..d16b88d82 100644 --- a/conversation.go +++ b/conversation.go @@ -479,7 +479,7 @@ func (api *Client) KickUserFromConversationContext(ctx context.Context, channelI "user": {user}, } - response := SlackResponse{} + response := KickUserFromConversationSlackResponse{} err := api.postMethod(ctx, "conversations.kick", values, &response) if err != nil { return err diff --git a/misc.go b/misc.go index 48411ee19..586551cf0 100644 --- a/misc.go +++ b/misc.go @@ -118,6 +118,68 @@ type SlackResponse struct { ResponseMetadata ResponseMetadata `json:"response_metadata"` } +// KickUserFromConversationSlackResponse is a variant of SlackResponse that can handle the case where +// "errors" can be either an empty object {} or an array of errors. +// This addresses issue #1446 where conversations.kick endpoint returns {"ok":true,"errors":{}} +type KickUserFromConversationSlackResponse struct { + Ok bool `json:"ok"` + Error string `json:"error"` + Errors []SlackResponseErrors `json:"-"` + ResponseMetadata ResponseMetadata `json:"response_metadata"` +} + +// UnmarshalJSON implements custom unmarshaling for KickUserFromConversationSlackResponse to handle +// the case where "errors" can be either an empty object {} or an array of errors +func (s *KickUserFromConversationSlackResponse) UnmarshalJSON(data []byte) error { + // First, unmarshal everything except errors + type Alias KickUserFromConversationSlackResponse + aux := &struct { + *Alias + ErrorsRaw json.RawMessage `json:"errors,omitempty"` + }{ + Alias: (*Alias)(s), + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + // Handle the errors field + if len(aux.ErrorsRaw) > 0 { + // Check if it's an empty object by looking for just "{}" + trimmed := bytes.TrimSpace(aux.ErrorsRaw) + if bytes.Equal(trimmed, []byte("{}")) { + // Empty object, leave errors as nil/empty slice + s.Errors = nil + } else { + // Try to unmarshal as array of errors + var errors []SlackResponseErrors + if err := json.Unmarshal(aux.ErrorsRaw, &errors); err != nil { + return err + } + s.Errors = errors + } + } + + return nil +} + +// Err returns any API error present in the response. +func (s KickUserFromConversationSlackResponse) Err() error { + if s.Ok { + return nil + } + + // handle pure text based responses like chat.post + // which while they have a slack response in their data structure + // it doesn't actually get set during parsing. + if strings.TrimSpace(s.Error) == "" { + return nil + } + + return SlackErrorResponse{Err: s.Error, Errors: s.Errors, ResponseMetadata: s.ResponseMetadata} +} + func (t SlackResponse) Err() error { if t.Ok { return nil diff --git a/misc_test.go b/misc_test.go index d872199aa..631115d93 100644 --- a/misc_test.go +++ b/misc_test.go @@ -336,3 +336,67 @@ func TestSlackResponseWithErrors(t *testing.T) { }) } } + +func TestKickUserFromConversationSlackResponseWithErrors(t *testing.T) { + tests := []struct { + name string + input string + expected KickUserFromConversationSlackResponse + }{ + { + name: "ResponseWithEmptyErrorsObject", + input: `{"ok":true,"errors":{}}`, + expected: KickUserFromConversationSlackResponse{ + Ok: true, + Errors: nil, + }, + }, + { + name: "ResponseWithErrorsArray", + input: `{"ok":false,"error":"some_error","errors":[]}`, + expected: KickUserFromConversationSlackResponse{ + Ok: false, + Error: "some_error", + Errors: []SlackResponseErrors{}, + }, + }, + { + name: "ResponseWithoutErrors", + input: `{"ok":true}`, + expected: KickUserFromConversationSlackResponse{ + Ok: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var response KickUserFromConversationSlackResponse + err := json.Unmarshal([]byte(tt.input), &response) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if response.Ok != tt.expected.Ok { + t.Errorf("got Ok=%v; want Ok=%v", response.Ok, tt.expected.Ok) + } + if response.Error != tt.expected.Error { + t.Errorf("got Error=%q; want Error=%q", response.Error, tt.expected.Error) + } + + if tt.expected.Errors == nil { + if response.Errors != nil { + t.Error("expected nil Errors, got non-nil") + } + } else { + if response.Errors == nil { + t.Error("expected non-nil Errors, got nil") + return + } + if len(tt.expected.Errors) != len(response.Errors) { + t.Errorf("got Errors length=%d; want length=%d", len(response.Errors), len(tt.expected.Errors)) + } + } + }) + } +} From d3f9c6427865088918ef22802c7c04cb9dd31a29 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 30 Aug 2025 16:14:06 +0100 Subject: [PATCH 419/606] fix: ensure we return channel for section channel rich text --- block_rich_text.go | 2 +- block_rich_text_test.go | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/block_rich_text.go b/block_rich_text.go index 73233d23a..5df775991 100644 --- a/block_rich_text.go +++ b/block_rich_text.go @@ -328,7 +328,7 @@ func (r RichTextSectionChannelElement) RichTextSectionElementType() RichTextSect func NewRichTextSectionChannelElement(channelID string, style *RichTextSectionTextStyle) *RichTextSectionChannelElement { return &RichTextSectionChannelElement{ - Type: RTSEText, + Type: RTSEChannel, ChannelID: channelID, Style: style, } diff --git a/block_rich_text_test.go b/block_rich_text_test.go index 1e26f9e27..5854602e8 100644 --- a/block_rich_text_test.go +++ b/block_rich_text_test.go @@ -395,4 +395,19 @@ func TestRichTextQuote_Marshal(t *testing.T) { }) } +func TestNewRichTextSectionChannelElement(t *testing.T) { + style := &RichTextSectionTextStyle{Bold: true} + element := NewRichTextSectionChannelElement("C012345678", style) + + assert.Equal(t, RTSEChannel, element.Type) + assert.Equal(t, "C012345678", element.ChannelID) + assert.Equal(t, style, element.Style) + + // Test JSON marshaling to ensure correct type + data, err := json.Marshal(element) + assert.NoError(t, err) + assert.Contains(t, string(data), `"type":"channel"`) + assert.Contains(t, string(data), `"channel_id":"C012345678"`) +} + func strp(in string) *string { return &in } From 1e960333d21ecc15ba33c7a0aa91e50e6cf8c4aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 30 Aug 2025 15:18:32 +0000 Subject: [PATCH 420/606] chore(deps): bump github.com/stretchr/testify from 1.10.0 to 1.11.0 Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.10.0 to 1.11.0. - [Release notes](https://github.com/stretchr/testify/releases) - [Commits](https://github.com/stretchr/testify/compare/v1.10.0...v1.11.0) --- updated-dependencies: - dependency-name: github.com/stretchr/testify dependency-version: 1.11.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 52f68aba4..3d447db20 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.22 require ( github.com/go-test/deep v1.1.1 github.com/gorilla/websocket v1.5.3 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 ) require ( diff --git a/go.sum b/go.sum index 7d8d8f8a8..2655928c7 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= From c87baaec95de6a70b40575c35e27c65159a2abea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 06:20:36 +0000 Subject: [PATCH 421/606] chore(deps): bump actions/stale from 9.1.0 to 10.0.0 Bumps [actions/stale](https://github.com/actions/stale) from 9.1.0 to 10.0.0. - [Release notes](https://github.com/actions/stale/releases) - [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/stale/compare/5bef64f19d7facfb25b37b414482c7164d639639...3a9db7e6a41a89f618792c92c0e97cc736e1b13f) --- updated-dependencies: - dependency-name: actions/stale dependency-version: 10.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 8b9867c96..ab94295b1 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -13,7 +13,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 + - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 with: any-of-labels: 'feedback given' days-before-stale: 45 From 9003a994228dd99ae20b1dffd1471784989e735e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 14 Sep 2025 16:44:53 +0000 Subject: [PATCH 422/606] chore(deps): bump actions/setup-go from 5 to 6 Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5 to 6. - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7a7616123..744c558de 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,7 +21,7 @@ jobs: name: test go-${{ matrix.go }} steps: - uses: actions/checkout@v5 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version: ${{ matrix.go }} - name: run test @@ -30,7 +30,7 @@ jobs: runs-on: ubuntu-24.04 name: lint steps: - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version: "1.22" cache: false From 97cc155fa263bd82e1093ecaed423e004fd2960a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Oct 2025 06:28:50 +0000 Subject: [PATCH 423/606] chore(deps): bump actions/stale from 10.0.0 to 10.1.0 Bumps [actions/stale](https://github.com/actions/stale) from 10.0.0 to 10.1.0. - [Release notes](https://github.com/actions/stale/releases) - [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/stale/compare/3a9db7e6a41a89f618792c92c0e97cc736e1b13f...5f858e3efba33a5ca4407a664cc011ad407f2008) --- updated-dependencies: - dependency-name: actions/stale dependency-version: 10.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index ab94295b1..b6e4022a4 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -13,7 +13,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 + - uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 with: any-of-labels: 'feedback given' days-before-stale: 45 From 8d11ee8fb0c2c9f455fc90c3474e8743e9886ee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=C3=A9rio=20Val=C3=A9rio?= Date: Tue, 4 Nov 2025 12:28:21 +0200 Subject: [PATCH 424/606] feat: add support for optional "loading_messages" parameter in SetAssistantThreadsStatus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This parameter can be used in assistant-enabled slack apps to display the app status progress under user created threads inside a channel. When not set, slack will show default states with generic text. Setting the "Status" parameter to a empty string also cleans the loading messages. Signed-off-by: Valério Valério --- assistant.go | 12 +++++++++--- assistant_test.go | 7 ++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/assistant.go b/assistant.go index 057e89cce..25a5d6ed1 100644 --- a/assistant.go +++ b/assistant.go @@ -5,13 +5,15 @@ import ( "encoding/json" "net/url" "strconv" + "strings" ) // AssistantThreadSetStatusParameters are the parameters for AssistantThreadSetStatus type AssistantThreadsSetStatusParameters struct { - ChannelID string `json:"channel_id"` - Status string `json:"status"` - ThreadTS string `json:"thread_ts"` + ChannelID string `json:"channel_id"` + Status string `json:"status"` + ThreadTS string `json:"thread_ts"` + LoadingMessages []string `json:"loading_messages,omitempty"` } // AssistantThreadSetTitleParameters are the parameters for AssistantThreadSetTitle @@ -143,6 +145,10 @@ func (api *Client) SetAssistantThreadsStatusContext(ctx context.Context, params // Always send the status parameter, if empty, it will clear any existing status values.Add("status", params.Status) + if len(params.LoadingMessages) > 0 { + values.Add("loading_messages", strings.Join(params.LoadingMessages, ",")) + } + response := struct { SlackResponse }{} diff --git a/assistant_test.go b/assistant_test.go index 6c8960e4f..c6895c20f 100644 --- a/assistant_test.go +++ b/assistant_test.go @@ -35,9 +35,10 @@ func TestSetAssistantThreadsStatus(t *testing.T) { api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) params := AssistantThreadsSetStatusParameters{ - ChannelID: "CXXXXXXXX", - ThreadTS: "1234567890.123456", - Status: "updated status", + ChannelID: "CXXXXXXXX", + ThreadTS: "1234567890.123456", + Status: "updated status", + LoadingMessages: []string{"updating status..."}, } err := api.SetAssistantThreadsStatus(params) From 89bd7ce416b35621eac21192a19b3bbc9ec5a926 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 06:22:41 +0000 Subject: [PATCH 425/606] chore(deps): bump golangci/golangci-lint-action from 8.0.0 to 9.0.0 Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 8.0.0 to 9.0.0. - [Release notes](https://github.com/golangci/golangci-lint-action/releases) - [Commits](https://github.com/golangci/golangci-lint-action/compare/4afd733a84b1f43292c63897423277bb7f4313a9...0a35821d5c230e903fcfe077583637dea1b27b47) --- updated-dependencies: - dependency-name: golangci/golangci-lint-action dependency-version: 9.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 744c558de..636be361e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,7 +38,7 @@ jobs: with: fetch-depth: 0 - name: golangci-lint - uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + uses: golangci/golangci-lint-action@0a35821d5c230e903fcfe077583637dea1b27b47 # v9.0.0 with: version: v2.1.6 - name: Check 'go mod tidy' makes no changes From 73c4d3fcf393cf8866eda0a7bb75c078d46911f0 Mon Sep 17 00:00:00 2001 From: Nancy Hsieh Date: Mon, 3 Nov 2025 13:25:33 -0800 Subject: [PATCH 426/606] fix: ensure imageURL or SlackFile for ImageBlockElement --- block_element.go | 4 ++-- block_element_test.go | 3 ++- block_object_test.go | 2 +- examples/blocks/blocks.go | 3 ++- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/block_element.go b/block_element.go index 2b32d331e..d91e6140a 100644 --- a/block_element.go +++ b/block_element.go @@ -118,7 +118,7 @@ func (s UnknownBlockElement) ElementType() MessageElementType { // More Information: https://api.slack.com/reference/messaging/block-elements#image type ImageBlockElement struct { Type MessageElementType `json:"type"` - ImageURL string `json:"image_url"` + ImageURL *string `json:"image_url,omitempty"` AltText string `json:"alt_text"` SlackFile *SlackFileObject `json:"slack_file,omitempty"` } @@ -136,7 +136,7 @@ func (s ImageBlockElement) MixedElementType() MixedElementType { func NewImageBlockElement(imageURL, altText string) *ImageBlockElement { return &ImageBlockElement{ Type: METImage, - ImageURL: imageURL, + ImageURL: &imageURL, AltText: altText, } } diff --git a/block_element_test.go b/block_element_test.go index ed29868cb..66aa29c13 100644 --- a/block_element_test.go +++ b/block_element_test.go @@ -10,7 +10,7 @@ func TestNewImageBlockElement(t *testing.T) { imageElement := NewImageBlockElement("https://api.slack.com/img/blocks/bkb_template_images/tripAgentLocationMarker.png", "Location Pin Icon") assert.Equal(t, string(imageElement.Type), "image") - assert.Contains(t, imageElement.ImageURL, "tripAgentLocationMarker") + assert.Contains(t, *imageElement.ImageURL, "tripAgentLocationMarker") assert.Equal(t, imageElement.AltText, "Location Pin Icon") } @@ -21,6 +21,7 @@ func TestNewImageBlockElementSlackFile(t *testing.T) { assert.Equal(t, string(imageElement.Type), "image") assert.Contains(t, imageElement.SlackFile.URL, "tripAgentLocationMarker") assert.Equal(t, imageElement.AltText, "Location Pin Icon") + assert.Nil(t, imageElement.ImageURL, "ImageURL should be nil when SlackFile is provided") } func TestNewButtonBlockElement(t *testing.T) { diff --git a/block_object_test.go b/block_object_test.go index 61ea9dfa3..8e2954c0c 100644 --- a/block_object_test.go +++ b/block_object_test.go @@ -15,7 +15,7 @@ func TestNewImageBlockObject(t *testing.T) { assert.Equal(t, string(imageObject.Type), "image") assert.Equal(t, imageObject.AltText, "Beagle") - assert.Contains(t, imageObject.ImageURL, "beagle.png") + assert.Contains(t, *imageObject.ImageURL, "beagle.png") } func TestNewTextBlockObject(t *testing.T) { diff --git a/examples/blocks/blocks.go b/examples/blocks/blocks.go index 28d54d141..e16fd6073 100644 --- a/examples/blocks/blocks.go +++ b/examples/blocks/blocks.go @@ -483,7 +483,8 @@ func unmarshalExample() { case slack.MixedElementImage: // Assert the block's type to manipulate/extract values imageBlockElem := elem.(*slack.ImageBlockElement) - imageBlockElem.ImageURL = "https://api.slack.com/img/blocks/bkb_template_images/profile_1.png" + imageURL := "https://api.slack.com/img/blocks/bkb_template_images/profile_1.png" + imageBlockElem.ImageURL = &imageURL imageBlockElem.AltText = "MichaelScott" respMixedElements = append(respMixedElements, imageBlockElem) case slack.MixedElementText: From 2f42ec8f80925b98007e3f3976ebd0603a632430 Mon Sep 17 00:00:00 2001 From: Tom Ashworth Date: Thu, 13 Nov 2025 08:07:45 +0000 Subject: [PATCH 427/606] feat: add support for context_actions block This commit adds support for Slack's context_actions block type, which enables interactive feedback elements. The implementation includes: - ContextActionsBlock: New block type for holding interactive elements - FeedbackButtonsBlockElement: Positive/negative feedback buttons - IconButtonBlockElement: Icon-based interactive buttons with confirmation dialogs - Test coverage for all new types - Working example demonstrating usage - Updated documentation with examples and API references This feature is particularly useful for AI-generated content where users need to provide quick feedback or perform actions on messages. --- block.go | 25 +++--- block_context_actions.go | 31 ++++++++ block_context_actions_test.go | 139 +++++++++++++++++++++++++++++++++ block_conv.go | 10 +++ block_element.go | 141 ++++++++++++++++++++++++++++++---- block_element_test.go | 118 ++++++++++++++++++++++++++++ examples/blocks/README.md | 12 +++ examples/blocks/blocks.go | 58 ++++++++++++++ 8 files changed, 508 insertions(+), 26 deletions(-) create mode 100644 block_context_actions.go create mode 100644 block_context_actions_test.go diff --git a/block.go b/block.go index 7c4f99308..1a057936d 100644 --- a/block.go +++ b/block.go @@ -5,18 +5,19 @@ package slack type MessageBlockType string const ( - MBTSection MessageBlockType = "section" - MBTDivider MessageBlockType = "divider" - MBTImage MessageBlockType = "image" - MBTAction MessageBlockType = "actions" - MBTContext MessageBlockType = "context" - MBTFile MessageBlockType = "file" - MBTInput MessageBlockType = "input" - MBTHeader MessageBlockType = "header" - MBTRichText MessageBlockType = "rich_text" - MBTCall MessageBlockType = "call" - MBTVideo MessageBlockType = "video" - MBTMarkdown MessageBlockType = "markdown" + MBTSection MessageBlockType = "section" + MBTDivider MessageBlockType = "divider" + MBTImage MessageBlockType = "image" + MBTAction MessageBlockType = "actions" + MBTContext MessageBlockType = "context" + MBTContextActions MessageBlockType = "context_actions" + MBTFile MessageBlockType = "file" + MBTInput MessageBlockType = "input" + MBTHeader MessageBlockType = "header" + MBTRichText MessageBlockType = "rich_text" + MBTCall MessageBlockType = "call" + MBTVideo MessageBlockType = "video" + MBTMarkdown MessageBlockType = "markdown" ) // Block defines an interface all block types should implement diff --git a/block_context_actions.go b/block_context_actions.go new file mode 100644 index 000000000..d1cf532c3 --- /dev/null +++ b/block_context_actions.go @@ -0,0 +1,31 @@ +package slack + +// ContextActionsBlock defines data that is used to hold interactive action elements. +// +// More Information: https://docs.slack.dev/reference/block-kit/blocks/context-actions-block/ +type ContextActionsBlock struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + Elements *BlockElements `json:"elements"` +} + +// BlockType returns the type of the block +func (s ContextActionsBlock) BlockType() MessageBlockType { + return s.Type +} + +// ID returns the ID of the block +func (s ContextActionsBlock) ID() string { + return s.BlockID +} + +// NewContextActionsBlock returns a new instance of a Context Actions Block +func NewContextActionsBlock(blockID string, elements ...BlockElement) *ContextActionsBlock { + return &ContextActionsBlock{ + Type: MBTContextActions, + BlockID: blockID, + Elements: &BlockElements{ + ElementSet: elements, + }, + } +} diff --git a/block_context_actions_test.go b/block_context_actions_test.go new file mode 100644 index 000000000..07bd3001b --- /dev/null +++ b/block_context_actions_test.go @@ -0,0 +1,139 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewContextActionsBlock(t *testing.T) { + positiveBtnText := NewTextBlockObject("plain_text", "Good", false, false) + negativeBtnText := NewTextBlockObject("plain_text", "Bad", false, false) + positiveBtn := NewFeedbackButton(positiveBtnText, "positive_feedback") + negativeBtn := NewFeedbackButton(negativeBtnText, "negative_feedback") + feedbackElement := NewFeedbackButtonsBlockElement("feedback_1", positiveBtn, negativeBtn) + + contextActionsBlock := NewContextActionsBlock("test_block", feedbackElement) + + assert.Equal(t, contextActionsBlock.BlockType(), MBTContextActions) + assert.Equal(t, string(contextActionsBlock.Type), "context_actions") + assert.Equal(t, contextActionsBlock.BlockID, "test_block") + assert.Equal(t, contextActionsBlock.ID(), "test_block") + assert.Equal(t, len(contextActionsBlock.Elements.ElementSet), 1) +} + +func TestContextActionsBlockWithIconButton(t *testing.T) { + deleteText := NewTextBlockObject("plain_text", "Delete", false, false) + iconButton := NewIconButtonBlockElement("trash", deleteText, "delete_action") + + contextActionsBlock := NewContextActionsBlock("icon_block", iconButton) + + assert.Equal(t, contextActionsBlock.BlockType(), MBTContextActions) + assert.Equal(t, string(contextActionsBlock.Type), "context_actions") + assert.Equal(t, contextActionsBlock.BlockID, "icon_block") + assert.Equal(t, len(contextActionsBlock.Elements.ElementSet), 1) +} + +func TestContextActionsBlockWithMultipleElements(t *testing.T) { + // Create feedback buttons + positiveBtnText := NewTextBlockObject("plain_text", "👍", false, false) + negativeBtnText := NewTextBlockObject("plain_text", "👎", false, false) + positiveBtn := NewFeedbackButton(positiveBtnText, "positive") + negativeBtn := NewFeedbackButton(negativeBtnText, "negative") + feedbackElement := NewFeedbackButtonsBlockElement("feedback_1", positiveBtn, negativeBtn) + + // Create icon button + deleteText := NewTextBlockObject("plain_text", "Delete", false, false) + iconButton := NewIconButtonBlockElement("trash", deleteText, "delete_action") + + contextActionsBlock := NewContextActionsBlock("multi_block", feedbackElement, iconButton) + + assert.Equal(t, contextActionsBlock.BlockType(), MBTContextActions) + assert.Equal(t, len(contextActionsBlock.Elements.ElementSet), 2) +} + +func TestContextActionsBlockJSONMarshalling(t *testing.T) { + positiveBtnText := NewTextBlockObject("plain_text", "Good", false, false) + negativeBtnText := NewTextBlockObject("plain_text", "Bad", false, false) + positiveBtn := NewFeedbackButton(positiveBtnText, "positive_feedback") + negativeBtn := NewFeedbackButton(negativeBtnText, "negative_feedback") + feedbackElement := NewFeedbackButtonsBlockElement("feedback_buttons_1", positiveBtn, negativeBtn) + + contextActionsBlock := NewContextActionsBlock("test_block", feedbackElement) + + // Marshal to JSON + data, err := json.Marshal(contextActionsBlock) + assert.NoError(t, err) + assert.NotNil(t, data) + + // Unmarshal back + var unmarshalled ContextActionsBlock + err = json.Unmarshal(data, &unmarshalled) + assert.NoError(t, err) + assert.Equal(t, "context_actions", string(unmarshalled.Type)) + assert.Equal(t, "test_block", unmarshalled.BlockID) + assert.Equal(t, 1, len(unmarshalled.Elements.ElementSet)) +} + +func TestContextActionsBlockUnmarshalJSON(t *testing.T) { + jsonData := []byte(`{ + "type": "context_actions", + "block_id": "test_block", + "elements": [ + { + "type": "feedback_buttons", + "action_id": "feedback_buttons_1", + "positive_button": { + "text": { + "type": "plain_text", + "text": "Good" + }, + "value": "positive_feedback" + }, + "negative_button": { + "text": { + "type": "plain_text", + "text": "Bad" + }, + "value": "negative_feedback" + } + } + ] + }`) + + var block ContextActionsBlock + err := json.Unmarshal(jsonData, &block) + assert.NoError(t, err) + assert.Equal(t, "context_actions", string(block.Type)) + assert.Equal(t, "test_block", block.BlockID) + assert.Equal(t, 1, len(block.Elements.ElementSet)) +} + +func TestContextActionsBlockInBlocks(t *testing.T) { + // Test that context_actions block can be unmarshalled as part of a Blocks collection + jsonData := []byte(`[ + { + "type": "context_actions", + "block_id": "actions_block", + "elements": [ + { + "type": "icon_button", + "icon": "trash", + "text": { + "type": "plain_text", + "text": "Delete" + }, + "action_id": "delete_button_1", + "value": "delete_item" + } + ] + } + ]`) + + var blocks Blocks + err := json.Unmarshal(jsonData, &blocks) + assert.NoError(t, err) + assert.Equal(t, 1, len(blocks.BlockSet)) + assert.Equal(t, MBTContextActions, blocks.BlockSet[0].BlockType()) +} diff --git a/block_conv.go b/block_conv.go index 7df9b107b..989812589 100644 --- a/block_conv.go +++ b/block_conv.go @@ -53,6 +53,8 @@ func (b *Blocks) UnmarshalJSON(data []byte) error { block = &ActionBlock{} case "context": block = &ContextBlock{} + case "context_actions": + block = &ContextActionsBlock{} case "divider": block = &DividerBlock{} case "file": @@ -141,6 +143,10 @@ func (b *InputBlock) UnmarshalJSON(data []byte) error { e = &NumberInputBlockElement{} case "file_input": e = &FileInputBlockElement{} + case "feedback_buttons": + e = &FeedbackButtonsBlockElement{} + case "icon_button": + e = &IconButtonBlockElement{} default: return fmt.Errorf("unsupported block element type %v", s.TypeVal) } @@ -221,6 +227,10 @@ func (b *BlockElements) UnmarshalJSON(data []byte) error { blockElement = &SelectBlockElement{} case "number_input": blockElement = &NumberInputBlockElement{} + case "feedback_buttons": + blockElement = &FeedbackButtonsBlockElement{} + case "icon_button": + blockElement = &IconButtonBlockElement{} default: return fmt.Errorf("unsupported block element type %v", blockElementType) } diff --git a/block_element.go b/block_element.go index 2b32d331e..a3b18e343 100644 --- a/block_element.go +++ b/block_element.go @@ -3,20 +3,22 @@ package slack // https://api.slack.com/reference/messaging/block-elements const ( - METCheckboxGroups MessageElementType = "checkboxes" - METImage MessageElementType = "image" - METButton MessageElementType = "button" - METOverflow MessageElementType = "overflow" - METDatepicker MessageElementType = "datepicker" - METTimepicker MessageElementType = "timepicker" - METDatetimepicker MessageElementType = "datetimepicker" - METPlainTextInput MessageElementType = "plain_text_input" - METRadioButtons MessageElementType = "radio_buttons" - METRichTextInput MessageElementType = "rich_text_input" - METEmailTextInput MessageElementType = "email_text_input" - METURLTextInput MessageElementType = "url_text_input" - METNumber MessageElementType = "number_input" - METFileInput MessageElementType = "file_input" + METCheckboxGroups MessageElementType = "checkboxes" + METImage MessageElementType = "image" + METButton MessageElementType = "button" + METOverflow MessageElementType = "overflow" + METDatepicker MessageElementType = "datepicker" + METTimepicker MessageElementType = "timepicker" + METDatetimepicker MessageElementType = "datetimepicker" + METPlainTextInput MessageElementType = "plain_text_input" + METRadioButtons MessageElementType = "radio_buttons" + METRichTextInput MessageElementType = "rich_text_input" + METEmailTextInput MessageElementType = "email_text_input" + METURLTextInput MessageElementType = "url_text_input" + METNumber MessageElementType = "number_input" + METFileInput MessageElementType = "file_input" + METFeedbackButtons MessageElementType = "feedback_buttons" + METIconButton MessageElementType = "icon_button" MixedElementImage MixedElementType = "mixed_image" MixedElementText MixedElementType = "mixed_text" @@ -811,3 +813,114 @@ func (s *FileInputBlockElement) WithMaxFiles(maxFiles int) *FileInputBlockElemen s.MaxFiles = maxFiles return s } + +// FeedbackButton defines a button within a feedback buttons element +type FeedbackButton struct { + Text *TextBlockObject `json:"text"` + Value string `json:"value"` + AccessibilityLabel string `json:"accessibility_label,omitempty"` +} + +// NewFeedbackButton returns a new instance of a feedback button +func NewFeedbackButton(text *TextBlockObject, value string) *FeedbackButton { + return &FeedbackButton{ + Text: text, + Value: value, + } +} + +// WithAccessibilityLabel sets the accessibility label for the feedback button +func (fb *FeedbackButton) WithAccessibilityLabel(label string) *FeedbackButton { + fb.AccessibilityLabel = label + return fb +} + +// FeedbackButtonsBlockElement defines an element that provides positive/negative feedback options +// +// More Information: https://docs.slack.dev/reference/block-kit/block-elements/feedback-buttons-element +type FeedbackButtonsBlockElement struct { + Type MessageElementType `json:"type"` + ActionID string `json:"action_id,omitempty"` + PositiveButton *FeedbackButton `json:"positive_button"` + NegativeButton *FeedbackButton `json:"negative_button"` +} + +// ElementType returns the type of the element +func (s FeedbackButtonsBlockElement) ElementType() MessageElementType { + return s.Type +} + +// NewFeedbackButtonsBlockElement returns a new instance of a feedback buttons element +func NewFeedbackButtonsBlockElement(actionID string, positiveButton, negativeButton *FeedbackButton) *FeedbackButtonsBlockElement { + return &FeedbackButtonsBlockElement{ + Type: METFeedbackButtons, + ActionID: actionID, + PositiveButton: positiveButton, + NegativeButton: negativeButton, + } +} + +// WithPositiveButton sets the positive button for the feedback buttons element +func (s *FeedbackButtonsBlockElement) WithPositiveButton(button *FeedbackButton) *FeedbackButtonsBlockElement { + s.PositiveButton = button + return s +} + +// WithNegativeButton sets the negative button for the feedback buttons element +func (s *FeedbackButtonsBlockElement) WithNegativeButton(button *FeedbackButton) *FeedbackButtonsBlockElement { + s.NegativeButton = button + return s +} + +// IconButtonBlockElement defines an element that displays icon-based interactive buttons +// +// More Information: https://docs.slack.dev/reference/block-kit/block-elements/icon-button-element +type IconButtonBlockElement struct { + Type MessageElementType `json:"type"` + Icon string `json:"icon"` + Text *TextBlockObject `json:"text"` + ActionID string `json:"action_id,omitempty"` + Value string `json:"value,omitempty"` + Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` + AccessibilityLabel string `json:"accessibility_label,omitempty"` + VisibleToUserIDs []string `json:"visible_to_user_ids,omitempty"` +} + +// ElementType returns the type of the element +func (s IconButtonBlockElement) ElementType() MessageElementType { + return s.Type +} + +// NewIconButtonBlockElement returns a new instance of an icon button element +func NewIconButtonBlockElement(icon string, text *TextBlockObject, actionID string) *IconButtonBlockElement { + return &IconButtonBlockElement{ + Type: METIconButton, + Icon: icon, + Text: text, + ActionID: actionID, + } +} + +// WithValue sets the value for the icon button element +func (s *IconButtonBlockElement) WithValue(value string) *IconButtonBlockElement { + s.Value = value + return s +} + +// WithConfirm sets the confirmation dialog for the icon button element +func (s *IconButtonBlockElement) WithConfirm(confirm *ConfirmationBlockObject) *IconButtonBlockElement { + s.Confirm = confirm + return s +} + +// WithAccessibilityLabel sets the accessibility label for the icon button element +func (s *IconButtonBlockElement) WithAccessibilityLabel(label string) *IconButtonBlockElement { + s.AccessibilityLabel = label + return s +} + +// WithVisibleToUserIDs sets the user IDs who can see the icon button element +func (s *IconButtonBlockElement) WithVisibleToUserIDs(userIDs []string) *IconButtonBlockElement { + s.VisibleToUserIDs = userIDs + return s +} diff --git a/block_element_test.go b/block_element_test.go index ed29868cb..044ab0ef3 100644 --- a/block_element_test.go +++ b/block_element_test.go @@ -1,6 +1,7 @@ package slack import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -242,3 +243,120 @@ func TestNewFileInputBlockElement(t *testing.T) { fileInputElement.WithMaxFiles(10) assert.Equal(t, fileInputElement.MaxFiles, 10) } + +func TestNewFeedbackButton(t *testing.T) { + btnText := NewTextBlockObject("plain_text", "Good", false, false) + feedbackButton := NewFeedbackButton(btnText, "positive_feedback") + + assert.Equal(t, feedbackButton.Text.Text, "Good") + assert.Equal(t, feedbackButton.Value, "positive_feedback") + assert.Equal(t, feedbackButton.AccessibilityLabel, "") + + feedbackButton.WithAccessibilityLabel("Mark as good") + assert.Equal(t, feedbackButton.AccessibilityLabel, "Mark as good") +} + +func TestNewFeedbackButtonsBlockElement(t *testing.T) { + positiveBtnText := NewTextBlockObject("plain_text", "👍", false, false) + negativeBtnText := NewTextBlockObject("plain_text", "👎", false, false) + positiveBtn := NewFeedbackButton(positiveBtnText, "positive") + negativeBtn := NewFeedbackButton(negativeBtnText, "negative") + + feedbackElement := NewFeedbackButtonsBlockElement("feedback_1", positiveBtn, negativeBtn) + + assert.Equal(t, string(feedbackElement.Type), "feedback_buttons") + assert.Equal(t, feedbackElement.ActionID, "feedback_1") + assert.Equal(t, feedbackElement.PositiveButton.Value, "positive") + assert.Equal(t, feedbackElement.NegativeButton.Value, "negative") +} + +func TestFeedbackButtonsFluentMethods(t *testing.T) { + positiveBtnText := NewTextBlockObject("plain_text", "Good", false, false) + negativeBtnText := NewTextBlockObject("plain_text", "Bad", false, false) + positiveBtn := NewFeedbackButton(positiveBtnText, "pos") + negativeBtn := NewFeedbackButton(negativeBtnText, "neg") + + feedbackElement := NewFeedbackButtonsBlockElement("feedback_1", positiveBtn, negativeBtn) + + newPositiveText := NewTextBlockObject("plain_text", "Excellent", false, false) + newPositiveBtn := NewFeedbackButton(newPositiveText, "excellent") + feedbackElement.WithPositiveButton(newPositiveBtn) + assert.Equal(t, feedbackElement.PositiveButton.Value, "excellent") + + newNegativeText := NewTextBlockObject("plain_text", "Poor", false, false) + newNegativeBtn := NewFeedbackButton(newNegativeText, "poor") + feedbackElement.WithNegativeButton(newNegativeBtn) + assert.Equal(t, feedbackElement.NegativeButton.Value, "poor") +} + +func TestFeedbackButtonsJSONMarshalling(t *testing.T) { + positiveBtnText := NewTextBlockObject("plain_text", "Good", false, false) + negativeBtnText := NewTextBlockObject("plain_text", "Bad", false, false) + positiveBtn := NewFeedbackButton(positiveBtnText, "positive_feedback") + negativeBtn := NewFeedbackButton(negativeBtnText, "negative_feedback") + feedbackElement := NewFeedbackButtonsBlockElement("feedback_buttons_1", positiveBtn, negativeBtn) + + data, err := json.Marshal(feedbackElement) + assert.NoError(t, err) + assert.NotNil(t, data) + + var unmarshalled FeedbackButtonsBlockElement + err = json.Unmarshal(data, &unmarshalled) + assert.NoError(t, err) + assert.Equal(t, "feedback_buttons", string(unmarshalled.Type)) + assert.Equal(t, "feedback_buttons_1", unmarshalled.ActionID) + assert.Equal(t, "positive_feedback", unmarshalled.PositiveButton.Value) + assert.Equal(t, "negative_feedback", unmarshalled.NegativeButton.Value) +} + +func TestNewIconButtonBlockElement(t *testing.T) { + btnText := NewTextBlockObject("plain_text", "Delete", false, false) + iconButton := NewIconButtonBlockElement("trash", btnText, "delete_action") + + assert.Equal(t, string(iconButton.Type), "icon_button") + assert.Equal(t, iconButton.Icon, "trash") + assert.Equal(t, iconButton.Text.Text, "Delete") + assert.Equal(t, iconButton.ActionID, "delete_action") +} + +func TestIconButtonFluentMethods(t *testing.T) { + btnText := NewTextBlockObject("plain_text", "Delete", false, false) + iconButton := NewIconButtonBlockElement("trash", btnText, "delete_action") + + iconButton.WithValue("item_123") + assert.Equal(t, iconButton.Value, "item_123") + + iconButton.WithAccessibilityLabel("Delete this item") + assert.Equal(t, iconButton.AccessibilityLabel, "Delete this item") + + iconButton.WithVisibleToUserIDs([]string{"U123", "U456"}) + assert.Equal(t, len(iconButton.VisibleToUserIDs), 2) + assert.Contains(t, iconButton.VisibleToUserIDs, "U123") + + titleText := NewTextBlockObject("plain_text", "Are you sure?", false, false) + messageText := NewTextBlockObject("plain_text", "This will delete the item", false, false) + confirmText := NewTextBlockObject("plain_text", "Yes", false, false) + denyText := NewTextBlockObject("plain_text", "No", false, false) + confirmObj := NewConfirmationBlockObject(titleText, messageText, confirmText, denyText) + iconButton.WithConfirm(confirmObj) + assert.NotNil(t, iconButton.Confirm) + assert.Equal(t, iconButton.Confirm.Title.Text, "Are you sure?") +} + +func TestIconButtonJSONMarshalling(t *testing.T) { + btnText := NewTextBlockObject("plain_text", "Delete", false, false) + iconButton := NewIconButtonBlockElement("trash", btnText, "delete_button_1") + iconButton.WithValue("delete_item") + + data, err := json.Marshal(iconButton) + assert.NoError(t, err) + assert.NotNil(t, data) + + var unmarshalled IconButtonBlockElement + err = json.Unmarshal(data, &unmarshalled) + assert.NoError(t, err) + assert.Equal(t, "icon_button", string(unmarshalled.Type)) + assert.Equal(t, "trash", unmarshalled.Icon) + assert.Equal(t, "delete_button_1", unmarshalled.ActionID) + assert.Equal(t, "delete_item", unmarshalled.Value) +} diff --git a/examples/blocks/README.md b/examples/blocks/README.md index a3e3aad24..b677cf0b7 100644 --- a/examples/blocks/README.md +++ b/examples/blocks/README.md @@ -67,3 +67,15 @@ This example introduces overflow elements, allowing you to populate a select sty #### Example 6 - Search Results with Options and Actions Using a combination of overflow elements containing selectable options and actions, this examples allows you to prompt the user with multiple actions in a single response. You can view the rendered [Search Results with Actions](https://api.slack.com/tools/block-kit-builder?blocks=%5B%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22%3Amag%3A%20Search%20results%20for%20*Cata*%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22divider%22%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*%3CfakeLink.toYourApp.com%7CUse%20Case%20Catalogue%3E*%5CnUse%20Case%20Catalogue%20for%20the%20following%20departments%2Froles...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Edit%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*%3CfakeLink.toYourApp.com%7CCustomer%20Support%20-%20Workflow%20Diagram%20Catalogue%3E*%5CnThis%20resource%20was%20put%20together%20by%20members%20of...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Manage%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*%3CfakeLink.toYourApp.com%7CSelf-Serve%20Learning%20Options%20Catalogue%3E*%5CnSee%20the%20learning%20and%20development%20options%20we...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Manage%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*%3CfakeLink.toYourApp.com%7CUse%20Case%20Catalogue%20-%20CF%20Presentation%20-%20%5BJune%2012%2C%202018%5D%3E*%5CnThis%20is%20presentation%20will%20continue%20to%20be%20updated%20as...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Manage%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*%3CfakeLink.toYourApp.com%7CComprehensive%20Benefits%20Catalogue%20-%202019%3E*%5CnInformation%20about%20all%20the%20benfits%20we%20offer%20is...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Manage%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22divider%22%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22actions%22%2C%0A%09%09%22elements%22%3A%20%5B%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%22text%22%3A%20%22Next%205%20Results%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%09%7D%0A%09%09%5D%0A%09%7D%0A%5D) example on the block kit builder website. Refer to the function named `exampleSix` for more information on building this block. + +#### Context Actions Example +This example demonstrates the `context_actions` block, which is designed for interactive feedback elements like thumbs up/down buttons and icon-based actions. This block type is particularly useful for AI-generated content where you want to collect user feedback. + +The example shows two use cases: + - Using feedback buttons combined with an icon button (delete action) + - Using simple feedback buttons with accessibility labels + +You can view the [rendered example](https://app.slack.com/block-kit-builder/T01DMDZT3PD#%7B%22blocks%22:%5B%7B%22type%22:%22section%22,%22text%22:%7B%22type%22:%22mrkdwn%22,%22text%22:%22*AI%20Assistant%20Response:*%5CnBased%20on%20your%20query,%20I%20recommend%20using%20the%20%60context_actions%60%20block%20for%20interactive%20feedback.%20This%20allows%20users%20to%20provide%20quick%20feedback%20on%20AI-generated%20content.%22%7D%7D,%7B%22type%22:%22context_actions%22,%22block_id%22:%22actions_1%22,%22elements%22:%5B%7B%22type%22:%22feedback_buttons%22,%22action_id%22:%22ai_feedback_1%22,%22positive_button%22:%7B%22text%22:%7B%22type%22:%22plain_text%22,%22text%22:%22%F0%9F%91%8D%22,%22emoji%22:false%7D,%22value%22:%22positive_feedback%22%7D,%22negative_button%22:%7B%22text%22:%7B%22type%22:%22plain_text%22,%22text%22:%22%F0%9F%91%8E%22,%22emoji%22:false%7D,%22value%22:%22negative_feedback%22%7D%7D,%7B%22type%22:%22icon_button%22,%22icon%22:%22trash%22,%22text%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Delete%22,%22emoji%22:false%7D,%22action_id%22:%22delete_response_1%22,%22value%22:%22response_123%22%7D%5D%7D,%7B%22type%22:%22divider%22%7D,%7B%22type%22:%22section%22,%22text%22:%7B%22type%22:%22mrkdwn%22,%22text%22:%22*Another%20AI%20Response:*%5CnHere's%20an%20alternative%20solution%20to%20your%20problem...%22%7D%7D,%7B%22type%22:%22context_actions%22,%22block_id%22:%22actions_2%22,%22elements%22:%5B%7B%22type%22:%22feedback_buttons%22,%22action_id%22:%22ai_feedback_2%22,%22positive_button%22:%7B%22text%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Good%22,%22emoji%22:false%7D,%22value%22:%22good%22,%22accessibility_label%22:%22Mark%20this%20response%20as%20good%22%7D,%22negative_button%22:%7B%22text%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Bad%22,%22emoji%22:false%7D,%22value%22:%22bad%22,%22accessibility_label%22:%22Mark%20this%20response%20as%20bad%22%7D%7D%5D%7D%5D%7D) on the block kit builder site. + +Refer to the function named `contextActionsExample` for more information. For additional +details on the context_actions block, see the [Slack API documentation](https://docs.slack.dev/reference/block-kit/blocks/context-actions-block/). \ No newline at end of file diff --git a/examples/blocks/blocks.go b/examples/blocks/blocks.go index 28d54d141..eee67f5f0 100644 --- a/examples/blocks/blocks.go +++ b/examples/blocks/blocks.go @@ -37,6 +37,10 @@ func main() { exampleSix() fmt.Println("--- End Example Six ---") + fmt.Println("--- Begin Context Actions Example ---") + contextActionsExample() + fmt.Println("--- End Context Actions Example ---") + fmt.Println("--- Begin Example Unmarshalling ---") unmarshalExample() fmt.Println("--- End Example Unmarshalling ---") @@ -537,3 +541,57 @@ func unmarshalExample() { fmt.Println(string(b)) } + +// contextActionsExample demonstrates the context_actions block with feedback buttons and icon buttons +func contextActionsExample() { + // Section with AI-generated response + responseText := slack.NewTextBlockObject("mrkdwn", "*AI Assistant Response:*\nBased on your query, I recommend using the `context_actions` block for interactive feedback. This allows users to provide quick feedback on AI-generated content.", false, false) + responseSection := slack.NewSectionBlock(responseText, nil, nil) + + // Divider + divider := slack.NewDividerBlock() + + // Create feedback buttons for the AI response + positiveBtnText := slack.NewTextBlockObject("plain_text", "👍", false, false) + negativeBtnText := slack.NewTextBlockObject("plain_text", "👎", false, false) + positiveBtn := slack.NewFeedbackButton(positiveBtnText, "positive_feedback") + negativeBtn := slack.NewFeedbackButton(negativeBtnText, "negative_feedback") + feedbackElement := slack.NewFeedbackButtonsBlockElement("ai_feedback_1", positiveBtn, negativeBtn) + + // Create icon button for delete action + deleteText := slack.NewTextBlockObject("plain_text", "Delete", false, false) + iconButton := slack.NewIconButtonBlockElement("trash", deleteText, "delete_response_1"). + WithValue("response_123") + + // Context actions block with both feedback and delete + contextActionsBlock := slack.NewContextActionsBlock("actions_1", feedbackElement, iconButton) + + // Another example: just feedback buttons + anotherResponseText := slack.NewTextBlockObject("mrkdwn", "*Another AI Response:*\nHere's an alternative solution to your problem...", false, false) + anotherSection := slack.NewSectionBlock(anotherResponseText, nil, nil) + + goodBtnText := slack.NewTextBlockObject("plain_text", "Good", false, false) + badBtnText := slack.NewTextBlockObject("plain_text", "Bad", false, false) + goodBtn := slack.NewFeedbackButton(goodBtnText, "good").WithAccessibilityLabel("Mark this response as good") + badBtn := slack.NewFeedbackButton(badBtnText, "bad").WithAccessibilityLabel("Mark this response as bad") + simpleFeedback := slack.NewFeedbackButtonsBlockElement("ai_feedback_2", goodBtn, badBtn) + + simpleContextActions := slack.NewContextActionsBlock("actions_2", simpleFeedback) + + // Build Message with blocks created above + msg := slack.NewBlockMessage( + responseSection, + contextActionsBlock, + divider, + anotherSection, + simpleContextActions, + ) + + b, err := json.MarshalIndent(msg, "", " ") + if err != nil { + fmt.Println(err) + return + } + + fmt.Println(string(b)) +} From 34e99a50e6d87ad61efdc945cb4f99889f2fcf42 Mon Sep 17 00:00:00 2001 From: Tom Ashworth Date: Thu, 13 Nov 2025 09:25:11 +0000 Subject: [PATCH 428/606] feat: add mise.toml Adds mise.toml configuration file for project settings. --- mise.toml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 mise.toml diff --git a/mise.toml b/mise.toml new file mode 100644 index 000000000..aaf1458b2 --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +go = "1.22" \ No newline at end of file From afbabff37eadf05af82dad5bf52f2b8020204c83 Mon Sep 17 00:00:00 2001 From: William Clot <34165723+williamclot@users.noreply.github.com> Date: Thu, 6 Nov 2025 13:02:39 +0000 Subject: [PATCH 429/606] add a table block --- block.go | 1 + block_table.go | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 block_table.go diff --git a/block.go b/block.go index 7c4f99308..dd321bae5 100644 --- a/block.go +++ b/block.go @@ -17,6 +17,7 @@ const ( MBTCall MessageBlockType = "call" MBTVideo MessageBlockType = "video" MBTMarkdown MessageBlockType = "markdown" + MBTTable MessageBlockType = "table" ) // Block defines an interface all block types should implement diff --git a/block_table.go b/block_table.go new file mode 100644 index 000000000..5c7b0f13a --- /dev/null +++ b/block_table.go @@ -0,0 +1,55 @@ +package slack + +// TableBlock defines a block that lets you use a table to display your data. +// +// More Information: https://docs.slack.dev/reference/block-kit/blocks/table-block/ +type TableBlock struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + Rows [][]*RichTextBlock `json:"rows"` + ColumnSettings []ColumnSetting `json:"column_settings,omitempty"` +} + +type ColumnAlignment string + +const ( + ColumnAlignmentLeft ColumnAlignment = "left" + ColumnAlignmentCenter ColumnAlignment = "center" + ColumnAlignmentRight ColumnAlignment = "right" +) + +type ColumnSetting struct { + Align ColumnAlignment `json:"align"` + IsWrapped bool `json:"is_wrapped"` +} + +// BlockType returns the type of the block +func (s TableBlock) BlockType() MessageBlockType { + return s.Type +} + +// ID returns the ID of the block +func (s TableBlock) ID() string { + return s.BlockID +} + +// WithColumnSettings sets the column settings for the Table Block +func (s *TableBlock) WithColumnSettings(columnSettings ...ColumnSetting) *TableBlock { + s.ColumnSettings = columnSettings + return s +} + +// AddRow adds a new row of cells to the Table Block +func (s *TableBlock) AddRow(cells ...*RichTextBlock) *TableBlock { + s.Rows = append(s.Rows, append([]*RichTextBlock{}, cells...)) + return s +} + +// NewTableBlock returns an instance of a Table Block type +func NewTableBlock(blockID string) *TableBlock { + return &TableBlock{ + Type: MBTTable, + BlockID: blockID, + Rows: make([][]*RichTextBlock, 0), + } +} From 22db143d6701ec40d52ddc5ec0f0d12042e97be7 Mon Sep 17 00:00:00 2001 From: William Clot <34165723+williamclot@users.noreply.github.com> Date: Thu, 6 Nov 2025 13:02:46 +0000 Subject: [PATCH 430/606] add unit test --- block_table_test.go | 138 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 block_table_test.go diff --git a/block_table_test.go b/block_table_test.go new file mode 100644 index 000000000..f74360ca1 --- /dev/null +++ b/block_table_test.go @@ -0,0 +1,138 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewTableBlock(t *testing.T) { + testPayload := `{ + "type":"table", + "block_id":"test1", + "rows": [ + [{ + "type":"rich_text", + "elements": [ + { + "type":"rich_text_section", + "elements": [ + { + "type":"text", + "text":"Col1" + } + ] + } + ] + }, + { + "type":"rich_text", + "elements": [ + { + "type":"rich_text_section", + "elements": [ + { + "type":"text", + "text":"Col2" + } + ] + } + ] + }], + [{ + "type":"rich_text", + "elements": [ + { + "type":"rich_text_section", + "elements": [ + { + "type":"text", + "text":"Val1" + } + ] + } + ] + }, + { + "type":"rich_text", + "elements": [ + { + "type":"rich_text_section", + "elements": [ + { + "type":"text", + "text":"Val2" + } + ] + } + ] + }] + ] + }` + + tableBlock := NewTableBlock("test1") + + tableBlock.AddRow(&RichTextBlock{ + Type: MBTRichText, + Elements: []RichTextElement{ + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "Col1"}, + }, + }, + }, + }, &RichTextBlock{ + Type: MBTRichText, + Elements: []RichTextElement{ + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "Col2"}, + }, + }, + }, + }) + + tableBlock.AddRow(&RichTextBlock{ + Type: MBTRichText, + Elements: []RichTextElement{ + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "Val1"}, + }, + }, + }, + }, &RichTextBlock{ + Type: MBTRichText, + Elements: []RichTextElement{ + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "Val2"}, + }, + }, + }, + }) + + assert.Equal(t, tableBlock.BlockType(), MBTTable) + assert.Equal(t, string(tableBlock.Type), "table") + assert.Equal(t, tableBlock.BlockID, "test1") + assert.Equal(t, tableBlock.ID(), "test1") + assert.Equal(t, len(tableBlock.Rows), 2) + assert.Equal(t, len(tableBlock.ColumnSettings), 0) + + // Check if marshalled payload matches expected JSON + marshalled, err := json.Marshal(tableBlock) + assert.NoError(t, err) + + var expected, actual map[string]interface{} + err = json.Unmarshal([]byte(testPayload), &expected) + assert.NoError(t, err) + err = json.Unmarshal(marshalled, &actual) + assert.NoError(t, err) + + assert.Equal(t, expected, actual) +} From 5be15593180fc472ab5d466a6f5a074baae68637 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Thu, 13 Nov 2025 17:00:19 +0000 Subject: [PATCH 431/606] Align MessageBlockType constants for consistency --- block.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/block.go b/block.go index cf92cd437..cf9f36bc2 100644 --- a/block.go +++ b/block.go @@ -5,20 +5,20 @@ package slack type MessageBlockType string const ( - MBTSection MessageBlockType = "section" - MBTDivider MessageBlockType = "divider" - MBTImage MessageBlockType = "image" - MBTAction MessageBlockType = "actions" - MBTContext MessageBlockType = "context" + MBTSection MessageBlockType = "section" + MBTDivider MessageBlockType = "divider" + MBTImage MessageBlockType = "image" + MBTAction MessageBlockType = "actions" + MBTContext MessageBlockType = "context" MBTContextActions MessageBlockType = "context_actions" - MBTFile MessageBlockType = "file" - MBTInput MessageBlockType = "input" - MBTHeader MessageBlockType = "header" - MBTRichText MessageBlockType = "rich_text" - MBTCall MessageBlockType = "call" - MBTVideo MessageBlockType = "video" - MBTMarkdown MessageBlockType = "markdown" - MBTTable MessageBlockType = "table" + MBTFile MessageBlockType = "file" + MBTInput MessageBlockType = "input" + MBTHeader MessageBlockType = "header" + MBTRichText MessageBlockType = "rich_text" + MBTCall MessageBlockType = "call" + MBTVideo MessageBlockType = "video" + MBTMarkdown MessageBlockType = "markdown" + MBTTable MessageBlockType = "table" ) // Block defines an interface all block types should implement From e7f77c20f2b4e11e731a55b9a65c06e163e8fdc5 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Thu, 13 Nov 2025 19:14:04 +0000 Subject: [PATCH 432/606] chore: fix linting --- block.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/block.go b/block.go index cf9f36bc2..df784a33c 100644 --- a/block.go +++ b/block.go @@ -5,20 +5,20 @@ package slack type MessageBlockType string const ( - MBTSection MessageBlockType = "section" - MBTDivider MessageBlockType = "divider" - MBTImage MessageBlockType = "image" - MBTAction MessageBlockType = "actions" - MBTContext MessageBlockType = "context" + MBTSection MessageBlockType = "section" + MBTDivider MessageBlockType = "divider" + MBTImage MessageBlockType = "image" + MBTAction MessageBlockType = "actions" + MBTContext MessageBlockType = "context" MBTContextActions MessageBlockType = "context_actions" - MBTFile MessageBlockType = "file" - MBTInput MessageBlockType = "input" - MBTHeader MessageBlockType = "header" - MBTRichText MessageBlockType = "rich_text" - MBTCall MessageBlockType = "call" - MBTVideo MessageBlockType = "video" - MBTMarkdown MessageBlockType = "markdown" - MBTTable MessageBlockType = "table" + MBTFile MessageBlockType = "file" + MBTInput MessageBlockType = "input" + MBTHeader MessageBlockType = "header" + MBTRichText MessageBlockType = "rich_text" + MBTCall MessageBlockType = "call" + MBTVideo MessageBlockType = "video" + MBTMarkdown MessageBlockType = "markdown" + MBTTable MessageBlockType = "table" ) // Block defines an interface all block types should implement From a0e98e3c22dd1b9841b4ec0cd72ac64593819c07 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 14 Nov 2025 16:19:54 +0000 Subject: [PATCH 433/606] feat: add support for workflow_button as a block element --- block_element.go | 60 ++++++++++++++++++++++ block_element_test.go | 112 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/block_element.go b/block_element.go index 2e541a87c..b24caedac 100644 --- a/block_element.go +++ b/block_element.go @@ -19,6 +19,7 @@ const ( METFileInput MessageElementType = "file_input" METFeedbackButtons MessageElementType = "feedback_buttons" METIconButton MessageElementType = "icon_button" + METWorkflowButton MessageElementType = "workflow_button" MixedElementImage MixedElementType = "mixed_image" MixedElementText MixedElementType = "mixed_text" @@ -60,6 +61,7 @@ type Accessory struct { SelectElement *SelectBlockElement MultiSelectElement *MultiSelectBlockElement CheckboxGroupsBlockElement *CheckboxGroupsBlockElement + WorkflowButtonElement *WorkflowButtonBlockElement UnknownElement *UnknownBlockElement } @@ -88,6 +90,8 @@ func NewAccessory(element BlockElement) *Accessory { return &Accessory{MultiSelectElement: element.(*MultiSelectBlockElement)} case *CheckboxGroupsBlockElement: return &Accessory{CheckboxGroupsBlockElement: element.(*CheckboxGroupsBlockElement)} + case *WorkflowButtonBlockElement: + return &Accessory{WorkflowButtonElement: element.(*WorkflowButtonBlockElement)} default: return &Accessory{UnknownElement: element.(*UnknownBlockElement)} } @@ -924,3 +928,59 @@ func (s *IconButtonBlockElement) WithVisibleToUserIDs(userIDs []string) *IconBut s.VisibleToUserIDs = userIDs return s } + +// WorkflowTrigger defines the workflow to be executed when a workflow button is clicked +type WorkflowTrigger struct { + URL string `json:"url"` + CustomizableInputParameters []CustomizableInputParameter `json:"customizable_input_parameters,omitempty"` +} + +// CustomizableInputParameter defines a parameter that can be passed to a workflow +type CustomizableInputParameter struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// Workflow contains the trigger details for a workflow button +type Workflow struct { + Trigger *WorkflowTrigger `json:"trigger"` +} + +// WorkflowButtonBlockElement defines an element that triggers a workflow when clicked +// +// More Information: https://docs.slack.dev/reference/block-kit/block-elements/workflow-button-element +type WorkflowButtonBlockElement struct { + Type MessageElementType `json:"type"` + Text *TextBlockObject `json:"text"` + Workflow *Workflow `json:"workflow"` + ActionID string `json:"action_id"` + Style Style `json:"style,omitempty"` + AccessibilityLabel string `json:"accessibility_label,omitempty"` +} + +// ElementType returns the type of the element +func (s WorkflowButtonBlockElement) ElementType() MessageElementType { + return s.Type +} + +// NewWorkflowButtonBlockElement returns a new instance of a workflow button element +func NewWorkflowButtonBlockElement(text *TextBlockObject, workflow *Workflow, actionID string) *WorkflowButtonBlockElement { + return &WorkflowButtonBlockElement{ + Type: METWorkflowButton, + Text: text, + Workflow: workflow, + ActionID: actionID, + } +} + +// WithStyle sets the style for the workflow button element +func (s *WorkflowButtonBlockElement) WithStyle(style Style) *WorkflowButtonBlockElement { + s.Style = style + return s +} + +// WithAccessibilityLabel sets the accessibility label for the workflow button element +func (s *WorkflowButtonBlockElement) WithAccessibilityLabel(label string) *WorkflowButtonBlockElement { + s.AccessibilityLabel = label + return s +} diff --git a/block_element_test.go b/block_element_test.go index f8db41024..2579cdf2a 100644 --- a/block_element_test.go +++ b/block_element_test.go @@ -361,3 +361,115 @@ func TestIconButtonJSONMarshalling(t *testing.T) { assert.Equal(t, "delete_button_1", unmarshalled.ActionID) assert.Equal(t, "delete_item", unmarshalled.Value) } + +func TestNewWorkflowButtonBlockElement(t *testing.T) { + btnText := NewTextBlockObject("plain_text", "Run Workflow", false, false) + workflow := &Workflow{ + Trigger: &WorkflowTrigger{ + URL: "https://slack.com/shortcuts/Ft123456/xyz123", + CustomizableInputParameters: []CustomizableInputParameter{ + {Name: "input_param_a", Value: "Value for input param A"}, + {Name: "input_param_b", Value: "Value for input param B"}, + }, + }, + } + workflowButton := NewWorkflowButtonBlockElement(btnText, workflow, "workflow_action_1") + + assert.Equal(t, string(workflowButton.Type), "workflow_button") + assert.Equal(t, "workflow_action_1", workflowButton.ActionID) + assert.Equal(t, "Run Workflow", workflowButton.Text.Text) + assert.NotNil(t, workflowButton.Workflow) + assert.Equal(t, "https://slack.com/shortcuts/Ft123456/xyz123", workflowButton.Workflow.Trigger.URL) + assert.Equal(t, 2, len(workflowButton.Workflow.Trigger.CustomizableInputParameters)) + assert.Equal(t, "input_param_a", workflowButton.Workflow.Trigger.CustomizableInputParameters[0].Name) + assert.Equal(t, "Value for input param A", workflowButton.Workflow.Trigger.CustomizableInputParameters[0].Value) +} + +func TestWorkflowButtonFluentMethods(t *testing.T) { + btnText := NewTextBlockObject("plain_text", "Execute", false, false) + workflow := &Workflow{ + Trigger: &WorkflowTrigger{ + URL: "https://slack.com/shortcuts/Ft123456/xyz123", + }, + } + workflowButton := NewWorkflowButtonBlockElement(btnText, workflow, "workflow_1") + + // Test WithStyle + workflowButton.WithStyle(StylePrimary) + assert.Equal(t, StylePrimary, workflowButton.Style) + + workflowButton.WithStyle(StyleDanger) + assert.Equal(t, StyleDanger, workflowButton.Style) + + // Test WithAccessibilityLabel + workflowButton.WithAccessibilityLabel("This button triggers an important workflow") + assert.Equal(t, "This button triggers an important workflow", workflowButton.AccessibilityLabel) + + // Test method chaining + chainedButton := NewWorkflowButtonBlockElement(btnText, workflow, "workflow_2"). + WithStyle(StylePrimary). + WithAccessibilityLabel("Chained accessibility label") + + assert.Equal(t, StylePrimary, chainedButton.Style) + assert.Equal(t, "Chained accessibility label", chainedButton.AccessibilityLabel) +} + +func TestWorkflowButtonJSONMarshalling(t *testing.T) { + btnText := NewTextBlockObject("plain_text", "Start Process", false, false) + workflow := &Workflow{ + Trigger: &WorkflowTrigger{ + URL: "https://slack.com/shortcuts/Ft123456/abc789", + CustomizableInputParameters: []CustomizableInputParameter{ + {Name: "user_id", Value: "U123456"}, + {Name: "channel_id", Value: "C789012"}, + }, + }, + } + workflowButton := NewWorkflowButtonBlockElement(btnText, workflow, "start_workflow"). + WithStyle(StylePrimary). + WithAccessibilityLabel("Start the approval process") + + jsonData, err := json.Marshal(workflowButton) + assert.NoError(t, err) + + var unmarshalled WorkflowButtonBlockElement + err = json.Unmarshal(jsonData, &unmarshalled) + assert.NoError(t, err) + + assert.Equal(t, "workflow_button", string(unmarshalled.Type)) + assert.Equal(t, "start_workflow", unmarshalled.ActionID) + assert.Equal(t, "Start Process", unmarshalled.Text.Text) + assert.Equal(t, StylePrimary, unmarshalled.Style) + assert.Equal(t, "Start the approval process", unmarshalled.AccessibilityLabel) + assert.NotNil(t, unmarshalled.Workflow) + assert.Equal(t, "https://slack.com/shortcuts/Ft123456/abc789", unmarshalled.Workflow.Trigger.URL) + assert.Equal(t, 2, len(unmarshalled.Workflow.Trigger.CustomizableInputParameters)) + assert.Equal(t, "user_id", unmarshalled.Workflow.Trigger.CustomizableInputParameters[0].Name) + assert.Equal(t, "U123456", unmarshalled.Workflow.Trigger.CustomizableInputParameters[0].Value) +} + +func TestWorkflowButtonMinimalConfiguration(t *testing.T) { + // Test with minimal required fields only + btnText := NewTextBlockObject("plain_text", "Simple Workflow", false, false) + workflow := &Workflow{ + Trigger: &WorkflowTrigger{ + URL: "https://slack.com/shortcuts/Ft123456/minimal", + }, + } + workflowButton := NewWorkflowButtonBlockElement(btnText, workflow, "minimal_workflow") + + // Verify no optional fields are set + assert.Equal(t, Style(""), workflowButton.Style) + assert.Equal(t, "", workflowButton.AccessibilityLabel) + assert.Nil(t, workflowButton.Workflow.Trigger.CustomizableInputParameters) + + // Ensure it marshals correctly without optional fields + jsonData, err := json.Marshal(workflowButton) + assert.NoError(t, err) + + // Check that optional fields are omitted from JSON + jsonStr := string(jsonData) + assert.NotContains(t, jsonStr, "style") + assert.NotContains(t, jsonStr, "accessibility_label") + assert.NotContains(t, jsonStr, "customizable_input_parameters") +} From 1dbcde2a337eee0502228c48c7572ed8f88598a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 06:27:36 +0000 Subject: [PATCH 434/606] chore(deps): bump actions/checkout from 5 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 636be361e..cd84980f7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: - "1.24" name: test go-${{ matrix.go }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version: ${{ matrix.go }} @@ -34,7 +34,7 @@ jobs: with: go-version: "1.22" cache: false - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: golangci-lint From 06f84e8a7d053eab98ea768c24b2d7e9911bf53e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 29 Nov 2025 14:51:07 +0000 Subject: [PATCH 435/606] chore(deps): bump golangci/golangci-lint-action from 9.0.0 to 9.1.0 Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 9.0.0 to 9.1.0. - [Release notes](https://github.com/golangci/golangci-lint-action/releases) - [Commits](https://github.com/golangci/golangci-lint-action/compare/0a35821d5c230e903fcfe077583637dea1b27b47...e7fa5ac41e1cf5b7d48e45e42232ce7ada589601) --- updated-dependencies: - dependency-name: golangci/golangci-lint-action dependency-version: 9.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cd84980f7..9121a8abd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,7 +38,7 @@ jobs: with: fetch-depth: 0 - name: golangci-lint - uses: golangci/golangci-lint-action@0a35821d5c230e903fcfe077583637dea1b27b47 # v9.0.0 + uses: golangci/golangci-lint-action@e7fa5ac41e1cf5b7d48e45e42232ce7ada589601 # v9.1.0 with: version: v2.1.6 - name: Check 'go mod tidy' makes no changes From a921cb10409722aac87ee1679477421fe1a712b8 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 29 Nov 2025 15:19:32 +0000 Subject: [PATCH 436/606] ci,fix: bump base go version to 1.24 Also clarify that we no longer support 3+ older versions of go, instead we follow go release policy. As part of this change, we fixed a bunch of calls to log.Printf to sort the following warning: ``` non-constant format string in call to log.Printf ``` --- .github/workflows/test.yml | 11 ++++++++--- README.md | 37 +++++++++++++++++++++---------------- examples/modal/modal.go | 6 +++--- go.mod | 4 +++- mise.toml | 2 +- slacktest/handlers.go | 28 ++++++++++++++-------------- 6 files changed, 50 insertions(+), 38 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9121a8abd..d12f966f2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,9 +15,8 @@ jobs: strategy: matrix: go: - - "1.22" - - "1.23" - "1.24" + - "1.25" name: test go-${{ matrix.go }} steps: - uses: actions/checkout@v6 @@ -26,13 +25,19 @@ jobs: go-version: ${{ matrix.go }} - name: run test run: go test -v -race ./... + lint: runs-on: ubuntu-24.04 + strategy: + matrix: + go: + - "1.24" + - "1.25" name: lint steps: - uses: actions/setup-go@v6 with: - go-version: "1.22" + go-version: ${{ matrix.go }} cache: false - uses: actions/checkout@v6 with: diff --git a/README.md b/README.md index 127ae253c..7d0ed91eb 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,11 @@ Therefore, minor version releases may include backward incompatible changes. See [Releases](https://github.com/slack-go/slack/releases) for more information about the changes. +## Go Versions supported + +We support the same versions of Go as the officially supported Go versions (see [Go +Release Policy](https://go.dev/doc/devel/release#policy)). + ## Installing ### *go get* @@ -29,24 +34,24 @@ See [Releases](https://github.com/slack-go/slack/releases) for more information ```golang import ( - "fmt" + "fmt" - "github.com/slack-go/slack" + "github.com/slack-go/slack" ) func main() { - api := slack.New("YOUR_TOKEN_HERE") - // If you set debugging, it will log all requests to the console - // Useful when encountering issues - // slack.New("YOUR_TOKEN_HERE", slack.OptionDebug(true)) - groups, err := api.GetUserGroups(slack.GetUserGroupsOptionIncludeUsers(false)) - if err != nil { - fmt.Printf("%s\n", err) - return - } - for _, group := range groups { - fmt.Printf("ID: %s, Name: %s\n", group.ID, group.Name) - } + api := slack.New("YOUR_TOKEN_HERE") + // If you set debugging, it will log all requests to the console + // Useful when encountering issues + // slack.New("YOUR_TOKEN_HERE", slack.OptionDebug(true)) + groups, err := api.GetUserGroups(slack.GetUserGroupsOptionIncludeUsers(false)) + if err != nil { + fmt.Printf("%s\n", err) + return + } + for _, group := range groups { + fmt.Printf("ID: %s, Name: %s\n", group.ID, group.Name) + } } ``` @@ -63,8 +68,8 @@ func main() { api := slack.New("YOUR_TOKEN_HERE") user, err := api.GetUserInfo("U023BECGF") if err != nil { - fmt.Printf("%s\n", err) - return + fmt.Printf("%s\n", err) + return } fmt.Printf("ID: %s, Fullname: %s, Email: %s\n", user.ID, user.Profile.RealName, user.Profile.Email) } diff --git a/examples/modal/modal.go b/examples/modal/modal.go index 8de65c529..636586338 100644 --- a/examples/modal/modal.go +++ b/examples/modal/modal.go @@ -122,7 +122,7 @@ func handleSlash(token string) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { err := verifySigningSecret(r) if err != nil { - fmt.Printf(err.Error()) + fmt.Printf("%s", err.Error()) w.WriteHeader(http.StatusUnauthorized) return } @@ -153,7 +153,7 @@ func handleModal(token string) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { err := verifySigningSecret(r) if err != nil { - fmt.Printf(err.Error()) + fmt.Printf("%s", err.Error()) w.WriteHeader(http.StatusUnauthorized) return } @@ -161,7 +161,7 @@ func handleModal(token string) func(w http.ResponseWriter, r *http.Request) { var i slack.InteractionCallback err = json.Unmarshal([]byte(r.FormValue("payload")), &i) if err != nil { - fmt.Printf(err.Error()) + fmt.Printf("%s", err.Error()) w.WriteHeader(http.StatusUnauthorized) return } diff --git a/go.mod b/go.mod index 3d447db20..6ed33cbe3 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/slack-go/slack -go 1.22 +go 1.24 + +toolchain go1.24.10 require ( github.com/go-test/deep v1.1.1 diff --git a/mise.toml b/mise.toml index aaf1458b2..886e9fb71 100644 --- a/mise.toml +++ b/mise.toml @@ -1,2 +1,2 @@ [tools] -go = "1.22" \ No newline at end of file +go = "1.24" diff --git a/slacktest/handlers.go b/slacktest/handlers.go index 460977fa6..f0f66df14 100644 --- a/slacktest/handlers.go +++ b/slacktest/handlers.go @@ -49,14 +49,14 @@ func (sts *Server) conversationsInfoHandler(w http.ResponseWriter, r *http.Reque data, err := io.ReadAll(r.Body) if err != nil { msg := fmt.Sprintf("error reading body: %s", err.Error()) - log.Printf(msg) + log.Printf("%s", msg) http.Error(w, msg, http.StatusInternalServerError) return } values, vErr := url.ParseQuery(string(data)) if vErr != nil { msg := fmt.Sprintf("Unable to decode query params: %s", vErr.Error()) - log.Printf(msg) + log.Printf("%s", msg) http.Error(w, msg, http.StatusInternalServerError) return } @@ -75,7 +75,7 @@ func (sts *Server) conversationsInfoHandler(w http.ResponseWriter, r *http.Reque encoded, err := json.Marshal(&response) if err != nil { msg := fmt.Sprintf("Unable to encode response: %s", err.Error()) - log.Printf(msg) + log.Printf("%s", msg) http.Error(w, msg, http.StatusInternalServerError) return } @@ -134,14 +134,14 @@ func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) { data, err := io.ReadAll(r.Body) if err != nil { msg := fmt.Sprintf("error reading body: %s", err.Error()) - log.Printf(msg) + log.Printf("%s", msg) http.Error(w, msg, http.StatusInternalServerError) return } values, vErr := url.ParseQuery(string(data)) if vErr != nil { msg := fmt.Sprintf("Unable to decode query params: %s", vErr.Error()) - log.Printf(msg) + log.Printf("%s", msg) http.Error(w, msg, http.StatusInternalServerError) return } @@ -177,7 +177,7 @@ func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) { decoded, err := url.QueryUnescape(attachments) if err != nil { msg := fmt.Sprintf("Unable to decode attachments: %s", err.Error()) - log.Printf(msg) + log.Printf("%s", msg) http.Error(w, msg, http.StatusInternalServerError) return } @@ -185,7 +185,7 @@ func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) { aJErr := json.Unmarshal([]byte(decoded), &attaches) if aJErr != nil { msg := fmt.Sprintf("Unable to decode attachments string to json: %s", aJErr.Error()) - log.Printf(msg) + log.Printf("%s", msg) http.Error(w, msg, http.StatusInternalServerError) return } @@ -196,7 +196,7 @@ func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) { decoded, err := url.QueryUnescape(blocks) if err != nil { msg := fmt.Sprintf("Unable to decode blocks: %s", err.Error()) - log.Printf(msg) + log.Printf("%s", msg) http.Error(w, msg, http.StatusInternalServerError) return } @@ -204,7 +204,7 @@ func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) { dbJErr := json.Unmarshal([]byte(decoded), &decodedBlocks) if dbJErr != nil { msg := fmt.Sprintf("Unable to decode blocks string to json: %s", dbJErr.Error()) - log.Printf(msg) + log.Printf("%s", msg) http.Error(w, msg, http.StatusInternalServerError) return } @@ -213,7 +213,7 @@ func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) { jsonMessage, jsonErr := json.Marshal(m) if jsonErr != nil { msg := fmt.Sprintf("Unable to marshal message: %s", jsonErr.Error()) - log.Printf(msg) + log.Printf("%s", msg) http.Error(w, msg, http.StatusInternalServerError) return } @@ -226,14 +226,14 @@ func RTMConnectHandler(w http.ResponseWriter, r *http.Request) { _, err := io.ReadAll(r.Body) if err != nil { msg := fmt.Sprintf("Error reading body: %s", err.Error()) - log.Printf(msg) + log.Printf("%s", msg) http.Error(w, msg, http.StatusInternalServerError) return } wsurl := r.Context().Value(ServerWSContextKey).(string) if wsurl == "" { msg := "missing webservice url from context" - log.Printf(msg) + log.Printf("%s", msg) http.Error(w, msg, http.StatusInternalServerError) return } @@ -256,14 +256,14 @@ func rtmStartHandler(w http.ResponseWriter, r *http.Request) { _, err := io.ReadAll(r.Body) if err != nil { msg := fmt.Sprintf("Error reading body: %s", err.Error()) - log.Printf(msg) + log.Printf("%s", msg) http.Error(w, msg, http.StatusInternalServerError) return } wsurl := r.Context().Value(ServerWSContextKey).(string) if wsurl == "" { msg := "missing webservice url from context" - log.Printf(msg) + log.Printf("%s", msg) http.Error(w, msg, http.StatusInternalServerError) return } From dc5fad7cb766119d0bac2fbf56721989f329d605 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 29 Nov 2025 15:38:52 +0000 Subject: [PATCH 437/606] ci: fix lint step --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d12f966f2..8e9ebe576 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,7 @@ jobs: go: - "1.24" - "1.25" - name: lint + name: lint go-${{ matrix.go }} steps: - uses: actions/setup-go@v6 with: From b991a80bb42fa3b62ecf0e4b86a37396c9261a9e Mon Sep 17 00:00:00 2001 From: yatiac Date: Wed, 3 Dec 2025 02:42:26 +0000 Subject: [PATCH 438/606] docs(conversation): update GetConversationHistory function comments The function comments for GetConversationHistory and GetConversationHistoryContext were updated to more accurately describe their purpose. The previous comments incorrectly stated that the functions "join an existing conversation" when they actually retrieve message history from a conversation. --- conversation.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/conversation.go b/conversation.go index d16b88d82..6c108ebf0 100644 --- a/conversation.go +++ b/conversation.go @@ -958,13 +958,13 @@ type GetConversationHistoryResponse struct { Messages []Message `json:"messages"` } -// GetConversationHistory joins an existing conversation. +// GetConversationHistory retrieves the message history from the specified conversation. // For more details, see GetConversationHistoryContext documentation. func (api *Client) GetConversationHistory(params *GetConversationHistoryParameters) (*GetConversationHistoryResponse, error) { return api.GetConversationHistoryContext(context.Background(), params) } -// GetConversationHistoryContext joins an existing conversation with a custom context. +// GetConversationHistoryContext retrieves the message history from the specified conversation with a custom context. // Slack API docs: https://api.slack.com/methods/conversations.history func (api *Client) GetConversationHistoryContext(ctx context.Context, params *GetConversationHistoryParameters) (*GetConversationHistoryResponse, error) { values := url.Values{"token": {api.token}, "channel": {params.ChannelID}} From 9315ac86cc76844afea3ec7f531251f1c0d57c2a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 06:16:33 +0000 Subject: [PATCH 439/606] chore(deps): bump actions/stale from 10.1.0 to 10.1.1 Bumps [actions/stale](https://github.com/actions/stale) from 10.1.0 to 10.1.1. - [Release notes](https://github.com/actions/stale/releases) - [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/stale/compare/5f858e3efba33a5ca4407a664cc011ad407f2008...997185467fa4f803885201cee163a9f38240193d) --- updated-dependencies: - dependency-name: actions/stale dependency-version: 10.1.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index b6e4022a4..743e44e8d 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -13,7 +13,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 + - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 with: any-of-labels: 'feedback given' days-before-stale: 45 From 405f2e2fda78131d8e83d16d97d4803d32dc673d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 06:16:36 +0000 Subject: [PATCH 440/606] chore(deps): bump golangci/golangci-lint-action from 9.1.0 to 9.2.0 Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 9.1.0 to 9.2.0. - [Release notes](https://github.com/golangci/golangci-lint-action/releases) - [Commits](https://github.com/golangci/golangci-lint-action/compare/e7fa5ac41e1cf5b7d48e45e42232ce7ada589601...1e7e51e771db61008b38414a730f564565cf7c20) --- updated-dependencies: - dependency-name: golangci/golangci-lint-action dependency-version: 9.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8e9ebe576..242d6f54f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,7 +43,7 @@ jobs: with: fetch-depth: 0 - name: golangci-lint - uses: golangci/golangci-lint-action@e7fa5ac41e1cf5b7d48e45e42232ce7ada589601 # v9.1.0 + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: version: v2.1.6 - name: Check 'go mod tidy' makes no changes From 8ae2cd15c3ec4fe33874acb5e685b8e0f4ff824b Mon Sep 17 00:00:00 2001 From: Keith Kirk Date: Tue, 2 Dec 2025 11:43:59 -0500 Subject: [PATCH 441/606] Adds streaming api support --- chat.go | 107 +++++++++++++++++++++++++++ chat_test.go | 204 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 311 insertions(+) diff --git a/chat.go b/chat.go index 85c4848ba..b4bb25df3 100644 --- a/chat.go +++ b/chat.go @@ -307,6 +307,9 @@ const ( chatResponse sendMode = "chat.responseURL" chatMeMessage sendMode = "chat.meMessage" chatUnfurl sendMode = "chat.unfurl" + chatStartStream sendMode = "chat.startStream" + chatAppendStream sendMode = "chat.appendStream" + chatStopStream sendMode = "chat.stopStream" ) type sendConfig struct { @@ -735,6 +738,56 @@ func MsgOptionFileIDs(fileIDs []string) MsgOption { } } +// MsgOptionStartStream starts a streaming message. +func MsgOptionStartStream() MsgOption { + return func(config *sendConfig) error { + config.endpoint = config.apiurl + string(chatStartStream) + return nil + } +} + +// MsgOptionAppendStream appends to a streaming message. +func MsgOptionAppendStream(timestamp string) MsgOption { + return func(config *sendConfig) error { + config.endpoint = config.apiurl + string(chatAppendStream) + config.values.Add("ts", timestamp) + return nil + } +} + +// MsgOptionStopStream stops a streaming message. +func MsgOptionStopStream(timestamp string) MsgOption { + return func(config *sendConfig) error { + config.endpoint = config.apiurl + string(chatStopStream) + config.values.Add("ts", timestamp) + return nil + } +} + +// MsgOptionRecipientTeamID sets the recipient team ID for streaming messages. +func MsgOptionRecipientTeamID(teamID string) MsgOption { + return func(config *sendConfig) error { + config.values.Set("recipient_team_id", teamID) + return nil + } +} + +// MsgOptionRecipientUserID sets the recipient user ID for streaming messages. +func MsgOptionRecipientUserID(userID string) MsgOption { + return func(config *sendConfig) error { + config.values.Set("recipient_user_id", userID) + return nil + } +} + +// MsgOptionMarkdownText sets the markdown text for streaming messages. +func MsgOptionMarkdownText(text string) MsgOption { + return func(config *sendConfig) error { + config.values.Set("markdown_text", text) + return nil + } +} + // UnsafeMsgOptionEndpoint deliver the message to the specified endpoint. // NOTE: USE AT YOUR OWN RISK: No issues relating to the use of this Option // will be supported by the library, it is subject to change without notice that @@ -924,3 +977,57 @@ func (api *Client) DeleteScheduledMessageContext(ctx context.Context, params *De return response.Ok, response.Err() } + +// StartStream starts a streaming message in a channel. +// For more details, see StartStreamContext documentation. +func (api *Client) StartStream(channelID string, options ...MsgOption) (string, string, error) { + return api.StartStreamContext(context.Background(), channelID, options...) +} + +// StartStreamContext starts a streaming message in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.startStream +func (api *Client) StartStreamContext(ctx context.Context, channelID string, options ...MsgOption) (string, string, error) { + respChannel, respTimestamp, _, err := api.SendMessageContext( + ctx, + channelID, + MsgOptionStartStream(), + MsgOptionCompose(options...), + ) + return respChannel, respTimestamp, err +} + +// AppendStream appends text to a streaming message. +// For more details, see AppendStreamContext documentation. +func (api *Client) AppendStream(channelID, timestamp string, options ...MsgOption) (string, string, error) { + return api.AppendStreamContext(context.Background(), channelID, timestamp, options...) +} + +// AppendStreamContext appends text to a streaming message with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.appendStream +func (api *Client) AppendStreamContext(ctx context.Context, channelID, timestamp string, options ...MsgOption) (string, string, error) { + respChannel, respTimestamp, _, err := api.SendMessageContext( + ctx, + channelID, + MsgOptionAppendStream(timestamp), + MsgOptionCompose(options...), + ) + return respChannel, respTimestamp, err +} + +// StopStream stops a streaming message. +// For more details, see StopStreamContext documentation. +func (api *Client) StopStream(channelID, timestamp string, options ...MsgOption) (string, string, error) { + return api.StopStreamContext(context.Background(), channelID, timestamp, options...) +} + +// StopStreamContext stops a streaming message with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.stopStream +func (api *Client) StopStreamContext(ctx context.Context, channelID, timestamp string, options ...MsgOption) (string, string, error) { + respChannel, respTimestamp, _, err := api.SendMessageContext( + ctx, + channelID, + MsgOptionStopStream(timestamp), + MsgOptionCompose(options...), + ) + return respChannel, respTimestamp, err +} diff --git a/chat_test.go b/chat_test.go index 010389d6a..4b02453c0 100644 --- a/chat_test.go +++ b/chat_test.go @@ -425,3 +425,207 @@ func TestUpdateMessage(t *testing.T) { }) } } + +func TestStartStream(t *testing.T) { + type messageTest struct { + endpoint string + opt []MsgOption + expected url.Values + } + tests := map[string]messageTest{ + "basic": { + endpoint: "/chat.startStream", + opt: []MsgOption{ + MsgOptionTS("1234567890.123456"), + }, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "thread_ts": []string{"1234567890.123456"}, + }, + }, + "with recipients": { + endpoint: "/chat.startStream", + opt: []MsgOption{ + MsgOptionTS("1234567890.123456"), + MsgOptionRecipientTeamID("T12345"), + MsgOptionRecipientUserID("U12345"), + }, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "thread_ts": []string{"1234567890.123456"}, + "recipient_team_id": []string{"T12345"}, + "recipient_user_id": []string{"U12345"}, + }, + }, + } + + once.Do(startServer) + api := New(validToken, OptionAPIURL("http://"+serverAddr+"/")) + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc(test.endpoint, func(rw http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + actual, err := url.ParseQuery(string(body)) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + if !reflect.DeepEqual(actual, test.expected) { + t.Errorf("\nexpected: %s\n actual: %s", test.expected, actual) + return + } + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(chatResponseFull{ + Channel: "CXXX", + Timestamp: "1234567890.123456", + SlackResponse: SlackResponse{ + Ok: true, + }, + }) + rw.Write(response) + }) + + // Fixed: removed timestamp parameter, pass via MsgOptionTS + _, _, _ = api.StartStream("CXXX", test.opt...) + }) + } +} + +func TestAppendStream(t *testing.T) { + type messageTest struct { + endpoint string + opt []MsgOption + expected url.Values + } + tests := map[string]messageTest{ + "basic": { + endpoint: "/chat.appendStream", + opt: []MsgOption{ + MsgOptionMarkdownText("Hello, world!"), + }, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "ts": []string{"1234567890.123456"}, + "markdown_text": []string{"Hello, world!"}, + }, + }, + } + + once.Do(startServer) + api := New(validToken, OptionAPIURL("http://"+serverAddr+"/")) + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc(test.endpoint, func(rw http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + actual, err := url.ParseQuery(string(body)) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + if !reflect.DeepEqual(actual, test.expected) { + t.Errorf("\nexpected: %s\n actual: %s", test.expected, actual) + return + } + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(chatResponseFull{ + Channel: "CXXX", + Timestamp: "1234567890.123456", + SlackResponse: SlackResponse{ + Ok: true, + }, + }) + rw.Write(response) + }) + + _, _, _ = api.AppendStream("CXXX", "1234567890.123456", test.opt...) + }) + } +} + +func TestStopStream(t *testing.T) { + type messageTest struct { + endpoint string + opt []MsgOption + expected url.Values + } + + blocks := []Block{NewContextBlock("context", NewTextBlockObject(PlainTextType, "feedback", false, false))} + blockStr := `[{"type":"context","block_id":"context","elements":[{"type":"plain_text","text":"feedback","emoji":false}]}]` + + tests := map[string]messageTest{ + "basic": { + endpoint: "/chat.stopStream", + opt: []MsgOption{}, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "ts": []string{"1234567890.123456"}, + }, + }, + "with final text and blocks": { + endpoint: "/chat.stopStream", + opt: []MsgOption{ + MsgOptionMarkdownText("Final message"), + MsgOptionBlocks(blocks...), + }, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "ts": []string{"1234567890.123456"}, + "markdown_text": []string{"Final message"}, + "blocks": []string{blockStr}, + }, + }, + } + + once.Do(startServer) + api := New(validToken, OptionAPIURL("http://"+serverAddr+"/")) + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc(test.endpoint, func(rw http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + actual, err := url.ParseQuery(string(body)) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + if !reflect.DeepEqual(actual, test.expected) { + t.Errorf("\nexpected: %s\n actual: %s", test.expected, actual) + return + } + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(chatResponseFull{ + Channel: "CXXX", + Timestamp: "1234567890.123456", + SlackResponse: SlackResponse{ + Ok: true, + }, + }) + rw.Write(response) + }) + + _, _, _ = api.StopStream("CXXX", "1234567890.123456", test.opt...) + }) + } +} From a3fd3ddee522e0eb41274b1f3181e0a83cb955d4 Mon Sep 17 00:00:00 2001 From: Keith Kirk Date: Tue, 2 Dec 2025 11:47:07 -0500 Subject: [PATCH 442/606] Adds an example of using the streaming apis --- examples/chat_streaming/chat_streaming.go | 186 ++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 examples/chat_streaming/chat_streaming.go diff --git a/examples/chat_streaming/chat_streaming.go b/examples/chat_streaming/chat_streaming.go new file mode 100644 index 000000000..2c6996893 --- /dev/null +++ b/examples/chat_streaming/chat_streaming.go @@ -0,0 +1,186 @@ +package main + +import ( + "fmt" + "log" + "os" + "strings" + "time" + + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" +) + +// This example demonstrates using Slack's chat streaming API. +// It listens for app mentions and streams a response back in real-time. +// +// Required environment variables: +// - SLACK_APP_TOKEN: Your Slack app token (starts with xapp-) +// - SLACK_BOT_TOKEN: Your Slack bot token (starts with xoxb-) +// +// Required Slack app scopes: +// - app_mentions:read +// - chat:write +// +// Required Event Subscriptions: +// - app_mention + +func main() { + appToken := os.Getenv("SLACK_APP_TOKEN") + if appToken == "" { + panic("SLACK_APP_TOKEN must be set.\n") + } + + if !strings.HasPrefix(appToken, "xapp-") { + panic("SLACK_APP_TOKEN must have the prefix \"xapp-\".") + } + + botToken := os.Getenv("SLACK_BOT_TOKEN") + if botToken == "" { + panic("SLACK_BOT_TOKEN must be set.\n") + } + + if !strings.HasPrefix(botToken, "xoxb-") { + panic("SLACK_BOT_TOKEN must have the prefix \"xoxb-\".") + } + + api := slack.New( + botToken, + slack.OptionDebug(true), + slack.OptionLog(log.New(os.Stdout, "api: ", log.Lshortfile|log.LstdFlags)), + slack.OptionAppLevelToken(appToken), + ) + + client := socketmode.New( + api, + socketmode.OptionDebug(true), + socketmode.OptionLog(log.New(os.Stdout, "socketmode: ", log.Lshortfile|log.LstdFlags)), + ) + + socketmodeHandler := socketmode.NewSocketmodeHandler(client) + + // Handle app mentions + socketmodeHandler.HandleEvents(slackevents.AppMention, func(evt *socketmode.Event, clt *socketmode.Client) { + handleAppMention(evt, clt) + }) + + log.Println("Starting chat streaming bot...") + socketmodeHandler.RunEventLoop() +} + +func handleAppMention(evt *socketmode.Event, client *socketmode.Client) { + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + fmt.Printf("Ignored: %+v\n", evt) + return + } + + // Acknowledge the event + client.Ack(*evt.Request) + + ev, ok := eventsAPIEvent.InnerEvent.Data.(*slackevents.AppMentionEvent) + if !ok { + fmt.Printf("Ignored: %+v\n", evt) + return + } + + log.Printf("Received app mention in channel %s: %s", ev.Channel, ev.Text) + + // Start the stream + channel, streamTS, err := client.Client.StartStream( + ev.Channel, + slack.MsgOptionTS(ev.TimeStamp), // Reply in thread + ) + if err != nil { + log.Printf("Failed to start stream: %v", err) + return + } + + log.Printf("Started stream in channel %s with timestamp %s", channel, streamTS) + + // Simulate a streaming response by breaking up a message into chunks + response := "Here's a streaming response! " + + "This example demonstrates how to use Slack's chat streaming API. " + + "The streaming API consists of three methods: " + + "StartStream to begin streaming, " + + "AppendStream to add content incrementally, " + + "and StopStream to finish the stream. " + + "This is perfect for AI-powered apps that generate responses progressively." + + // Stream the response in chunks + if err := streamResponse(&client.Client, channel, streamTS, response); err != nil { + log.Printf("Error during streaming: %v", err) + + // Try to stop stream with error message + _, _, _ = client.Client.StopStream( + channel, + streamTS, + slack.MsgOptionMarkdownText("\n\n_Error occurred while streaming response._"), + ) + return + } + + // Create feedback buttons + thumbsUpText := slack.NewTextBlockObject(slack.PlainTextType, "👍 Helpful", true, false) + thumbsDownText := slack.NewTextBlockObject(slack.PlainTextType, "👎 Not Helpful", true, false) + + feedbackButtons := slack.NewActionBlock( + "feedback", + slack.NewButtonBlockElement("thumbs_up", "thumbs_up", thumbsUpText), + slack.NewButtonBlockElement("thumbs_down", "thumbs_down", thumbsDownText), + ) + + // Stop the stream with feedback buttons + _, _, err = client.Client.StopStream( + channel, + streamTS, + slack.MsgOptionBlocks(feedbackButtons), + ) + if err != nil { + log.Printf("Failed to stop stream: %v", err) + return + } + + log.Printf("Successfully completed streaming response in channel %s", channel) +} + +// streamResponse demonstrates buffering and streaming text chunks to Slack +func streamResponse(api *slack.Client, channel, streamTS, response string) error { + const ( + chunkSize = 5 // Characters to simulate per "chunk" + bufferSize = 20 // Send to Slack when buffer reaches this size + delayMS = 50 // Milliseconds between chunks (simulates generation) + ) + + buffer := strings.Builder{} + words := strings.Split(response, " ") + + for i, word := range words { + // Add word and space to buffer + if i > 0 { + buffer.WriteString(" ") + } + buffer.WriteString(word) + + // Simulate streaming delay + time.Sleep(time.Duration(delayMS) * time.Millisecond) + + // Send to Slack when buffer reaches threshold or at the end + if buffer.Len() >= bufferSize || i == len(words)-1 { + _, _, err := api.AppendStream( + channel, + streamTS, + slack.MsgOptionMarkdownText(buffer.String()), + ) + if err != nil { + return fmt.Errorf("failed to append to stream: %w", err) + } + + log.Printf("Appended %d characters to stream", buffer.Len()) + buffer.Reset() + } + } + + return nil +} From 360722cb5ce43db3fa134c19b775e30aab88798b Mon Sep 17 00:00:00 2001 From: Keith Kirk Date: Thu, 4 Dec 2025 14:14:47 -0500 Subject: [PATCH 443/606] Remove unnecessary code comment --- chat_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/chat_test.go b/chat_test.go index 4b02453c0..eaed40c42 100644 --- a/chat_test.go +++ b/chat_test.go @@ -493,7 +493,6 @@ func TestStartStream(t *testing.T) { rw.Write(response) }) - // Fixed: removed timestamp parameter, pass via MsgOptionTS _, _, _ = api.StartStream("CXXX", test.opt...) }) } From 64cba44a3203af0c3fd52c03a930b0d1fe75c2f0 Mon Sep 17 00:00:00 2001 From: Khing Panditya Date: Tue, 16 Dec 2025 16:56:21 +0700 Subject: [PATCH 444/606] feat: handle unmarshal table block --- block_conv.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/block_conv.go b/block_conv.go index 989812589..c69d6b633 100644 --- a/block_conv.go +++ b/block_conv.go @@ -77,6 +77,8 @@ func (b *Blocks) UnmarshalJSON(data []byte) error { block = &CallBlock{} case "video": block = &VideoBlock{} + case "table": + block = &TableBlock{} default: block = &UnknownBlock{} } From 64e4c3279ec8997012be05b2a521f6bdf74d55fc Mon Sep 17 00:00:00 2001 From: Charlie Hothersall-Thomas Date: Wed, 31 Dec 2025 12:14:29 +0000 Subject: [PATCH 445/606] Add RecordChannel to conversation Properties --- conversation.go | 18 +++++++--- conversation_test.go | 82 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/conversation.go b/conversation.go index 6c108ebf0..26654796c 100644 --- a/conversation.go +++ b/conversation.go @@ -68,12 +68,13 @@ type Purpose struct { LastSet JSONTime `json:"last_set"` } -// Properties contains the Canvas associated to the channel. +// Properties contains additional fields that appear based on the context of the conversation type Properties struct { - Canvas Canvas `json:"canvas"` - PostingRestrictedTo RestrictedTo `json:"posting_restricted_to"` - Tabs []Tab `json:"tabs"` - ThreadsRestrictedTo RestrictedTo `json:"threads_restricted_to"` + Canvas Canvas `json:"canvas"` + PostingRestrictedTo RestrictedTo `json:"posting_restricted_to"` + Tabs []Tab `json:"tabs"` + ThreadsRestrictedTo RestrictedTo `json:"threads_restricted_to"` + RecordChannel RecordChannel `json:"record_channel"` } type RestrictedTo struct { @@ -93,6 +94,13 @@ type Canvas struct { QuipThreadId string `json:"quip_thread_id"` } +type RecordChannel struct { + RecordID string `json:"record_id"` + RecordType string `json:"record_type"` + RecordLabel string `json:"record_label"` + RecordLabelPlural string `json:"record_label_plural"` +} + type GetUsersInConversationParameters struct { ChannelID string Cursor string diff --git a/conversation_test.go b/conversation_test.go index 069a7849a..8597e85cb 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -325,6 +325,88 @@ func TestCreateChannelWithCanvas(t *testing.T) { assertChannelWithCanvas(t, channel) } +// Channel with RecordChannel +var channelWithRecordChannel = `{ + "id": "C024BE91L", + "name": "fun", + "is_channel": true, + "created": 1360782804, + "creator": "U024BE7LH", + "is_archived": false, + "is_general": false, + "members": [ + "U024BE7LH" + ], + "topic": { + "value": "Fun times", + "creator": "U024BE7LV", + "last_set": 1369677212 + }, + "purpose": { + "value": "This channel is for fun", + "creator": "U024BE7LH", + "last_set": 1360782804 + }, + "is_member": true, + "last_read": "1401383885.000061", + "unread_count": 0, + "unread_count_display": 0, + "properties": { + "record_channel": { + "record_id": "S:00D0000000000000EAU:0010000000000000AA2", + "record_type": "Account", + "record_label": "Account", + "record_label_plural": "Accounts" + } + } +}` + +func unmarshalChannelWithRecordChannel(j string) (*Channel, error) { + channel := &Channel{} + if err := json.Unmarshal([]byte(j), &channel); err != nil { + return nil, err + } + return channel, nil +} + +func TestChannelWithRecordChannel(t *testing.T) { + channel, err := unmarshalChannelWithRecordChannel(channelWithRecordChannel) + assert.Nil(t, err) + assertChannelWithRecordChannel(t, channel) +} + +func assertChannelWithRecordChannel(t *testing.T, channel *Channel) { + assertSimpleChannel(t, channel) + assert.Equal(t, "S:00D0000000000000EAU:0010000000000000AA2", channel.Properties.RecordChannel.RecordID) + assert.Equal(t, "Account", channel.Properties.RecordChannel.RecordType) + assert.Equal(t, "Account", channel.Properties.RecordChannel.RecordLabel) + assert.Equal(t, "Accounts", channel.Properties.RecordChannel.RecordLabelPlural) +} + +func TestCreateChannelWithRecordChannel(t *testing.T) { + channel := &Channel{} + channel.ID = "C024BE91L" + channel.Name = "fun" + channel.IsChannel = true + channel.Created = JSONTime(1360782804) + channel.Creator = "U024BE7LH" + channel.IsArchived = false + channel.IsGeneral = false + channel.IsMember = true + channel.LastRead = "1401383885.000061" + channel.UnreadCount = 0 + channel.UnreadCountDisplay = 0 + channel.Properties = &Properties{ + RecordChannel: RecordChannel{ + RecordID: "S:00D0000000000000EAU:0010000000000000AA2", + RecordType: "Account", + RecordLabel: "Account", + RecordLabelPlural: "Accounts", + }, + } + assertChannelWithRecordChannel(t, channel) +} + // IM var simpleIM = `{ "id": "D024BFF1M", From cc6e18ee7ac3c6b486d6d0a6e7d6807f798c7abd Mon Sep 17 00:00:00 2001 From: Jaro Spisak Date: Wed, 1 Oct 2025 16:52:49 +0300 Subject: [PATCH 446/606] Add support for title argument in CreateChannelCanvas method --- conversation.go | 9 ++++++--- conversation_test.go | 21 ++++++++++++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/conversation.go b/conversation.go index 26654796c..9a4c69ec0 100644 --- a/conversation.go +++ b/conversation.go @@ -1035,17 +1035,20 @@ func (api *Client) MarkConversationContext(ctx context.Context, channel, ts stri // CreateChannelCanvas creates a new canvas in a channel. // For more details, see CreateChannelCanvasContext documentation. -func (api *Client) CreateChannelCanvas(channel string, documentContent DocumentContent) (string, error) { - return api.CreateChannelCanvasContext(context.Background(), channel, documentContent) +func (api *Client) CreateChannelCanvas(channel string, title string, documentContent DocumentContent) (string, error) { + return api.CreateChannelCanvasContext(context.Background(), channel, title, documentContent) } // CreateChannelCanvasContext creates a new canvas in a channel with a custom context. // Slack API docs: https://api.slack.com/methods/conversations.canvases.create -func (api *Client) CreateChannelCanvasContext(ctx context.Context, channel string, documentContent DocumentContent) (string, error) { +func (api *Client) CreateChannelCanvasContext(ctx context.Context, channel string, title string, documentContent DocumentContent) (string, error) { values := url.Values{ "token": {api.token}, "channel_id": {channel}, } + if title != "" { + values.Add("title", title) + } if documentContent.Type != "" { documentContentJSON, err := json.Marshal(documentContent) if err != nil { diff --git a/conversation_test.go b/conversation_test.go index 8597e85cb..20c8c9c87 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -957,6 +957,7 @@ func TestMarkConversation(t *testing.T) { func createChannelCanvasHandler(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(struct { SlackResponse CanvasID string `json:"canvas_id"` @@ -977,7 +978,7 @@ func TestCreateChannelCanvas(t *testing.T) { Markdown: "> channel canvas!", } - canvasID, err := api.CreateChannelCanvas("C1234567890", documentContent) + canvasID, err := api.CreateChannelCanvas("C1234567890", "Test Canvas Title", documentContent) if err != nil { t.Errorf("Failed to create channel canvas: %v", err) return @@ -986,6 +987,24 @@ func TestCreateChannelCanvas(t *testing.T) { assert.Equal(t, "F05RQ01LJU0", canvasID) } +func TestCreateChannelCanvasWithEmptyTitle(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + documentContent := DocumentContent{ + Type: "markdown", + Markdown: "> channel canvas with empty title!", + } + + canvasID, err := api.CreateChannelCanvas("C1234567890", "", documentContent) + if err != nil { + t.Errorf("Failed to create channel canvas with empty title: %v", err) + return + } + + assert.Equal(t, "F05RQ01LJU0", canvasID) +} + func getTestChannelWithId(id string) Channel { return Channel{ GroupConversation: GroupConversation{ From d630a676e042bad7a285f2521baecd5de86cdb06 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 24 Jan 2026 23:26:17 +0000 Subject: [PATCH 447/606] fix(conversation): use variadic pattern to avoid breaking change --- conversation.go | 45 +++++++++++++++++++++++++++++++++++++------- conversation_test.go | 14 +++++++++----- 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/conversation.go b/conversation.go index 9a4c69ec0..2f8e7f42d 100644 --- a/conversation.go +++ b/conversation.go @@ -1033,24 +1033,55 @@ func (api *Client) MarkConversationContext(ctx context.Context, channel, ts stri return response.Err() } +// createChannelCanvasParams contains arguments for CreateChannelCanvas method call. +type createChannelCanvasParams struct { + title string + documentContent *DocumentContent +} + +// CreateChannelCanvasOption options for the CreateChannelCanvas method call. +type CreateChannelCanvasOption func(*createChannelCanvasParams) + +// CreateChannelCanvasOptionTitle sets the title of the canvas. +func CreateChannelCanvasOptionTitle(title string) CreateChannelCanvasOption { + return func(params *createChannelCanvasParams) { + params.title = title + } +} + +// CreateChannelCanvasOptionDocumentContent sets the document content of the canvas. +func CreateChannelCanvasOptionDocumentContent(documentContent DocumentContent) CreateChannelCanvasOption { + return func(params *createChannelCanvasParams) { + params.documentContent = &documentContent + } +} + // CreateChannelCanvas creates a new canvas in a channel. // For more details, see CreateChannelCanvasContext documentation. -func (api *Client) CreateChannelCanvas(channel string, title string, documentContent DocumentContent) (string, error) { - return api.CreateChannelCanvasContext(context.Background(), channel, title, documentContent) +func (api *Client) CreateChannelCanvas(channel string, documentContent DocumentContent, options ...CreateChannelCanvasOption) (string, error) { + return api.CreateChannelCanvasContext(context.Background(), channel, documentContent, options...) } // CreateChannelCanvasContext creates a new canvas in a channel with a custom context. // Slack API docs: https://api.slack.com/methods/conversations.canvases.create -func (api *Client) CreateChannelCanvasContext(ctx context.Context, channel string, title string, documentContent DocumentContent) (string, error) { +func (api *Client) CreateChannelCanvasContext(ctx context.Context, channel string, documentContent DocumentContent, options ...CreateChannelCanvasOption) (string, error) { + params := createChannelCanvasParams{ + documentContent: &documentContent, + } + + for _, opt := range options { + opt(¶ms) + } + values := url.Values{ "token": {api.token}, "channel_id": {channel}, } - if title != "" { - values.Add("title", title) + if params.title != "" { + values.Add("title", params.title) } - if documentContent.Type != "" { - documentContentJSON, err := json.Marshal(documentContent) + if params.documentContent != nil && params.documentContent.Type != "" { + documentContentJSON, err := json.Marshal(params.documentContent) if err != nil { return "", err } diff --git a/conversation_test.go b/conversation_test.go index 20c8c9c87..488fe9169 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -978,7 +978,7 @@ func TestCreateChannelCanvas(t *testing.T) { Markdown: "> channel canvas!", } - canvasID, err := api.CreateChannelCanvas("C1234567890", "Test Canvas Title", documentContent) + canvasID, err := api.CreateChannelCanvas("C1234567890", documentContent) if err != nil { t.Errorf("Failed to create channel canvas: %v", err) return @@ -987,18 +987,22 @@ func TestCreateChannelCanvas(t *testing.T) { assert.Equal(t, "F05RQ01LJU0", canvasID) } -func TestCreateChannelCanvasWithEmptyTitle(t *testing.T) { +func TestCreateChannelCanvasWithTitle(t *testing.T) { once.Do(startServer) api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) documentContent := DocumentContent{ Type: "markdown", - Markdown: "> channel canvas with empty title!", + Markdown: "> channel canvas with title!", } - canvasID, err := api.CreateChannelCanvas("C1234567890", "", documentContent) + canvasID, err := api.CreateChannelCanvas( + "C1234567890", + documentContent, + CreateChannelCanvasOptionTitle("Test Canvas Title"), + ) if err != nil { - t.Errorf("Failed to create channel canvas with empty title: %v", err) + t.Errorf("Failed to create channel canvas with title: %v", err) return } From b4fa961ed022006cd1b59bce6e41b2322d9be51b Mon Sep 17 00:00:00 2001 From: Oleg Bespalov Date: Tue, 9 Sep 2025 13:34:27 +0200 Subject: [PATCH 448/606] feat: Align GetReactions with Slack API !BREAKING CHANGE Previously this method returned only reaction details, but with this change it started to also return the item itself (e.g. message, file, file_comment). This makes this closer to the actual response of Slack API. To migrate an existing code, you can just use resp.Reactions to access the slice of []ItemReaction. --- examples/reactions/reactions.go | 12 +- reactions.go | 38 ++++--- reactions_test.go | 189 ++++++++++++++++++++++++++------ 3 files changed, 186 insertions(+), 53 deletions(-) diff --git a/examples/reactions/reactions.go b/examples/reactions/reactions.go index 122dc2d74..ead72017b 100644 --- a/examples/reactions/reactions.go +++ b/examples/reactions/reactions.go @@ -77,14 +77,14 @@ func main() { } // Get all reactions on the message. - msgReactions, err := api.GetReactions(msgRef, slack.NewGetReactionsParameters()) + msgReactionsResp, err := api.GetReactions(msgRef, slack.NewGetReactionsParameters()) if err != nil { fmt.Printf("Error getting reactions: %s\n", err) return } fmt.Printf("\n") - fmt.Printf("%d reactions to message...\n", len(msgReactions)) - for _, r := range msgReactions { + fmt.Printf("%d reactions to message...\n", len(msgReactionsResp.Reactions)) + for _, r := range msgReactionsResp.Reactions { fmt.Printf(" %d users say %s\n", r.Count, r.Name) } @@ -111,14 +111,14 @@ func main() { } // Get all reactions on the message. - msgReactions, err = api.GetReactions(msgRef, slack.NewGetReactionsParameters()) + msgReactionsResp, err = api.GetReactions(msgRef, slack.NewGetReactionsParameters()) if err != nil { fmt.Printf("Error getting reactions: %s\n", err) return } fmt.Printf("\n") - fmt.Printf("%d reactions to message after removing cry...\n", len(msgReactions)) - for _, r := range msgReactions { + fmt.Printf("%d reactions to message after removing cry...\n", len(msgReactionsResp.Reactions)) + for _, r := range msgReactionsResp.Reactions { fmt.Printf(" %d users say %s\n", r.Count, r.Name) } } diff --git a/reactions.go b/reactions.go index 240f5ba92..6ed2fbbdd 100644 --- a/reactions.go +++ b/reactions.go @@ -35,27 +35,37 @@ func NewGetReactionsParameters() GetReactionsParameters { type getReactionsResponseFull struct { Type string M struct { - Reactions []ItemReaction + *Message // message structure already contains reactions } `json:"message"` F struct { + *File Reactions []ItemReaction } `json:"file"` FC struct { + *Comment Reactions []ItemReaction } `json:"comment"` SlackResponse } -func (res getReactionsResponseFull) extractReactions() []ItemReaction { - switch res.Type { +func (res getReactionsResponseFull) extractReactedItem() ReactedItem { + item := ReactedItem{} + item.Type = res.Type + + switch item.Type { case "message": - return res.M.Reactions + item.Channel = res.M.Channel + item.Message = res.M.Message + item.Reactions = res.M.Reactions case "file": - return res.F.Reactions + item.File = res.F.File + item.Reactions = res.F.Reactions case "file_comment": - return res.FC.Reactions + item.File = res.F.File + item.Comment = res.FC.Comment + item.Reactions = res.FC.Reactions } - return []ItemReaction{} + return item } const ( @@ -200,15 +210,15 @@ func (api *Client) RemoveReactionContext(ctx context.Context, name string, item return response.Err() } -// GetReactions returns details about the reactions on an item. +// GetReactions returns item and details about the reactions on an item. // For more details, see GetReactionsContext documentation. -func (api *Client) GetReactions(item ItemRef, params GetReactionsParameters) ([]ItemReaction, error) { +func (api *Client) GetReactions(item ItemRef, params GetReactionsParameters) (ReactedItem, error) { return api.GetReactionsContext(context.Background(), item, params) } -// GetReactionsContext returns details about the reactions on an item with a custom context. +// GetReactionsContext returns item and details about the reactions on an item with a custom context. // Slack API docs: https://api.slack.com/methods/reactions.get -func (api *Client) GetReactionsContext(ctx context.Context, item ItemRef, params GetReactionsParameters) ([]ItemReaction, error) { +func (api *Client) GetReactionsContext(ctx context.Context, item ItemRef, params GetReactionsParameters) (ReactedItem, error) { values := url.Values{ "token": {api.token}, } @@ -230,14 +240,14 @@ func (api *Client) GetReactionsContext(ctx context.Context, item ItemRef, params response := &getReactionsResponseFull{} if err := api.postMethod(ctx, "reactions.get", values, response); err != nil { - return nil, err + return ReactedItem{}, err } if err := response.Err(); err != nil { - return nil, err + return ReactedItem{}, err } - return response.extractReactions(), nil + return response.extractReactedItem(), nil } // ListReactions returns information about the items a user reacted to. diff --git a/reactions_test.go b/reactions_test.go index cf1ed42ed..f0f2c1fa8 100644 --- a/reactions_test.go +++ b/reactions_test.go @@ -139,11 +139,11 @@ func TestSlack_GetReactions(t *testing.T) { once.Do(startServer) api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) tests := []struct { - ref ItemRef - params GetReactionsParameters - wantParams map[string]string - json string - wantReactions []ItemReaction + ref ItemRef + params GetReactionsParameters + wantParams map[string]string + json string + wantReactedItem ReactedItem }{ { NewRefToMessage("ChannelID", "123"), @@ -153,24 +153,40 @@ func TestSlack_GetReactions(t *testing.T) { "timestamp": "123", }, `{"ok": true, - "type": "message", - "message": { - "reactions": [ - { - "name": "astonished", - "count": 3, - "users": [ "U1", "U2", "U3" ] - }, - { - "name": "clock1", - "count": 3, - "users": [ "U1", "U2" ] - } - ] - }}`, - []ItemReaction{ - {Name: "astonished", Count: 3, Users: []string{"U1", "U2", "U3"}}, - {Name: "clock1", Count: 3, Users: []string{"U1", "U2"}}, + "type": "message", + "message": { + "channel": "ChannelID", + "text": "lorem ipsum dolor sit amet", + "ts": "123", + "user": "U2147483828", + "reactions": [ + { + "name": "astonished", + "count": 3, + "users": [ "U1", "U2", "U3" ] + }, + { + "name": "clock1", + "count": 3, + "users": [ "U1", "U2" ] + } + ] + }}`, + ReactedItem{ + Item: Item{ + Type: "message", Message: &Message{ + Msg: Msg{ + Text: "lorem ipsum dolor sit amet", + Channel: "ChannelID", + User: "U2147483828", + Timestamp: "123", + }, + }, + }, + Reactions: []ItemReaction{ + {Name: "astonished", Count: 3, Users: []string{"U1", "U2", "U3"}}, + {Name: "clock1", Count: 3, Users: []string{"U1", "U2"}}, + }, }, }, { @@ -183,6 +199,19 @@ func TestSlack_GetReactions(t *testing.T) { `{"ok": true, "type": "file", "file": { + "id": "F0A12BCDE", + "created": 1531763342, + "timestamp": 1531763342, + "name": "tedair.gif", + "title": "tedair.gif", + "mimetype": "image/gif", + "filetype": "gif", + "pretty_type": "GIF", + "user": "U012A3BCD", + "editable": false, + "size": 137531, + "mode": "hosted", + "is_external": false, "reactions": [ { "name": "astonished", @@ -196,13 +225,25 @@ func TestSlack_GetReactions(t *testing.T) { } ] }}`, - []ItemReaction{ - {Name: "astonished", Count: 3, Users: []string{"U1", "U2", "U3"}}, - {Name: "clock1", Count: 3, Users: []string{"U1", "U2"}}, + ReactedItem{ + Item: Item{ + Type: "file", File: &File{ + Name: "tedair.gif", + ID: "F0A12BCDE", + Created: 1531763342, + Timestamp: 1531763342, + User: "U012A3BCD", + Editable: false, + Size: 137531, + }, + }, + Reactions: []ItemReaction{ + {Name: "astonished", Count: 3, Users: []string{"U1", "U2", "U3"}}, + {Name: "clock1", Count: 3, Users: []string{"U1", "U2"}}, + }, }, }, { - NewRefToComment("FileCommentID"), GetReactionsParameters{}, map[string]string{ @@ -210,8 +251,22 @@ func TestSlack_GetReactions(t *testing.T) { }, `{"ok": true, "type": "file_comment", - "file": {}, + "file": { + "id": "F0A12BCDE", + "created": 1531763342, + "timestamp": 1531763342, + "name": "tedair.gif", + "title": "tedair.gif", + "mimetype": "image/gif", + "filetype": "gif", + "pretty_type": "GIF", + "user": "U012A3BCD", + "editable": false, + "size": 137531, + "is_external": false + }, "comment": { + "comment": "lorem ipsum dolor sit amet comment", "reactions": [ { "name": "astonished", @@ -225,9 +280,19 @@ func TestSlack_GetReactions(t *testing.T) { } ] }}`, - []ItemReaction{ - {Name: "astonished", Count: 3, Users: []string{"U1", "U2", "U3"}}, - {Name: "clock1", Count: 3, Users: []string{"U1", "U2"}}, + ReactedItem{ + Item: Item{ + Type: "file_comment", File: &File{ + Name: "tedair.gif", + }, + Comment: &Comment{ + Comment: "lorem ipsum dolor sit amet comment", + }, + }, + Reactions: []ItemReaction{ + {Name: "astonished", Count: 3, Users: []string{"U1", "U2", "U3"}}, + {Name: "clock1", Count: 3, Users: []string{"U1", "U2"}}, + }, }, }, } @@ -240,12 +305,70 @@ func TestSlack_GetReactions(t *testing.T) { if err != nil { t.Fatalf("%d: Unexpected error: %s", i, err) } - if !reflect.DeepEqual(got, test.wantReactions) { - t.Errorf("%d: Got reaction %#v, want %#v", i, got, test.wantReactions) + if !reflect.DeepEqual(got.Reactions, test.wantReactedItem.Reactions) { + t.Errorf("%d: Got reaction %#v, want %#v", i, got.Reactions, test.wantReactedItem.Reactions) } if !reflect.DeepEqual(rh.gotParams, test.wantParams) { t.Errorf("%d: Got params %#v, want %#v", i, rh.gotParams, test.wantParams) } + + switch got.Type { + case "message": + if got.Message == nil { + t.Fatalf("%d: Got message %#v, want %#v", i, got.Message, test.wantReactedItem.Message) + } + + if got.Message.Text != test.wantReactedItem.Message.Text { + t.Errorf("%d: Got message text %#v, want %#v", i, got.Message.Text, test.wantReactedItem.Message.Text) + } + if got.Message.Channel != test.wantReactedItem.Message.Channel { + t.Errorf("%d: Got message channel %#v, want %#v", i, got.Message.Channel, test.wantReactedItem.Message.Channel) + } + if got.Message.User != test.wantReactedItem.Message.User { + t.Errorf("%d: Got message user %#v, want %#v", i, got.Message.User, test.wantReactedItem.Message.User) + } + if got.Message.Timestamp != test.wantReactedItem.Message.Timestamp { + t.Errorf("%d: Got message timestamp %#v, want %#v", i, got.Message.Timestamp, test.wantReactedItem.Message.Timestamp) + } + case "file": + if got.File == nil { + t.Fatalf("%d: Got file %#v, want %#v", i, got.File, test.wantReactedItem.File) + } + if got.File.Name != test.wantReactedItem.File.Name { + t.Errorf("%d: Got file name %#v, want %#v", i, got.File.Name, test.wantReactedItem.File.Name) + } + if got.File.ID != test.wantReactedItem.File.ID { + t.Errorf("%d: Got file ID %#v, want %#v", i, got.File.ID, test.wantReactedItem.File.ID) + } + if got.File.Created != test.wantReactedItem.File.Created { + t.Errorf("%d: Got file created %#v, want %#v", i, got.File.Created, test.wantReactedItem.File.Created) + } + if got.File.Timestamp != test.wantReactedItem.File.Timestamp { + t.Errorf("%d: Got file timestamp %#v, want %#v", i, got.File.Timestamp, test.wantReactedItem.File.Timestamp) + } + if got.File.User != test.wantReactedItem.File.User { + t.Errorf("%d: Got file user %#v, want %#v", i, got.File.User, test.wantReactedItem.File.User) + } + if got.File.Editable != test.wantReactedItem.File.Editable { + t.Errorf("%d: Got file editable %#v, want %#v", i, got.File.Editable, test.wantReactedItem.File.Editable) + } + if got.File.Size != test.wantReactedItem.File.Size { + t.Errorf("%d: Got file size %#v, want %#v", i, got.File.Size, test.wantReactedItem.File.Size) + } + case "file_comment": + if got.Comment == nil { + t.Fatalf("%d: Got comment %#v, want %#v", i, got.Comment, test.wantReactedItem.Comment) + } + if got.File == nil { + t.Fatalf("%d: Got file %#v, want %#v", i, got.File, test.wantReactedItem.File) + } + if got.File.Name != test.wantReactedItem.File.Name { + t.Errorf("%d: Got file name %#v, want %#v", i, got.File.Name, test.wantReactedItem.File.Name) + } + if got.Comment.Comment != test.wantReactedItem.Comment.Comment { + t.Errorf("%d: Got comment comment %#v, want %#v", i, got.Comment.Comment, test.wantReactedItem.Comment.Comment) + } + } } } From 5a5028986ee5d5441761a612ea23306cac944db5 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 25 Jan 2026 14:36:41 +0000 Subject: [PATCH 449/606] feat(reactions): align GetReactions with Slack API response I think the Slack API returns the channel field at the root level of the reactions.get response for message types, not inside the message object. --- examples/reactions/reactions.go | 46 ++++++++++++++------------------- reactions.go | 11 ++++---- reactions_test.go | 11 ++++---- slack.go | 10 +++---- 4 files changed, 37 insertions(+), 41 deletions(-) diff --git a/examples/reactions/reactions.go b/examples/reactions/reactions.go index ead72017b..89abe06e3 100644 --- a/examples/reactions/reactions.go +++ b/examples/reactions/reactions.go @@ -10,23 +10,21 @@ import ( func main() { debug := flag.Bool("debug", false, "Show JSON output") + channelID := flag.String("channel", "", "Channel ID (required)") flag.Parse() // Get token from environment variable - apiToken := os.Getenv("SLACK_BOT_TOKEN") + apiToken := os.Getenv("SLACK_USER_TOKEN") if apiToken == "" { - fmt.Println("SLACK_BOT_TOKEN environment variable is required") + fmt.Println("SLACK_USER_TOKEN environment variable is required") os.Exit(1) } api := slack.New(apiToken, slack.OptionDebug(*debug)) var ( - postAsUserName string - postAsUserID string - postToUserName string - postToUserID string - postToChannelID string + postAsUserName string + postAsUserID string ) // Find the user to post as. @@ -40,29 +38,19 @@ func main() { postAsUserName = authTest.User postAsUserID = authTest.UserID - // Posting to DM with self causes a conversation with slackbot. - postToUserName = authTest.User - postToUserID = authTest.UserID - - // Find the channel. - channel, _, _, err := api.OpenConversation(&slack.OpenConversationParameters{ChannelID: postToUserID}) - if err != nil { - fmt.Printf("Error opening IM: %s\n", err) - return - } - postToChannelID = channel.ID - - fmt.Printf("Posting as %s (%s) in DM with %s (%s), channel %s\n", postAsUserName, postAsUserID, postToUserName, postToUserID, postToChannelID) + fmt.Printf("Posting as %s (%s) in channel %s\n", postAsUserName, postAsUserID, *channelID) // Post a message. - channelID, timestamp, err := api.PostMessage(postToChannelID, slack.MsgOptionText("Is this any good?", false)) + _, timestamp, err := api.PostMessage(*channelID, slack.MsgOptionText("Is this any good?", false)) if err != nil { fmt.Printf("Error posting message: %s\n", err) return } - // Grab a reference to the message. - msgRef := slack.NewRefToMessage(channelID, timestamp) + // // Grab a reference to the message. + msgRef := slack.NewRefToMessage(*channelID, timestamp) + + fmt.Printf("Adding reaction to message with reference %v\n", msgRef) // React with :+1: if err = api.AddReaction("+1", msgRef); err != nil { @@ -85,13 +73,19 @@ func main() { fmt.Printf("\n") fmt.Printf("%d reactions to message...\n", len(msgReactionsResp.Reactions)) for _, r := range msgReactionsResp.Reactions { - fmt.Printf(" %d users say %s\n", r.Count, r.Name) + fmt.Printf(" %d users say %s in channel %s\n", r.Count, r.Name, msgReactionsResp.Item.Channel) } // List all of the users reactions. - listReactions, _, err := api.ListReactions(slack.NewListReactionsParameters()) + listParams := slack.NewListReactionsParameters() + fmt.Printf("Listing reactions with params: User=%q, TeamID=%q, Count=%d, Page=%d, Full=%v\n", + listParams.User, listParams.TeamID, listParams.Count, listParams.Page, listParams.Full) + listReactions, _, err := api.ListReactions(listParams) if err != nil { - fmt.Printf("Error listing reactions: %s\n", err) + fmt.Printf("Error listing reactions: %v\n", err) + if slackErr, ok := err.(slack.SlackErrorResponse); ok { + fmt.Printf(" ResponseMetadata.Messages: %v\n", slackErr.ResponseMetadata.Messages) + } return } fmt.Printf("\n") diff --git a/reactions.go b/reactions.go index 6ed2fbbdd..743b22a1f 100644 --- a/reactions.go +++ b/reactions.go @@ -33,8 +33,9 @@ func NewGetReactionsParameters() GetReactionsParameters { } type getReactionsResponseFull struct { - Type string - M struct { + Type string + Channel string `json:"channel,omitempty"` // channel is at the root level for message types + M struct { *Message // message structure already contains reactions } `json:"message"` F struct { @@ -54,7 +55,7 @@ func (res getReactionsResponseFull) extractReactedItem() ReactedItem { switch item.Type { case "message": - item.Channel = res.M.Channel + item.Channel = res.Channel item.Message = res.M.Message item.Reactions = res.M.Reactions case "file": @@ -234,7 +235,7 @@ func (api *Client) GetReactionsContext(ctx context.Context, item ItemRef, params if item.Comment != "" { values.Set("file_comment", item.Comment) } - if params.Full != DEFAULT_REACTIONS_FULL { + if params.Full { values.Set("full", strconv.FormatBool(params.Full)) } @@ -274,7 +275,7 @@ func (api *Client) ListReactionsContext(ctx context.Context, params ListReaction if params.Page != DEFAULT_REACTIONS_PAGE { values.Add("page", strconv.Itoa(params.Page)) } - if params.Full != DEFAULT_REACTIONS_FULL { + if params.Full { values.Add("full", strconv.FormatBool(params.Full)) } diff --git a/reactions_test.go b/reactions_test.go index f0f2c1fa8..9cec7e565 100644 --- a/reactions_test.go +++ b/reactions_test.go @@ -154,8 +154,8 @@ func TestSlack_GetReactions(t *testing.T) { }, `{"ok": true, "type": "message", + "channel": "ChannelID", "message": { - "channel": "ChannelID", "text": "lorem ipsum dolor sit amet", "ts": "123", "user": "U2147483828", @@ -174,10 +174,11 @@ func TestSlack_GetReactions(t *testing.T) { }}`, ReactedItem{ Item: Item{ - Type: "message", Message: &Message{ + Type: "message", + Channel: "ChannelID", + Message: &Message{ Msg: Msg{ Text: "lorem ipsum dolor sit amet", - Channel: "ChannelID", User: "U2147483828", Timestamp: "123", }, @@ -321,8 +322,8 @@ func TestSlack_GetReactions(t *testing.T) { if got.Message.Text != test.wantReactedItem.Message.Text { t.Errorf("%d: Got message text %#v, want %#v", i, got.Message.Text, test.wantReactedItem.Message.Text) } - if got.Message.Channel != test.wantReactedItem.Message.Channel { - t.Errorf("%d: Got message channel %#v, want %#v", i, got.Message.Channel, test.wantReactedItem.Message.Channel) + if got.Channel != test.wantReactedItem.Channel { + t.Errorf("%d: Got channel %#v, want %#v", i, got.Channel, test.wantReactedItem.Channel) } if got.Message.User != test.wantReactedItem.Message.User { t.Errorf("%d: Got message user %#v, want %#v", i, got.Message.User, test.wantReactedItem.Message.User) diff --git a/slack.go b/slack.go index 756106fe4..10873edfd 100644 --- a/slack.go +++ b/slack.go @@ -53,9 +53,9 @@ type authTestResponseFull struct { AuthTestResponse } -// Client for the slack api. type ParamOption func(*url.Values) +// Client for the slack api. type Client struct { token string appLevelToken string @@ -145,14 +145,14 @@ func (api *Client) AuthTestContext(ctx context.Context) (response *AuthTestRespo } // Debugf print a formatted debug line. -func (api *Client) Debugf(format string, v ...interface{}) { +func (api *Client) Debugf(format string, v ...any) { if api.debug { api.log.Output(2, fmt.Sprintf(format, v...)) } } // Debugln print a debug line. -func (api *Client) Debugln(v ...interface{}) { +func (api *Client) Debugln(v ...any) { if api.debug { api.log.Output(2, fmt.Sprintln(v...)) } @@ -164,11 +164,11 @@ func (api *Client) Debug() bool { } // post to a slack web method. -func (api *Client) postMethod(ctx context.Context, path string, values url.Values, intf interface{}) error { +func (api *Client) postMethod(ctx context.Context, path string, values url.Values, intf any) error { return postForm(ctx, api.httpclient, api.endpoint+path, values, intf, api) } // get a slack web method. -func (api *Client) getMethod(ctx context.Context, path string, token string, values url.Values, intf interface{}) error { +func (api *Client) getMethod(ctx context.Context, path string, token string, values url.Values, intf any) error { return getResource(ctx, api.httpclient, api.endpoint+path, token, values, intf, api) } From 5e4c0d3a7ce632e79666ae1501698e541a5aa081 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 25 Jan 2026 15:45:38 +0000 Subject: [PATCH 450/606] fix(views): don't send empty hash with PublishView Fixes #1462. --- views.go | 6 +++++- views_test.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/views.go b/views.go index 5f55b537a..94c7dae78 100644 --- a/views.go +++ b/views.go @@ -212,7 +212,11 @@ func (api *Client) OpenViewContext( // PublishView publishes a static view for a user. // For more information see the PublishViewContext documentation. func (api *Client) PublishView(userID string, view HomeTabViewRequest, hash string) (*ViewResponse, error) { - return api.PublishViewContext(context.Background(), PublishViewContextRequest{UserID: userID, View: view, Hash: &hash}) + var hashPtr *string + if hash != "" { + hashPtr = &hash + } + return api.PublishViewContext(context.Background(), PublishViewContextRequest{UserID: userID, View: view, Hash: hashPtr}) } // PublishViewContext publishes a static view for a user with a custom context. diff --git a/views_test.go b/views_test.go index c86c0a085..9d767f1ee 100644 --- a/views_test.go +++ b/views_test.go @@ -846,3 +846,52 @@ func TestSlack_ErrorsViewSubmissionResponse(t *testing.T) { assertViewSubmissionResponse(t, resp, rawResp) } + +func TestPublishViewContextRequest_HashOmittedWhenNil(t *testing.T) { + tests := []struct { + name string + hash *string + wantHashKey bool + wantHashVal string + }{ + { + name: "nil hash omits field", + hash: nil, + wantHashKey: false, + }, + { + name: "non-empty hash includes field", + hash: ptrString("156772938.1827394"), + wantHashKey: true, + wantHashVal: "156772938.1827394", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := PublishViewContextRequest{ + UserID: "U12345", + View: HomeTabViewRequest{Type: VTHomeTab}, + Hash: tt.hash, + } + + data, err := json.Marshal(req) + assert.NoError(t, err) + + var decoded map[string]any + err = json.Unmarshal(data, &decoded) + assert.NoError(t, err) + + _, hasHash := decoded["hash"] + assert.Equal(t, tt.wantHashKey, hasHash, "hash key presence mismatch") + + if tt.wantHashKey { + assert.Equal(t, tt.wantHashVal, decoded["hash"]) + } + }) + } +} + +func ptrString(s string) *string { + return &s +} From 203fe9d967a44d4d96bb2714077d7f174e38154e Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 25 Jan 2026 16:11:27 +0000 Subject: [PATCH 451/606] feat(remotefiles): allow user to define `PreviewImageName` We use `preview.jpg` as a fallback to ensure backwards compatibility. --- misc.go | 26 +++++++++++++------------- remotefiles.go | 23 +++++++++++++++++++---- remotefiles_test.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 17 deletions(-) diff --git a/misc.go b/misc.go index 586551cf0..da7a8484b 100644 --- a/misc.go +++ b/misc.go @@ -19,7 +19,7 @@ import ( "time" ) -// Apps Manifest Create Response Errors ("/apps.manifest.create") +// AppsManifestCreateResponseError ("/apps.manifest.create") type AppsManifestCreateResponseError struct { Code string `json:"code,omitempty"` Message string `json:"message"` @@ -27,7 +27,7 @@ type AppsManifestCreateResponseError struct { RelatedComponent string `json:"related_component,omitempty"` } -// Conversations Invite Response Errors ("/conversations.invite") +// ConversationsInviteResponseError ("/conversations.invite") type ConversationsInviteResponseError struct { Error string `json:"error"` Ok bool `json:"ok"` @@ -69,7 +69,7 @@ func (e *SlackResponseErrors) UnmarshalJSON(data []byte) error { } // Try to determine the error type by checking for unique fields - var raw map[string]interface{} + var raw map[string]any if err := json.Unmarshal(data, &raw); err != nil { // If we can't unmarshal as object, try as string (fallback case) // @@ -265,7 +265,7 @@ func formReq(ctx context.Context, endpoint string, values url.Values) (req *http return req, nil } -func jsonReq(ctx context.Context, endpoint string, body interface{}) (req *http.Request, err error) { +func jsonReq(ctx context.Context, endpoint string, body any) (req *http.Request, err error) { buffer := bytes.NewBuffer([]byte{}) if err = json.NewEncoder(buffer).Encode(body); err != nil { return nil, err @@ -279,7 +279,7 @@ func jsonReq(ctx context.Context, endpoint string, body interface{}) (req *http. return req, nil } -func postLocalWithMultipartResponse(ctx context.Context, client httpClient, method, fpath, fieldname, token string, values url.Values, intf interface{}, d Debug) error { +func postLocalWithMultipartResponse(ctx context.Context, client httpClient, method, fpath, fieldname, token string, values url.Values, intf any, d Debug) error { fullpath, err := filepath.Abs(fpath) if err != nil { return err @@ -293,7 +293,7 @@ func postLocalWithMultipartResponse(ctx context.Context, client httpClient, meth return postWithMultipartResponse(ctx, client, method, filepath.Base(fpath), fieldname, token, values, file, intf, d) } -func postWithMultipartResponse(ctx context.Context, client httpClient, path, name, fieldname, token string, values url.Values, r io.Reader, intf interface{}, d Debug) error { +func postWithMultipartResponse(ctx context.Context, client httpClient, path, name, fieldname, token string, values url.Values, r io.Reader, intf any, d Debug) error { pipeReader, pipeWriter := io.Pipe() wr := multipart.NewWriter(pipeWriter) @@ -378,7 +378,7 @@ func doPost(client httpClient, req *http.Request, parser responseParser, d Debug } // post JSON. -func postJSON(ctx context.Context, client httpClient, endpoint, token string, json []byte, intf interface{}, d Debug) error { +func postJSON(ctx context.Context, client httpClient, endpoint, token string, json []byte, intf any, d Debug) error { reqBody := bytes.NewBuffer(json) req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, reqBody) if err != nil { @@ -391,7 +391,7 @@ func postJSON(ctx context.Context, client httpClient, endpoint, token string, js } // post a url encoded form. -func postForm(ctx context.Context, client httpClient, endpoint string, values url.Values, intf interface{}, d Debug) error { +func postForm(ctx context.Context, client httpClient, endpoint string, values url.Values, intf any, d Debug) error { reqBody := strings.NewReader(values.Encode()) req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, reqBody) if err != nil { @@ -401,7 +401,7 @@ func postForm(ctx context.Context, client httpClient, endpoint string, values ur return doPost(client, req, newJSONParser(intf), d) } -func getResource(ctx context.Context, client httpClient, endpoint, token string, values url.Values, intf interface{}, d Debug) error { +func getResource(ctx context.Context, client httpClient, endpoint, token string, values url.Values, intf any, d Debug) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return err @@ -414,7 +414,7 @@ func getResource(ctx context.Context, client httpClient, endpoint, token string, return doPost(client, req, newJSONParser(intf), d) } -func parseAdminResponse(ctx context.Context, client httpClient, method string, teamName string, values url.Values, intf interface{}, d Debug) error { +func parseAdminResponse(ctx context.Context, client httpClient, method string, teamName string, values url.Values, intf any, d Debug) error { endpoint := fmt.Sprintf(WEBAPIURLFormat, teamName, method, time.Now().Unix()) return postForm(ctx, client, endpoint, values, intf, d) } @@ -459,7 +459,7 @@ func checkStatusCode(resp *http.Response, d Debug) error { type responseParser func(*http.Response) error -func newJSONParser(dst interface{}) responseParser { +func newJSONParser(dst any) responseParser { return func(resp *http.Response) error { if dst == nil { return nil @@ -468,7 +468,7 @@ func newJSONParser(dst interface{}) responseParser { } } -func newTextParser(dst interface{}) responseParser { +func newTextParser(dst any) responseParser { return func(resp *http.Response) error { if dst == nil { return nil @@ -487,7 +487,7 @@ func newTextParser(dst interface{}) responseParser { } } -func newContentTypeParser(dst interface{}) responseParser { +func newContentTypeParser(dst any) responseParser { return func(req *http.Response) (err error) { var ( ctype string diff --git a/remotefiles.go b/remotefiles.go index 42639a178..51d3e8565 100644 --- a/remotefiles.go +++ b/remotefiles.go @@ -56,6 +56,10 @@ type RemoteFile struct { // ExternalID is a user defined GUID, ExternalURL is where the remote file can be accessed, // and Title is the name of the file. // +// PreviewImage is a file path to upload as preview. PreviewImageReader is an io.Reader +// alternative. When using PreviewImageReader, set PreviewImageName to specify the filename +// with proper extension (e.g., "preview.jpg") to preserve image format. +// // For more details: // https://api.slack.com/methods/files.remote.add type RemoteFileParameters struct { @@ -66,6 +70,7 @@ type RemoteFileParameters struct { IndexableFileContents string PreviewImage string PreviewImageReader io.Reader + PreviewImageName string // filename for PreviewImageReader (e.g., "preview.jpg") } // ListRemoteFilesParameters contains arguments for the ListRemoteFiles method. @@ -124,7 +129,11 @@ func (api *Client) AddRemoteFileContext(ctx context.Context, params RemoteFilePa if params.PreviewImage != "" { err = postLocalWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.add", params.PreviewImage, "preview_image", api.token, values, response, api) } else if params.PreviewImageReader != nil { - err = postWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.add", "preview.png", "preview_image", api.token, values, params.PreviewImageReader, response, api) + name := params.PreviewImageName + if name == "" { + name = "preview.png" + } + err = postWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.add", name, "preview_image", api.token, values, params.PreviewImageReader, response, api) } else { response, err = api.remoteFileRequest(ctx, "files.remote.add", values) } @@ -214,7 +223,7 @@ func (api *Client) ShareRemoteFile(channels []string, externalID, fileID string) // ShareRemoteFileContext shares a remote file to channels with a custom context. // Slack API docs: https://api.slack.com/methods/files.remote.share func (api *Client) ShareRemoteFileContext(ctx context.Context, channels []string, externalID, fileID string) (file *RemoteFile, err error) { - if channels == nil || len(channels) == 0 { + if len(channels) == 0 { return nil, ErrParametersMissing } if fileID == "" && externalID == "" { @@ -266,8 +275,14 @@ func (api *Client) UpdateRemoteFileContext(ctx context.Context, fileID string, p if params.IndexableFileContents != "" { values.Add("indexable_file_contents", params.IndexableFileContents) } - if params.PreviewImageReader != nil { - err = postWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.update", "preview.png", "preview_image", api.token, values, params.PreviewImageReader, response, api) + if params.PreviewImage != "" { + err = postLocalWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.update", params.PreviewImage, "preview_image", api.token, values, response, api) + } else if params.PreviewImageReader != nil { + name := params.PreviewImageName + if name == "" { + name = "preview.png" + } + err = postWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.update", name, "preview_image", api.token, values, params.PreviewImageReader, response, api) } else { values.Add("token", api.token) response, err = api.remoteFileRequest(ctx, "files.remote.update", values) diff --git a/remotefiles_test.go b/remotefiles_test.go index 4744b5cbe..e4d9d794e 100644 --- a/remotefiles_test.go +++ b/remotefiles_test.go @@ -192,3 +192,31 @@ func TestRemoveRemoteFileWithFileIDAndExternalID(t *testing.T) { t.Errorf("Error message should mention don't providing both externalID and fileID") } } + +func TestAddRemoteFileWithPreviewImageReader(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + params := RemoteFileParameters{ + ExternalID: "externalID", + ExternalURL: "http://example.com/", + Title: "example", + PreviewImageReader: strings.NewReader("fake image data"), + PreviewImageName: "preview.jpg", + } + if _, err := api.AddRemoteFile(params); err != nil { + t.Errorf("Unexpected error: %s", err) + } +} + +func TestUpdateRemoteFileWithPreviewImageReader(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + params := RemoteFileParameters{ + Title: "updated example", + PreviewImageReader: strings.NewReader("fake image data"), + PreviewImageName: "preview.jpg", + } + if _, err := api.UpdateRemoteFile("fileID", params); err != nil { + t.Errorf("Unexpected error: %s", err) + } +} From 506f916f49d2e2738786bf86afcbfadbfc44ebdc Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 25 Jan 2026 16:25:31 +0000 Subject: [PATCH 452/606] feat(attachments): add ImageBytes, ImageHeight and ImageWidth --- attachments.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/attachments.go b/attachments.go index f4eb9b932..be04e9049 100644 --- a/attachments.go +++ b/attachments.go @@ -77,8 +77,11 @@ type Attachment struct { Pretext string `json:"pretext,omitempty"` Text string `json:"text,omitempty"` - ImageURL string `json:"image_url,omitempty"` - ThumbURL string `json:"thumb_url,omitempty"` + ImageURL string `json:"image_url,omitempty"` + ImageBytes int `json:"image_bytes,omitempty"` + ImageHeight int `json:"image_height,omitempty"` + ImageWidth int `json:"image_width,omitempty"` + ThumbURL string `json:"thumb_url,omitempty"` ServiceName string `json:"service_name,omitempty"` ServiceIcon string `json:"service_icon,omitempty"` From 4a35a725ae779b3cf4e4a69fbf08109086d6faaa Mon Sep 17 00:00:00 2001 From: mtintes Date: Tue, 30 Aug 2022 14:06:53 -0500 Subject: [PATCH 453/606] adds postephemeral handler to audit outgoing msgs --- slacktest/handlers.go | 92 ++++++++++++++++++++++++++++++++++++++ slacktest/handlers_test.go | 10 +++++ slacktest/server.go | 1 + 3 files changed, 103 insertions(+) diff --git a/slacktest/handlers.go b/slacktest/handlers.go index f0f66df14..030e0858e 100644 --- a/slacktest/handlers.go +++ b/slacktest/handlers.go @@ -221,6 +221,98 @@ func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(resp) } +// handle chat.postEphemeralMessage +func (sts *Server) postEphemeralHandler(w http.ResponseWriter, r *http.Request) { + serverAddr := r.Context().Value(ServerBotHubNameContextKey).(string) + data, err := ioutil.ReadAll(r.Body) + if err != nil { + msg := fmt.Sprintf("error reading body: %s", err.Error()) + log.Printf(msg) + http.Error(w, msg, http.StatusInternalServerError) + return + } + values, vErr := url.ParseQuery(string(data)) + if vErr != nil { + msg := fmt.Sprintf("Unable to decode query params: %s", vErr.Error()) + log.Printf(msg) + http.Error(w, msg, http.StatusInternalServerError) + return + } + + ts := time.Now().Unix() + resp := &struct { + Ok bool `json:"ok"` + Channel string `json:"channel"` + Ts string `json:"ts"` + Text string `json:"text"` + }{ + Ok: true, + Channel: values.Get("channel"), + Ts: fmt.Sprintf("%d", ts), + Text: values.Get("text"), + } + + m := slack.Message{} + m.Type = "message" + m.Channel = values.Get("channel") + m.Timestamp = fmt.Sprintf("%d", ts) + m.Text = values.Get("text") + m.ThreadTimestamp = values.Get("thread_ts") + m.User = values.Get("user") + if values.Get("as_user") != "true" { + m.Username = defaultNonBotUserName + } else { + m.Username = BotNameFromContext(r.Context()) + } + attachments := values.Get("attachments") + if attachments != "" { + decoded, err := url.QueryUnescape(attachments) + if err != nil { + msg := fmt.Sprintf("Unable to decode attachments: %s", err.Error()) + log.Printf(msg) + http.Error(w, msg, http.StatusInternalServerError) + return + } + var attaches []slack.Attachment + aJErr := json.Unmarshal([]byte(decoded), &attaches) + if aJErr != nil { + msg := fmt.Sprintf("Unable to decode attachments string to json: %s", aJErr.Error()) + log.Printf(msg) + http.Error(w, msg, http.StatusInternalServerError) + return + } + m.Attachments = attaches + } + blocks := values.Get("blocks") + if blocks != "" { + decoded, err := url.QueryUnescape(blocks) + if err != nil { + msg := fmt.Sprintf("Unable to decode blocks: %s", err.Error()) + log.Printf(msg) + http.Error(w, msg, http.StatusInternalServerError) + return + } + var decodedBlocks slack.Blocks + dbJErr := json.Unmarshal([]byte(decoded), &decodedBlocks) + if dbJErr != nil { + msg := fmt.Sprintf("Unable to decode blocks string to json: %s", dbJErr.Error()) + log.Printf(msg) + http.Error(w, msg, http.StatusInternalServerError) + return + } + m.Blocks = decodedBlocks + } + jsonMessage, jsonErr := json.Marshal(m) + if jsonErr != nil { + msg := fmt.Sprintf("Unable to marshal message: %s", jsonErr.Error()) + log.Printf(msg) + http.Error(w, msg, http.StatusInternalServerError) + return + } + go sts.queueForWebsocket(string(jsonMessage), serverAddr) + _ = json.NewEncoder(w).Encode(resp) +} + // RTMConnectHandler generates a valid connection func RTMConnectHandler(w http.ResponseWriter, r *http.Request) { _, err := io.ReadAll(r.Body) diff --git a/slacktest/handlers_test.go b/slacktest/handlers_test.go index 8ccb704cd..71cd5a6d7 100644 --- a/slacktest/handlers_test.go +++ b/slacktest/handlers_test.go @@ -32,6 +32,16 @@ func TestPostMessageHandler(t *testing.T) { assert.NotEmpty(t, tstamp, "timestamp should not be empty") } +func TestPostEphemeralHandler(t *testing.T) { + s := NewTestServer() + go s.Start() + + client := slack.New("ABCDEFG", slack.OptionAPIURL(s.GetAPIURL())) + tstamp, err := client.PostEphemeral("fake_channel", "fake_user", slack.MsgOptionText("some ephemeral text", false), slack.MsgOptionPostMessageParameters(slack.PostMessageParameters{})) + assert.NoError(t, err, "should not error out") + assert.NotEmpty(t, tstamp, "timestamp should not be empty") +} + func TestServerCreateConversationHandler(t *testing.T) { s := NewTestServer() go s.Start() diff --git a/slacktest/server.go b/slacktest/server.go index c7214505f..338290b86 100644 --- a/slacktest/server.go +++ b/slacktest/server.go @@ -50,6 +50,7 @@ func NewTestServer(custom ...Binder) *Server { s.Handle("/rtm.start", rtmStartHandler) s.Handle("/rtm.connect", RTMConnectHandler) s.Handle("/chat.postMessage", s.postMessageHandler) + s.Handle("/chat.postEphemeral", s.postEphemeralHandler) s.Handle("/conversations.create", createConversationHandler) s.Handle("/conversations.setTopic", setConversationTopicHandler) s.Handle("/conversations.setPurpose", setConversationPurposeHandler) From 671f04032711d4e2a0ba1bb80e6f2239e13b519a Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 25 Jan 2026 16:38:39 +0000 Subject: [PATCH 454/606] chore: fix import and a few log.Printfs Also test for the message coming out the other end of the websocket. --- slacktest/handlers.go | 21 ++++++++++----------- slacktest/handlers_test.go | 2 ++ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/slacktest/handlers.go b/slacktest/handlers.go index 030e0858e..7d5777689 100644 --- a/slacktest/handlers.go +++ b/slacktest/handlers.go @@ -221,20 +221,19 @@ func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(resp) } -// handle chat.postEphemeralMessage +// handle chat.postEphemeral func (sts *Server) postEphemeralHandler(w http.ResponseWriter, r *http.Request) { - serverAddr := r.Context().Value(ServerBotHubNameContextKey).(string) - data, err := ioutil.ReadAll(r.Body) + data, err := io.ReadAll(r.Body) if err != nil { msg := fmt.Sprintf("error reading body: %s", err.Error()) - log.Printf(msg) + log.Printf("%s", msg) http.Error(w, msg, http.StatusInternalServerError) return } values, vErr := url.ParseQuery(string(data)) if vErr != nil { msg := fmt.Sprintf("Unable to decode query params: %s", vErr.Error()) - log.Printf(msg) + log.Printf("%s", msg) http.Error(w, msg, http.StatusInternalServerError) return } @@ -269,7 +268,7 @@ func (sts *Server) postEphemeralHandler(w http.ResponseWriter, r *http.Request) decoded, err := url.QueryUnescape(attachments) if err != nil { msg := fmt.Sprintf("Unable to decode attachments: %s", err.Error()) - log.Printf(msg) + log.Printf("%s", msg) http.Error(w, msg, http.StatusInternalServerError) return } @@ -277,7 +276,7 @@ func (sts *Server) postEphemeralHandler(w http.ResponseWriter, r *http.Request) aJErr := json.Unmarshal([]byte(decoded), &attaches) if aJErr != nil { msg := fmt.Sprintf("Unable to decode attachments string to json: %s", aJErr.Error()) - log.Printf(msg) + log.Printf("%s", msg) http.Error(w, msg, http.StatusInternalServerError) return } @@ -288,7 +287,7 @@ func (sts *Server) postEphemeralHandler(w http.ResponseWriter, r *http.Request) decoded, err := url.QueryUnescape(blocks) if err != nil { msg := fmt.Sprintf("Unable to decode blocks: %s", err.Error()) - log.Printf(msg) + log.Printf("%s", msg) http.Error(w, msg, http.StatusInternalServerError) return } @@ -296,7 +295,7 @@ func (sts *Server) postEphemeralHandler(w http.ResponseWriter, r *http.Request) dbJErr := json.Unmarshal([]byte(decoded), &decodedBlocks) if dbJErr != nil { msg := fmt.Sprintf("Unable to decode blocks string to json: %s", dbJErr.Error()) - log.Printf(msg) + log.Printf("%s", msg) http.Error(w, msg, http.StatusInternalServerError) return } @@ -305,11 +304,11 @@ func (sts *Server) postEphemeralHandler(w http.ResponseWriter, r *http.Request) jsonMessage, jsonErr := json.Marshal(m) if jsonErr != nil { msg := fmt.Sprintf("Unable to marshal message: %s", jsonErr.Error()) - log.Printf(msg) + log.Printf("%s", msg) http.Error(w, msg, http.StatusInternalServerError) return } - go sts.queueForWebsocket(string(jsonMessage), serverAddr) + sts.SendToWebsocket(string(jsonMessage)) _ = json.NewEncoder(w).Encode(resp) } diff --git a/slacktest/handlers_test.go b/slacktest/handlers_test.go index 71cd5a6d7..f155d0154 100644 --- a/slacktest/handlers_test.go +++ b/slacktest/handlers_test.go @@ -40,6 +40,8 @@ func TestPostEphemeralHandler(t *testing.T) { tstamp, err := client.PostEphemeral("fake_channel", "fake_user", slack.MsgOptionText("some ephemeral text", false), slack.MsgOptionPostMessageParameters(slack.PostMessageParameters{})) assert.NoError(t, err, "should not error out") assert.NotEmpty(t, tstamp, "timestamp should not be empty") + + assert.True(t, s.SawOutgoingMessage("some ephemeral text")) } func TestServerCreateConversationHandler(t *testing.T) { From 8589ec72b163ab4cb3d7cb6fae001bb849faf12e Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Mon, 26 Jan 2026 22:07:54 +0000 Subject: [PATCH 455/606] docs: add changelog --- CHANGELOG.md | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..7b52b7f57 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,86 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- **Chat Streaming API support** - New streaming API for real-time chat interactions + with example usage. ([#1506]) +- **Data Access API support** - Full support for Slack's Data Access API with + example implementation. ([#1439]) +- **Cursor-based pagination for `GetUsers`** - More efficient user retrieval + with cursor pagination. ([#1465]) +- **`GetAllConversations` with pagination** - Retrieve all conversations with + automatic pagination handling, including rate limit and server error handling. ([#1463]) +- **Table blocks support** - Parse and create table blocks with proper + unmarshaling. ([#1490], [#1511]) +- **Context actions block support** - New `context_actions` block type. ([#1495]) +- **Workflow button block element** - Support for `workflow_button` in block + elements. ([#1499]) +- **`loading_messages` parameter for `SetAssistantThreadsStatus`** - Optional + parameter to customize loading state messages. ([#1489]) +- **Attachment image fields** - Added `ImageBytes`, `ImageHeight`, and `ImageWidth` + fields to attachments. ([#1516]) +- **`RecordChannel` to conversation properties** - New property for conversation + metadata. ([#1513]) +- **Title argument for `CreateChannelCanvas`** - Canvas creation now supports + custom titles. ([#1483]) +- **`PostEphemeral` handler for slacktest** - Audit outgoing ephemeral messages + in test environments. ([#1517]) +- **`PreviewImageName` for remote files** - Customize preview image filename + instead of using the default `preview.jpg`. + +### Fixed + +- **`PublishView` no longer sends empty hash** - Prevents unnecessary payload + when hash is empty. ([#1515]) +- **`ImageBlockElement` validation** - Now properly validates that either + `imageURL` or `SlackFile` is provided. ([#1488]) +- **Rich text section channel return** - Correctly returns channel for section + channel rich text elements. ([#1472]) +- **`KickUserFromConversation` error handling** - Errors are now properly parsed + as a map structure. ([#1471]) + +### Changed + +- **BREAKING**: `GetReactions` now returns `ReactedItem` instead of `[]ItemReaction`. + This aligns the response with the actual Slack API, which includes the item itself + (message, file, or file_comment) alongside reactions. To migrate, use `resp.Reactions` + to access the slice of reactions. ([#1480]) +- **BREAKING**: `Settings` struct fields `Interactivity` and `EventSubscriptions` + are now pointers, allowing them to be omitted when empty. ([#1461]) +- Minimum Go version bumped to 1.24. ([#1504]) + +## [0.17.3] - 2025-07-04 + +Previous release. See [GitHub releases](https://github.com/slack-go/slack/releases/tag/v0.17.3) +for details. + +[#1439]: https://github.com/slack-go/slack/pull/1439 +[#1461]: https://github.com/slack-go/slack/pull/1461 +[#1463]: https://github.com/slack-go/slack/pull/1463 +[#1465]: https://github.com/slack-go/slack/pull/1465 +[#1471]: https://github.com/slack-go/slack/pull/1471 +[#1472]: https://github.com/slack-go/slack/pull/1472 +[#1480]: https://github.com/slack-go/slack/pull/1480 +[#1483]: https://github.com/slack-go/slack/pull/1483 +[#1488]: https://github.com/slack-go/slack/pull/1488 +[#1489]: https://github.com/slack-go/slack/pull/1489 +[#1490]: https://github.com/slack-go/slack/pull/1490 +[#1495]: https://github.com/slack-go/slack/pull/1495 +[#1499]: https://github.com/slack-go/slack/pull/1499 +[#1504]: https://github.com/slack-go/slack/pull/1504 +[#1506]: https://github.com/slack-go/slack/pull/1506 +[#1511]: https://github.com/slack-go/slack/pull/1511 +[#1513]: https://github.com/slack-go/slack/pull/1513 +[#1515]: https://github.com/slack-go/slack/pull/1515 +[#1516]: https://github.com/slack-go/slack/pull/1516 +[#1517]: https://github.com/slack-go/slack/pull/1517 + +[Unreleased]: https://github.com/slack-go/slack/compare/v0.17.3...HEAD +[0.17.3]: https://github.com/slack-go/slack/releases/tag/v0.17.3 From c641d27c9fd10d4aa1be0b64b0bee7c15f1cec84 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Mon, 26 Jan 2026 22:37:37 +0000 Subject: [PATCH 456/606] feat(block_call): add call block data to CallBlock Closes #897. --- block_call.go | 68 +++++++++++++- block_call_test.go | 228 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 289 insertions(+), 7 deletions(-) diff --git a/block_call.go b/block_call.go index c81dcb8be..724d0060e 100644 --- a/block_call.go +++ b/block_call.go @@ -7,6 +7,54 @@ type CallBlock struct { Type MessageBlockType `json:"type"` BlockID string `json:"block_id,omitempty"` CallID string `json:"call_id"` + // Call is populated by Slack when retrieving messages containing a call block. + // When creating a call block to post, only CallID is required. + // Note: The structure differs from the Call type used in API responses. + Call *CallBlockData `json:"call,omitempty"` + APIDecorationAvailable bool `json:"api_decoration_available,omitempty"` +} + +// CallBlockData represents the call data structure as it appears in CallBlocks. +// This differs from the Call type used in API responses - CallBlock data is nested under V1. +type CallBlockData struct { + V1 *CallBlockDataV1 `json:"v1,omitempty"` + MediaBackendType string `json:"media_backend_type,omitempty"` +} + +// CallBlockDataV1 contains the actual call information within a CallBlock. +type CallBlockDataV1 struct { + ID string `json:"id"` + AppID string `json:"app_id,omitempty"` + AppIconURLs *CallBlockIconURLs `json:"app_icon_urls,omitempty"` + DateStart int64 `json:"date_start"` + DateEnd int64 `json:"date_end"` + ActiveParticipants []CallParticipant `json:"active_participants,omitempty"` + AllParticipants []CallParticipant `json:"all_participants,omitempty"` + DisplayID string `json:"display_id,omitempty"` + JoinURL string `json:"join_url,omitempty"` + DesktopAppJoinURL string `json:"desktop_app_join_url,omitempty"` + Name string `json:"name,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + Channels []string `json:"channels,omitempty"` + IsDMCall bool `json:"is_dm_call"` + WasRejected bool `json:"was_rejected"` + WasMissed bool `json:"was_missed"` + WasAccepted bool `json:"was_accepted"` + HasEnded bool `json:"has_ended"` +} + +// CallBlockIconURLs contains app icon URLs at various sizes for a call integration. +type CallBlockIconURLs struct { + Image32 string `json:"image_32,omitempty"` + Image36 string `json:"image_36,omitempty"` + Image48 string `json:"image_48,omitempty"` + Image64 string `json:"image_64,omitempty"` + Image72 string `json:"image_72,omitempty"` + Image96 string `json:"image_96,omitempty"` + Image128 string `json:"image_128,omitempty"` + Image192 string `json:"image_192,omitempty"` + Image512 string `json:"image_512,omitempty"` + Image1024 string `json:"image_1024,omitempty"` } // BlockType returns the type of the block @@ -19,10 +67,26 @@ func (s CallBlock) ID() string { return s.BlockID } +// CallBlockOption allows configuration of options for a new call block +type CallBlockOption func(*CallBlock) + +// CallBlockOptionBlockID sets the block_id for the call block +func CallBlockOptionBlockID(blockID string) CallBlockOption { + return func(block *CallBlock) { + block.BlockID = blockID + } +} + // NewCallBlock returns a new instance of a call block -func NewCallBlock(callID string) *CallBlock { - return &CallBlock{ +func NewCallBlock(callID string, options ...CallBlockOption) *CallBlock { + block := &CallBlock{ Type: MBTCall, CallID: callID, } + + for _, option := range options { + option(block) + } + + return block } diff --git a/block_call_test.go b/block_call_test.go index 4966e3dc1..8e92ce6fe 100644 --- a/block_call_test.go +++ b/block_call_test.go @@ -1,17 +1,235 @@ package slack import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewCallBlock(t *testing.T) { callBlock := NewCallBlock("ACallID") - assert.Equal(t, callBlock.BlockType(), MBTCall) - assert.Equal(t, string(callBlock.Type), "call") - assert.Equal(t, callBlock.CallID, "ACallID") - assert.Equal(t, callBlock.BlockID, "") - assert.Equal(t, callBlock.ID(), "") + assert.Equal(t, MBTCall, callBlock.BlockType()) + assert.Equal(t, "call", string(callBlock.Type)) + assert.Equal(t, "ACallID", callBlock.CallID) + assert.Equal(t, "", callBlock.BlockID) + assert.Equal(t, "", callBlock.ID()) +} + +func TestNewCallBlockWithBlockID(t *testing.T) { + callBlock := NewCallBlock("ACallID", CallBlockOptionBlockID("block-123")) + + assert.Equal(t, MBTCall, callBlock.BlockType()) + assert.Equal(t, "ACallID", callBlock.CallID) + assert.Equal(t, "block-123", callBlock.BlockID) + assert.Equal(t, "block-123", callBlock.ID()) +} + +func TestCallBlockJSONRoundTrip(t *testing.T) { + // Create block with call data using the V1 structure + callBlock := NewCallBlock("R123", CallBlockOptionBlockID("block-1")) + callBlock.Call = &CallBlockData{ + V1: &CallBlockDataV1{ + ID: "R123", + Name: "Team Standup", + JoinURL: "https://example.com/call", + }, + MediaBackendType: "platform_call", + } + + // Marshal to JSON + data, err := json.Marshal(callBlock) + require.NoError(t, err) + + // Verify expected JSON structure + var jsonMap map[string]any + err = json.Unmarshal(data, &jsonMap) + require.NoError(t, err) + assert.Equal(t, "call", jsonMap["type"]) + assert.Equal(t, "R123", jsonMap["call_id"]) + assert.Equal(t, "block-1", jsonMap["block_id"]) + + // Verify nested structure + callData, ok := jsonMap["call"].(map[string]any) + require.True(t, ok, "call should be a map") + assert.Equal(t, "platform_call", callData["media_backend_type"]) + v1Data, ok := callData["v1"].(map[string]any) + require.True(t, ok, "v1 should be a map") + assert.Equal(t, "R123", v1Data["id"]) + + // Unmarshal back + var parsed CallBlock + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + assert.Equal(t, MBTCall, parsed.Type) + assert.Equal(t, callBlock.CallID, parsed.CallID) + assert.Equal(t, callBlock.BlockID, parsed.BlockID) + require.NotNil(t, parsed.Call.V1) + assert.Equal(t, callBlock.Call.V1.ID, parsed.Call.V1.ID) + assert.Equal(t, callBlock.Call.V1.Name, parsed.Call.V1.Name) + assert.Equal(t, callBlock.Call.V1.JoinURL, parsed.Call.V1.JoinURL) + assert.Equal(t, callBlock.Call.MediaBackendType, parsed.Call.MediaBackendType) +} + +func TestCallBlockInBlocks(t *testing.T) { + // Test that call block can be unmarshalled as part of a Blocks collection + // Using the actual Slack structure with v1 wrapper + jsonData := []byte(`[ + { + "type": "call", + "block_id": "call-block-1", + "call_id": "R123456", + "api_decoration_available": false, + "call": { + "v1": { + "id": "R123456", + "name": "Team Standup", + "join_url": "https://example.com/join/123", + "desktop_app_join_url": "slack://call/123", + "date_start": 1769457524, + "date_end": 0, + "is_dm_call": false, + "was_rejected": false, + "was_missed": false, + "was_accepted": false, + "has_ended": false + }, + "media_backend_type": "platform_call" + } + } + ]`) + + var blocks Blocks + err := json.Unmarshal(jsonData, &blocks) + require.NoError(t, err) + require.Len(t, blocks.BlockSet, 1) + + assert.Equal(t, MBTCall, blocks.BlockSet[0].BlockType()) + assert.Equal(t, "call-block-1", blocks.BlockSet[0].ID()) + + callBlock, ok := blocks.BlockSet[0].(*CallBlock) + require.True(t, ok, "expected *CallBlock, got %T", blocks.BlockSet[0]) + assert.Equal(t, "R123456", callBlock.CallID) + assert.False(t, callBlock.APIDecorationAvailable) + assert.Equal(t, "platform_call", callBlock.Call.MediaBackendType) + require.NotNil(t, callBlock.Call.V1) + assert.Equal(t, "R123456", callBlock.Call.V1.ID) + assert.Equal(t, "Team Standup", callBlock.Call.V1.Name) + assert.Equal(t, "https://example.com/join/123", callBlock.Call.V1.JoinURL) + assert.Equal(t, "slack://call/123", callBlock.Call.V1.DesktopAppJoinURL) + assert.Equal(t, int64(1769457524), callBlock.Call.V1.DateStart) + assert.False(t, callBlock.Call.V1.HasEnded) +} + +func TestCallBlockWithParticipants(t *testing.T) { + jsonData := []byte(`{ + "type": "call", + "call_id": "R789", + "call": { + "v1": { + "id": "R789", + "name": "Design Review", + "date_start": 0, + "date_end": 0, + "active_participants": [ + {"slack_id": "U123", "display_name": "Alice"}, + {"slack_id": "U456", "display_name": "Bob"} + ], + "all_participants": [ + {"slack_id": "U123", "display_name": "Alice"}, + {"slack_id": "U456", "display_name": "Bob"}, + {"slack_id": "U789", "display_name": "Charlie"} + ], + "is_dm_call": false, + "was_rejected": false, + "was_missed": false, + "was_accepted": false, + "has_ended": false + } + } + }`) + + var callBlock CallBlock + err := json.Unmarshal(jsonData, &callBlock) + require.NoError(t, err) + + assert.Equal(t, "R789", callBlock.CallID) + require.NotNil(t, callBlock.Call.V1) + assert.Equal(t, "Design Review", callBlock.Call.V1.Name) + require.Len(t, callBlock.Call.V1.ActiveParticipants, 2) + assert.Equal(t, "U123", callBlock.Call.V1.ActiveParticipants[0].SlackID) + assert.Equal(t, "Alice", callBlock.Call.V1.ActiveParticipants[0].DisplayName) + assert.Equal(t, "U456", callBlock.Call.V1.ActiveParticipants[1].SlackID) + assert.Equal(t, "Bob", callBlock.Call.V1.ActiveParticipants[1].DisplayName) + + require.Len(t, callBlock.Call.V1.AllParticipants, 3) + assert.Equal(t, "U789", callBlock.Call.V1.AllParticipants[2].SlackID) + assert.Equal(t, "Charlie", callBlock.Call.V1.AllParticipants[2].DisplayName) +} + +func TestCallBlockWithAppIcons(t *testing.T) { + // Test parsing of app icon URLs as seen in real Zoom integration + jsonData := []byte(`{ + "type": "call", + "call_id": "R0ABF31RWGH", + "block_id": "+cgoe", + "api_decoration_available": false, + "call": { + "v1": { + "id": "R0ABF31RWGH", + "app_id": "A5GE9BMQC", + "app_icon_urls": { + "image_32": "https://example.com/icon_32.png", + "image_48": "https://example.com/icon_48.png", + "image_72": "https://example.com/icon_72.png", + "image_192": "https://example.com/icon_192.png" + }, + "date_start": 1769457524, + "date_end": 0, + "display_id": "863-5835-0956", + "join_url": "https://zoom.us/j/123", + "desktop_app_join_url": "zoommtg://zoom.us/join?confno=123", + "name": "Zoom meeting started by user", + "created_by": "U0ABF1CJPG9", + "channels": ["C0AACTVQ2EB"], + "is_dm_call": false, + "was_rejected": false, + "was_missed": false, + "was_accepted": false, + "has_ended": false + }, + "media_backend_type": "platform_call" + } + }`) + + var callBlock CallBlock + err := json.Unmarshal(jsonData, &callBlock) + require.NoError(t, err) + + assert.Equal(t, "R0ABF31RWGH", callBlock.CallID) + assert.Equal(t, "+cgoe", callBlock.BlockID) + assert.False(t, callBlock.APIDecorationAvailable) + assert.Equal(t, "platform_call", callBlock.Call.MediaBackendType) + + require.NotNil(t, callBlock.Call.V1) + v1 := callBlock.Call.V1 + assert.Equal(t, "R0ABF31RWGH", v1.ID) + assert.Equal(t, "A5GE9BMQC", v1.AppID) + assert.Equal(t, "863-5835-0956", v1.DisplayID) + assert.Equal(t, "Zoom meeting started by user", v1.Name) + assert.Equal(t, "U0ABF1CJPG9", v1.CreatedBy) + assert.Equal(t, int64(1769457524), v1.DateStart) + assert.Equal(t, int64(0), v1.DateEnd) + assert.False(t, v1.HasEnded) + require.Len(t, v1.Channels, 1) + assert.Equal(t, "C0AACTVQ2EB", v1.Channels[0]) + + require.NotNil(t, v1.AppIconURLs) + assert.Equal(t, "https://example.com/icon_32.png", v1.AppIconURLs.Image32) + assert.Equal(t, "https://example.com/icon_48.png", v1.AppIconURLs.Image48) + assert.Equal(t, "https://example.com/icon_72.png", v1.AppIconURLs.Image72) + assert.Equal(t, "https://example.com/icon_192.png", v1.AppIconURLs.Image192) } From 775ecac8b82dad05a466221cc7486ac376957d92 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Mon, 26 Jan 2026 22:41:13 +0000 Subject: [PATCH 457/606] feat(block_call): add image_original to CallBlockIconURLs --- block_call.go | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/block_call.go b/block_call.go index 724d0060e..621a8b644 100644 --- a/block_call.go +++ b/block_call.go @@ -45,16 +45,17 @@ type CallBlockDataV1 struct { // CallBlockIconURLs contains app icon URLs at various sizes for a call integration. type CallBlockIconURLs struct { - Image32 string `json:"image_32,omitempty"` - Image36 string `json:"image_36,omitempty"` - Image48 string `json:"image_48,omitempty"` - Image64 string `json:"image_64,omitempty"` - Image72 string `json:"image_72,omitempty"` - Image96 string `json:"image_96,omitempty"` - Image128 string `json:"image_128,omitempty"` - Image192 string `json:"image_192,omitempty"` - Image512 string `json:"image_512,omitempty"` - Image1024 string `json:"image_1024,omitempty"` + Image32 string `json:"image_32,omitempty"` + Image36 string `json:"image_36,omitempty"` + Image48 string `json:"image_48,omitempty"` + Image64 string `json:"image_64,omitempty"` + Image72 string `json:"image_72,omitempty"` + Image96 string `json:"image_96,omitempty"` + Image128 string `json:"image_128,omitempty"` + Image192 string `json:"image_192,omitempty"` + Image512 string `json:"image_512,omitempty"` + Image1024 string `json:"image_1024,omitempty"` + ImageOriginal string `json:"image_original,omitempty"` } // BlockType returns the type of the block From 6833e86b80f2d2f5684ac91b144e7a263d3993bb Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Mon, 26 Jan 2026 23:21:05 +0000 Subject: [PATCH 458/606] feat: add huddle information --- huddle.go | 64 +++++++++++++ huddle_test.go | 174 ++++++++++++++++++++++++++++++++++++ slackevents/inner_events.go | 5 ++ 3 files changed, 243 insertions(+) create mode 100644 huddle.go create mode 100644 huddle_test.go diff --git a/huddle.go b/huddle.go new file mode 100644 index 000000000..701236471 --- /dev/null +++ b/huddle.go @@ -0,0 +1,64 @@ +package slack + +// HuddleRoom represents a Slack huddle room as it appears in message events +// with subtype "huddle_thread". This is different from CallBlock which is used +// for external call integrations (Zoom, etc.). +type HuddleRoom struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + MediaServer string `json:"media_server,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + DateStart int64 `json:"date_start"` + DateEnd int64 `json:"date_end"` + Participants []string `json:"participants,omitempty"` + ParticipantHistory []string `json:"participant_history,omitempty"` + ParticipantsEvents map[string]HuddleParticipantEvent `json:"participants_events,omitempty"` + ParticipantsCameraOn []string `json:"participants_camera_on,omitempty"` + ParticipantsCameraOff []string `json:"participants_camera_off,omitempty"` + ParticipantsScreenshareOn []string `json:"participants_screenshare_on,omitempty"` + ParticipantsScreenshareOff []string `json:"participants_screenshare_off,omitempty"` + CanvasThreadTs string `json:"canvas_thread_ts,omitempty"` + ThreadRootTs string `json:"thread_root_ts,omitempty"` + Channels []string `json:"channels,omitempty"` + IsDMCall bool `json:"is_dm_call"` + WasRejected bool `json:"was_rejected"` + WasMissed bool `json:"was_missed"` + WasAccepted bool `json:"was_accepted"` + HasEnded bool `json:"has_ended"` + BackgroundID string `json:"background_id,omitempty"` + CanvasBackground string `json:"canvas_background,omitempty"` + IsPrewarmed bool `json:"is_prewarmed"` + IsScheduled bool `json:"is_scheduled"` + Recording *HuddleRecording `json:"recording,omitempty"` + Locale string `json:"locale,omitempty"` + AttachedFileIDs []string `json:"attached_file_ids,omitempty"` + MediaBackendType string `json:"media_backend_type,omitempty"` + DisplayID string `json:"display_id,omitempty"` + ExternalUniqueID string `json:"external_unique_id,omitempty"` + AppID string `json:"app_id,omitempty"` + CallFamily string `json:"call_family,omitempty"` + PendingInvitees map[string]any `json:"pending_invitees,omitempty"` + LastInviteStatusByUser map[string]any `json:"last_invite_status_by_user,omitempty"` + Knocks map[string]any `json:"knocks,omitempty"` + HuddleLink string `json:"huddle_link,omitempty"` +} + +// HuddleParticipantEvent tracks a participant's activity in a huddle. +type HuddleParticipantEvent struct { + UserTeam map[string]any `json:"user_team,omitempty"` + Joined bool `json:"joined"` + CameraOn bool `json:"camera_on"` + CameraOff bool `json:"camera_off"` + ScreenshareOn bool `json:"screenshare_on"` + ScreenshareOff bool `json:"screenshare_off"` +} + +// HuddleRecording contains recording status for a huddle. +type HuddleRecording struct { + CanRecordSummary string `json:"can_record_summary,omitempty"` + NoteTaking bool `json:"note_taking,omitempty"` + Summary bool `json:"summary,omitempty"` + SummaryStatus string `json:"summary_status,omitempty"` + Transcript bool `json:"transcript,omitempty"` + RecordingUser string `json:"recording_user,omitempty"` +} diff --git a/huddle_test.go b/huddle_test.go new file mode 100644 index 000000000..92bf37369 --- /dev/null +++ b/huddle_test.go @@ -0,0 +1,174 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHuddleRoomUnmarshal(t *testing.T) { + jsonData := []byte(`{ + "id": "R0AAZKMD88M", + "name": "", + "media_server": "", + "created_by": "U031L4VDD", + "date_start": 1769466090, + "date_end": 0, + "participants": [], + "participant_history": ["U031L4VDD"], + "participants_events": { + "U031L4VDD": { + "user_team": {}, + "joined": true, + "camera_on": false, + "camera_off": false, + "screenshare_on": false, + "screenshare_off": false + } + }, + "participants_camera_on": [], + "participants_camera_off": [], + "participants_screenshare_on": [], + "participants_screenshare_off": [], + "canvas_thread_ts": "1769466090.342109", + "thread_root_ts": "1769466090.342109", + "channels": ["D0QJK3LDA"], + "is_dm_call": true, + "was_rejected": false, + "was_missed": false, + "was_accepted": false, + "has_ended": false, + "background_id": "GRADIENT_03", + "canvas_background": "GRADIENT_03", + "is_prewarmed": false, + "is_scheduled": false, + "recording": { + "can_record_summary": "unavailable" + }, + "locale": "en-US", + "attached_file_ids": [], + "media_backend_type": "free_willy", + "display_id": "", + "external_unique_id": "7069679a-3cb6-4622-a900-51b8d2ff2713", + "app_id": "A00", + "call_family": "huddle", + "pending_invitees": {}, + "last_invite_status_by_user": {}, + "knocks": {}, + "huddle_link": "https://app.slack.com/huddle/T031L4VD9/D0QJK3LDA" + }`) + + var room HuddleRoom + err := json.Unmarshal(jsonData, &room) + require.NoError(t, err) + + assert.Equal(t, "R0AAZKMD88M", room.ID) + assert.Equal(t, "U031L4VDD", room.CreatedBy) + assert.Equal(t, int64(1769466090), room.DateStart) + assert.Equal(t, int64(0), room.DateEnd) + assert.True(t, room.IsDMCall) + assert.False(t, room.HasEnded) + assert.Equal(t, "GRADIENT_03", room.BackgroundID) + assert.Equal(t, "GRADIENT_03", room.CanvasBackground) + assert.Equal(t, "free_willy", room.MediaBackendType) + assert.Equal(t, "huddle", room.CallFamily) + assert.Equal(t, "https://app.slack.com/huddle/T031L4VD9/D0QJK3LDA", room.HuddleLink) + assert.Equal(t, "1769466090.342109", room.CanvasThreadTs) + assert.Equal(t, "1769466090.342109", room.ThreadRootTs) + assert.Equal(t, "en-US", room.Locale) + + require.Len(t, room.Channels, 1) + assert.Equal(t, "D0QJK3LDA", room.Channels[0]) + + require.Len(t, room.ParticipantHistory, 1) + assert.Equal(t, "U031L4VDD", room.ParticipantHistory[0]) + + require.NotNil(t, room.Recording) + assert.Equal(t, "unavailable", room.Recording.CanRecordSummary) + + require.Contains(t, room.ParticipantsEvents, "U031L4VDD") + pe := room.ParticipantsEvents["U031L4VDD"] + assert.True(t, pe.Joined) + assert.False(t, pe.CameraOn) + assert.False(t, pe.ScreenshareOn) +} + +func TestHuddleRoomWithActiveParticipants(t *testing.T) { + jsonData := []byte(`{ + "id": "R123", + "date_start": 1769466090, + "date_end": 0, + "participants": ["U001", "U002"], + "participant_history": ["U001", "U002", "U003"], + "participants_events": { + "U001": {"joined": true, "camera_on": true, "camera_off": false, "screenshare_on": false, "screenshare_off": false}, + "U002": {"joined": true, "camera_on": false, "camera_off": false, "screenshare_on": true, "screenshare_off": false} + }, + "participants_camera_on": ["U001"], + "participants_screenshare_on": ["U002"], + "is_dm_call": false, + "was_rejected": false, + "was_missed": false, + "was_accepted": true, + "has_ended": false, + "call_family": "huddle" + }`) + + var room HuddleRoom + err := json.Unmarshal(jsonData, &room) + require.NoError(t, err) + + assert.Equal(t, "R123", room.ID) + require.Len(t, room.Participants, 2) + assert.Equal(t, "U001", room.Participants[0]) + assert.Equal(t, "U002", room.Participants[1]) + + require.Len(t, room.ParticipantHistory, 3) + + require.Len(t, room.ParticipantsCameraOn, 1) + assert.Equal(t, "U001", room.ParticipantsCameraOn[0]) + + require.Len(t, room.ParticipantsScreenshareOn, 1) + assert.Equal(t, "U002", room.ParticipantsScreenshareOn[0]) + + assert.True(t, room.WasAccepted) + assert.False(t, room.IsDMCall) + + // Check participant events + u1 := room.ParticipantsEvents["U001"] + assert.True(t, u1.Joined) + assert.True(t, u1.CameraOn) + + u2 := room.ParticipantsEvents["U002"] + assert.True(t, u2.Joined) + assert.True(t, u2.ScreenshareOn) +} + +func TestHuddleRoomEndedState(t *testing.T) { + jsonData := []byte(`{ + "id": "R456", + "date_start": 1769454922, + "date_end": 1769455026, + "participants": [], + "participant_history": ["U031L4VDD"], + "is_dm_call": false, + "was_rejected": false, + "was_missed": false, + "was_accepted": false, + "has_ended": true, + "call_family": "huddle" + }`) + + var room HuddleRoom + err := json.Unmarshal(jsonData, &room) + require.NoError(t, err) + + assert.Equal(t, "R456", room.ID) + assert.Equal(t, int64(1769454922), room.DateStart) + assert.Equal(t, int64(1769455026), room.DateEnd) + assert.True(t, room.HasEnded) + assert.Empty(t, room.Participants) + require.Len(t, room.ParticipantHistory, 1) +} diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 3879031f1..f37d52fa5 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -326,6 +326,11 @@ type MessageEvent struct { // AssistantThread contains action token for Data Access API queries in message events AssistantThread *AssistantThreadActionToken `json:"assistant_thread,omitempty"` + + // Huddle-related fields (subtype "huddle_thread") + Room *slack.HuddleRoom `json:"room,omitempty"` + NoNotifications bool `json:"no_notifications,omitempty"` + Permalink string `json:"permalink,omitempty"` } // UnmarshalJSON implements the json.Unmarshaler interface for MessageEvent. From 5d17e4d9d7a7ef3e111247d33f746d70b7a9682a Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Mon, 26 Jan 2026 23:23:38 +0000 Subject: [PATCH 459/606] docs: update changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b52b7f57..3824441fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Huddle support** - New `HuddleRoom`, `HuddleParticipantEvent`, and `HuddleRecording` + types for handling Slack huddle events (`huddle_thread` subtype messages). +- **Call block data parsing** - `CallBlock` now includes full call data when retrieved + from Slack messages, with new `CallBlockData`, `CallBlockDataV1`, and `CallBlockIconURLs` + types. ([#897]) - **Chat Streaming API support** - New streaming API for real-time chat interactions with example usage. ([#1506]) - **Data Access API support** - Full support for Slack's Data Access API with @@ -61,6 +66,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Previous release. See [GitHub releases](https://github.com/slack-go/slack/releases/tag/v0.17.3) for details. +[#897]: https://github.com/slack-go/slack/issues/897 [#1439]: https://github.com/slack-go/slack/pull/1439 [#1461]: https://github.com/slack-go/slack/pull/1461 [#1463]: https://github.com/slack-go/slack/pull/1463 From e9852524a1ea67e0eb7460988e8d920a116966c7 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Mon, 26 Jan 2026 23:27:10 +0000 Subject: [PATCH 460/606] docs: set changelog too v0.18.0-rc1 --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3824441fb..e45b9b3cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.18.0-rc1] - 2026-01-26 + ### Added - **Huddle support** - New `HuddleRoom`, `HuddleParticipantEvent`, and `HuddleRecording` @@ -88,5 +90,6 @@ for details. [#1516]: https://github.com/slack-go/slack/pull/1516 [#1517]: https://github.com/slack-go/slack/pull/1517 -[Unreleased]: https://github.com/slack-go/slack/compare/v0.17.3...HEAD +[Unreleased]: https://github.com/slack-go/slack/compare/v0.18.0-rc1...HEAD +[0.18.0-rc.1]: https://github.com/slack-go/slack/releases/tag/v0.18.0-rc1 [0.17.3]: https://github.com/slack-go/slack/releases/tag/v0.17.3 From cb885688da9c977625d80b4188454195be815e65 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Mon, 26 Jan 2026 23:48:53 +0000 Subject: [PATCH 461/606] fix(chat): pass MetaData through MsgOptionPostMessageParameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MetaData was silently dropped when using PostMessageParameters. Now delegates to MsgOptionMetadata() when EventType is set. Also: Id → ID naming, interface{} → any. Fixes #1343 --- CHANGELOG.md | 5 +++++ chat.go | 12 +++++++++--- chat_test.go | 21 +++++++++++++++++++++ metadata.go | 4 ++-- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e45b9b3cb..ae5e08aac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **`MsgOptionPostMessageParameters` now passes `MetaData`** - Previously, metadata + was silently dropped when using `PostMessageParameters`. ([#1343]) + ## [0.18.0-rc1] - 2026-01-26 ### Added diff --git a/chat.go b/chat.go index b4bb25df3..86b1ef54b 100644 --- a/chat.go +++ b/chat.go @@ -119,13 +119,13 @@ func (api *Client) ScheduleMessage(channelID, postAt string, options ...MsgOptio // ScheduleMessageContext sends a message to a channel with a custom context. // Slack API docs: https://api.slack.com/methods/chat.scheduleMessage func (api *Client) ScheduleMessageContext(ctx context.Context, channelID, postAt string, options ...MsgOption) (string, string, error) { - respChannel, scheduledMessageId, _, err := api.SendMessageContext( + respChannel, scheduledMessageID, _, err := api.SendMessageContext( ctx, channelID, MsgOptionSchedule(postAt), MsgOptionCompose(options...), ) - return respChannel, scheduledMessageId, err + return respChannel, scheduledMessageID, err } // PostMessage sends a message to a channel. @@ -217,7 +217,7 @@ func (api *Client) SendMessage(channel string, options ...MsgOption) (string, st // SendMessageContext more flexible method for configuring messages with a custom context. // Slack API docs: https://api.slack.com/methods/chat.postMessage -func (api *Client) SendMessageContext(ctx context.Context, channelID string, options ...MsgOption) (_channel string, _timestampOrScheduledMessageId string, _text string, err error) { +func (api *Client) SendMessageContext(ctx context.Context, channelID string, options ...MsgOption) (_channel string, _timestampOrScheduledMessageID string, _text string, err error) { var ( req *http.Request parser func(*chatResponseFull) responseParser @@ -851,6 +851,12 @@ func MsgOptionPostMessageParameters(params PostMessageParameters) MsgOption { config.values.Set("reply_broadcast", "true") } + if params.MetaData.EventType != "" { + if err := MsgOptionMetadata(params.MetaData)(config); err != nil { + return err + } + } + if len(params.FileIDs) > 0 { return MsgOptionFileIDs(params.FileIDs)(config) } diff --git a/chat_test.go b/chat_test.go index eaed40c42..0071c3ce6 100644 --- a/chat_test.go +++ b/chat_test.go @@ -207,6 +207,27 @@ func TestPostMessage(t *testing.T) { "link_names": []string{"false"}, }, }, + "MetadataViaPostMessageParameters": { + endpoint: "/chat.postMessage", + opt: []MsgOption{ + MsgOptionPostMessageParameters(PostMessageParameters{ + MetaData: SlackMetadata{ + EventType: "testing-event", + EventPayload: map[string]interface{}{ + "id": 13, + "name": "testing-name", + }, + }, + }), + }, + expected: url.Values{ + "metadata": []string{`{"event_type":"testing-event","event_payload":{"id":13,"name":"testing-name"}}`}, + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "mrkdwn": []string{"false"}, + "unfurl_media": []string{"false"}, + }, + }, } once.Do(startServer) diff --git a/metadata.go b/metadata.go index a8c065046..e0e82957b 100644 --- a/metadata.go +++ b/metadata.go @@ -2,6 +2,6 @@ package slack // SlackMetadata https://api.slack.com/reference/metadata type SlackMetadata struct { - EventType string `json:"event_type"` - EventPayload map[string]interface{} `json:"event_payload"` + EventType string `json:"event_type"` + EventPayload map[string]any `json:"event_payload"` } From 3fb89d17f750618eea47dffd3260ecb000f0cc26 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Mon, 26 Jan 2026 23:58:36 +0000 Subject: [PATCH 462/606] docs: fix typo in changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae5e08aac..bc11b3d80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,5 +96,5 @@ for details. [#1517]: https://github.com/slack-go/slack/pull/1517 [Unreleased]: https://github.com/slack-go/slack/compare/v0.18.0-rc1...HEAD -[0.18.0-rc.1]: https://github.com/slack-go/slack/releases/tag/v0.18.0-rc1 +[0.18.0-rc1]: https://github.com/slack-go/slack/releases/tag/v0.18.0-rc1 [0.17.3]: https://github.com/slack-go/slack/releases/tag/v0.17.3 From 1110f6360d1feda0de4f9681e9c067d70634ff0a Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Tue, 27 Jan 2026 23:22:58 +0000 Subject: [PATCH 463/606] feat(admin): add a bunch of admin.conversations.* API methods Implements support for a bunch of admin.conversations.* methods (closes #1329): - Core methods: archive, unarchive, create, delete, rename, invite, search, lookup, getTeams, convertToPrivate, convertToPublic, disconnectShared, setTeams - Bulk operations: bulkArchive, bulkDelete, bulkMove - Preferences: getConversationPrefs, setConversationPrefs - Retention: getCustomRetention, setCustomRetention, removeCustomRetention - RestrictAccess: addGroup, listGroups, removeGroup - EKM: listOriginalConnectedChannelInfo --- admin_conversations.go | 722 ++++++++++++++++++ admin_conversations_ekm.go | 101 +++ admin_conversations_ekm_test.go | 58 ++ admin_conversations_restrictAccess.go | 150 ++++ admin_conversations_restrictAccess_test.go | 119 +++ admin_conversations_test.go | 501 +++++++++++- .../admin_conversations.go | 567 ++++++++++++++ 7 files changed, 2217 insertions(+), 1 deletion(-) create mode 100644 admin_conversations_ekm.go create mode 100644 admin_conversations_ekm_test.go create mode 100644 admin_conversations_restrictAccess.go create mode 100644 admin_conversations_restrictAccess_test.go create mode 100644 examples/admin_conversations/admin_conversations.go diff --git a/admin_conversations.go b/admin_conversations.go index 6f76568ba..eba5d98ea 100644 --- a/admin_conversations.go +++ b/admin_conversations.go @@ -2,11 +2,733 @@ package slack import ( "context" + "encoding/json" "net/url" "strconv" "strings" ) +// AdminConversationsInviteParams contains arguments for AdminConversationsInvite method call. +type AdminConversationsInviteParams struct { + ChannelID string + UserIDs []string +} + +// AdminConversationsInvite invites users to a channel. +// For more information see the admin.conversations.invite docs: +// https://api.slack.com/methods/admin.conversations.invite +func (api *Client) AdminConversationsInvite(ctx context.Context, params AdminConversationsInviteParams) error { + values := url.Values{ + "token": {api.token}, + "channel_id": {params.ChannelID}, + "user_ids": {strings.Join(params.UserIDs, ",")}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.invite", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// AdminConversationsArchive archives a public or private channel. +// For more information see the admin.conversations.archive docs: +// https://api.slack.com/methods/admin.conversations.archive +func (api *Client) AdminConversationsArchive(ctx context.Context, channelID string) error { + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.archive", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// AdminConversationsUnarchive unarchives a public or private channel. +// For more information see the admin.conversations.unarchive docs: +// https://api.slack.com/methods/admin.conversations.unarchive +func (api *Client) AdminConversationsUnarchive(ctx context.Context, channelID string) error { + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.unarchive", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// AdminConversationsRename renames a public or private channel. +// For more information see the admin.conversations.rename docs: +// https://api.slack.com/methods/admin.conversations.rename +func (api *Client) AdminConversationsRename(ctx context.Context, channelID, name string) error { + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + "name": {name}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.rename", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// AdminConversationsDelete deletes a public or private channel. +// For more information see the admin.conversations.delete docs: +// https://api.slack.com/methods/admin.conversations.delete +func (api *Client) AdminConversationsDelete(ctx context.Context, channelID string) error { + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.delete", values, response) + if err != nil { + return err + } + + return response.Err() +} + +type adminConversationsDisconnectSharedParams struct { + leavingTeamIDs []string +} + +// AdminConversationsDisconnectSharedOption is an option for AdminConversationsDisconnectShared. +type AdminConversationsDisconnectSharedOption func(*adminConversationsDisconnectSharedParams) + +// AdminConversationsDisconnectSharedOptionLeavingTeamIDs sets the team IDs of the workspaces to disconnect. +func AdminConversationsDisconnectSharedOptionLeavingTeamIDs(teamIDs []string) AdminConversationsDisconnectSharedOption { + return func(params *adminConversationsDisconnectSharedParams) { + params.leavingTeamIDs = teamIDs + } +} + +// AdminConversationsDisconnectShared disconnects a connected channel from one or more workspaces. +// For more information see the admin.conversations.disconnectShared docs: +// https://api.slack.com/methods/admin.conversations.disconnectShared +func (api *Client) AdminConversationsDisconnectShared(ctx context.Context, channelID string, options ...AdminConversationsDisconnectSharedOption) error { + params := adminConversationsDisconnectSharedParams{} + for _, opt := range options { + opt(¶ms) + } + + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + } + + if len(params.leavingTeamIDs) > 0 { + values.Add("leaving_team_ids", strings.Join(params.leavingTeamIDs, ",")) + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.disconnectShared", values, response) + if err != nil { + return err + } + + return response.Err() +} + +type adminConversationsCreateParams struct { + description string + orgWide bool + teamID string +} + +// AdminConversationsCreateOption is an option for AdminConversationsCreate. +type AdminConversationsCreateOption func(*adminConversationsCreateParams) + +// AdminConversationsCreateOptionDescription sets the description of the channel. +func AdminConversationsCreateOptionDescription(description string) AdminConversationsCreateOption { + return func(params *adminConversationsCreateParams) { + params.description = description + } +} + +// AdminConversationsCreateOptionOrgWide sets whether the channel should be org-wide. +func AdminConversationsCreateOptionOrgWide(orgWide bool) AdminConversationsCreateOption { + return func(params *adminConversationsCreateParams) { + params.orgWide = orgWide + } +} + +// AdminConversationsCreateOptionTeamID sets the team ID where the channel should be created. +func AdminConversationsCreateOptionTeamID(teamID string) AdminConversationsCreateOption { + return func(params *adminConversationsCreateParams) { + params.teamID = teamID + } +} + +// AdminConversationsCreateResponse represents the response from admin.conversations.create. +type AdminConversationsCreateResponse struct { + SlackResponse + ChannelID string `json:"channel_id"` +} + +// AdminConversationsCreate creates a public or private channel-based conversation. +// For more information see the admin.conversations.create docs: +// https://api.slack.com/methods/admin.conversations.create +func (api *Client) AdminConversationsCreate(ctx context.Context, name string, isPrivate bool, options ...AdminConversationsCreateOption) (string, error) { + params := adminConversationsCreateParams{} + for _, opt := range options { + opt(¶ms) + } + + values := url.Values{ + "token": {api.token}, + "is_private": {strconv.FormatBool(isPrivate)}, + "name": {name}, + } + + if params.description != "" { + values.Add("description", params.description) + } + + if params.orgWide { + values.Add("org_wide", "true") + } + + if params.teamID != "" { + values.Add("team_id", params.teamID) + } + + response := &AdminConversationsCreateResponse{} + err := api.postMethod(ctx, "admin.conversations.create", values, response) + if err != nil { + return "", err + } + + return response.ChannelID, response.Err() +} + +// AdminConversationsGetTeamsParams contains arguments for AdminConversationsGetTeams method call. +type AdminConversationsGetTeamsParams struct { + ChannelID string + Cursor string + Limit int +} + +// AdminConversationsGetTeamsResponse represents the response from admin.conversations.getTeams. +type AdminConversationsGetTeamsResponse struct { + SlackResponse + TeamIDs []string `json:"team_ids"` +} + +// AdminConversationsGetTeams gets all the workspaces a given public or private channel is connected to within this Enterprise org. +// For more information see the admin.conversations.getTeams docs: +// https://api.slack.com/methods/admin.conversations.getTeams +func (api *Client) AdminConversationsGetTeams(ctx context.Context, params AdminConversationsGetTeamsParams) ([]string, string, error) { + values := url.Values{ + "token": {api.token}, + "channel_id": {params.ChannelID}, + } + + if params.Cursor != "" { + values.Add("cursor", params.Cursor) + } + + if params.Limit > 0 { + values.Add("limit", strconv.Itoa(params.Limit)) + } + + response := &AdminConversationsGetTeamsResponse{} + err := api.postMethod(ctx, "admin.conversations.getTeams", values, response) + if err != nil { + return nil, "", err + } + + return response.TeamIDs, response.ResponseMetadata.Cursor, response.Err() +} + +type adminConversationsSearchParams struct { + cursor string + limit int + query string + searchChannelType []string + sort string + sortDir string + teamIDs []string + connectedTeamIDs []string + totalCountOnly bool +} + +// AdminConversationsSearchOption is an option for AdminConversationsSearch. +type AdminConversationsSearchOption func(*adminConversationsSearchParams) + +// AdminConversationsSearchOptionCursor sets the cursor for pagination. +func AdminConversationsSearchOptionCursor(cursor string) AdminConversationsSearchOption { + return func(params *adminConversationsSearchParams) { + params.cursor = cursor + } +} + +// AdminConversationsSearchOptionLimit sets the maximum number of results to return. +func AdminConversationsSearchOptionLimit(limit int) AdminConversationsSearchOption { + return func(params *adminConversationsSearchParams) { + params.limit = limit + } +} + +// AdminConversationsSearchOptionQuery sets the search query. +func AdminConversationsSearchOptionQuery(query string) AdminConversationsSearchOption { + return func(params *adminConversationsSearchParams) { + params.query = query + } +} + +// AdminConversationsSearchOptionSearchChannelTypes sets the channel types to search. +// Valid values: "private", "public", "private_exclude", "multi_workspace", "org_wide", "external_shared_exclude", "external_shared" +func AdminConversationsSearchOptionSearchChannelTypes(types []string) AdminConversationsSearchOption { + return func(params *adminConversationsSearchParams) { + params.searchChannelType = types + } +} + +// AdminConversationsSearchOptionSort sets the sort field. +// Valid values: "name", "member_count", "created" +func AdminConversationsSearchOptionSort(sort string) AdminConversationsSearchOption { + return func(params *adminConversationsSearchParams) { + params.sort = sort + } +} + +// AdminConversationsSearchOptionSortDir sets the sort direction. +// Valid values: "asc", "desc" +func AdminConversationsSearchOptionSortDir(sortDir string) AdminConversationsSearchOption { + return func(params *adminConversationsSearchParams) { + params.sortDir = sortDir + } +} + +// AdminConversationsSearchOptionTeamIDs filters results to channels in the specified teams. +func AdminConversationsSearchOptionTeamIDs(teamIDs []string) AdminConversationsSearchOption { + return func(params *adminConversationsSearchParams) { + params.teamIDs = teamIDs + } +} + +// AdminConversationsSearchOptionConnectedTeamIDs filters results to channels connected to the specified teams. +func AdminConversationsSearchOptionConnectedTeamIDs(teamIDs []string) AdminConversationsSearchOption { + return func(params *adminConversationsSearchParams) { + params.connectedTeamIDs = teamIDs + } +} + +// AdminConversationsSearchOptionTotalCountOnly when true, only returns the total count of matching channels. +func AdminConversationsSearchOptionTotalCountOnly(totalCountOnly bool) AdminConversationsSearchOption { + return func(params *adminConversationsSearchParams) { + params.totalCountOnly = totalCountOnly + } +} + +// ChannelEmailAddress represents an email address associated with a channel. +type ChannelEmailAddress struct { + Address string `json:"address"` + CreatorID string `json:"creator_id"` + TeamID string `json:"team_id"` +} + +// AdminConversationOwnershipDetail represents ownership details for lists/canvas. +type AdminConversationOwnershipDetail struct { + Count int `json:"count,omitempty"` + TeamID string `json:"team_id,omitempty"` +} + +// AdminConversationLists represents lists/canvas information in admin conversations. +type AdminConversationLists struct { + OwnershipDetails []AdminConversationOwnershipDetail `json:"ownership_details,omitempty"` + TotalCount int `json:"total_count,omitempty"` +} + +// AdminConversation represents a conversation in admin API responses. +type AdminConversation struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Purpose string `json:"purpose,omitempty"` + MemberCount int `json:"member_count,omitempty"` + Created int64 `json:"created,omitempty"` + CreatorID string `json:"creator_id,omitempty"` + IsPrivate bool `json:"is_private,omitempty"` + IsArchived bool `json:"is_archived,omitempty"` + IsGeneral bool `json:"is_general,omitempty"` + LastActivityTimestamp int64 `json:"last_activity_ts,omitempty"` + IsFrozen bool `json:"is_frozen,omitempty"` + IsOrgDefault bool `json:"is_org_default,omitempty"` + IsOrgMandatory bool `json:"is_org_mandatory,omitempty"` + IsOrgShared bool `json:"is_org_shared,omitempty"` + IsExtShared bool `json:"is_ext_shared,omitempty"` + IsGlobalShared bool `json:"is_global_shared,omitempty"` + IsPendingExtShared bool `json:"is_pending_ext_shared,omitempty"` + IsDisconnectInProgress bool `json:"is_disconnect_in_progress,omitempty"` + ConnectedTeamIDs []string `json:"connected_team_ids,omitempty"` + ConnectedLimitedTeamIDs []string `json:"connected_limited_team_ids,omitempty"` + PendingConnectedTeamIDs []string `json:"pending_connected_team_ids,omitempty"` + InternalTeamIDs []string `json:"internal_team_ids,omitempty"` + InternalTeamIDsCount int `json:"internal_team_ids_count,omitempty"` + InternalTeamIDsSampleTeam string `json:"internal_team_ids_sample_team,omitempty"` + ContextTeamID string `json:"context_team_id,omitempty"` + ConversationHostID string `json:"conversation_host_id,omitempty"` + ChannelEmailAddresses []ChannelEmailAddress `json:"channel_email_addresses,omitempty"` + ChannelManagerCount int `json:"channel_manager_count,omitempty"` + ExternalUserCount int `json:"external_user_count,omitempty"` + Canvas *AdminConversationLists `json:"canvas,omitempty"` + Lists *AdminConversationLists `json:"lists,omitempty"` + Properties *Properties `json:"properties,omitempty"` +} + +// AdminConversationsSearchResponse represents the response from admin.conversations.search. +type AdminConversationsSearchResponse struct { + SlackResponse + Conversations []AdminConversation `json:"conversations"` + TotalCount int `json:"total_count"` + NextCursor string `json:"next_cursor"` +} + +// AdminConversationsSearch searches for public or private channels in an Enterprise organization. +// For more information see the admin.conversations.search docs: +// https://api.slack.com/methods/admin.conversations.search +func (api *Client) AdminConversationsSearch(ctx context.Context, options ...AdminConversationsSearchOption) (*AdminConversationsSearchResponse, error) { + params := adminConversationsSearchParams{} + for _, opt := range options { + opt(¶ms) + } + + values := url.Values{ + "token": {api.token}, + } + + if params.cursor != "" { + values.Add("cursor", params.cursor) + } + + if params.limit > 0 { + values.Add("limit", strconv.Itoa(params.limit)) + } + + if params.query != "" { + values.Add("query", params.query) + } + + if len(params.searchChannelType) > 0 { + values.Add("search_channel_types", strings.Join(params.searchChannelType, ",")) + } + + if params.sort != "" { + values.Add("sort", params.sort) + } + + if params.sortDir != "" { + values.Add("sort_dir", params.sortDir) + } + + if len(params.teamIDs) > 0 { + values.Add("team_ids", strings.Join(params.teamIDs, ",")) + } + + if len(params.connectedTeamIDs) > 0 { + values.Add("connected_team_ids", strings.Join(params.connectedTeamIDs, ",")) + } + + if params.totalCountOnly { + values.Add("total_count_only", "true") + } + + response := &AdminConversationsSearchResponse{} + err := api.postMethod(ctx, "admin.conversations.search", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +type adminConversationsLookupParams struct { + cursor string + limit int + maxMemberCount int +} + +// AdminConversationsLookupOption is an option for AdminConversationsLookup. +type AdminConversationsLookupOption func(*adminConversationsLookupParams) + +// AdminConversationsLookupOptionCursor sets the cursor for pagination. +func AdminConversationsLookupOptionCursor(cursor string) AdminConversationsLookupOption { + return func(params *adminConversationsLookupParams) { + params.cursor = cursor + } +} + +// AdminConversationsLookupOptionLimit sets the maximum number of results to return. +func AdminConversationsLookupOptionLimit(limit int) AdminConversationsLookupOption { + return func(params *adminConversationsLookupParams) { + params.limit = limit + } +} + +// AdminConversationsLookupOptionMaxMemberCount filters to channels with at most this many members. +func AdminConversationsLookupOptionMaxMemberCount(maxMemberCount int) AdminConversationsLookupOption { + return func(params *adminConversationsLookupParams) { + params.maxMemberCount = maxMemberCount + } +} + +// AdminConversationsLookupResponse represents the response from admin.conversations.lookup. +type AdminConversationsLookupResponse struct { + SlackResponse + Channels []string `json:"channels"` +} + +// AdminConversationsLookup returns channels on the given team matching the specified filters. +// For more information see the admin.conversations.lookup docs: +// https://api.slack.com/methods/admin.conversations.lookup +func (api *Client) AdminConversationsLookup(ctx context.Context, teamIDs []string, lastMessageActivityBefore int64, options ...AdminConversationsLookupOption) ([]string, string, error) { + params := adminConversationsLookupParams{} + for _, opt := range options { + opt(¶ms) + } + + values := url.Values{ + "token": {api.token}, + "last_message_activity_before": {strconv.FormatInt(lastMessageActivityBefore, 10)}, + "team_ids": {strings.Join(teamIDs, ",")}, + } + + if params.cursor != "" { + values.Add("cursor", params.cursor) + } + + if params.limit > 0 { + values.Add("limit", strconv.Itoa(params.limit)) + } + + if params.maxMemberCount > 0 { + values.Add("max_member_count", strconv.Itoa(params.maxMemberCount)) + } + + response := &AdminConversationsLookupResponse{} + err := api.postMethod(ctx, "admin.conversations.lookup", values, response) + if err != nil { + return nil, "", err + } + + return response.Channels, response.ResponseMetadata.Cursor, response.Err() +} + +// AdminConversationsBulkArchive archives public or private channels in bulk. +// For more information see the admin.conversations.bulkArchive docs: +// https://api.slack.com/methods/admin.conversations.bulkArchive +func (api *Client) AdminConversationsBulkArchive(ctx context.Context, channelIDs []string) error { + values := url.Values{ + "token": {api.token}, + "channel_ids": {strings.Join(channelIDs, ",")}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.bulkArchive", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// AdminConversationsBulkDelete deletes public or private channels in bulk. +// For more information see the admin.conversations.bulkDelete docs: +// https://api.slack.com/methods/admin.conversations.bulkDelete +func (api *Client) AdminConversationsBulkDelete(ctx context.Context, channelIDs []string) error { + values := url.Values{ + "token": {api.token}, + "channel_ids": {strings.Join(channelIDs, ",")}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.bulkDelete", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// AdminConversationsBulkMoveParams contains arguments for AdminConversationsBulkMove method call. +type AdminConversationsBulkMoveParams struct { + ChannelIDs []string + TargetTeamID string +} + +// AdminConversationsBulkMove moves public or private channels in bulk. +// For more information see the admin.conversations.bulkMove docs: +// https://api.slack.com/methods/admin.conversations.bulkMove +func (api *Client) AdminConversationsBulkMove(ctx context.Context, params AdminConversationsBulkMoveParams) error { + values := url.Values{ + "token": {api.token}, + "channel_ids": {strings.Join(params.ChannelIDs, ",")}, + "target_team_id": {params.TargetTeamID}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.bulkMove", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// AdminConversationPrefs represents conversation preferences. +type AdminConversationPrefs struct { + WhoCanPost *AdminConversationPref `json:"who_can_post,omitempty"` + CanThread *AdminConversationPref `json:"can_thread,omitempty"` + CanHuddle *AdminConversationPref `json:"can_huddle,omitempty"` + EnableAtHere *AdminConversationPrefEnabled `json:"enable_at_here,omitempty"` + EnableAtChannel *AdminConversationPrefEnabled `json:"enable_at_channel,omitempty"` +} + +// AdminConversationPrefEnabled represents an enabled/disabled preference. +type AdminConversationPrefEnabled struct { + Enabled bool `json:"enabled"` +} + +// AdminConversationPref represents a single conversation preference. +type AdminConversationPref struct { + Type []string `json:"type,omitempty"` + User []string `json:"user,omitempty"` +} + +// AdminConversationsGetConversationPrefsResponse represents the response from admin.conversations.getConversationPrefs. +type AdminConversationsGetConversationPrefsResponse struct { + SlackResponse + Prefs AdminConversationPrefs `json:"prefs"` +} + +// AdminConversationsGetConversationPrefs gets conversation preferences for a public or private channel. +// For more information see the admin.conversations.getConversationPrefs docs: +// https://api.slack.com/methods/admin.conversations.getConversationPrefs +func (api *Client) AdminConversationsGetConversationPrefs(ctx context.Context, channelID string) (*AdminConversationPrefs, error) { + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + } + + response := &AdminConversationsGetConversationPrefsResponse{} + err := api.postMethod(ctx, "admin.conversations.getConversationPrefs", values, response) + if err != nil { + return nil, err + } + + return &response.Prefs, response.Err() +} + +// AdminConversationsSetConversationPrefsParams contains arguments for AdminConversationsSetConversationPrefs method call. +type AdminConversationsSetConversationPrefsParams struct { + ChannelID string + Prefs AdminConversationPrefs +} + +// AdminConversationsSetConversationPrefs sets conversation preferences for a public or private channel. +// For more information see the admin.conversations.setConversationPrefs docs: +// https://api.slack.com/methods/admin.conversations.setConversationPrefs +func (api *Client) AdminConversationsSetConversationPrefs(ctx context.Context, params AdminConversationsSetConversationPrefsParams) error { + prefsJSON, err := json.Marshal(params.Prefs) + if err != nil { + return err + } + + values := url.Values{ + "token": {api.token}, + "channel_id": {params.ChannelID}, + "prefs": {string(prefsJSON)}, + } + + response := &SlackResponse{} + err = api.postMethod(ctx, "admin.conversations.setConversationPrefs", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// AdminConversationsGetCustomRetentionResponse represents the response from admin.conversations.getCustomRetention. +type AdminConversationsGetCustomRetentionResponse struct { + SlackResponse + DurationDays int `json:"duration_days"` + IsPolicyEnabled bool `json:"is_policy_enabled"` +} + +// AdminConversationsGetCustomRetention gets a conversation's custom retention policy. +// For more information see the admin.conversations.getCustomRetention docs: +// https://api.slack.com/methods/admin.conversations.getCustomRetention +func (api *Client) AdminConversationsGetCustomRetention(ctx context.Context, channelID string) (*AdminConversationsGetCustomRetentionResponse, error) { + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + } + + response := &AdminConversationsGetCustomRetentionResponse{} + err := api.postMethod(ctx, "admin.conversations.getCustomRetention", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +// AdminConversationsSetCustomRetention sets a conversation's custom retention policy. +// For more information see the admin.conversations.setCustomRetention docs: +// https://api.slack.com/methods/admin.conversations.setCustomRetention +func (api *Client) AdminConversationsSetCustomRetention(ctx context.Context, channelID string, durationDays int) error { + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + "duration_days": {strconv.Itoa(durationDays)}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.setCustomRetention", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// AdminConversationsRemoveCustomRetention removes a conversation's custom retention policy. +// For more information see the admin.conversations.removeCustomRetention docs: +// https://api.slack.com/methods/admin.conversations.removeCustomRetention +func (api *Client) AdminConversationsRemoveCustomRetention(ctx context.Context, channelID string) error { + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.removeCustomRetention", values, response) + if err != nil { + return err + } + + return response.Err() +} + // AdminConversationsSetTeamsParams contains arguments for AdminConversationsSetTeams // method calls. type AdminConversationsSetTeamsParams struct { diff --git a/admin_conversations_ekm.go b/admin_conversations_ekm.go new file mode 100644 index 000000000..f4b4e14ef --- /dev/null +++ b/admin_conversations_ekm.go @@ -0,0 +1,101 @@ +package slack + +import ( + "context" + "net/url" + "strconv" + "strings" +) + +type adminConversationsEKMListOriginalConnectedChannelInfoParams struct { + channelIDs []string + teamIDs []string + cursor string + limit int +} + +// AdminConversationsEKMListOriginalConnectedChannelInfoOption is an option for +// AdminConversationsEKMListOriginalConnectedChannelInfo. +type AdminConversationsEKMListOriginalConnectedChannelInfoOption func(*adminConversationsEKMListOriginalConnectedChannelInfoParams) + +// AdminConversationsEKMListOriginalConnectedChannelInfoOptionChannelIDs filters results to specific channels. +func AdminConversationsEKMListOriginalConnectedChannelInfoOptionChannelIDs(channelIDs []string) AdminConversationsEKMListOriginalConnectedChannelInfoOption { + return func(params *adminConversationsEKMListOriginalConnectedChannelInfoParams) { + params.channelIDs = channelIDs + } +} + +// AdminConversationsEKMListOriginalConnectedChannelInfoOptionTeamIDs filters results to specific teams. +func AdminConversationsEKMListOriginalConnectedChannelInfoOptionTeamIDs(teamIDs []string) AdminConversationsEKMListOriginalConnectedChannelInfoOption { + return func(params *adminConversationsEKMListOriginalConnectedChannelInfoParams) { + params.teamIDs = teamIDs + } +} + +// AdminConversationsEKMListOriginalConnectedChannelInfoOptionCursor sets the cursor for pagination. +func AdminConversationsEKMListOriginalConnectedChannelInfoOptionCursor(cursor string) AdminConversationsEKMListOriginalConnectedChannelInfoOption { + return func(params *adminConversationsEKMListOriginalConnectedChannelInfoParams) { + params.cursor = cursor + } +} + +// AdminConversationsEKMListOriginalConnectedChannelInfoOptionLimit sets the maximum number of results to return. +func AdminConversationsEKMListOriginalConnectedChannelInfoOptionLimit(limit int) AdminConversationsEKMListOriginalConnectedChannelInfoOption { + return func(params *adminConversationsEKMListOriginalConnectedChannelInfoParams) { + params.limit = limit + } +} + +// AdminConversationsEKMOriginalConnectedChannelInfo represents channel info for EKM response. +type AdminConversationsEKMOriginalConnectedChannelInfo struct { + ID string `json:"id"` + OriginalConnectedHostID string `json:"original_connected_host_id"` + OriginalConnectedChannelID string `json:"original_connected_channel_id"` + InternalTeamIDs []string `json:"internal_team_ids_count"` +} + +// AdminConversationsEKMListOriginalConnectedChannelInfoResponse represents the response from +// admin.conversations.ekm.listOriginalConnectedChannelInfo. +type AdminConversationsEKMListOriginalConnectedChannelInfoResponse struct { + SlackResponse + Channels []AdminConversationsEKMOriginalConnectedChannelInfo `json:"channels"` +} + +// AdminConversationsEKMListOriginalConnectedChannelInfo lists the original connected channel +// information for Slack Connect channels. +// For more information see the admin.conversations.ekm.listOriginalConnectedChannelInfo docs: +// https://api.slack.com/methods/admin.conversations.ekm.listOriginalConnectedChannelInfo +func (api *Client) AdminConversationsEKMListOriginalConnectedChannelInfo(ctx context.Context, options ...AdminConversationsEKMListOriginalConnectedChannelInfoOption) (*AdminConversationsEKMListOriginalConnectedChannelInfoResponse, error) { + params := adminConversationsEKMListOriginalConnectedChannelInfoParams{} + for _, opt := range options { + opt(¶ms) + } + + values := url.Values{ + "token": {api.token}, + } + + if len(params.channelIDs) > 0 { + values.Add("channel_ids", strings.Join(params.channelIDs, ",")) + } + + if len(params.teamIDs) > 0 { + values.Add("team_ids", strings.Join(params.teamIDs, ",")) + } + + if params.cursor != "" { + values.Add("cursor", params.cursor) + } + + if params.limit > 0 { + values.Add("limit", strconv.Itoa(params.limit)) + } + + response := &AdminConversationsEKMListOriginalConnectedChannelInfoResponse{} + err := api.postMethod(ctx, "admin.conversations.ekm.listOriginalConnectedChannelInfo", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} diff --git a/admin_conversations_ekm_test.go b/admin_conversations_ekm_test.go new file mode 100644 index 000000000..bd5fb3a1b --- /dev/null +++ b/admin_conversations_ekm_test.go @@ -0,0 +1,58 @@ +package slack + +import ( + "context" + "encoding/json" + "net/http" + "testing" +) + +func TestAdminConversationsEKMListOriginalConnectedChannelInfo(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.ekm.listOriginalConnectedChannelInfo", mockEKMListHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + response, err := api.AdminConversationsEKMListOriginalConnectedChannelInfo(context.Background(), + AdminConversationsEKMListOriginalConnectedChannelInfoOptionChannelIDs([]string{"C123", "C456"}), + AdminConversationsEKMListOriginalConnectedChannelInfoOptionTeamIDs([]string{"T789"}), + AdminConversationsEKMListOriginalConnectedChannelInfoOptionLimit(100), + ) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(response.Channels) != 1 { + t.Errorf("unexpected channel count: %d", len(response.Channels)) + return + } + + if response.Channels[0].ID != "C1234567890" { + t.Errorf("unexpected channel ID: %s", response.Channels[0].ID) + return + } +} + +func mockEKMListHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(AdminConversationsEKMListOriginalConnectedChannelInfoResponse{ + SlackResponse: SlackResponse{Ok: true}, + Channels: []AdminConversationsEKMOriginalConnectedChannelInfo{ + { + ID: "C1234567890", + OriginalConnectedHostID: "T123", + OriginalConnectedChannelID: "C9876543210", + InternalTeamIDs: []string{"T001", "T002"}, + }, + }, + }) + + _, _ = rw.Write(response) + } +} diff --git a/admin_conversations_restrictAccess.go b/admin_conversations_restrictAccess.go new file mode 100644 index 000000000..344579972 --- /dev/null +++ b/admin_conversations_restrictAccess.go @@ -0,0 +1,150 @@ +package slack + +import ( + "context" + "net/url" +) + +// AdminConversationsRestrictAccessAddGroup + +type adminConversationsRestrictAccessAddGroupParams struct { + teamID string +} + +// AdminConversationsRestrictAccessAddGroupOption is an option for AdminConversationsRestrictAccessAddGroup. +type AdminConversationsRestrictAccessAddGroupOption func(*adminConversationsRestrictAccessAddGroupParams) + +// AdminConversationsRestrictAccessAddGroupOptionTeamID sets the workspace where the channel exists. +// Required if using an org token. +func AdminConversationsRestrictAccessAddGroupOptionTeamID(teamID string) AdminConversationsRestrictAccessAddGroupOption { + return func(params *adminConversationsRestrictAccessAddGroupParams) { + params.teamID = teamID + } +} + +// AdminConversationsRestrictAccessAddGroup adds an allowlist of IDP groups +// for accessing a channel. +// For more information see the admin.conversations.restrictAccess.addGroup docs: +// https://api.slack.com/methods/admin.conversations.restrictAccess.addGroup +func (api *Client) AdminConversationsRestrictAccessAddGroup(ctx context.Context, channelID, groupID string, options ...AdminConversationsRestrictAccessAddGroupOption) error { + params := adminConversationsRestrictAccessAddGroupParams{} + for _, opt := range options { + opt(¶ms) + } + + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + "group_id": {groupID}, + } + + if params.teamID != "" { + values.Add("team_id", params.teamID) + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.restrictAccess.addGroup", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// AdminConversationsRestrictAccessListGroups + +type adminConversationsRestrictAccessListGroupsParams struct { + teamID string +} + +// AdminConversationsRestrictAccessListGroupsOption is an option for AdminConversationsRestrictAccessListGroups. +type AdminConversationsRestrictAccessListGroupsOption func(*adminConversationsRestrictAccessListGroupsParams) + +// AdminConversationsRestrictAccessListGroupsOptionTeamID sets the workspace where the channel exists. +// Required if using an org token. +func AdminConversationsRestrictAccessListGroupsOptionTeamID(teamID string) AdminConversationsRestrictAccessListGroupsOption { + return func(params *adminConversationsRestrictAccessListGroupsParams) { + params.teamID = teamID + } +} + +// AdminConversationsRestrictAccessListGroupsResponse represents the response from +// admin.conversations.restrictAccess.listGroups. +type AdminConversationsRestrictAccessListGroupsResponse struct { + SlackResponse + GroupIDs []string `json:"group_ids"` +} + +// AdminConversationsRestrictAccessListGroups lists the allowlist of IDP groups +// for a private channel. +// For more information see the admin.conversations.restrictAccess.listGroups docs: +// https://api.slack.com/methods/admin.conversations.restrictAccess.listGroups +func (api *Client) AdminConversationsRestrictAccessListGroups(ctx context.Context, channelID string, options ...AdminConversationsRestrictAccessListGroupsOption) ([]string, error) { + params := adminConversationsRestrictAccessListGroupsParams{} + for _, opt := range options { + opt(¶ms) + } + + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + } + + if params.teamID != "" { + values.Add("team_id", params.teamID) + } + + response := &AdminConversationsRestrictAccessListGroupsResponse{} + err := api.postMethod(ctx, "admin.conversations.restrictAccess.listGroups", values, response) + if err != nil { + return nil, err + } + + return response.GroupIDs, response.Err() +} + +// AdminConversationsRestrictAccessRemoveGroup + +type adminConversationsRestrictAccessRemoveGroupParams struct { + teamID string +} + +// AdminConversationsRestrictAccessRemoveGroupOption is an option for AdminConversationsRestrictAccessRemoveGroup. +type AdminConversationsRestrictAccessRemoveGroupOption func(*adminConversationsRestrictAccessRemoveGroupParams) + +// AdminConversationsRestrictAccessRemoveGroupOptionTeamID sets the workspace where the channel exists. +// Required if using an org token. +func AdminConversationsRestrictAccessRemoveGroupOptionTeamID(teamID string) AdminConversationsRestrictAccessRemoveGroupOption { + return func(params *adminConversationsRestrictAccessRemoveGroupParams) { + params.teamID = teamID + } +} + +// AdminConversationsRestrictAccessRemoveGroup removes an IDP group from the +// allowlist of a private channel. +// For more information see the admin.conversations.restrictAccess.removeGroup docs: +// https://api.slack.com/methods/admin.conversations.restrictAccess.removeGroup +func (api *Client) AdminConversationsRestrictAccessRemoveGroup(ctx context.Context, channelID, groupID string, options ...AdminConversationsRestrictAccessRemoveGroupOption) error { + params := adminConversationsRestrictAccessRemoveGroupParams{} + for _, opt := range options { + opt(¶ms) + } + + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + "group_id": {groupID}, + } + + if params.teamID != "" { + values.Add("team_id", params.teamID) + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.restrictAccess.removeGroup", values, response) + if err != nil { + return err + } + + return response.Err() +} diff --git a/admin_conversations_restrictAccess_test.go b/admin_conversations_restrictAccess_test.go new file mode 100644 index 000000000..dbcc4e02c --- /dev/null +++ b/admin_conversations_restrictAccess_test.go @@ -0,0 +1,119 @@ +package slack + +import ( + "context" + "encoding/json" + "net/http" + "testing" +) + +func TestAdminConversationsRestrictAccessAddGroup(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.restrictAccess.addGroup", mockRestrictAccessHandler(t, "group_id")) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsRestrictAccessAddGroup(context.Background(), + "C1234567890", + "G123", + AdminConversationsRestrictAccessAddGroupOptionTeamID("T789"), + ) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsRestrictAccessListGroups(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.restrictAccess.listGroups", mockRestrictAccessListGroupsHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + groupIDs, err := api.AdminConversationsRestrictAccessListGroups(context.Background(), + "C1234567890", + AdminConversationsRestrictAccessListGroupsOptionTeamID("T789"), + ) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(groupIDs) != 2 { + t.Errorf("unexpected group count: %d", len(groupIDs)) + return + } +} + +func TestAdminConversationsRestrictAccessRemoveGroup(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.restrictAccess.removeGroup", mockRestrictAccessHandler(t, "group_id")) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsRestrictAccessRemoveGroup(context.Background(), + "C1234567890", + "G123", + AdminConversationsRestrictAccessRemoveGroupOptionTeamID("T789"), + ) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func mockRestrictAccessHandler(t *testing.T, requiredField string) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["channel_id"]) == 0 { + t.Error("missing channel_id in request") + return + } + + if len(r.Form[requiredField]) == 0 { + t.Errorf("missing %s in request", requiredField) + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(SlackResponse{ + Ok: true, + }) + + _, _ = rw.Write(response) + } +} + +func mockRestrictAccessListGroupsHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["channel_id"]) == 0 { + t.Error("missing channel_id in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(AdminConversationsRestrictAccessListGroupsResponse{ + SlackResponse: SlackResponse{Ok: true}, + GroupIDs: []string{"G123", "G456"}, + }) + + _, _ = rw.Write(response) + } +} diff --git a/admin_conversations_test.go b/admin_conversations_test.go index 66ad86207..1727aebba 100644 --- a/admin_conversations_test.go +++ b/admin_conversations_test.go @@ -7,6 +7,22 @@ import ( "testing" ) +func TestAdminConversationsInvite(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.invite", mockAdminInviteHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsInvite(context.Background(), AdminConversationsInviteParams{ + ChannelID: "C1234567890", + UserIDs: []string{"U123", "U456"}, + }) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + func TestAdminConversationsSetTeams(t *testing.T) { http.DefaultServeMux = new(http.ServeMux) http.HandleFunc("/admin.conversations.setTeams", mockAdminChannelIDHandler(t)) @@ -54,6 +70,232 @@ func TestAdminConversationsConvertToPublic(t *testing.T) { } } +func TestAdminConversationsArchive(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.archive", mockAdminChannelIDHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsArchive(context.Background(), "C1234567890") + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsUnarchive(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.unarchive", mockAdminChannelIDHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsUnarchive(context.Background(), "C1234567890") + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsRename(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.rename", mockAdminRenameHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsRename(context.Background(), "C1234567890", "new-channel-name") + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsDelete(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.delete", mockAdminChannelIDHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsDelete(context.Background(), "C1234567890") + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsCreate(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.create", mockAdminCreateHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + channelID, err := api.AdminConversationsCreate(context.Background(), "test-channel", true, + AdminConversationsCreateOptionDescription("A test channel"), + AdminConversationsCreateOptionTeamID("T123"), + ) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if channelID != "C1234567890" { + t.Errorf("unexpected channel_id: %s", channelID) + return + } +} + +func TestAdminConversationsGetTeams(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.getTeams", mockAdminGetTeamsHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + teamIDs, cursor, err := api.AdminConversationsGetTeams(context.Background(), AdminConversationsGetTeamsParams{ + ChannelID: "C1234567890", + Limit: 100, + }) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(teamIDs) != 2 { + t.Errorf("unexpected team count: %d", len(teamIDs)) + return + } + + if cursor != "next_cursor_value" { + t.Errorf("unexpected cursor: %s", cursor) + return + } +} + +func TestAdminConversationsSearch(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.search", mockAdminSearchHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + response, err := api.AdminConversationsSearch(context.Background(), + AdminConversationsSearchOptionQuery("test"), + AdminConversationsSearchOptionLimit(100), + ) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(response.Conversations) != 1 { + t.Errorf("unexpected conversation count: %d", len(response.Conversations)) + return + } + + if response.Conversations[0].ID != "C1234567890" { + t.Errorf("unexpected conversation ID: %s", response.Conversations[0].ID) + return + } +} + +func TestAdminConversationsBulkArchive(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.bulkArchive", mockAdminChannelIDsHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsBulkArchive(context.Background(), []string{"C123", "C456"}) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsBulkDelete(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.bulkDelete", mockAdminChannelIDsHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsBulkDelete(context.Background(), []string{"C123", "C456"}) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsBulkMove(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.bulkMove", mockAdminBulkMoveHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsBulkMove(context.Background(), AdminConversationsBulkMoveParams{ + ChannelIDs: []string{"C123", "C456"}, + TargetTeamID: "T789", + }) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsGetConversationPrefs(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.getConversationPrefs", mockAdminGetConversationPrefsHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + prefs, err := api.AdminConversationsGetConversationPrefs(context.Background(), "C1234567890") + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if prefs.WhoCanPost == nil || len(prefs.WhoCanPost.Type) != 1 || prefs.WhoCanPost.Type[0] != "admin" { + t.Errorf("unexpected prefs: %+v", prefs) + return + } +} + +func TestAdminConversationsSetCustomRetention(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.setCustomRetention", mockAdminRetentionHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsSetCustomRetention(context.Background(), "C1234567890", 90) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsRemoveCustomRetention(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.removeCustomRetention", mockAdminChannelIDHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsRemoveCustomRetention(context.Background(), "C1234567890") + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsDisconnectShared(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.disconnectShared", mockAdminChannelIDHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsDisconnectShared(context.Background(), "C1234567890", + AdminConversationsDisconnectSharedOptionLeavingTeamIDs([]string{"T123", "T456"}), + ) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + // mockAdminChannelIDHandler returns a handler which expects a channel_id to be present // in the request, and will fail the test if it isn't present. func mockAdminChannelIDHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { @@ -77,6 +319,263 @@ func mockAdminChannelIDHandler(t *testing.T) func(rw http.ResponseWriter, r *htt Ok: true, }) - rw.Write(response) + _, _ = rw.Write(response) + } +} + +func mockAdminInviteHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["channel_id"]) == 0 { + t.Error("missing channel_id in request") + return + } + + if len(r.Form["user_ids"]) == 0 { + t.Error("missing user_ids in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(SlackResponse{ + Ok: true, + }) + + _, _ = rw.Write(response) + } +} + +func mockAdminRenameHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["channel_id"]) == 0 { + t.Error("missing channel_id in request") + return + } + + if len(r.Form["name"]) == 0 { + t.Error("missing name in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(SlackResponse{ + Ok: true, + }) + + _, _ = rw.Write(response) + } +} + +func mockAdminCreateHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["name"]) == 0 { + t.Error("missing name in request") + return + } + + if len(r.Form["is_private"]) == 0 { + t.Error("missing is_private in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(AdminConversationsCreateResponse{ + SlackResponse: SlackResponse{Ok: true}, + ChannelID: "C1234567890", + }) + + _, _ = rw.Write(response) + } +} + +func mockAdminGetTeamsHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["channel_id"]) == 0 { + t.Error("missing channel_id in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(AdminConversationsGetTeamsResponse{ + SlackResponse: SlackResponse{ + Ok: true, + ResponseMetadata: ResponseMetadata{Cursor: "next_cursor_value"}, + }, + TeamIDs: []string{"T123", "T456"}, + }) + + _, _ = rw.Write(response) + } +} + +func mockAdminSearchHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(AdminConversationsSearchResponse{ + SlackResponse: SlackResponse{Ok: true}, + Conversations: []AdminConversation{ + { + ID: "C1234567890", + Name: "test-channel", + IsPrivate: false, + MemberCount: 10, + }, + }, + TotalCount: 1, + }) + + _, _ = rw.Write(response) + } +} + +func mockAdminChannelIDsHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["channel_ids"]) == 0 { + t.Error("missing channel_ids in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(SlackResponse{ + Ok: true, + }) + + _, _ = rw.Write(response) + } +} + +func mockAdminBulkMoveHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["channel_ids"]) == 0 { + t.Error("missing channel_ids in request") + return + } + + if len(r.Form["target_team_id"]) == 0 { + t.Error("missing target_team_id in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(SlackResponse{ + Ok: true, + }) + + _, _ = rw.Write(response) + } +} + +func mockAdminGetConversationPrefsHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["channel_id"]) == 0 { + t.Error("missing channel_id in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(AdminConversationsGetConversationPrefsResponse{ + SlackResponse: SlackResponse{Ok: true}, + Prefs: AdminConversationPrefs{ + WhoCanPost: &AdminConversationPref{Type: []string{"admin"}}, + }, + }) + + _, _ = rw.Write(response) + } +} + +func mockAdminRetentionHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["channel_id"]) == 0 { + t.Error("missing channel_id in request") + return + } + + if len(r.Form["duration_days"]) == 0 { + t.Error("missing duration_days in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(SlackResponse{ + Ok: true, + }) + + _, _ = rw.Write(response) } } diff --git a/examples/admin_conversations/admin_conversations.go b/examples/admin_conversations/admin_conversations.go new file mode 100644 index 000000000..95473ca15 --- /dev/null +++ b/examples/admin_conversations/admin_conversations.go @@ -0,0 +1,567 @@ +// This example demonstrates the admin.conversations.* API methods. +// These methods require an Enterprise Grid organization and an app installed +// at the org level with admin.* scopes. +// +// Usage: +// +// export SLACK_USER_TOKEN="xoxp-..." +// export SLACK_TEAM_ID="T..." # Optional: workspace ID for scoping operations +// go run admin_conversations.go +// +// The example provides a menu to test different operations. Read-only operations +// are safe to run. Destructive operations are clearly marked. +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/slack-go/slack" +) + +var ( + api *slack.Client + teamID string +) + +func main() { + token := os.Getenv("SLACK_USER_TOKEN") + if token == "" { + fmt.Fprintln(os.Stderr, "SLACK_USER_TOKEN environment variable is required") + fmt.Fprintln(os.Stderr, "This must be an org-level token with admin.conversations:* scopes") + os.Exit(1) + } + + teamID = os.Getenv("SLACK_TEAM_ID") + + api = slack.New(token) + + reader := bufio.NewReader(os.Stdin) + + for { + printMenu() + fmt.Print("\nChoice: ") + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + + switch input { + case "1": + testSearch() + case "2": + testGetTeams(reader) + case "3": + testGetConversationPrefs(reader) + case "4": + testGetCustomRetention(reader) + case "5": + testLookup() + case "6": + testRestrictAccessListGroups(reader) + case "7": + testEKMListOriginalConnectedChannelInfo() + case "8": + testCreate(reader) + case "9": + testInvite(reader) + case "10": + testRename(reader) + case "11": + testSetConversationPrefs(reader) + case "12": + testSetCustomRetention(reader) + case "13": + testArchive(reader) + case "14": + testUnarchive(reader) + case "15": + testDelete(reader) + case "q", "Q": + fmt.Println("Goodbye!") + return + default: + fmt.Println("Invalid choice") + } + fmt.Println() + } +} + +func printMenu() { + fmt.Println("\n" + strings.Repeat("=", 60)) + fmt.Println("Admin Conversations API Demo") + fmt.Println(strings.Repeat("=", 60)) + fmt.Println("\nREAD-ONLY (safe):") + fmt.Println(" 1. Search conversations") + fmt.Println(" 2. Get teams for a channel") + fmt.Println(" 3. Get conversation preferences") + fmt.Println(" 4. Get custom retention policy") + fmt.Println(" 5. Lookup channels by activity") + fmt.Println(" 6. List restrict access groups") + fmt.Println(" 7. List EKM original connected channel info") + fmt.Println("\nCREATE/MODIFY:") + fmt.Println(" 8. Create a test channel") + fmt.Println(" 9. Invite users to a channel") + fmt.Println(" 10. Rename a channel") + fmt.Println(" 11. Set conversation preferences") + fmt.Println(" 12. Set custom retention policy") + fmt.Println("\nARCHIVE/DELETE (use with caution):") + fmt.Println(" 13. Archive a channel") + fmt.Println(" 14. Unarchive a channel") + fmt.Println(" 15. Delete a channel [DESTRUCTIVE]") + fmt.Println("\n q. Quit") +} + +func testSearch() { + fmt.Println("\n--- Searching conversations ---") + + options := []slack.AdminConversationsSearchOption{ + slack.AdminConversationsSearchOptionLimit(10), + slack.AdminConversationsSearchOptionSort("member_count"), + } + + if teamID != "" { + options = append(options, slack.AdminConversationsSearchOptionTeamIDs([]string{teamID})) + } + + response, err := api.AdminConversationsSearch(context.Background(), options...) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Found %d conversations (showing up to 10)\n", response.TotalCount) + fmt.Printf("Next cursor: %q\n\n", response.NextCursor) + + for _, conv := range response.Conversations { + fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + fmt.Printf("Channel: #%s (%s)\n", conv.Name, conv.ID) + fmt.Printf(" Purpose: %s\n", conv.Purpose) + fmt.Printf(" Created: %d | Creator: %s\n", conv.Created, conv.CreatorID) + fmt.Printf(" Members: %d | External users: %d | Channel managers: %d\n", + conv.MemberCount, conv.ExternalUserCount, conv.ChannelManagerCount) + fmt.Printf(" Last activity: %d\n", conv.LastActivityTimestamp) + + // Visibility flags + fmt.Printf(" Flags: ") + flags := []string{} + if conv.IsPrivate { + flags = append(flags, "private") + } else { + flags = append(flags, "public") + } + if conv.IsArchived { + flags = append(flags, "archived") + } + if conv.IsGeneral { + flags = append(flags, "general") + } + if conv.IsFrozen { + flags = append(flags, "frozen") + } + if conv.IsOrgShared { + flags = append(flags, "org-shared") + } + if conv.IsExtShared { + flags = append(flags, "ext-shared") + } + if conv.IsGlobalShared { + flags = append(flags, "global-shared") + } + if conv.IsPendingExtShared { + flags = append(flags, "pending-ext-shared") + } + if conv.IsDisconnectInProgress { + flags = append(flags, "disconnect-in-progress") + } + if conv.IsOrgDefault { + flags = append(flags, "org-default") + } + if conv.IsOrgMandatory { + flags = append(flags, "org-mandatory") + } + fmt.Printf("%s\n", strings.Join(flags, ", ")) + + // Team IDs + if len(conv.ConnectedTeamIDs) > 0 { + fmt.Printf(" Connected teams: %v\n", conv.ConnectedTeamIDs) + } + if len(conv.ConnectedLimitedTeamIDs) > 0 { + fmt.Printf(" Connected limited teams: %v\n", conv.ConnectedLimitedTeamIDs) + } + if len(conv.PendingConnectedTeamIDs) > 0 { + fmt.Printf(" Pending connected teams: %v\n", conv.PendingConnectedTeamIDs) + } + if len(conv.InternalTeamIDs) > 0 { + fmt.Printf(" Internal teams: %v\n", conv.InternalTeamIDs) + } + if conv.InternalTeamIDsCount > 0 { + fmt.Printf(" Internal teams count: %d (sample: %s)\n", + conv.InternalTeamIDsCount, conv.InternalTeamIDsSampleTeam) + } + if conv.ContextTeamID != "" { + fmt.Printf(" Context team: %s\n", conv.ContextTeamID) + } + if conv.ConversationHostID != "" { + fmt.Printf(" Conversation host: %s\n", conv.ConversationHostID) + } + + // Email addresses + if len(conv.ChannelEmailAddresses) > 0 { + fmt.Printf(" Email addresses:\n") + for _, email := range conv.ChannelEmailAddresses { + fmt.Printf(" - %s (team: %s, creator: %s)\n", + email.Address, email.TeamID, email.CreatorID) + } + } + + // Canvas/Lists + if conv.Canvas != nil { + fmt.Printf(" Canvas: total_count=%d\n", conv.Canvas.TotalCount) + for _, od := range conv.Canvas.OwnershipDetails { + fmt.Printf(" - team %s: %d\n", od.TeamID, od.Count) + } + } + if conv.Lists != nil { + fmt.Printf(" Lists: total_count=%d\n", conv.Lists.TotalCount) + for _, od := range conv.Lists.OwnershipDetails { + fmt.Printf(" - team %s: %d\n", od.TeamID, od.Count) + } + } + + // Properties + if conv.Properties != nil { + fmt.Printf(" Properties: present\n") + } + + fmt.Println() + } +} + +func testGetTeams(reader *bufio.Reader) { + fmt.Println("\n--- Get teams for channel ---") + channelID := prompt(reader, "Channel ID (e.g., C1234567890): ") + + teamIDs, cursor, err := api.AdminConversationsGetTeams(context.Background(), slack.AdminConversationsGetTeamsParams{ + ChannelID: channelID, + Limit: 100, + }) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Teams connected to %s:\n", channelID) + for _, tid := range teamIDs { + fmt.Printf(" - %s\n", tid) + } + if cursor != "" { + fmt.Printf("(more results available, cursor: %s)\n", cursor) + } +} + +func testGetConversationPrefs(reader *bufio.Reader) { + fmt.Println("\n--- Get conversation preferences ---") + channelID := prompt(reader, "Channel ID: ") + + prefs, err := api.AdminConversationsGetConversationPrefs(context.Background(), channelID) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Preferences for %s:\n", channelID) + if prefs.WhoCanPost != nil { + fmt.Printf(" Who can post: types=%v, users=%v\n", prefs.WhoCanPost.Type, prefs.WhoCanPost.User) + } + if prefs.CanThread != nil { + fmt.Printf(" Can thread: types=%v\n", prefs.CanThread.Type) + } + if prefs.CanHuddle != nil { + fmt.Printf(" Can huddle: types=%v\n", prefs.CanHuddle.Type) + } +} + +func testGetCustomRetention(reader *bufio.Reader) { + fmt.Println("\n--- Get custom retention policy ---") + channelID := prompt(reader, "Channel ID: ") + + resp, err := api.AdminConversationsGetCustomRetention(context.Background(), channelID) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + if !resp.IsPolicyEnabled { + fmt.Printf("Channel %s does not have a custom retention policy enabled\n", channelID) + } else { + fmt.Printf("Channel %s has custom retention: %d days\n", channelID, resp.DurationDays) + } +} + +func testLookup() { + fmt.Println("\n--- Lookup channels by activity ---") + + if teamID == "" { + fmt.Println("Error: SLACK_TEAM_ID environment variable is required for lookup") + return + } + + // Find channels with no activity in the last 90 days + cutoff := time.Now().AddDate(0, 0, -90).Unix() + + channels, cursor, err := api.AdminConversationsLookup(context.Background(), + []string{teamID}, cutoff, + slack.AdminConversationsLookupOptionLimit(10), + ) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Channels with no activity in last 90 days:\n") + if len(channels) == 0 { + fmt.Println(" (none found)") + } + for _, ch := range channels { + fmt.Printf(" - %s\n", ch) + } + if cursor != "" { + fmt.Printf("(more results available)\n") + } +} + +func testRestrictAccessListGroups(reader *bufio.Reader) { + fmt.Println("\n--- List restrict access groups ---") + channelID := prompt(reader, "Channel ID: ") + + var options []slack.AdminConversationsRestrictAccessListGroupsOption + if teamID != "" { + options = append(options, slack.AdminConversationsRestrictAccessListGroupsOptionTeamID(teamID)) + } + + groupIDs, err := api.AdminConversationsRestrictAccessListGroups(context.Background(), channelID, options...) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("IDP groups with access to %s:\n", channelID) + if len(groupIDs) == 0 { + fmt.Println(" (no IDP group restrictions)") + } + for _, gid := range groupIDs { + fmt.Printf(" - %s\n", gid) + } +} + +func testEKMListOriginalConnectedChannelInfo() { + fmt.Println("\n--- List EKM original connected channel info ---") + + var options []slack.AdminConversationsEKMListOriginalConnectedChannelInfoOption + options = append(options, slack.AdminConversationsEKMListOriginalConnectedChannelInfoOptionLimit(10)) + + if teamID != "" { + options = append(options, slack.AdminConversationsEKMListOriginalConnectedChannelInfoOptionTeamIDs([]string{teamID})) + } + + response, err := api.AdminConversationsEKMListOriginalConnectedChannelInfo(context.Background(), options...) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Found %d channels\n\n", len(response.Channels)) + + for _, ch := range response.Channels { + fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + fmt.Printf("Channel: %s\n", ch.ID) + fmt.Printf(" Original connected host ID: %s\n", ch.OriginalConnectedHostID) + fmt.Printf(" Original connected channel ID: %s\n", ch.OriginalConnectedChannelID) + fmt.Printf(" Internal team IDs: %v\n", ch.InternalTeamIDs) + fmt.Println() + } + + if len(response.Channels) == 0 { + fmt.Println(" (no EKM connected channel info found)") + } +} + +func testCreate(reader *bufio.Reader) { + fmt.Println("\n--- Create test channel ---") + name := prompt(reader, "Channel name (e.g., test-admin-api): ") + isPrivate := strings.ToLower(prompt(reader, "Private? (y/n): ")) == "y" + description := prompt(reader, "Description (optional): ") + + var options []slack.AdminConversationsCreateOption + if description != "" { + options = append(options, slack.AdminConversationsCreateOptionDescription(description)) + } + if teamID != "" { + options = append(options, slack.AdminConversationsCreateOptionTeamID(teamID)) + } + + channelID, err := api.AdminConversationsCreate(context.Background(), name, isPrivate, options...) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Created channel: %s\n", channelID) + fmt.Println("Save this ID to test other operations!") +} + +func testInvite(reader *bufio.Reader) { + fmt.Println("\n--- Invite users to channel ---") + channelID := prompt(reader, "Channel ID: ") + userIDsStr := prompt(reader, "User IDs (comma-separated, e.g., U123,U456): ") + + userIDs := strings.Split(userIDsStr, ",") + for i := range userIDs { + userIDs[i] = strings.TrimSpace(userIDs[i]) + } + + err := api.AdminConversationsInvite(context.Background(), slack.AdminConversationsInviteParams{ + ChannelID: channelID, + UserIDs: userIDs, + }) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Invited %d user(s) to %s\n", len(userIDs), channelID) +} + +func testRename(reader *bufio.Reader) { + fmt.Println("\n--- Rename channel ---") + channelID := prompt(reader, "Channel ID: ") + newName := prompt(reader, "New name: ") + + err := api.AdminConversationsRename(context.Background(), channelID, newName) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Renamed %s to #%s\n", channelID, newName) +} + +func testSetConversationPrefs(reader *bufio.Reader) { + fmt.Println("\n--- Set conversation preferences ---") + channelID := prompt(reader, "Channel ID: ") + + fmt.Println("Who can post?") + fmt.Println(" 1. Everyone") + fmt.Println(" 2. Admins only") + fmt.Println(" 3. Org admins only") + choice := prompt(reader, "Choice: ") + + var whoCanPost []string + switch choice { + case "1": + whoCanPost = []string{"owner", "admin", "org_admin", "member", "ra_member", "guest"} + case "2": + whoCanPost = []string{"owner", "admin", "org_admin"} + case "3": + whoCanPost = []string{"owner", "org_admin"} + default: + fmt.Println("Invalid choice") + return + } + + err := api.AdminConversationsSetConversationPrefs(context.Background(), slack.AdminConversationsSetConversationPrefsParams{ + ChannelID: channelID, + Prefs: slack.AdminConversationPrefs{ + WhoCanPost: &slack.AdminConversationPref{Type: whoCanPost}, + }, + }) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Updated posting preferences for %s\n", channelID) +} + +func testSetCustomRetention(reader *bufio.Reader) { + fmt.Println("\n--- Set custom retention policy ---") + channelID := prompt(reader, "Channel ID: ") + daysStr := prompt(reader, "Retention days (e.g., 90): ") + + var days int + _, err := fmt.Sscanf(daysStr, "%d", &days) + if err != nil || days <= 0 { + fmt.Println("Invalid number of days") + return + } + + err = api.AdminConversationsSetCustomRetention(context.Background(), channelID, days) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Set %d-day retention policy for %s\n", days, channelID) +} + +func testArchive(reader *bufio.Reader) { + fmt.Println("\n--- Archive channel ---") + fmt.Println("WARNING: This will archive the channel!") + channelID := prompt(reader, "Channel ID: ") + + confirm := prompt(reader, "Type 'archive' to confirm: ") + if confirm != "archive" { + fmt.Println("Cancelled") + return + } + + err := api.AdminConversationsArchive(context.Background(), channelID) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Archived %s\n", channelID) +} + +func testUnarchive(reader *bufio.Reader) { + fmt.Println("\n--- Unarchive channel ---") + channelID := prompt(reader, "Channel ID: ") + + err := api.AdminConversationsUnarchive(context.Background(), channelID) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Unarchived %s\n", channelID) +} + +func testDelete(reader *bufio.Reader) { + fmt.Println("\n--- Delete channel ---") + fmt.Println("!!! WARNING: THIS IS PERMANENT AND CANNOT BE UNDONE !!!") + channelID := prompt(reader, "Channel ID: ") + + confirm := prompt(reader, "Type 'DELETE' (all caps) to confirm: ") + if confirm != "DELETE" { + fmt.Println("Cancelled") + return + } + + err := api.AdminConversationsDelete(context.Background(), channelID) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Deleted %s\n", channelID) +} + +func prompt(reader *bufio.Reader, message string) string { + fmt.Print(message) + input, _ := reader.ReadString('\n') + return strings.TrimSpace(input) +} From 08e3edc34a028fe554ffc446cd72dc91de05bf5d Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Tue, 27 Jan 2026 23:35:46 +0000 Subject: [PATCH 464/606] docs: update changelog --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc11b3d80..ac56c6ef7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Admin Conversations API support** - Comprehensive support for `admin.conversations.*` + methods including core operations (archive, unarchive, create, delete, rename, invite, + search, lookup, getTeams, convertToPrivate, convertToPublic, disconnectShared, setTeams), + bulk operations (bulkArchive, bulkDelete, bulkMove), preferences, retention management, + restrict access controls, and EKM channel info. ([#1329]) + ### Fixed - **`MsgOptionPostMessageParameters` now passes `MetaData`** - Previously, metadata @@ -74,6 +82,8 @@ Previous release. See [GitHub releases](https://github.com/slack-go/slack/releas for details. [#897]: https://github.com/slack-go/slack/issues/897 +[#1329]: https://github.com/slack-go/slack/issues/1329 +[#1343]: https://github.com/slack-go/slack/issues/1343 [#1439]: https://github.com/slack-go/slack/pull/1439 [#1461]: https://github.com/slack-go/slack/pull/1461 [#1463]: https://github.com/slack-go/slack/pull/1463 From 48a805dea3b6fb21a65bf9806102026f8d6b2c6d Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Tue, 27 Jan 2026 23:42:14 +0000 Subject: [PATCH 465/606] fix(socketmode): add debug logging for websocket dial failures Add visibility into websocket connection issues by: - Logging when a custom dialer is being used via OptionDialer - Capturing and logging the HTTP response status on dial failures - Properly closing response body on successful dial Closes #1360. --- CHANGELOG.md | 8 ++++++-- socketmode/socket_mode_managed_conn.go | 9 ++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac56c6ef7..f7af61a06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **`MsgOptionPostMessageParameters` now passes `MetaData`** - Previously, metadata - was silently dropped when using `PostMessageParameters`. ([#1343]) +- **Socket mode websocket dial debugging** - Added debug logging when a custom dialer is + used including HTTP response status on dial failures. This helps diagnose proxy/TLS + issues like "bad handshake" errors. ([#1360]) +- **`MsgOptionPostMessageParameters` now passes `MetaData`** - Previously, metadata was + silently dropped when using `PostMessageParameters`. ([#1343]) ## [0.18.0-rc1] - 2026-01-26 @@ -84,6 +87,7 @@ for details. [#897]: https://github.com/slack-go/slack/issues/897 [#1329]: https://github.com/slack-go/slack/issues/1329 [#1343]: https://github.com/slack-go/slack/issues/1343 +[#1360]: https://github.com/slack-go/slack/issues/1360 [#1439]: https://github.com/slack-go/slack/pull/1439 [#1461]: https://github.com/slack-go/slack/pull/1461 [#1463]: https://github.com/slack-go/slack/pull/1463 diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index b94456f49..94664b237 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -304,13 +304,20 @@ func (smc *Client) openAndDial(ctx context.Context, additionalPingHandler func(s upgradeHeader.Add("Origin", "https://api.slack.com") dialer := websocket.DefaultDialer if smc.dialer != nil { + smc.Debugf("Using custom websocket dialer") dialer = smc.dialer } - conn, _, err := dialer.DialContext(ctx, url, upgradeHeader) + conn, resp, err := dialer.DialContext(ctx, url, upgradeHeader) if err != nil { smc.Debugf("Failed to dial to the websocket: %s", err) + if resp != nil { + smc.Debugf("WebSocket dial response status: %s", resp.Status) + } return nil, nil, err } + if resp != nil && resp.Body != nil { + resp.Body.Close() + } if additionalPingHandler == nil { additionalPingHandler = func(_ string) error { return nil } } From 39e816a09b2a1b38afdad192401ddccf788c9c48 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Wed, 28 Jan 2026 01:16:03 +0000 Subject: [PATCH 466/606] fix(audit): use correct endpoint for Audit Logs API The Audit Logs API uses a different base URL (api.slack.com) than the regular Slack API (slack.com/api). Previously, GetAuditLogs was sending requests to the wrong endpoint, causing requests to fail with HTML error pages. Fixes #1144 --- CHANGELOG.md | 4 ++ audit.go | 3 +- audit_test.go | 2 +- examples/audit/audit.go | 104 ++++++++++++++++++++++++++++++++++++++++ slack.go | 17 +++++-- 5 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 examples/audit/audit.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f7af61a06..f344ba206 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Audit Logs example** - New example demonstrating how to use the Audit Logs API. ([#1144]) - **Admin Conversations API support** - Comprehensive support for `admin.conversations.*` methods including core operations (archive, unarchive, create, delete, rename, invite, search, lookup, getTeams, convertToPrivate, convertToPublic, disconnectShared, setTeams), @@ -17,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Audit Logs API endpoint** - Fixed `GetAuditLogs` to use the correct endpoint + (`api.slack.com`) instead of the regular API endpoint (`slack.com/api`). The Audit + Logs API requires a different base URL. Added `OptionAuditAPIURL` for testing. ([#1144]) - **Socket mode websocket dial debugging** - Added debug logging when a custom dialer is used including HTTP response status on dial failures. This helps diagnose proxy/TLS issues like "bad handshake" errors. ([#1360]) diff --git a/audit.go b/audit.go index a3ea7ebdf..bb9ae6dc9 100644 --- a/audit.go +++ b/audit.go @@ -107,7 +107,8 @@ type AuditLogParameters struct { func (api *Client) auditLogsRequest(ctx context.Context, path string, values url.Values) (*AuditLogResponse, error) { response := &AuditLogResponse{} - err := api.getMethod(ctx, path, api.token, values, response) + // The Audit Logs API uses a different base URL (api.slack.com instead of slack.com/api) + err := getResource(ctx, api.httpclient, api.auditEndpoint+path, api.token, values, response, api) if err != nil { return nil, err } diff --git a/audit_test.go b/audit_test.go index 631b631e2..d27f8a86f 100644 --- a/audit_test.go +++ b/audit_test.go @@ -48,7 +48,7 @@ func TestGetAuditLogs(t *testing.T) { http.HandleFunc("/audit/v1/logs", getAuditLogs) once.Do(startServer) - api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + api := New("testing-token", OptionAuditAPIURL("http://"+serverAddr+"/")) events, nextCursor, err := api.GetAuditLogs(AuditLogParameters{}) if err != nil { diff --git a/examples/audit/audit.go b/examples/audit/audit.go new file mode 100644 index 000000000..778080802 --- /dev/null +++ b/examples/audit/audit.go @@ -0,0 +1,104 @@ +// This example demonstrates the Audit Logs API. +// The Audit Logs API requires an Enterprise Grid organization with an app +// that has the auditlogs:read scope. +// +// Usage: +// +// export SLACK_USER_TOKEN="xoxp-..." # User token with auditlogs:read scope +// go run audit.go +// +// Note: The Audit Logs API uses a different endpoint (api.slack.com) than +// the regular Slack API (slack.com/api). This is handled automatically by +// the library. +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/slack-go/slack" +) + +func main() { + token := os.Getenv("SLACK_USER_TOKEN") + if token == "" { + fmt.Fprintln(os.Stderr, "SLACK_USER_TOKEN environment variable is required") + fmt.Fprintln(os.Stderr, "This must be a user token with auditlogs:read scope") + os.Exit(1) + } + + api := slack.New(token) + + // Fetch the last 24 hours of audit logs + now := time.Now() + yesterday := now.Add(-24 * time.Hour) + + fmt.Println("Fetching audit logs from the last 24 hours...") + fmt.Printf(" From: %s\n", yesterday.Format(time.RFC3339)) + fmt.Printf(" To: %s\n\n", now.Format(time.RFC3339)) + + params := slack.AuditLogParameters{ + Limit: 10, + Oldest: int(yesterday.Unix()), + Latest: int(now.Unix()), + } + + entries, nextCursor, err := api.GetAuditLogsContext(context.Background(), params) + if err != nil { + fmt.Fprintf(os.Stderr, "Error fetching audit logs: %s\n", err) + os.Exit(1) + } + + fmt.Printf("Found %d audit log entries (limited to 10)\n", len(entries)) + if nextCursor != "" { + fmt.Printf("More entries available (cursor: %s)\n", nextCursor) + } + fmt.Println() + + for i, entry := range entries { + fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + fmt.Printf("Entry %d: %s\n", i+1, entry.ID) + fmt.Printf(" Action: %s\n", entry.Action) + fmt.Printf(" Date: %s\n", time.Unix(int64(entry.DateCreate), 0).Format(time.RFC3339)) + + // Actor info + fmt.Printf(" Actor: %s (%s)\n", entry.Actor.User.Name, entry.Actor.User.Email) + + // Entity info + fmt.Printf(" Entity Type: %s\n", entry.Entity.Type) + switch entry.Entity.Type { + case "user": + fmt.Printf(" Entity: %s (%s)\n", entry.Entity.User.Name, entry.Entity.User.Email) + case "channel": + fmt.Printf(" Entity: #%s (%s)\n", entry.Entity.Channel.Name, entry.Entity.Channel.ID) + case "file": + fmt.Printf(" Entity: %s (%s)\n", entry.Entity.File.Name, entry.Entity.File.ID) + case "app": + fmt.Printf(" Entity: %s (%s)\n", entry.Entity.App.Name, entry.Entity.App.ID) + case "workspace": + fmt.Printf(" Entity: %s (%s)\n", entry.Entity.Workspace.Name, entry.Entity.Workspace.Domain) + case "enterprise": + fmt.Printf(" Entity: %s (%s)\n", entry.Entity.Enterprise.Name, entry.Entity.Enterprise.Domain) + } + + // Context + if entry.Context.Location.Name != "" { + fmt.Printf(" Location: %s (%s)\n", entry.Context.Location.Name, entry.Context.Location.Type) + } + if entry.Context.IPAddress != "" { + fmt.Printf(" IP Address: %s\n", entry.Context.IPAddress) + } + + fmt.Println() + } + + if len(entries) == 0 { + fmt.Println("No audit log entries found in the specified time range.") + fmt.Println("This could mean:") + fmt.Println(" - No actions were taken in the last 24 hours") + fmt.Println(" - The token doesn't have the auditlogs:read scope") + fmt.Println(" - The workspace is not part of an Enterprise Grid") + } +} diff --git a/slack.go b/slack.go index 10873edfd..ea8808b83 100644 --- a/slack.go +++ b/slack.go @@ -12,6 +12,8 @@ import ( const ( // APIURL of the slack api. APIURL = "https://slack.com/api/" + // AuditAPIURL is the base URL for the Audit Logs API. + AuditAPIURL = "https://api.slack.com/" // WEBAPIURLFormat ... WEBAPIURLFormat = "https://%s.slack.com/api/users.admin.%s?t=%d" ) @@ -62,6 +64,7 @@ type Client struct { configToken string configRefreshToken string endpoint string + auditEndpoint string debug bool log ilogger httpclient httpClient @@ -96,6 +99,11 @@ func OptionAPIURL(u string) func(*Client) { return func(c *Client) { c.endpoint = u } } +// OptionAuditAPIURL set the url for the Audit Logs API. only useful for testing. +func OptionAuditAPIURL(u string) func(*Client) { + return func(c *Client) { c.auditEndpoint = u } +} + // OptionAppLevelToken sets an app-level token for the client. func OptionAppLevelToken(token string) func(*Client) { return func(c *Client) { c.appLevelToken = token } @@ -114,10 +122,11 @@ func OptionConfigRefreshToken(token string) func(*Client) { // New builds a slack client from the provided token and options. func New(token string, options ...Option) *Client { s := &Client{ - token: token, - endpoint: APIURL, - httpclient: &http.Client{}, - log: log.New(os.Stderr, "slack-go/slack", log.LstdFlags|log.Lshortfile), + token: token, + endpoint: APIURL, + auditEndpoint: AuditAPIURL, + httpclient: &http.Client{}, + log: log.New(os.Stderr, "slack-go/slack", log.LstdFlags|log.Lshortfile), } for _, opt := range options { From e8e769b1177bed60f66e5d5037597572872a8d7d Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Wed, 28 Jan 2026 21:55:18 +0000 Subject: [PATCH 467/606] fix(files): wrap UploadFileV2 errors with step context Errors from UploadFileV2 now include which of the three upload steps failed (GetUploadURLExternal, UploadToURL, or CompleteUploadExternal). This makes debugging file upload failures much easier. Fixes #1491 --- CHANGELOG.md | 5 +++++ files.go | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f344ba206..029efdcb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **File upload error wrapping** - `UploadFileV2` now wraps errors with the step name + (`GetUploadURLExternal`, `UploadToURL`, or `CompleteUploadExternal`) so callers can + identify which of the three upload steps failed. ([#1491]) - **Audit Logs API endpoint** - Fixed `GetAuditLogs` to use the correct endpoint (`api.slack.com`) instead of the regular API endpoint (`slack.com/api`). The Audit Logs API requires a different base URL. Added `OptionAuditAPIURL` for testing. ([#1144]) @@ -89,6 +92,7 @@ Previous release. See [GitHub releases](https://github.com/slack-go/slack/releas for details. [#897]: https://github.com/slack-go/slack/issues/897 +[#1144]: https://github.com/slack-go/slack/issues/1144 [#1329]: https://github.com/slack-go/slack/issues/1329 [#1343]: https://github.com/slack-go/slack/issues/1343 [#1360]: https://github.com/slack-go/slack/issues/1360 @@ -103,6 +107,7 @@ for details. [#1488]: https://github.com/slack-go/slack/pull/1488 [#1489]: https://github.com/slack-go/slack/pull/1489 [#1490]: https://github.com/slack-go/slack/pull/1490 +[#1491]: https://github.com/slack-go/slack/issues/1491 [#1495]: https://github.com/slack-go/slack/pull/1495 [#1499]: https://github.com/slack-go/slack/pull/1499 [#1504]: https://github.com/slack-go/slack/pull/1504 diff --git a/files.go b/files.go index 810c476b7..122c34960 100644 --- a/files.go +++ b/files.go @@ -636,7 +636,7 @@ func (api *Client) UploadFileV2Context(ctx context.Context, params UploadFileV2P SnippetType: params.SnippetType, }) if err != nil { - return nil, err + return nil, fmt.Errorf("GetUploadURLExternal: %w", err) } err = api.UploadToURL(ctx, UploadToURLParameters{ @@ -647,7 +647,7 @@ func (api *Client) UploadFileV2Context(ctx context.Context, params UploadFileV2P Filename: params.Filename, }) if err != nil { - return nil, err + return nil, fmt.Errorf("UploadToURL: %w", err) } c, err := api.CompleteUploadExternalContext(ctx, CompleteUploadExternalParameters{ @@ -661,7 +661,7 @@ func (api *Client) UploadFileV2Context(ctx context.Context, params UploadFileV2P Blocks: params.Blocks, }) if err != nil { - return nil, err + return nil, fmt.Errorf("CompleteUploadExternal: %w", err) } if len(c.Files) != 1 { return nil, fmt.Errorf("file.upload.v2: something went wrong; received %d files instead of 1", len(c.Files)) From e18bfa195ff9485d67e99a5204329cc713b63f3b Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Wed, 28 Jan 2026 22:17:36 +0000 Subject: [PATCH 468/606] feat(files)!: remove deprecated UploadFile API BREAKING CHANGE: Remove UploadFile, UploadFileContext, and FileUploadParameters. The files.upload API was discontinued by Slack on November 12, 2025. Use UploadFileV2 / UploadFileV2Context instead. Closes #1481 --- files.go | 85 --------------------------------------------------- files_test.go | 64 -------------------------------------- 2 files changed, 149 deletions(-) diff --git a/files.go b/files.go index 122c34960..581b96327 100644 --- a/files.go +++ b/files.go @@ -126,24 +126,6 @@ type ShareFileInfo struct { TeamID string `json:"team_id"` } -// FileUploadParameters contains all the parameters necessary (including the optional ones) for an UploadFile() request. -// -// There are three ways to upload a file. You can either set Content if file is small, set Reader if file is large, -// or provide a local file path in File to upload it from your filesystem. -// -// Note that when using the Reader option, you *must* specify the Filename, otherwise the Slack API isn't happy. -type FileUploadParameters struct { - File string - Content string - Reader io.Reader - Filetype string - Filename string - Title string - InitialComment string - Channels []string - ThreadTimestamp string -} - // GetFilesParameters contains all the parameters necessary (including the optional ones) for a GetFiles() request type GetFilesParameters struct { User string @@ -375,73 +357,6 @@ func (api *Client) ListFilesContext(ctx context.Context, params ListFilesParamet return response.Files, ¶ms, nil } -// UploadFile uploads a file. -// -// Deprecated: Use [Client.UploadFileV2] instead. -// -// Per Slack Changelog, specifically [https://api.slack.com/changelog#entry-march_2025_1](this entry), -// this will stop functioning on November 12, 2025. -// -// For more details, see: https://api.slack.com/methods/files.upload#markdown -func (api *Client) UploadFile(params FileUploadParameters) (file *File, err error) { - return api.UploadFileContext(context.Background(), params) -} - -// UploadFileContext uploads a file and setting a custom context. -// -// Deprecated: Use [Client.UploadFileV2Context] instead. -// -// Per Slack Changelog, specifically [https://api.slack.com/changelog#entry-march_2025_1](this entry), -// this will stop functioning on November 12, 2025. -// -// For more details, see: https://api.slack.com/methods/files.upload#markdown -func (api *Client) UploadFileContext(ctx context.Context, params FileUploadParameters) (file *File, err error) { - // Test if user token is valid. This helps because client.Do doesn't like this for some reason. XXX: More - // investigation needed, but for now this will do. - _, err = api.AuthTestContext(ctx) - if err != nil { - return nil, err - } - response := &fileResponseFull{} - values := url.Values{} - if params.Filetype != "" { - values.Add("filetype", params.Filetype) - } - if params.Filename != "" { - values.Add("filename", params.Filename) - } - if params.Title != "" { - values.Add("title", params.Title) - } - if params.InitialComment != "" { - values.Add("initial_comment", params.InitialComment) - } - if params.ThreadTimestamp != "" { - values.Add("thread_ts", params.ThreadTimestamp) - } - if len(params.Channels) != 0 { - values.Add("channels", strings.Join(params.Channels, ",")) - } - if params.Content != "" { - values.Add("content", params.Content) - values.Add("token", api.token) - err = api.postMethod(ctx, "files.upload", values, response) - } else if params.File != "" { - err = postLocalWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.upload", params.File, "file", api.token, values, response, api) - } else if params.Reader != nil { - if params.Filename == "" { - return nil, fmt.Errorf("files.upload: FileUploadParameters.Filename is mandatory when using FileUploadParameters.Reader") - } - err = postWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.upload", params.Filename, "file", api.token, values, params.Reader, response, api) - } - - if err != nil { - return nil, err - } - - return &response.File, response.Err() -} - // DeleteFileComment deletes a file's comment. // For more details, see DeleteFileCommentContext documentation. func (api *Client) DeleteFileComment(commentID, fileID string) error { diff --git a/files_test.go b/files_test.go index 1de9859fc..73b13c85e 100644 --- a/files_test.go +++ b/files_test.go @@ -10,7 +10,6 @@ import ( "net/http" "net/url" "reflect" - "strings" "testing" ) @@ -151,69 +150,6 @@ func TestSlack_DeleteFileComment(t *testing.T) { } } -func authTestHandler(rw http.ResponseWriter, r *http.Request) { - rw.Header().Set("Content-Type", "application/json") - response, _ := json.Marshal(authTestResponseFull{ - SlackResponse: SlackResponse{Ok: true}}) - rw.Write(response) -} - -func uploadFileHandler(rw http.ResponseWriter, r *http.Request) { - rw.Header().Set("Content-Type", "application/json") - response, _ := json.Marshal(fileResponseFull{ - SlackResponse: SlackResponse{Ok: true}}) - rw.Write(response) -} - -func TestUploadFile(t *testing.T) { - http.HandleFunc("/auth.test", authTestHandler) - http.HandleFunc("/files.upload", uploadFileHandler) - once.Do(startServer) - api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - params := FileUploadParameters{ - Filename: "test.txt", Content: "test content", - Channels: []string{"CXXXXXXXX"}} - if _, err := api.UploadFile(params); err != nil { - t.Errorf("Unexpected error: %s", err) - } - - reader := bytes.NewBufferString("test reader") - params = FileUploadParameters{ - Filename: "test.txt", - Reader: reader, - Channels: []string{"CXXXXXXXX"}} - if _, err := api.UploadFile(params); err != nil { - t.Errorf("Unexpected error: %s", err) - } - - largeByt := make([]byte, 107374200) - reader = bytes.NewBuffer(largeByt) - params = FileUploadParameters{ - Filename: "test.txt", Reader: reader, - Channels: []string{"CXXXXXXXX"}} - if _, err := api.UploadFile(params); err != nil { - t.Errorf("Unexpected error: %s", err) - } -} - -func TestUploadFileWithoutFilename(t *testing.T) { - once.Do(startServer) - api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - - reader := bytes.NewBufferString("test reader") - params := FileUploadParameters{ - Reader: reader, - Channels: []string{"CXXXXXXXX"}} - _, err := api.UploadFile(params) - if err == nil { - t.Fatal("Expected error when omitting filename, instead got nil") - } - - if !strings.Contains(err.Error(), ".Filename is mandatory") { - t.Errorf("Error message should mention empty FileUploadParameters.Filename") - } -} - func uploadURLHandler(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "application/json") response, _ := json.Marshal(GetUploadURLExternalResponse{ From 103a284e3d2d595bf96b0aa9f63ddd3cdc5fb174 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Wed, 28 Jan 2026 22:31:56 +0000 Subject: [PATCH 469/606] feat(files)!: rename UploadFileV2 to UploadFile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: Rename the following: - UploadFileV2 → UploadFile - UploadFileV2Context → UploadFileContext - UploadFileV2Parameters → FileUploadParameters The "V2" suffix was only needed to distinguish from the deprecated `files.upload` API, which has now been removed. Closes #1481. --- CHANGELOG.md | 11 ++++++++++- examples/files/files.go | 4 ++-- .../files/multiple_files/multiple_files.go | 2 +- files.go | 14 +++++++------- files_test.go | 18 +++++++++--------- 5 files changed, 29 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 029efdcb8..89168da9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 bulk operations (bulkArchive, bulkDelete, bulkMove), preferences, retention management, restrict access controls, and EKM channel info. ([#1329]) +### Changed + +- **BREAKING**: Removed deprecated `UploadFile`, `UploadFileContext`, and + `FileUploadParameters`. The `files.upload` API was discontinued by Slack on + November 12, 2025. ([#1481]) +- **BREAKING**: Renamed `UploadFileV2` → `UploadFile`, `UploadFileV2Context` → + `UploadFileContext`, and `UploadFileV2Parameters` → `FileUploadParameters`. + The "V2" suffix is no longer needed now that the old API is removed. ([#1481]) + ### Fixed -- **File upload error wrapping** - `UploadFileV2` now wraps errors with the step name +- **File upload error wrapping** - `UploadFile` now wraps errors with the step name (`GetUploadURLExternal`, `UploadToURL`, or `CompleteUploadExternal`) so callers can identify which of the three upload steps failed. ([#1491]) - **Audit Logs API endpoint** - Fixed `GetAuditLogs` to use the correct endpoint diff --git a/examples/files/files.go b/examples/files/files.go index 55b3417fd..c3cfc3fca 100644 --- a/examples/files/files.go +++ b/examples/files/files.go @@ -19,13 +19,13 @@ func main() { ctx := context.Background() // Upload a file - params := slack.UploadFileV2Parameters{ + params := slack.FileUploadParameters{ Title: "Batman Example", Filename: "example.txt", File: "example.txt", FileSize: 38, } - file, err := api.UploadFileV2Context(ctx, params) + file, err := api.UploadFileContext(ctx, params) if err != nil { fmt.Printf("%s\n", err) return diff --git a/examples/files/multiple_files/multiple_files.go b/examples/files/multiple_files/multiple_files.go index 87541f2f8..35d528641 100644 --- a/examples/files/multiple_files/multiple_files.go +++ b/examples/files/multiple_files/multiple_files.go @@ -19,7 +19,7 @@ func main() { api := slack.New(token, slack.OptionDebug(true)) ctx := context.Background() - files := []slack.UploadFileV2Parameters{ + files := []slack.FileUploadParameters{ { Title: "Batman Example", Filename: "batman.txt", diff --git a/files.go b/files.go index 581b96327..d8ced1361 100644 --- a/files.go +++ b/files.go @@ -149,7 +149,7 @@ type ListFilesParameters struct { Cursor string } -type UploadFileV2Parameters struct { +type FileUploadParameters struct { File string FileSize int Content string @@ -524,19 +524,19 @@ func (api *Client) CompleteUploadExternalContext(ctx context.Context, params Com return response, nil } -// UploadFileV2 uploads file to a given slack channel using 3 steps. -// For more details, see UploadFileV2Context documentation. -func (api *Client) UploadFileV2(params UploadFileV2Parameters) (*FileSummary, error) { - return api.UploadFileV2Context(context.Background(), params) +// UploadFile uploads file to a given slack channel using 3 steps. +// For more details, see UploadFileContext documentation. +func (api *Client) UploadFile(params FileUploadParameters) (*FileSummary, error) { + return api.UploadFileContext(context.Background(), params) } -// UploadFileV2Context uploads file to a given slack channel using 3 steps - +// UploadFileContext uploads file to a given slack channel using 3 steps - // 1. Get an upload URL using files.getUploadURLExternal API // 2. Send the file as a post to the URL provided by slack // 3. Complete the upload and share it to the specified channel using files.completeUploadExternal // // Slack Docs: https://api.slack.com/messaging/files#uploading_files -func (api *Client) UploadFileV2Context(ctx context.Context, params UploadFileV2Parameters) (file *FileSummary, err error) { +func (api *Client) UploadFileContext(ctx context.Context, params FileUploadParameters) (file *FileSummary, err error) { if params.Filename == "" { return nil, fmt.Errorf("file.upload.v2: filename cannot be empty") } diff --git a/files_test.go b/files_test.go index 73b13c85e..73f3986f9 100644 --- a/files_test.go +++ b/files_test.go @@ -177,46 +177,46 @@ func completeURLUpload(rw http.ResponseWriter, r *http.Request) { rw.Write(response) } -func TestUploadFileV2(t *testing.T) { +func TestUploadFile(t *testing.T) { http.HandleFunc("/files.getUploadURLExternal", uploadURLHandler) http.HandleFunc("/abc", urlFileUploadHandler) http.HandleFunc("/files.completeUploadExternal", completeURLUpload) once.Do(startServer) api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - params := UploadFileV2Parameters{ + params := FileUploadParameters{ Filename: "test.txt", Content: "test content", FileSize: 10, Channel: "CXXXXXXXX", } - if _, err := api.UploadFileV2(params); err != nil { + if _, err := api.UploadFile(params); err != nil { t.Errorf("Unexpected error: %s", err) } reader := bytes.NewBufferString("test reader") - params = UploadFileV2Parameters{ + params = FileUploadParameters{ Filename: "test.txt", Reader: reader, FileSize: 10, Channel: "CXXXXXXXX"} - if _, err := api.UploadFileV2(params); err != nil { + if _, err := api.UploadFile(params); err != nil { t.Errorf("Unexpected error: %s", err) } largeByt := make([]byte, 107374200) reader = bytes.NewBuffer(largeByt) - params = UploadFileV2Parameters{ + params = FileUploadParameters{ Filename: "test.txt", Reader: reader, FileSize: len(largeByt), Channel: "CXXXXXXXX"} - if _, err := api.UploadFileV2(params); err != nil { + if _, err := api.UploadFile(params); err != nil { t.Errorf("Unexpected error: %s", err) } reader = bytes.NewBufferString("test no channel") - params = UploadFileV2Parameters{ + params = FileUploadParameters{ Filename: "test.txt", Reader: reader, FileSize: 15} - if _, err := api.UploadFileV2(params); err != nil { + if _, err := api.UploadFile(params); err != nil { t.Errorf("Unexpected error: %s", err) } } From 48c72e629a04a85ad3a8ca79ccf215a7e10cf56a Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Wed, 28 Jan 2026 22:58:52 +0000 Subject: [PATCH 470/606] refactor(files)!: rename FileUploadParameters to UploadFileParameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: Rename FileUploadParameters → UploadFileParameters for consistency with UploadFile and UploadFileContext method names. --- CHANGELOG.md | 8 ++++---- examples/files/files.go | 2 +- examples/files/multiple_files/multiple_files.go | 2 +- files.go | 6 +++--- files_test.go | 8 ++++---- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89168da9c..790e14043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,11 +19,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **BREAKING**: Removed deprecated `UploadFile`, `UploadFileContext`, and - `FileUploadParameters`. The `files.upload` API was discontinued by Slack on - November 12, 2025. ([#1481]) + `FileUploadParameters`. The `files.upload` API was discontinued by Slack on November + 12, 2025. ([#1481]) - **BREAKING**: Renamed `UploadFileV2` → `UploadFile`, `UploadFileV2Context` → - `UploadFileContext`, and `UploadFileV2Parameters` → `FileUploadParameters`. - The "V2" suffix is no longer needed now that the old API is removed. ([#1481]) + `UploadFileContext`, and `UploadFileV2Parameters` → `UploadFileParameters`. The "V2" + suffix is no longer needed now that the old API is removed. ([#1481]) ### Fixed diff --git a/examples/files/files.go b/examples/files/files.go index c3cfc3fca..4af226034 100644 --- a/examples/files/files.go +++ b/examples/files/files.go @@ -19,7 +19,7 @@ func main() { ctx := context.Background() // Upload a file - params := slack.FileUploadParameters{ + params := slack.UploadFileParameters{ Title: "Batman Example", Filename: "example.txt", File: "example.txt", diff --git a/examples/files/multiple_files/multiple_files.go b/examples/files/multiple_files/multiple_files.go index 35d528641..0fbfe2fad 100644 --- a/examples/files/multiple_files/multiple_files.go +++ b/examples/files/multiple_files/multiple_files.go @@ -19,7 +19,7 @@ func main() { api := slack.New(token, slack.OptionDebug(true)) ctx := context.Background() - files := []slack.FileUploadParameters{ + files := []slack.UploadFileParameters{ { Title: "Batman Example", Filename: "batman.txt", diff --git a/files.go b/files.go index d8ced1361..2109c5a0c 100644 --- a/files.go +++ b/files.go @@ -149,7 +149,7 @@ type ListFilesParameters struct { Cursor string } -type FileUploadParameters struct { +type UploadFileParameters struct { File string FileSize int Content string @@ -526,7 +526,7 @@ func (api *Client) CompleteUploadExternalContext(ctx context.Context, params Com // UploadFile uploads file to a given slack channel using 3 steps. // For more details, see UploadFileContext documentation. -func (api *Client) UploadFile(params FileUploadParameters) (*FileSummary, error) { +func (api *Client) UploadFile(params UploadFileParameters) (*FileSummary, error) { return api.UploadFileContext(context.Background(), params) } @@ -536,7 +536,7 @@ func (api *Client) UploadFile(params FileUploadParameters) (*FileSummary, error) // 3. Complete the upload and share it to the specified channel using files.completeUploadExternal // // Slack Docs: https://api.slack.com/messaging/files#uploading_files -func (api *Client) UploadFileContext(ctx context.Context, params FileUploadParameters) (file *FileSummary, err error) { +func (api *Client) UploadFileContext(ctx context.Context, params UploadFileParameters) (file *FileSummary, err error) { if params.Filename == "" { return nil, fmt.Errorf("file.upload.v2: filename cannot be empty") } diff --git a/files_test.go b/files_test.go index 73f3986f9..2c4ccbbe1 100644 --- a/files_test.go +++ b/files_test.go @@ -184,7 +184,7 @@ func TestUploadFile(t *testing.T) { once.Do(startServer) api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - params := FileUploadParameters{ + params := UploadFileParameters{ Filename: "test.txt", Content: "test content", FileSize: 10, Channel: "CXXXXXXXX", } @@ -193,7 +193,7 @@ func TestUploadFile(t *testing.T) { } reader := bytes.NewBufferString("test reader") - params = FileUploadParameters{ + params = UploadFileParameters{ Filename: "test.txt", Reader: reader, FileSize: 10, @@ -204,7 +204,7 @@ func TestUploadFile(t *testing.T) { largeByt := make([]byte, 107374200) reader = bytes.NewBuffer(largeByt) - params = FileUploadParameters{ + params = UploadFileParameters{ Filename: "test.txt", Reader: reader, FileSize: len(largeByt), Channel: "CXXXXXXXX"} if _, err := api.UploadFile(params); err != nil { @@ -212,7 +212,7 @@ func TestUploadFile(t *testing.T) { } reader = bytes.NewBufferString("test no channel") - params = FileUploadParameters{ + params = UploadFileParameters{ Filename: "test.txt", Reader: reader, FileSize: 15} From efd43a0f1e278db9f90c702082ee8ab6846a51a6 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Wed, 28 Jan 2026 23:27:17 +0000 Subject: [PATCH 471/606] docs: release v0.18.0-rc2 --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 790e14043..6b5a1caf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.18.0-rc2] - 2026-01-28 + ### Added - **Audit Logs example** - New example demonstrating how to use the Audit Logs API. ([#1144]) @@ -127,6 +129,7 @@ for details. [#1516]: https://github.com/slack-go/slack/pull/1516 [#1517]: https://github.com/slack-go/slack/pull/1517 -[Unreleased]: https://github.com/slack-go/slack/compare/v0.18.0-rc1...HEAD +[Unreleased]: https://github.com/slack-go/slack/compare/v0.18.0-rc2...HEAD +[0.18.0-rc2]: https://github.com/slack-go/slack/releases/tag/v0.18.0-rc2 [0.18.0-rc1]: https://github.com/slack-go/slack/releases/tag/v0.18.0-rc1 [0.17.3]: https://github.com/slack-go/slack/releases/tag/v0.17.3 From 825d69b955214dd489eb972d9fc4a099a2df4d22 Mon Sep 17 00:00:00 2001 From: Christian Gregg Date: Thu, 29 Jan 2026 11:05:23 +0000 Subject: [PATCH 472/606] Add `focus_on_load` support for remaining block elements This adds `focus_on_load` to the remaining Slack BlockKit Block Elements that support it: * `{static,external,users,conversations,channels}_select` * `multi_{static,external,users,conversations,channels}_select` * `datepicker` * `timepicker` * `plain_text_input` * `checkboxes` * `radio_buttons` * `number_input` Closes: #1518 --- block_element.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/block_element.go b/block_element.go index b24caedac..b271f132f 100644 --- a/block_element.go +++ b/block_element.go @@ -248,6 +248,7 @@ type SelectBlockElement struct { Filter *SelectBlockElementFilter `json:"filter,omitempty"` MinQueryLength *int `json:"min_query_length,omitempty"` Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` } // SelectBlockElementFilter allows to filter select element conversation options by type. @@ -339,6 +340,7 @@ type MultiSelectBlockElement struct { MinQueryLength *int `json:"min_query_length,omitempty"` MaxSelectedItems *int `json:"max_selected_items,omitempty"` Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` } // ElementType returns the type of the Element @@ -459,6 +461,7 @@ type DatePickerBlockElement struct { Placeholder *TextBlockObject `json:"placeholder,omitempty"` InitialDate string `json:"initial_date,omitempty"` Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` } // ElementType returns the type of the Element @@ -486,6 +489,7 @@ type TimePickerBlockElement struct { InitialTime string `json:"initial_time,omitempty"` Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` Timezone string `json:"timezone,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` } // ElementType returns the type of the Element @@ -597,6 +601,7 @@ type PlainTextInputBlockElement struct { MinLength int `json:"min_length,omitempty"` MaxLength int `json:"max_length,omitempty"` DispatchActionConfig *DispatchActionConfig `json:"dispatch_action_config,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` } type DispatchActionConfig struct { @@ -684,6 +689,7 @@ type CheckboxGroupsBlockElement struct { Options []*OptionBlockObject `json:"options"` InitialOptions []*OptionBlockObject `json:"initial_options,omitempty"` Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` } // ElementType returns the type of the Element @@ -710,6 +716,7 @@ type RadioButtonsBlockElement struct { Options []*OptionBlockObject `json:"options"` InitialOption *OptionBlockObject `json:"initial_option,omitempty"` Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` } // ElementType returns the type of the Element @@ -740,6 +747,7 @@ type NumberInputBlockElement struct { MinValue string `json:"min_value,omitempty"` MaxValue string `json:"max_value,omitempty"` DispatchActionConfig *DispatchActionConfig `json:"dispatch_action_config,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` } // ElementType returns the type of the Element From d6bcd0410baeb5d07f4a4a0fac1f13e030ed407b Mon Sep 17 00:00:00 2001 From: Shintaro Okada <38486312+derodero24@users.noreply.github.com> Date: Mon, 9 Feb 2026 08:13:52 +0900 Subject: [PATCH 473/606] feat: add PlainText and PreviewPlainText fields to File struct (#1522) ## Summary Add `plain_text` and `preview_plain_text` JSON fields to the `File` struct for email file objects. When emails are sent to Slack channels via the [Send emails to Slack](https://slack.com/help/articles/206819278-Send-emails-to-Slack) integration, the API returns these fields containing the email body as plain text. Currently they are silently discarded during unmarshalling. Fixes #1521 ## Changes Two fields added to `File` in `files.go`: ```go PlainText string `json:"plain_text"` PreviewPlainText string `json:"preview_plain_text"` ``` This follows the same pattern as #1370 (`Subject`, `To`, `From`, `Cc`) and #1380 (`Headers`). ## Test plan - `make pr-prep` passes (fmt, lint, test-race, test-integration) --- files.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/files.go b/files.go index 2109c5a0c..729ab4ffe 100644 --- a/files.go +++ b/files.go @@ -95,6 +95,9 @@ type File struct { From []EmailFileUserInfo `json:"from"` Cc []EmailFileUserInfo `json:"cc"` Headers EmailHeaders `json:"headers"` + + PlainText string `json:"plain_text"` + PreviewPlainText string `json:"preview_plain_text"` } type EmailFileUserInfo struct { From 7fe95a906d090711e36638f2c5c666a456c73e02 Mon Sep 17 00:00:00 2001 From: Shintaro Okada <38486312+derodero24@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:39:07 +0900 Subject: [PATCH 474/606] fix(users): correct JSON tag typo in UserProfile.Skype field (#1524) ## Summary The JSON tag for the `Skype` field in `UserProfile` (`users.go`) was `"skyp"` instead of `"skype"`, preventing the field from being properly populated when unmarshalling Slack API responses. - The correct tag `"skype"` is already used in `slackevents/inner_events.go` - Matches [Slack API documentation](https://docs.slack.dev/reference/objects/user-object) Fixes #1523 --- users.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/users.go b/users.go index 7d7d61f6e..53dd76485 100644 --- a/users.go +++ b/users.go @@ -25,7 +25,7 @@ type UserProfile struct { DisplayNameNormalized string `json:"display_name_normalized"` AvatarHash string `json:"avatar_hash"` Email string `json:"email,omitempty"` - Skype string `json:"skyp,omitempty"` + Skype string `json:"skype,omitempty"` Phone string `json:"phone,omitempty"` Image24 string `json:"image_24"` Image32 string `json:"image_32"` From 792849c3ae4bbfd4b6a83691d6e489c31b483751 Mon Sep 17 00:00:00 2001 From: Shintaro Okada <38486312+derodero24@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:30:05 +0900 Subject: [PATCH 475/606] feat(users): add missing fields to User, UserProfile, and EnterpriseUser (#1526) ## Summary Add fields documented in the [Slack API User object reference](https://docs.slack.dev/reference/objects/user-object) that were missing from the main structs in `users.go`. Some of these fields (e.g. `who_can_share_contact_card`, `status_text_canonical`) were already present in `slackevents/inner_events.go` but missing from the main package, causing inconsistency. ### Added fields **`User`:** - `who_can_share_contact_card` (string) - `always_active` (bool) **`UserProfile`:** - `pronouns` (string) - `image_1024` (string) - `is_custom_image` (bool) - `status_text_canonical` (string) - `huddle_state` (string) - `huddle_state_expiration_ts` (int) - `start_date` (string) **`EnterpriseUser`:** - `is_primary_owner` (bool) All new fields use `omitempty` to avoid breaking existing behavior. Fixes #1525 Related to #1459 --- users.go | 116 ++++++++++++++++++++++++--------------------- users_test.go | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 53 deletions(-) diff --git a/users.go b/users.go index 53dd76485..7f1676052 100644 --- a/users.go +++ b/users.go @@ -17,32 +17,40 @@ const ( // UserProfile contains all the information details of a given user type UserProfile struct { - FirstName string `json:"first_name,omitempty"` - LastName string `json:"last_name,omitempty"` - RealName string `json:"real_name"` - RealNameNormalized string `json:"real_name_normalized"` - DisplayName string `json:"display_name"` - DisplayNameNormalized string `json:"display_name_normalized"` - AvatarHash string `json:"avatar_hash"` - Email string `json:"email,omitempty"` - Skype string `json:"skype,omitempty"` - Phone string `json:"phone,omitempty"` - Image24 string `json:"image_24"` - Image32 string `json:"image_32"` - Image48 string `json:"image_48"` - Image72 string `json:"image_72"` - Image192 string `json:"image_192"` - Image512 string `json:"image_512"` - ImageOriginal string `json:"image_original,omitempty"` - Title string `json:"title,omitempty"` - BotID string `json:"bot_id,omitempty"` - ApiAppID string `json:"api_app_id,omitempty"` - StatusText string `json:"status_text,omitempty"` - StatusEmoji string `json:"status_emoji,omitempty"` - StatusEmojiDisplayInfo []UserProfileStatusEmojiDisplayInfo `json:"status_emoji_display_info,omitempty"` - StatusExpiration int `json:"status_expiration,omitempty"` - Team string `json:"team"` - Fields UserProfileCustomFields `json:"fields,omitempty"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + RealName string `json:"real_name"` + RealNameNormalized string `json:"real_name_normalized"` + DisplayName string `json:"display_name"` + DisplayNameNormalized string `json:"display_name_normalized"` + Pronouns string `json:"pronouns,omitempty"` + AvatarHash string `json:"avatar_hash"` + Email string `json:"email,omitempty"` + Skype string `json:"skype,omitempty"` + Phone string `json:"phone,omitempty"` + Image24 string `json:"image_24"` + Image32 string `json:"image_32"` + Image48 string `json:"image_48"` + Image72 string `json:"image_72"` + Image192 string `json:"image_192"` + Image512 string `json:"image_512"` + Image1024 string `json:"image_1024,omitempty"` + ImageOriginal string `json:"image_original,omitempty"` + IsCustomImage bool `json:"is_custom_image,omitempty"` + Title string `json:"title,omitempty"` + BotID string `json:"bot_id,omitempty"` + ApiAppID string `json:"api_app_id,omitempty"` + AlwaysActive bool `json:"always_active,omitempty"` + StatusText string `json:"status_text,omitempty"` + StatusEmoji string `json:"status_emoji,omitempty"` + StatusEmojiDisplayInfo []UserProfileStatusEmojiDisplayInfo `json:"status_emoji_display_info,omitempty"` + StatusExpiration int `json:"status_expiration,omitempty"` + StatusTextCanonical string `json:"status_text_canonical,omitempty"` + HuddleState string `json:"huddle_state,omitempty"` + HuddleStateExpirationTS int `json:"huddle_state_expiration_ts,omitempty"` + StartDate string `json:"start_date,omitempty"` + Team string `json:"team"` + Fields UserProfileCustomFields `json:"fields,omitempty"` } type UserProfileStatusEmojiDisplayInfo struct { @@ -111,33 +119,34 @@ type UserProfileCustomField struct { // User contains all the information of a user type User struct { - ID string `json:"id"` - TeamID string `json:"team_id"` - Name string `json:"name"` - Deleted bool `json:"deleted"` - Color string `json:"color"` - RealName string `json:"real_name"` - TZ string `json:"tz,omitempty"` - TZLabel string `json:"tz_label"` - TZOffset int `json:"tz_offset"` - Profile UserProfile `json:"profile"` - IsBot bool `json:"is_bot"` - IsAdmin bool `json:"is_admin"` - IsOwner bool `json:"is_owner"` - IsPrimaryOwner bool `json:"is_primary_owner"` - IsRestricted bool `json:"is_restricted"` - IsUltraRestricted bool `json:"is_ultra_restricted"` - IsStranger bool `json:"is_stranger"` - IsAppUser bool `json:"is_app_user"` - IsInvitedUser bool `json:"is_invited_user"` - IsEmailConfirmed bool `json:"is_email_confirmed"` - Has2FA bool `json:"has_2fa"` - TwoFactorType *string `json:"two_factor_type"` - HasFiles bool `json:"has_files"` - Presence string `json:"presence"` - Locale string `json:"locale"` - Updated JSONTime `json:"updated"` - Enterprise EnterpriseUser `json:"enterprise_user,omitempty"` + ID string `json:"id"` + TeamID string `json:"team_id"` + Name string `json:"name"` + Deleted bool `json:"deleted"` + Color string `json:"color"` + RealName string `json:"real_name"` + TZ string `json:"tz,omitempty"` + TZLabel string `json:"tz_label"` + TZOffset int `json:"tz_offset"` + Profile UserProfile `json:"profile"` + IsBot bool `json:"is_bot"` + IsAdmin bool `json:"is_admin"` + IsOwner bool `json:"is_owner"` + IsPrimaryOwner bool `json:"is_primary_owner"` + IsRestricted bool `json:"is_restricted"` + IsUltraRestricted bool `json:"is_ultra_restricted"` + IsStranger bool `json:"is_stranger"` + IsAppUser bool `json:"is_app_user"` + IsInvitedUser bool `json:"is_invited_user"` + IsEmailConfirmed bool `json:"is_email_confirmed"` + Has2FA bool `json:"has_2fa"` + TwoFactorType *string `json:"two_factor_type"` + HasFiles bool `json:"has_files"` + Presence string `json:"presence"` + Locale string `json:"locale"` + Updated JSONTime `json:"updated"` + WhoCanShareContactCard string `json:"who_can_share_contact_card,omitempty"` + Enterprise EnterpriseUser `json:"enterprise_user,omitempty"` } // UserPresence contains details about a user online status @@ -176,6 +185,7 @@ type EnterpriseUser struct { EnterpriseName string `json:"enterprise_name"` IsAdmin bool `json:"is_admin"` IsOwner bool `json:"is_owner"` + IsPrimaryOwner bool `json:"is_primary_owner"` Teams []string `json:"teams"` } diff --git a/users_test.go b/users_test.go index 49cf31fa5..97b313551 100644 --- a/users_test.go +++ b/users_test.go @@ -761,6 +761,134 @@ func TestGetUsersHandlesRateLimit(t *testing.T) { } } +func TestUserUnmarshalJSON(t *testing.T) { + userJSON := `{ + "id": "U12345678", + "team_id": "T12345678", + "name": "testuser", + "deleted": false, + "color": "4bbe2e", + "real_name": "Test User", + "tz": "America/Los_Angeles", + "tz_label": "Pacific Daylight Time", + "tz_offset": -25200, + "profile": { + "first_name": "Test", + "last_name": "User", + "real_name": "Test User", + "real_name_normalized": "Test User", + "display_name": "testuser", + "display_name_normalized": "testuser", + "pronouns": "they/them", + "avatar_hash": "abc123", + "email": "test@example.com", + "skype": "", + "phone": "+1234567890", + "image_24": "https://example.com/24.png", + "image_32": "https://example.com/32.png", + "image_48": "https://example.com/48.png", + "image_72": "https://example.com/72.png", + "image_192": "https://example.com/192.png", + "image_512": "https://example.com/512.png", + "image_1024": "https://example.com/1024.png", + "image_original": "https://example.com/original.png", + "is_custom_image": true, + "always_active": true, + "status_text": "Working", + "status_emoji": ":computer:", + "status_expiration": 0, + "status_text_canonical": "", + "huddle_state": "in_a_huddle", + "huddle_state_expiration_ts": 1648596421, + "start_date": "2022-01-01", + "team": "T12345678", + "fields": [] + }, + "is_bot": false, + "is_admin": true, + "is_owner": false, + "is_primary_owner": false, + "is_restricted": false, + "is_ultra_restricted": false, + "is_stranger": false, + "is_app_user": false, + "is_invited_user": false, + "is_email_confirmed": true, + "has_2fa": false, + "has_files": true, + "presence": "active", + "locale": "en-US", + "updated": 1648596421, + "who_can_share_contact_card": "EVERYONE", + "enterprise_user": { + "id": "E12345678", + "enterprise_id": "E99999999", + "enterprise_name": "Test Enterprise", + "is_admin": false, + "is_owner": false, + "is_primary_owner": false, + "teams": ["T12345678", "T87654321"] + } + }` + + var user User + if err := json.Unmarshal([]byte(userJSON), &user); err != nil { + t.Fatalf("Failed to unmarshal User: %s", err) + } + + // Verify User fields + if user.WhoCanShareContactCard != "EVERYONE" { + t.Fatalf(`user.WhoCanShareContactCard = %q, want "EVERYONE"`, user.WhoCanShareContactCard) + } + + // Verify UserProfile fields + if user.Profile.AlwaysActive != true { + t.Fatalf(`user.Profile.AlwaysActive = %v, want true`, user.Profile.AlwaysActive) + } + if user.Profile.Pronouns != "they/them" { + t.Fatalf(`user.Profile.Pronouns = %q, want "they/them"`, user.Profile.Pronouns) + } + if user.Profile.Image1024 != "https://example.com/1024.png" { + t.Fatalf(`user.Profile.Image1024 = %q, want "https://example.com/1024.png"`, user.Profile.Image1024) + } + if user.Profile.IsCustomImage != true { + t.Fatalf(`user.Profile.IsCustomImage = %v, want true`, user.Profile.IsCustomImage) + } + if user.Profile.HuddleState != "in_a_huddle" { + t.Fatalf(`user.Profile.HuddleState = %q, want "in_a_huddle"`, user.Profile.HuddleState) + } + if user.Profile.HuddleStateExpirationTS != 1648596421 { + t.Fatalf(`user.Profile.HuddleStateExpirationTS = %d, want 1648596421`, user.Profile.HuddleStateExpirationTS) + } + if user.Profile.StartDate != "2022-01-01" { + t.Fatalf(`user.Profile.StartDate = %q, want "2022-01-01"`, user.Profile.StartDate) + } + if user.Profile.StatusTextCanonical != "" { + t.Fatalf(`user.Profile.StatusTextCanonical = %q, want ""`, user.Profile.StatusTextCanonical) + } + + // Verify EnterpriseUser fields + if user.Enterprise.IsPrimaryOwner != false { + t.Fatalf(`user.Enterprise.IsPrimaryOwner = %v, want false`, user.Enterprise.IsPrimaryOwner) + } + if user.Enterprise.EnterpriseID != "E99999999" { + t.Fatalf(`user.Enterprise.EnterpriseID = %q, want "E99999999"`, user.Enterprise.EnterpriseID) + } + + // Verify round-trip: marshal and unmarshal should produce the same result + marshaled, err := json.Marshal(user) + if err != nil { + t.Fatalf("Failed to marshal User: %s", err) + } + var roundTripped User + if err := json.Unmarshal(marshaled, &roundTripped); err != nil { + t.Fatalf("Failed to unmarshal round-tripped User: %s", err) + } + if !reflect.DeepEqual(user, roundTripped) { + t.Fatal("Round-trip marshal/unmarshal produced different result") + } +} + func TestGetUsersReturnsServerError(t *testing.T) { http.DefaultServeMux = new(http.ServeMux) http.HandleFunc("/users.list", func(w http.ResponseWriter, r *http.Request) { From 6551d1087976717be8819377acd7670244667da7 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Tue, 10 Feb 2026 16:30:21 +0000 Subject: [PATCH 476/606] fix(assistant): send title parameter in setSuggestedPrompts API (#1528) If title is not empty, send it in the API call. See more at https://docs.slack.dev/reference/methods/assistant.threads.setSuggestedPrompts/. Closes #1527. --- assistant.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/assistant.go b/assistant.go index 25a5d6ed1..d95e68a52 100644 --- a/assistant.go +++ b/assistant.go @@ -102,6 +102,10 @@ func (api *Client) SetAssistantThreadsSuggestedPromptsContext(ctx context.Contex values.Add("channel_id", params.ChannelID) + if params.Title != "" { + values.Add("title", params.Title) + } + // Send Prompts as JSON prompts, err := json.Marshal(params.Prompts) if err != nil { From a9bf60d4ade11eb15699813496d880c80e7965a6 Mon Sep 17 00:00:00 2001 From: Oleg Bespalov Date: Sat, 21 Feb 2026 10:05:22 +0100 Subject: [PATCH 477/606] feat(work-objects): add Work Objects support for chat unfurl, entity details, and events (#1529) ## What? Add support for Slack Work Objects so apps can show rich previews and flexpane details for links. - Chat: Unfurl with Work Object metadata (UnfurlMessageWorkObject), optional metadata-only unfurl, unfurl by unfurl_id/source (composer), and user_auth_blocks for the auth prompt. - Entity: entity.presentDetails for flexpane (EntityPresentDetailsContext, WithMetadata, WithError, WithAuth). - Events: EntityDetailsRequestedEvent and mapping for entity_details_requested. - Types: WorkObjectMetadata, WorkObjectEntity, WorkObjectExternalRef, and entity type constants in metadata.go. See https://docs.slack.dev/messaging/work-objects/ ## Why? The Slack API offers the work objects, so to be able to use them, we need to expose types and methods in the library. Closes https://github.com/slack-go/slack/issues/1510 --- chat.go | 114 +++++++++++ chat_test.go | 224 +++++++++++++++++++++ entity.go | 132 +++++++++++++ entity_test.go | 322 +++++++++++++++++++++++++++++++ metadata.go | 31 +++ slackevents/inner_events.go | 33 ++++ slackevents/inner_events_test.go | 139 +++++++++++++ 7 files changed, 995 insertions(+) create mode 100644 entity.go create mode 100644 entity_test.go diff --git a/chat.go b/chat.go index 86b1ef54b..f1832d9a0 100644 --- a/chat.go +++ b/chat.go @@ -209,6 +209,33 @@ func (api *Client) UnfurlMessageWithAuthURLContext(ctx context.Context, channelI return api.SendMessageContext(ctx, channelID, MsgOptionUnfurlAuthURL(timestamp, userAuthURL), MsgOptionCompose(options...)) } +// UnfurlMessageWorkObject unfurls a message with Work Objects metadata. +// For more details, see UnfurlMessageWorkObjectContext documentation. +func (api *Client) UnfurlMessageWorkObject(channelID, timestamp string, unfurls map[string]Attachment, metadata WorkObjectMetadata, options ...MsgOption) (string, string, string, error) { + return api.UnfurlMessageWorkObjectContext(context.Background(), channelID, timestamp, unfurls, metadata, options...) +} + +// UnfurlMessageWorkObjectContext unfurls a message with Work Objects metadata with a custom context. +// This enables rich Work Object previews as described in https://docs.slack.dev/messaging/work-objects/ +// unfurls may be nil to send only Work Object metadata (no legacy attachment unfurls). +func (api *Client) UnfurlMessageWorkObjectContext(ctx context.Context, channelID, timestamp string, unfurls map[string]Attachment, metadata WorkObjectMetadata, options ...MsgOption) (string, string, string, error) { + return api.SendMessageContext(ctx, channelID, MsgOptionUnfurlWorkObject(timestamp, unfurls, metadata), MsgOptionCompose(options...)) +} + +// UnfurlMessageByID unfurls a link in the message composer using unfurl_id and source. +// Use this when Slack sends link_shared with unfurl_id (e.g. before the message is posted). +// For more details, see UnfurlMessageByIDContext documentation. +func (api *Client) UnfurlMessageByID(unfurlID, source string, unfurls map[string]Attachment, options ...MsgOption) (string, string, string, error) { + return api.UnfurlMessageByIDContext(context.Background(), unfurlID, source, unfurls, options...) +} + +// UnfurlMessageByIDContext unfurls by unfurl_id and source with a custom context. +// Both unfurl_id and source must be provided together (alternative to channel + ts). +// Slack API docs: https://api.slack.com/methods/chat.unfurl +func (api *Client) UnfurlMessageByIDContext(ctx context.Context, unfurlID, source string, unfurls map[string]Attachment, options ...MsgOption) (string, string, string, error) { + return api.SendMessageContext(ctx, "", MsgOptionUnfurlByID(unfurlID, source, unfurls), MsgOptionCompose(options...)) +} + // SendMessage more flexible method for configuring messages. // For more details, see SendMessageContext documentation. func (api *Client) SendMessage(channel string, options ...MsgOption) (string, string, string, error) { @@ -471,6 +498,51 @@ func MsgOptionUnfurl(timestamp string, unfurls map[string]Attachment) MsgOption } } +// MsgOptionUnfurlByID unfurls using unfurl_id and source (e.g. when link is in the composer). +// Use instead of channel+ts when Slack provides unfurl_id in the link_shared event. +// unfurls may be nil; the API expects a JSON object so nil is sent as {}. +func MsgOptionUnfurlByID(unfurlID, source string, unfurls map[string]Attachment) MsgOption { + return func(config *sendConfig) error { + config.endpoint = config.apiurl + string(chatUnfurl) + config.values.Del("channel") + config.values.Del("ts") + config.values.Set("unfurl_id", unfurlID) + config.values.Set("source", source) + if unfurls == nil { + unfurls = make(map[string]Attachment) + } + unfurlsStr, err := json.Marshal(unfurls) + if err == nil { + config.values.Set("unfurls", string(unfurlsStr)) + } + return err + } +} + +// MsgOptionUnfurlMetadataOnly sets chat.unfurl endpoint with only Work Object metadata (no unfurls). +func MsgOptionUnfurlMetadataOnly(timestamp string, metadata WorkObjectMetadata) MsgOption { + return MsgOptionCompose( + func(config *sendConfig) error { + config.endpoint = config.apiurl + string(chatUnfurl) + config.values.Add("ts", timestamp) + return nil + }, + MsgOptionWorkObjectMetadata(metadata), + ) +} + +// MsgOptionUnfurlWorkObject unfurls a message with Work Objects metadata. +// When unfurls is nil, only metadata is sent (no legacy attachment unfurls). +func MsgOptionUnfurlWorkObject(timestamp string, unfurls map[string]Attachment, metadata WorkObjectMetadata) MsgOption { + if len(unfurls) > 0 { + return MsgOptionCompose( + MsgOptionUnfurl(timestamp, unfurls), + MsgOptionWorkObjectMetadata(metadata), + ) + } + return MsgOptionUnfurlMetadataOnly(timestamp, metadata) +} + // MsgOptionUnfurlAuthURL unfurls a message using an auth url based on the timestamp. func MsgOptionUnfurlAuthURL(timestamp string, userAuthURL string) MsgOption { return func(config *sendConfig) error { @@ -503,6 +575,23 @@ func MsgOptionUnfurlAuthMessage(timestamp string, msg string) MsgOption { } } +// MsgOptionUnfurlAuthBlocks sets Block Kit blocks for the auth prompt (overrides default buttons). +// See https://docs.slack.com/methods/chat.unfurl for user_auth_blocks. +func MsgOptionUnfurlAuthBlocks(timestamp string, blocks ...Block) MsgOption { + return func(config *sendConfig) error { + config.endpoint = config.apiurl + string(chatUnfurl) + config.values.Add("ts", timestamp) + if len(blocks) == 0 { + return nil + } + blocksStr, err := json.Marshal(blocks) + if err == nil { + config.values.Set("user_auth_blocks", string(blocksStr)) + } + return err + } +} + // MsgOptionResponseURL supplies a url to use as the endpoint. func MsgOptionResponseURL(url string, responseType string) MsgOption { return func(config *sendConfig) error { @@ -713,6 +802,31 @@ func MsgOptionMetadata(metadata SlackMetadata) MsgOption { } } +// MsgOptionWorkObjectMetadata sets Work Objects metadata for unfurls and messages +// This enables Work Objects support as described in https://docs.slack.dev/messaging/work-objects/ +// If metadata.Entities is nil, it is marshaled as [] so the API receives a valid entities array. +func MsgOptionWorkObjectMetadata(metadata WorkObjectMetadata) MsgOption { + return func(config *sendConfig) error { + metaToMarshal := metadata + if metaToMarshal.Entities == nil { + metaToMarshal.Entities = []WorkObjectEntity{} + } + meta, err := json.Marshal(metaToMarshal) + if err == nil { + config.values.Set("metadata", string(meta)) + } + return err + } +} + +// MsgOptionWorkObjectEntity creates Work Objects metadata with a single entity +// This is a convenience function for the common case of unfurling a single Work Object +func MsgOptionWorkObjectEntity(entity WorkObjectEntity) MsgOption { + return MsgOptionWorkObjectMetadata(WorkObjectMetadata{ + Entities: []WorkObjectEntity{entity}, + }) +} + // MsgOptionLinkNames finds and links user groups. Does not support linking individual users func MsgOptionLinkNames(linkName bool) MsgOption { return func(config *sendConfig) error { diff --git a/chat_test.go b/chat_test.go index 0071c3ce6..ac4960542 100644 --- a/chat_test.go +++ b/chat_test.go @@ -185,6 +185,61 @@ func TestPostMessage(t *testing.T) { "user_auth_message": []string{"Please!"}, }, }, + "UnfurlAuthBlocks": { + endpoint: "/chat.unfurl", + opt: []MsgOption{ + MsgOptionUnfurlAuthBlocks("123", NewSectionBlock(NewTextBlockObject(MarkdownType, "*Authenticate* to view", false, false), nil, nil)), + }, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "ts": []string{"123"}, + "user_auth_blocks": []string{`[{"type":"section","text":{"type":"mrkdwn","text":"*Authenticate* to view"}}]`}, + }, + }, + "UnfurlByID": { + endpoint: "/chat.unfurl", + opt: []MsgOption{ + MsgOptionUnfurlByID("Uxxxxxxx-909b5454-75f8-4ac4-b325-1b40e230bbd8", "composer", map[string]Attachment{"https://example.com": {Text: "Preview"}}), + }, + expected: url.Values{ + "token": []string{"testing-token"}, + "unfurl_id": []string{"Uxxxxxxx-909b5454-75f8-4ac4-b325-1b40e230bbd8"}, + "source": []string{"composer"}, + "unfurls": []string{`{"https://example.com":{"text":"Preview","blocks":null}}`}, + }, + }, + "UnfurlByIDWithNilUnfurls": { + endpoint: "/chat.unfurl", + opt: []MsgOption{ + MsgOptionUnfurlByID("uf-123", "composer", nil), + }, + expected: url.Values{ + "token": []string{"testing-token"}, + "unfurl_id": []string{"uf-123"}, + "source": []string{"composer"}, + "unfurls": []string{`{}`}, + }, + }, + "UnfurlWorkObjectMetadataOnly": { + endpoint: "/chat.unfurl", + opt: []MsgOption{ + MsgOptionUnfurlWorkObject("123", nil, WorkObjectMetadata{ + Entities: []WorkObjectEntity{{ + URL: "https://example.com/doc/1", + ExternalRef: WorkObjectExternalRef{ID: "1"}, + EntityType: EntityTypeFile, + EntityPayload: map[string]interface{}{"title": "Doc"}, + }}, + }), + }, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "ts": []string{"123"}, + "metadata": []string{`{"entities":[{"url":"https://example.com/doc/1","external_ref":{"id":"1"},"entity_type":"slack#/entities/file","entity_payload":{"title":"Doc"}}]}`}, + }, + }, "LinkNames true": { endpoint: "/chat.postMessage", opt: []MsgOption{ @@ -519,6 +574,175 @@ func TestStartStream(t *testing.T) { } } +func TestWorkObjectMetadata(t *testing.T) { + // Test WorkObjectMetadata marshaling + metadata := WorkObjectMetadata{ + Entities: []WorkObjectEntity{ + { + AppUnfurlURL: "https://example.com/document/123?eid=123456&edit=abcxyz", + URL: "https://example.com/document/123", + ExternalRef: WorkObjectExternalRef{ + ID: "123", + Type: "document", + }, + EntityType: "slack#/entities/file", + EntityPayload: map[string]interface{}{ + "title": "Test Document", + "description": "A test document for Work Objects", + }, + }, + }, + } + + // Test JSON marshaling + jsonData, err := json.Marshal(metadata) + if err != nil { + t.Errorf("Failed to marshal WorkObjectMetadata: %v", err) + } + + // Test JSON unmarshaling + var unmarshaled WorkObjectMetadata + err = json.Unmarshal(jsonData, &unmarshaled) + if err != nil { + t.Errorf("Failed to unmarshal WorkObjectMetadata: %v", err) + } + + // Verify the data + if len(unmarshaled.Entities) != 1 { + t.Errorf("Expected 1 entity, got %d", len(unmarshaled.Entities)) + } + + entity := unmarshaled.Entities[0] + if entity.URL != "https://example.com/document/123" { + t.Errorf("Expected URL 'https://example.com/document/123', got '%s'", entity.URL) + } + + if entity.ExternalRef.ID != "123" { + t.Errorf("Expected external ref ID '123', got '%s'", entity.ExternalRef.ID) + } + + if entity.EntityType != "slack#/entities/file" { + t.Errorf("Expected entity type 'slack#/entities/file', got '%s'", entity.EntityType) + } +} + +func TestMsgOptionWorkObjectMetadata(t *testing.T) { + metadata := WorkObjectMetadata{ + Entities: []WorkObjectEntity{ + { + URL: "https://example.com/task/456", + ExternalRef: WorkObjectExternalRef{ + ID: "456", + }, + EntityType: "slack#/entities/task", + EntityPayload: map[string]interface{}{ + "title": "Test Task", + "status": "in_progress", + }, + }, + }, + } + + // Create a sendConfig to test the option + config := &sendConfig{ + values: url.Values{}, + } + + // Apply the option + opt := MsgOptionWorkObjectMetadata(metadata) + err := opt(config) + if err != nil { + t.Errorf("MsgOptionWorkObjectMetadata returned error: %v", err) + } + + // Check that metadata was set + metadataValue := config.values.Get("metadata") + if metadataValue == "" { + t.Error("Expected metadata to be set, but it was empty") + } + + // Verify the JSON structure + var result WorkObjectMetadata + err = json.Unmarshal([]byte(metadataValue), &result) + if err != nil { + t.Errorf("Failed to unmarshal metadata JSON: %v", err) + } + + if len(result.Entities) != 1 { + t.Errorf("Expected 1 entity, got %d", len(result.Entities)) + } +} + +func TestMsgOptionWorkObjectMetadataNilEntities(t *testing.T) { + // When Entities is nil, we should marshal as "entities":[] for API compatibility + metadata := WorkObjectMetadata{Entities: nil} + config := &sendConfig{values: url.Values{}} + opt := MsgOptionWorkObjectMetadata(metadata) + if err := opt(config); err != nil { + t.Errorf("MsgOptionWorkObjectMetadata with nil Entities returned error: %v", err) + } + metadataValue := config.values.Get("metadata") + if metadataValue == "" { + t.Error("Expected metadata to be set") + } + if metadataValue != `{"entities":[]}` { + t.Errorf("Expected metadata with empty entities array, got %q", metadataValue) + } +} + +func TestMsgOptionWorkObjectEntity(t *testing.T) { + entity := WorkObjectEntity{ + URL: "https://example.com/incident/789", + ExternalRef: WorkObjectExternalRef{ + ID: "789", + Type: "incident", + }, + EntityType: "slack#/entities/incident", + EntityPayload: map[string]interface{}{ + "title": "Production Outage", + "severity": "high", + }, + } + + // Create a sendConfig to test the option + config := &sendConfig{ + values: url.Values{}, + } + + // Apply the option + opt := MsgOptionWorkObjectEntity(entity) + err := opt(config) + if err != nil { + t.Errorf("MsgOptionWorkObjectEntity returned error: %v", err) + } + + // Check that metadata was set + metadataValue := config.values.Get("metadata") + if metadataValue == "" { + t.Error("Expected metadata to be set, but it was empty") + } + + // Verify the JSON structure + var result WorkObjectMetadata + err = json.Unmarshal([]byte(metadataValue), &result) + if err != nil { + t.Errorf("Failed to unmarshal metadata JSON: %v", err) + } + + if len(result.Entities) != 1 { + t.Errorf("Expected 1 entity, got %d", len(result.Entities)) + } + + resultEntity := result.Entities[0] + if resultEntity.URL != entity.URL { + t.Errorf("Expected URL '%s', got '%s'", entity.URL, resultEntity.URL) + } + + if resultEntity.ExternalRef.ID != entity.ExternalRef.ID { + t.Errorf("Expected external ref ID '%s', got '%s'", entity.ExternalRef.ID, resultEntity.ExternalRef.ID) + } +} + func TestAppendStream(t *testing.T) { type messageTest struct { endpoint string diff --git a/entity.go b/entity.go new file mode 100644 index 000000000..815de4d75 --- /dev/null +++ b/entity.go @@ -0,0 +1,132 @@ +package slack + +import ( + "context" + "encoding/json" + "net/url" +) + +// EntityPresentDetailsParameters contains the parameters for entity.presentDetails API method +type EntityPresentDetailsParameters struct { + TriggerID string `json:"trigger_id"` + Metadata *EntityDetailsMetadata `json:"metadata,omitempty"` + Error *EntityDetailsError `json:"error,omitempty"` + UserAuthRequired bool `json:"user_auth_required,omitempty"` + UserAuthURL string `json:"user_auth_url,omitempty"` + UserAuthMessage string `json:"user_auth_message,omitempty"` +} + +// EntityDetailsMetadata represents the metadata for entity details +type EntityDetailsMetadata struct { + EntityType string `json:"entity_type"` + URL string `json:"url,omitempty"` + ExternalRef WorkObjectExternalRef `json:"external_ref,omitempty"` + EntityPayload map[string]interface{} `json:"entity_payload"` +} + +// EntityDetailsError represents an error response for entity details +type EntityDetailsError struct { + Status string `json:"status"` + CustomTitle string `json:"custom_title,omitempty"` + CustomMessage string `json:"custom_message,omitempty"` + MessageFormat string `json:"message_format,omitempty"` + Actions []EntityDetailsAction `json:"actions,omitempty"` +} + +// EntityDetailsAction represents an action button in entity details error +type EntityDetailsAction struct { + Text string `json:"text"` + ActionID string `json:"action_id"` + Value string `json:"value,omitempty"` + Style string `json:"style,omitempty"` + URL string `json:"url,omitempty"` + ProcessingState *EntityDetailsProcessingState `json:"processing_state,omitempty"` +} + +// EntityDetailsProcessingState represents the processing state of an action +type EntityDetailsProcessingState struct { + Enabled bool `json:"enabled"` +} + +// EntityPresentDetailsResponse represents the response from entity.presentDetails +type EntityPresentDetailsResponse struct { + SlackResponse +} + +// EntityPresentDetails presents entity details in the flexpane +// For more details, see EntityPresentDetailsContext documentation. +func (api *Client) EntityPresentDetails(params EntityPresentDetailsParameters) error { + return api.EntityPresentDetailsContext(context.Background(), params) +} + +// EntityPresentDetailsContext presents entity details in the flexpane with a custom context. +// Slack API docs: https://docs.slack.dev/reference/methods/entity.presentDetails +func (api *Client) EntityPresentDetailsContext(ctx context.Context, params EntityPresentDetailsParameters) error { + values := url.Values{ + "token": {api.token}, + "trigger_id": {params.TriggerID}, + } + + // Add metadata if provided + if params.Metadata != nil { + metadataJSON, err := json.Marshal(params.Metadata) + if err != nil { + return err + } + values.Set("metadata", string(metadataJSON)) + } + + // Add error if provided + if params.Error != nil { + errorJSON, err := json.Marshal(params.Error) + if err != nil { + return err + } + values.Set("error", string(errorJSON)) + } + + // Add user auth parameters if provided + if params.UserAuthRequired { + values.Set("user_auth_required", "true") + } + if params.UserAuthURL != "" { + values.Set("user_auth_url", params.UserAuthURL) + } + if params.UserAuthMessage != "" { + values.Set("user_auth_message", params.UserAuthMessage) + } + + response := &EntityPresentDetailsResponse{} + err := api.postMethod(ctx, "entity.presentDetails", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// EntityPresentDetailsWithMetadata is a convenience method for presenting entity details with metadata +func (api *Client) EntityPresentDetailsWithMetadata(triggerID string, metadata EntityDetailsMetadata) error { + return api.EntityPresentDetailsContext(context.Background(), EntityPresentDetailsParameters{ + TriggerID: triggerID, + Metadata: &metadata, + }) +} + +// EntityPresentDetailsWithError is a convenience method for presenting entity details with an error +func (api *Client) EntityPresentDetailsWithError(triggerID string, errPayload EntityDetailsError) error { + return api.EntityPresentDetailsContext(context.Background(), EntityPresentDetailsParameters{ + TriggerID: triggerID, + Error: &errPayload, + }) +} + +// EntityPresentDetailsWithAuth is a convenience method for presenting entity details with authentication required +func (api *Client) EntityPresentDetailsWithAuth(triggerID, authURL, authMessage string) error { + return api.EntityPresentDetailsContext(context.Background(), EntityPresentDetailsParameters{ + TriggerID: triggerID, + UserAuthRequired: true, + UserAuthURL: authURL, + UserAuthMessage: authMessage, + }) +} diff --git a/entity_test.go b/entity_test.go new file mode 100644 index 000000000..dbc772fe4 --- /dev/null +++ b/entity_test.go @@ -0,0 +1,322 @@ +package slack + +import ( + "encoding/json" + "net/http" + "testing" +) + +func TestEntityPresentDetailsParameters(t *testing.T) { + // Test basic parameter structure + params := EntityPresentDetailsParameters{ + TriggerID: "1234567890123.1234567890123.abcdef01234567890abcdef012345689", + Metadata: &EntityDetailsMetadata{ + EntityType: "slack#/entities/file", + URL: "https://example.com/document/123", + ExternalRef: WorkObjectExternalRef{ + ID: "123", + Type: "document", + }, + EntityPayload: map[string]interface{}{ + "title": "Test Document", + "description": "A test document for Work Objects", + "status": "active", + }, + }, + } + + // Test JSON marshaling + jsonData, err := json.Marshal(params) + if err != nil { + t.Errorf("Failed to marshal EntityPresentDetailsParameters: %v", err) + } + + // Test JSON unmarshaling + var unmarshaled EntityPresentDetailsParameters + err = json.Unmarshal(jsonData, &unmarshaled) + if err != nil { + t.Errorf("Failed to unmarshal EntityPresentDetailsParameters: %v", err) + } + + // Verify the data + if unmarshaled.TriggerID != params.TriggerID { + t.Errorf("Expected trigger_id '%s', got '%s'", params.TriggerID, unmarshaled.TriggerID) + } + + if unmarshaled.Metadata.EntityType != params.Metadata.EntityType { + t.Errorf("Expected entity_type '%s', got '%s'", params.Metadata.EntityType, unmarshaled.Metadata.EntityType) + } + + if unmarshaled.Metadata.ExternalRef.ID != params.Metadata.ExternalRef.ID { + t.Errorf("Expected external_ref.id '%s', got '%s'", params.Metadata.ExternalRef.ID, unmarshaled.Metadata.ExternalRef.ID) + } +} + +func TestEntityDetailsError(t *testing.T) { + // Test error structure + errorObj := EntityDetailsError{ + Status: "restricted", + CustomTitle: "Access Denied", + CustomMessage: "You do not have permission to view this entity.", + MessageFormat: "markdown", + Actions: []EntityDetailsAction{ + { + Text: "Request Access", + ActionID: "request_access", + Value: "entity_123", + Style: "primary", + URL: "https://example.com/request-access", + ProcessingState: &EntityDetailsProcessingState{ + Enabled: true, + }, + }, + }, + } + + // Test JSON marshaling + jsonData, err := json.Marshal(errorObj) + if err != nil { + t.Errorf("Failed to marshal EntityDetailsError: %v", err) + } + + // Test JSON unmarshaling + var unmarshaled EntityDetailsError + err = json.Unmarshal(jsonData, &unmarshaled) + if err != nil { + t.Errorf("Failed to unmarshal EntityDetailsError: %v", err) + } + + // Verify the data + if unmarshaled.Status != errorObj.Status { + t.Errorf("Expected status '%s', got '%s'", errorObj.Status, unmarshaled.Status) + } + + if unmarshaled.CustomTitle != errorObj.CustomTitle { + t.Errorf("Expected custom_title '%s', got '%s'", errorObj.CustomTitle, unmarshaled.CustomTitle) + } + + if len(unmarshaled.Actions) != 1 { + t.Errorf("Expected 1 action, got %d", len(unmarshaled.Actions)) + } + + if len(unmarshaled.Actions) > 0 { + action := unmarshaled.Actions[0] + if action.Text != "Request Access" { + t.Errorf("Expected action text 'Request Access', got '%s'", action.Text) + } + if action.ProcessingState == nil || !action.ProcessingState.Enabled { + t.Error("Expected processing state to be enabled") + } + } +} + +func TestEntityPresentDetailsWithMetadata(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/entity.presentDetails", func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + // Verify the request + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + + // Parse form data + err := r.ParseForm() + if err != nil { + t.Errorf("Failed to parse form: %v", err) + return + } + + // Check required fields + triggerID := r.FormValue("trigger_id") + if triggerID != "1234567890123.1234567890123.abcdef01234567890abcdef012345689" { + t.Errorf("Expected trigger_id '1234567890123.1234567890123.abcdef01234567890abcdef012345689', got '%s'", triggerID) + } + + // Check metadata + metadataStr := r.FormValue("metadata") + if metadataStr == "" { + t.Error("Expected metadata to be present") + } else { + var metadata EntityDetailsMetadata + err := json.Unmarshal([]byte(metadataStr), &metadata) + if err != nil { + t.Errorf("Failed to unmarshal metadata: %v", err) + } + if metadata.EntityType != "slack#/entities/file" { + t.Errorf("Expected entity_type 'slack#/entities/file', got '%s'", metadata.EntityType) + } + } + + // Return success response + response := EntityPresentDetailsResponse{ + SlackResponse: SlackResponse{Ok: true}, + } + jsonResponse, _ := json.Marshal(response) + rw.Write(jsonResponse) + }) + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + metadata := EntityDetailsMetadata{ + EntityType: "slack#/entities/file", + URL: "https://example.com/document/123", + ExternalRef: WorkObjectExternalRef{ + ID: "123", + Type: "document", + }, + EntityPayload: map[string]interface{}{ + "title": "Test Document", + "description": "A test document for Work Objects", + }, + } + + err := api.EntityPresentDetailsWithMetadata("1234567890123.1234567890123.abcdef01234567890abcdef012345689", metadata) + if err != nil { + t.Errorf("EntityPresentDetailsWithMetadata returned error: %v", err) + } +} + +func TestEntityPresentDetailsWithError(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/entity.presentDetails", func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + // Parse form data + err := r.ParseForm() + if err != nil { + t.Errorf("Failed to parse form: %v", err) + return + } + + // Check error field + errorStr := r.FormValue("error") + if errorStr == "" { + t.Error("Expected error to be present") + } else { + var errorObj EntityDetailsError + err := json.Unmarshal([]byte(errorStr), &errorObj) + if err != nil { + t.Errorf("Failed to unmarshal error: %v", err) + } + if errorObj.Status != "restricted" { + t.Errorf("Expected error status 'restricted', got '%s'", errorObj.Status) + } + } + + // Return success response + response := EntityPresentDetailsResponse{ + SlackResponse: SlackResponse{Ok: true}, + } + jsonResponse, _ := json.Marshal(response) + rw.Write(jsonResponse) + }) + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + errorObj := EntityDetailsError{ + Status: "restricted", + CustomTitle: "Access Denied", + CustomMessage: "You do not have permission to view this entity.", + Actions: []EntityDetailsAction{ + { + Text: "Request Access", + ActionID: "request_access", + Style: "primary", + }, + }, + } + + err := api.EntityPresentDetailsWithError("1234567890123.1234567890123.abcdef01234567890abcdef012345689", errorObj) + if err != nil { + t.Errorf("EntityPresentDetailsWithError returned error: %v", err) + } +} + +func TestEntityPresentDetailsWithAuth(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/entity.presentDetails", func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + // Parse form data + err := r.ParseForm() + if err != nil { + t.Errorf("Failed to parse form: %v", err) + return + } + + // Check auth fields + userAuthRequired := r.FormValue("user_auth_required") + if userAuthRequired != "true" { + t.Errorf("Expected user_auth_required 'true', got '%s'", userAuthRequired) + } + + userAuthURL := r.FormValue("user_auth_url") + if userAuthURL != "https://example.com/auth" { + t.Errorf("Expected user_auth_url 'https://example.com/auth', got '%s'", userAuthURL) + } + + userAuthMessage := r.FormValue("user_auth_message") + if userAuthMessage != "Please authenticate to view this entity." { + t.Errorf("Expected user_auth_message 'Please authenticate to view this entity.', got '%s'", userAuthMessage) + } + + // Return success response + response := EntityPresentDetailsResponse{ + SlackResponse: SlackResponse{Ok: true}, + } + jsonResponse, _ := json.Marshal(response) + rw.Write(jsonResponse) + }) + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.EntityPresentDetailsWithAuth( + "1234567890123.1234567890123.abcdef01234567890abcdef012345689", + "https://example.com/auth", + "Please authenticate to view this entity.", + ) + if err != nil { + t.Errorf("EntityPresentDetailsWithAuth returned error: %v", err) + } +} + +func TestEntityPresentDetailsContext(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/entity.presentDetails", func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + // Return success response + response := EntityPresentDetailsResponse{ + SlackResponse: SlackResponse{Ok: true}, + } + jsonResponse, _ := json.Marshal(response) + rw.Write(jsonResponse) + }) + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := EntityPresentDetailsParameters{ + TriggerID: "1234567890123.1234567890123.abcdef01234567890abcdef012345689", + Metadata: &EntityDetailsMetadata{ + EntityType: "slack#/entities/task", + URL: "https://example.com/task/456", + ExternalRef: WorkObjectExternalRef{ + ID: "456", + }, + EntityPayload: map[string]interface{}{ + "title": "Test Task", + "status": "in_progress", + }, + }, + } + + err := api.EntityPresentDetails(params) + if err != nil { + t.Errorf("EntityPresentDetails returned error: %v", err) + } +} diff --git a/metadata.go b/metadata.go index e0e82957b..a1e34e990 100644 --- a/metadata.go +++ b/metadata.go @@ -5,3 +5,34 @@ type SlackMetadata struct { EventType string `json:"event_type"` EventPayload map[string]any `json:"event_payload"` } + +// Work Object entity type constants. +// See https://docs.slack.dev/messaging/work-objects/ +const ( + EntityTypeTask = "slack#/entities/task" + EntityTypeFile = "slack#/entities/file" + EntityTypeItem = "slack#/entities/item" + EntityTypeIncident = "slack#/entities/incident" + EntityTypeContentItem = "slack#/entities/content_item" +) + +// WorkObjectExternalRef represents an external reference for a Work Object +type WorkObjectExternalRef struct { + ID string `json:"id"` + Type string `json:"type,omitempty"` +} + +// WorkObjectEntity represents a single Work Object entity +type WorkObjectEntity struct { + AppUnfurlURL string `json:"app_unfurl_url,omitempty"` + URL string `json:"url"` + ExternalRef WorkObjectExternalRef `json:"external_ref"` + EntityType string `json:"entity_type"` + EntityPayload map[string]interface{} `json:"entity_payload"` +} + +// WorkObjectMetadata represents the metadata for Work Objects +// Used in chat.unfurl and chat.postMessage for Work Objects support +type WorkObjectMetadata struct { + Entities []WorkObjectEntity `json:"entities"` +} diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index f37d52fa5..058a5725c 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -1130,6 +1130,36 @@ type UserStatusChangedEvent struct { EventTS string `json:"event_ts"` } +// EntityDetailsRequestedEvent is sent when entity details are requested +// This event is fired when a user clicks on a Work Object link and Slack requests +// details about the entity from your app. +type EntityDetailsRequestedEvent struct { + Type string `json:"type"` + User string `json:"user"` + ExternalRef EntityDetailsRequestedExternalRef `json:"external_ref"` + EntityURL string `json:"entity_url"` + Link EntityDetailsRequestedLink `json:"link"` + AppUnfurlURL string `json:"app_unfurl_url"` + EventTS string `json:"event_ts"` + TriggerID string `json:"trigger_id"` + UserLocale string `json:"user_locale"` + Channel string `json:"channel,omitempty"` + MessageTs string `json:"message_ts,omitempty"` + ThreadTs string `json:"thread_ts,omitempty"` +} + +// EntityDetailsRequestedExternalRef represents the external reference in entity_details_requested event +type EntityDetailsRequestedExternalRef struct { + ID string `json:"id"` + Type string `json:"type,omitempty"` +} + +// EntityDetailsRequestedLink represents the link information in entity_details_requested event +type EntityDetailsRequestedLink struct { + URL string `json:"url"` + Domain string `json:"domain"` +} + type Actor struct { ID string `json:"id"` Name string `json:"name"` @@ -1339,6 +1369,8 @@ const ( UserStatusChanged = EventsAPIType("user_status_changed") // WorkflowStepExecute Happens, if a workflow step of your app is invoked WorkflowStepExecute = EventsAPIType("workflow_step_execute") + // EntityDetailsRequested is sent when entity details are requested + EntityDetailsRequested = EventsAPIType("entity_details_requested") ) // EventsAPIInnerEventMapping maps INNER Event API events to their corresponding struct @@ -1425,4 +1457,5 @@ var EventsAPIInnerEventMapping = map[EventsAPIType]interface{}{ UserHuddleChanged: UserHuddleChangedEvent{}, UserProfileChanged: UserProfileChangedEvent{}, UserStatusChanged: UserStatusChangedEvent{}, + EntityDetailsRequested: EntityDetailsRequestedEvent{}, } diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index b970445ff..78f6c84d1 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -3055,3 +3055,142 @@ func TestAppHomeOpenedEvent_FullEventParsing_WithoutView(t *testing.T) { assert.Equal(t, "1747319568.267214", appHomeEvent.EventTimeStamp) assert.Nil(t, appHomeEvent.View) } + +func TestEntityDetailsRequestedEvent(t *testing.T) { + jsonStr := `{ + "type": "entity_details_requested", + "user": "U123456789", + "trigger_id": "1234567890123.1234567890123.abcdef01234567890abcdef012345689", + "user_locale": "en-US", + "entity_url": "https://example.com/incidents/123", + "external_ref": { + "id": "123" + }, + "link": { + "url": "https://example.com/incidents/123", + "domain": "example.com" + }, + "app_unfurl_url": "https://example.com/incidents/123", + "channel": "C123456789", + "message_ts": "1234567890.123456", + "event_ts": "1234567890.123456" + }` + + var event EntityDetailsRequestedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal EntityDetailsRequestedEvent: %v", err) + } + + if event.Type != "entity_details_requested" { + t.Errorf("Expected type to be 'entity_details_requested', got %s", event.Type) + } + + if event.User != "U123456789" { + t.Errorf("Expected user to be 'U123456789', got %s", event.User) + } + + if event.ExternalRef.ID != "123" { + t.Errorf("Expected external_ref.id to be '123', got %s", event.ExternalRef.ID) + } + + if event.EntityURL != "https://example.com/incidents/123" { + t.Errorf("Expected entity_url to be 'https://example.com/incidents/123', got %s", event.EntityURL) + } + + if event.Link.URL != "https://example.com/incidents/123" { + t.Errorf("Expected link.url to be 'https://example.com/incidents/123', got %s", event.Link.URL) + } + + if event.Link.Domain != "example.com" { + t.Errorf("Expected link.domain to be 'example.com', got %s", event.Link.Domain) + } + + if event.TriggerID != "1234567890123.1234567890123.abcdef01234567890abcdef012345689" { + t.Errorf("Expected trigger_id to be '1234567890123.1234567890123.abcdef01234567890abcdef012345689', got %s", event.TriggerID) + } + + if event.EventTS != "1234567890.123456" { + t.Errorf("Expected event_ts to be '1234567890.123456', got %s", event.EventTS) + } + + if event.Channel != "C123456789" { + t.Errorf("Expected channel to be 'C123456789', got %s", event.Channel) + } + + if event.MessageTs != "1234567890.123456" { + t.Errorf("Expected message_ts to be '1234567890.123456', got %s", event.MessageTs) + } +} + +func TestParseEventAPIEntityDetailsRequested(t *testing.T) { + rawE := []byte(` + { + "token": "test-token", + "team_id": "T123456789", + "api_app_id": "A123456789", + "event": { + "type": "entity_details_requested", + "user": "U123456789", + "trigger_id": "1234567890123.1234567890123.abcdef01234567890abcdef012345689", + "user_locale": "en-US", + "entity_url": "https://example.com/incidents/123", + "external_ref": { + "id": "123" + }, + "link": { + "url": "https://example.com/incidents/123", + "domain": "example.com" + }, + "app_unfurl_url": "https://example.com/incidents/123", + "channel": "C123456789", + "message_ts": "1234567890.123456", + "event_ts": "1234567890.123456" + }, + "type": "event_callback", + "event_id": "Ev123456789", + "event_time": 1234567890 + } + `) + + parsedEvent, err := ParseEvent(rawE, OptionNoVerifyToken()) + if err != nil { + t.Errorf("Failed to parse EntityDetailsRequestedEvent: %v", err) + } + + if parsedEvent.Type != "event_callback" { + t.Errorf("Expected outer event type to be 'event_callback', got %s", parsedEvent.Type) + } + + if parsedEvent.InnerEvent.Type != "entity_details_requested" { + t.Errorf("Expected inner event type to be 'entity_details_requested', got %s", parsedEvent.InnerEvent.Type) + } + + innerEvent, ok := parsedEvent.InnerEvent.Data.(*EntityDetailsRequestedEvent) + if !ok { + t.Errorf("Expected inner event data to be *EntityDetailsRequestedEvent, got %T", parsedEvent.InnerEvent.Data) + } + + if innerEvent.Type != "entity_details_requested" { + t.Errorf("Expected inner event type to be 'entity_details_requested', got %s", innerEvent.Type) + } + + if innerEvent.User != "U123456789" { + t.Errorf("Expected user to be 'U123456789', got %s", innerEvent.User) + } + + if innerEvent.ExternalRef.ID != "123" { + t.Errorf("Expected external_ref.id to be '123', got %s", innerEvent.ExternalRef.ID) + } + + if innerEvent.TriggerID != "1234567890123.1234567890123.abcdef01234567890abcdef012345689" { + t.Errorf("Expected trigger_id to be '1234567890123.1234567890123.abcdef01234567890abcdef012345689', got %s", innerEvent.TriggerID) + } + + if innerEvent.EventTS != "1234567890.123456" { + t.Errorf("Expected event_ts to be '1234567890.123456', got %s", innerEvent.EventTS) + } + + if innerEvent.Link.Domain != "example.com" { + t.Errorf("Expected link.domain to be 'example.com', got %s", innerEvent.Link.Domain) + } +} From 16c0f77beeb7cc254b472882c68e992725a5f366 Mon Sep 17 00:00:00 2001 From: Nicolas Carlier <60900917+nicolas-carlier-ls@users.noreply.github.com> Date: Sat, 21 Feb 2026 05:28:48 -0500 Subject: [PATCH 478/606] feat(admin): Implement missing admin.roles.* API method: (#1520) Implements support for missing of admin.roles.* API methods - admin.roles.listAssignments - admin.roles.addAssignments - admin.roles.removeAssignments Co-authored-by: Norberto Lopes --- admin_roles.go | 204 ++++++++++++++++++++++++++++++++++++++++++++ admin_roles_test.go | 164 +++++++++++++++++++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 admin_roles.go create mode 100644 admin_roles_test.go diff --git a/admin_roles.go b/admin_roles.go new file mode 100644 index 000000000..676acf0bd --- /dev/null +++ b/admin_roles.go @@ -0,0 +1,204 @@ +package slack + +import ( + "context" + "net/url" + "strconv" + "strings" +) + +// AdminRolesAddAssignmentsParams contains arguments for AdminRolesAddAssignments method call. +type AdminRolesAddAssignmentsParams struct { + RoleID string + EntityIDs []string + UserIDs []string +} + +// AdminRolesRejectedUser represents a user that could not be assigned a role. +type AdminRolesRejectedUser struct { + ID string `json:"id"` + Error string `json:"error"` +} + +// AdminRolesRejectedEntity represents an entity that could not be assigned a role. +type AdminRolesRejectedEntity struct { + ID string `json:"id"` + Error string `json:"error"` +} + +// AdminRolesAddAssignmentsResponse represents the response from admin.roles.addAssignments. +type AdminRolesAddAssignmentsResponse struct { + SlackResponse + RejectedUsers []AdminRolesRejectedUser `json:"rejected_users"` + RejectedEntities []AdminRolesRejectedEntity `json:"rejected_entities"` +} + +// AdminRolesAddAssignments adds members to a specified role. +// For more information see the admin.roles.addAssignments docs: +// https://api.slack.com/methods/admin.roles.addAssignments +func (api *Client) AdminRolesAddAssignments(ctx context.Context, params AdminRolesAddAssignmentsParams) (*AdminRolesAddAssignmentsResponse, error) { + values := url.Values{ + "token": {api.token}, + "role_id": {params.RoleID}, + } + + if len(params.EntityIDs) > 0 { + values.Add("entity_ids", strings.Join(params.EntityIDs, ",")) + } + + if len(params.UserIDs) > 0 { + values.Add("user_ids", strings.Join(params.UserIDs, ",")) + } + + response := &AdminRolesAddAssignmentsResponse{} + err := api.postMethod(ctx, "admin.roles.addAssignments", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +type adminRolesListAssignmentsParams struct { + roleIDs []string + entityIDs []string + limit int + cursor string + sortDirection string +} + +// AdminRolesListAssignmentsOption is an option for AdminRolesListAssignments. +type AdminRolesListAssignmentsOption func(*adminRolesListAssignmentsParams) + +// AdminRolesListAssignmentsOptionRoleIDs filters results to the specified role IDs. +func AdminRolesListAssignmentsOptionRoleIDs(roleIDs []string) AdminRolesListAssignmentsOption { + return func(params *adminRolesListAssignmentsParams) { + params.roleIDs = roleIDs + } +} + +// AdminRolesListAssignmentsOptionEntityIDs filters results to the specified entity IDs. +func AdminRolesListAssignmentsOptionEntityIDs(entityIDs []string) AdminRolesListAssignmentsOption { + return func(params *adminRolesListAssignmentsParams) { + params.entityIDs = entityIDs + } +} + +// AdminRolesListAssignmentsOptionLimit sets the maximum number of results to return. +func AdminRolesListAssignmentsOptionLimit(limit int) AdminRolesListAssignmentsOption { + return func(params *adminRolesListAssignmentsParams) { + params.limit = limit + } +} + +// AdminRolesListAssignmentsOptionCursor sets the cursor for pagination. +func AdminRolesListAssignmentsOptionCursor(cursor string) AdminRolesListAssignmentsOption { + return func(params *adminRolesListAssignmentsParams) { + params.cursor = cursor + } +} + +// AdminRolesListAssignmentsOptionSortDir sets the sort direction. +// Valid values: "asc", "desc". +func AdminRolesListAssignmentsOptionSortDir(sortDir string) AdminRolesListAssignmentsOption { + return func(params *adminRolesListAssignmentsParams) { + params.sortDirection = sortDir + } +} + +// RoleAssignment represents a single role assignment. +type RoleAssignment struct { + RoleID string `json:"role_id"` + EntityID string `json:"entity_id,omitempty"` + UserID string `json:"user_id,omitempty"` + DateCreate int64 `json:"date_create,omitempty"` +} + +// AdminRolesListAssignmentsResponse represents the response from admin.roles.listAssignments. +type AdminRolesListAssignmentsResponse struct { + SlackResponse + RoleAssignments []RoleAssignment `json:"role_assignments"` + ResponseMetadata ResponseMetadata `json:"response_metadata"` +} + +// AdminRolesListAssignments lists assignments for roles. +// For more information see the admin.roles.listAssignments docs: +// https://api.slack.com/methods/admin.roles.listAssignments +func (api *Client) AdminRolesListAssignments(ctx context.Context, options ...AdminRolesListAssignmentsOption) (*AdminRolesListAssignmentsResponse, error) { + params := adminRolesListAssignmentsParams{} + for _, opt := range options { + opt(¶ms) + } + + values := url.Values{ + "token": {api.token}, + } + + if len(params.roleIDs) > 0 { + values.Add("role_ids", strings.Join(params.roleIDs, ",")) + } + + if len(params.entityIDs) > 0 { + values.Add("entity_ids", strings.Join(params.entityIDs, ",")) + } + + if params.limit > 0 { + values.Add("limit", strconv.Itoa(params.limit)) + } + + if params.cursor != "" { + values.Add("cursor", params.cursor) + } + + if params.sortDirection != "" { + values.Add("sort_dir", params.sortDirection) + } + + response := &AdminRolesListAssignmentsResponse{} + err := api.postMethod(ctx, "admin.roles.listAssignments", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +// AdminRolesRemoveAssignmentsParams contains arguments for AdminRolesRemoveAssignments method call. +type AdminRolesRemoveAssignmentsParams struct { + RoleID string + EntityIDs []string + UserIDs []string +} + +// AdminRolesRemoveAssignmentsResponse represents the response from admin.roles.removeAssignments. +type AdminRolesRemoveAssignmentsResponse struct { + SlackResponse + RejectedUsers []AdminRolesRejectedUser `json:"rejected_users"` + RejectedEntities []AdminRolesRejectedEntity `json:"rejected_entities"` +} + +// AdminRolesRemoveAssignments removes members from a specified role. +// For more information see the admin.roles.removeAssignments docs: +// https://api.slack.com/methods/admin.roles.removeAssignments +func (api *Client) AdminRolesRemoveAssignments(ctx context.Context, params AdminRolesRemoveAssignmentsParams) (*AdminRolesRemoveAssignmentsResponse, error) { + values := url.Values{ + "token": {api.token}, + "role_id": {params.RoleID}, + } + + if len(params.EntityIDs) > 0 { + values.Add("entity_ids", strings.Join(params.EntityIDs, ",")) + } + + if len(params.UserIDs) > 0 { + values.Add("user_ids", strings.Join(params.UserIDs, ",")) + } + + response := &AdminRolesRemoveAssignmentsResponse{} + err := api.postMethod(ctx, "admin.roles.removeAssignments", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} diff --git a/admin_roles_test.go b/admin_roles_test.go new file mode 100644 index 000000000..82787623e --- /dev/null +++ b/admin_roles_test.go @@ -0,0 +1,164 @@ +package slack + +import ( + "context" + "encoding/json" + "net/http" + "testing" +) + +func TestAdminRolesAddAssignments(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.roles.addAssignments", mockAdminRolesAddAssignmentsHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + resp, err := api.AdminRolesAddAssignments(context.Background(), AdminRolesAddAssignmentsParams{ + RoleID: "Rl0L", + UserIDs: []string{"U123", "U456"}, + EntityIDs: []string{"E123"}, + }) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if !resp.Ok { + t.Errorf("expected Ok to be true") + } +} + +func mockAdminRolesAddAssignmentsHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["role_id"]) == 0 { + t.Error("missing role_id in request") + return + } + + if len(r.Form["user_ids"]) == 0 && len(r.Form["entity_ids"]) == 0 { + t.Error("missing user_ids or entity_ids in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(AdminRolesAddAssignmentsResponse{ + SlackResponse: SlackResponse{Ok: true}, + }) + + _, _ = rw.Write(response) + } +} + +func TestAdminRolesListAssignments(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.roles.listAssignments", mockAdminRolesListAssignmentsHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + resp, err := api.AdminRolesListAssignments(context.Background(), + AdminRolesListAssignmentsOptionRoleIDs([]string{"Rl0L"}), + AdminRolesListAssignmentsOptionLimit(10), + ) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if !resp.Ok { + t.Errorf("expected Ok to be true") + } + + if len(resp.RoleAssignments) != 1 { + t.Errorf("expected 1 role assignment, got %d", len(resp.RoleAssignments)) + } + + if resp.RoleAssignments[0].RoleID != "Rl0L" { + t.Errorf("expected role ID Rl0L, got %s", resp.RoleAssignments[0].RoleID) + } +} + +func mockAdminRolesListAssignmentsHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(AdminRolesListAssignmentsResponse{ + SlackResponse: SlackResponse{Ok: true}, + RoleAssignments: []RoleAssignment{ + { + RoleID: "Rl0L", + UserID: "U123", + }, + }, + }) + + _, _ = rw.Write(response) + } +} + +func TestAdminRolesRemoveAssignments(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.roles.removeAssignments", mockAdminRolesRemoveAssignmentsHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + resp, err := api.AdminRolesRemoveAssignments(context.Background(), AdminRolesRemoveAssignmentsParams{ + RoleID: "Rl0L", + UserIDs: []string{"U123", "U456"}, + EntityIDs: []string{"E123"}, + }) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if !resp.Ok { + t.Errorf("expected Ok to be true") + } +} + +func mockAdminRolesRemoveAssignmentsHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["role_id"]) == 0 { + t.Error("missing role_id in request") + return + } + + if len(r.Form["user_ids"]) == 0 && len(r.Form["entity_ids"]) == 0 { + t.Error("missing user_ids or entity_ids in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(AdminRolesRemoveAssignmentsResponse{ + SlackResponse: SlackResponse{Ok: true}, + }) + + _, _ = rw.Write(response) + } +} From 615a158d61d2b1d843ca11a1c2bb7ba5ac315b9f Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 21 Feb 2026 10:50:19 +0000 Subject: [PATCH 479/606] chore: deprecate go 1.24 and introduce 1.26 (#1530) --- .github/workflows/test.yml | 6 +++--- go.mod | 4 ++-- mise.toml | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 242d6f54f..bd3c6e8b4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,8 +15,8 @@ jobs: strategy: matrix: go: - - "1.24" - "1.25" + - "1.26" name: test go-${{ matrix.go }} steps: - uses: actions/checkout@v6 @@ -31,8 +31,8 @@ jobs: strategy: matrix: go: - - "1.24" - "1.25" + - "1.26" name: lint go-${{ matrix.go }} steps: - uses: actions/setup-go@v6 @@ -45,7 +45,7 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: - version: v2.1.6 + version: v2.10.1 - name: Check 'go mod tidy' makes no changes run: | go mod tidy diff --git a/go.mod b/go.mod index 6ed33cbe3..2ab17a83c 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/slack-go/slack -go 1.24 +go 1.25 -toolchain go1.24.10 +toolchain go1.25.7 require ( github.com/go-test/deep v1.1.1 diff --git a/mise.toml b/mise.toml index 886e9fb71..ac7816d1f 100644 --- a/mise.toml +++ b/mise.toml @@ -1,2 +1,2 @@ [tools] -go = "1.24" +go = "1.25" From 841691540fd3ddacb364102f73f000305a149d8f Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 21 Feb 2026 11:10:40 +0000 Subject: [PATCH 480/606] chore: add missing entries to CHANGELOG --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b5a1caf3..87d81a3ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`focus_on_load` support for remaining block elements** — Static/external/users/conversations/channels select, multi-select variants, datepicker, timepicker, plain_text_input, checkboxes, radio_buttons, and number_input. ([#1519]) +- **`PlainText` and `PreviewPlainText` fields on `File`** — Email file objects now include the plain text body fields instead of silently discarding them. ([#1522]) +- **Missing fields on `User`, `UserProfile`, and `EnterpriseUser`** — `who_can_share_contact_card`, `always_active`, `pronouns`, `image_1024`, `is_custom_image`, `status_text_canonical`, `huddle_state`, `huddle_state_expiration_ts`, `start_date`, and `is_primary_owner`. ([#1526]) +- **Work Objects support** — Chat unfurl with Work Object metadata, entity details (flexpane), `entity_details_requested` event, and associated types (`WorkObjectMetadata`, `WorkObjectEntity`, `WorkObjectExternalRef`). ([#1529]) +- **`admin.roles.*` API methods** — `admin.roles.listAssignments`, `admin.roles.addAssignments`, and `admin.roles.removeAssignments`. ([#1520]) + +### Fixed + +- **`UserProfile.Skype` JSON tag** — Corrected typo from `"skyp"` to `"skype"`. ([#1524]) +- **`assistant.threads.setSuggestedPrompts` title parameter** — Title is now sent when non-empty. ([#1528]) + +### Changed + +- Minimum Go version bumped to 1.26 (dropped 1.24). ([#1530]) + ## [0.18.0-rc2] - 2026-01-28 ### Added @@ -128,6 +145,14 @@ for details. [#1515]: https://github.com/slack-go/slack/pull/1515 [#1516]: https://github.com/slack-go/slack/pull/1516 [#1517]: https://github.com/slack-go/slack/pull/1517 +[#1519]: https://github.com/slack-go/slack/pull/1519 +[#1520]: https://github.com/slack-go/slack/pull/1520 +[#1522]: https://github.com/slack-go/slack/pull/1522 +[#1524]: https://github.com/slack-go/slack/pull/1524 +[#1526]: https://github.com/slack-go/slack/pull/1526 +[#1528]: https://github.com/slack-go/slack/pull/1528 +[#1529]: https://github.com/slack-go/slack/pull/1529 +[#1530]: https://github.com/slack-go/slack/pull/1530 [Unreleased]: https://github.com/slack-go/slack/compare/v0.18.0-rc2...HEAD [0.18.0-rc2]: https://github.com/slack-go/slack/releases/tag/v0.18.0-rc2 From e5ea9692b29e2dc3701272f3b0e8224b6f990457 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 21 Feb 2026 15:47:51 +0000 Subject: [PATCH 481/606] chore: v0.18.0 --- CHANGELOG.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87d81a3ac..a58ce529b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.18.0] - 2026-02-21 + ### Added - **`focus_on_load` support for remaining block elements** — Static/external/users/conversations/channels select, multi-select variants, datepicker, timepicker, plain_text_input, checkboxes, radio_buttons, and number_input. ([#1519]) @@ -22,7 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Minimum Go version bumped to 1.26 (dropped 1.24). ([#1530]) +- CI test matrix updated: dropped Go 1.24, added Go 1.26; bumped golangci-lint to v2.10.1. ([#1530]) ## [0.18.0-rc2] - 2026-01-28 @@ -154,7 +156,8 @@ for details. [#1529]: https://github.com/slack-go/slack/pull/1529 [#1530]: https://github.com/slack-go/slack/pull/1530 -[Unreleased]: https://github.com/slack-go/slack/compare/v0.18.0-rc2...HEAD +[Unreleased]: https://github.com/slack-go/slack/compare/v0.18.0...HEAD +[0.18.0]: https://github.com/slack-go/slack/compare/v0.18.0-rc2...v0.18.0 [0.18.0-rc2]: https://github.com/slack-go/slack/releases/tag/v0.18.0-rc2 [0.18.0-rc1]: https://github.com/slack-go/slack/releases/tag/v0.18.0-rc1 [0.17.3]: https://github.com/slack-go/slack/releases/tag/v0.17.3 From a5a5f4612981ccaf05bc8729fcb96d4b172a41da Mon Sep 17 00:00:00 2001 From: Oleg Bespalov Date: Sun, 22 Feb 2026 15:38:43 +0100 Subject: [PATCH 482/606] chore: use golangci-lint in Makefile (#1533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What? Aligns linter with CI. Use the binary when present in PATH; otherwise, run via “go run” so no install is required. ## Why? So local lint matches CI, and contributors catch the same issues before pushing, without needing to install golangci-lint. --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 727964016..a8104014d 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ help: @echo "" @echo " make deps : Fetch all dependencies" @echo " make fmt : Run go fmt to fix any formatting issues" - @echo " make lint : Use go vet to check for linting issues" + @echo " make lint : Run golangci-lint for linting issues" @echo " make test : Run all short tests" @echo " make test-race : Run all tests with race condition checking" @echo " make test-integration : Run all tests without limiting to short" @@ -22,7 +22,7 @@ fmt: @go fmt . lint: - @go vet . + @command -v golangci-lint >/dev/null 2>&1 && golangci-lint run ./... || go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.10.1 run ./... test: @go test -v -count=1 -timeout 300s -short ./... From be04e65197451eadd156586b562f3ebc55993034 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 07:32:24 +0000 Subject: [PATCH 483/606] chore(deps): bump actions/stale from 10.1.1 to 10.2.0 (#1534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/stale](https://github.com/actions/stale) from 10.1.1 to 10.2.0.
    Release notes

    Sourced from actions/stale's releases.

    v10.2.0

    What's Changed

    Bug Fix

    Dependency Updates

    New Contributors

    Full Changelog: https://github.com/actions/stale/compare/v10...v10.2.0

    Commits

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/stale&package-manager=github_actions&previous-version=10.1.1&new-version=10.2.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 743e44e8d..d4e24cbf7 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -13,7 +13,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 + - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 with: any-of-labels: 'feedback given' days-before-stale: 45 From 9c057b314876f082f15f06352df982aca7e47334 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Thu, 26 Feb 2026 21:53:36 +0000 Subject: [PATCH 484/606] feat: add support for task_card and plan agent blocks (#1536) --- block.go | 2 + block_conv.go | 4 ++ block_plan.go | 55 ++++++++++++++ block_plan_test.go | 130 +++++++++++++++++++++++++++++++++ block_task_card.go | 101 ++++++++++++++++++++++++++ block_task_card_test.go | 155 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 447 insertions(+) create mode 100644 block_plan.go create mode 100644 block_plan_test.go create mode 100644 block_task_card.go create mode 100644 block_task_card_test.go diff --git a/block.go b/block.go index df784a33c..8963c7afd 100644 --- a/block.go +++ b/block.go @@ -19,6 +19,8 @@ const ( MBTVideo MessageBlockType = "video" MBTMarkdown MessageBlockType = "markdown" MBTTable MessageBlockType = "table" + MBTTaskCard MessageBlockType = "task_card" + MBTPlan MessageBlockType = "plan" ) // Block defines an interface all block types should implement diff --git a/block_conv.go b/block_conv.go index c69d6b633..cba651fc7 100644 --- a/block_conv.go +++ b/block_conv.go @@ -79,6 +79,10 @@ func (b *Blocks) UnmarshalJSON(data []byte) error { block = &VideoBlock{} case "table": block = &TableBlock{} + case "task_card": + block = &TaskCardBlock{} + case "plan": + block = &PlanBlock{} default: block = &UnknownBlock{} } diff --git a/block_plan.go b/block_plan.go new file mode 100644 index 000000000..c6e417490 --- /dev/null +++ b/block_plan.go @@ -0,0 +1,55 @@ +package slack + +// PlanBlock defines a block of type plan used by AI agents +// to group multiple task cards under a shared title. +// +// More Information: https://docs.slack.dev/reference/block-kit/blocks/plan-block/ +type PlanBlock struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + Title string `json:"title"` + Tasks []TaskCardBlock `json:"tasks,omitempty"` +} + +// BlockType returns the type of the block +func (s PlanBlock) BlockType() MessageBlockType { + return s.Type +} + +// ID returns the ID of the block +func (s PlanBlock) ID() string { + return s.BlockID +} + +// PlanBlockOption allows configuration of options for a new plan block +type PlanBlockOption func(*PlanBlock) + +// PlanBlockOptionBlockID sets the block ID for the plan block +func PlanBlockOptionBlockID(blockID string) PlanBlockOption { + return func(block *PlanBlock) { + block.BlockID = blockID + } +} + +// NewPlanBlock returns a new instance of a plan block +func NewPlanBlock(title string, options ...PlanBlockOption) *PlanBlock { + block := PlanBlock{ + Type: MBTPlan, + Title: title, + } + + for _, option := range options { + option(&block) + } + + return &block +} + +// WithTasks sets the tasks for the PlanBlock +func (s *PlanBlock) WithTasks(tasks ...*TaskCardBlock) *PlanBlock { + s.Tasks = make([]TaskCardBlock, len(tasks)) + for i, t := range tasks { + s.Tasks[i] = *t + } + return s +} diff --git a/block_plan_test.go b/block_plan_test.go new file mode 100644 index 000000000..1871daf1d --- /dev/null +++ b/block_plan_test.go @@ -0,0 +1,130 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewPlanBlock(t *testing.T) { + block := NewPlanBlock("My Plan", PlanBlockOptionBlockID("plan-block-1")) + + assert.Equal(t, MBTPlan, block.BlockType()) + assert.Equal(t, "plan", string(block.Type)) + assert.Equal(t, "plan-block-1", block.ID()) + assert.Equal(t, "My Plan", block.Title) +} + +func TestPlanBlockWithTasks(t *testing.T) { + task1 := NewTaskCardBlock("task-1", "First task").WithStatus(TaskCardStatusComplete) + task2 := NewTaskCardBlock("task-2", "Second task").WithStatus(TaskCardStatusInProgress) + + block := NewPlanBlock("My Plan").WithTasks(task1, task2) + + require.Len(t, block.Tasks, 2) + assert.Equal(t, "task-1", block.Tasks[0].TaskID) + assert.Equal(t, "First task", block.Tasks[0].Title) + assert.Equal(t, TaskCardStatusComplete, block.Tasks[0].Status) + assert.Equal(t, "task-2", block.Tasks[1].TaskID) + assert.Equal(t, TaskCardStatusInProgress, block.Tasks[1].Status) +} + +func TestPlanBlockJSONRoundTrip(t *testing.T) { + payload := `{ + "type": "plan", + "block_id": "plan-1", + "title": "Research Plan", + "tasks": [ + { + "type": "task_card", + "task_id": "task-1", + "title": "Search the web", + "status": "complete", + "sources": [ + { + "type": "url", + "url": "https://example.com", + "text": "Example" + } + ] + }, + { + "type": "task_card", + "task_id": "task-2", + "title": "Analyze results", + "status": "in_progress", + "details": { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Processing data..." + } + ] + } + ] + } + } + ] + }` + + var block PlanBlock + err := json.Unmarshal([]byte(payload), &block) + require.NoError(t, err) + + assert.Equal(t, MBTPlan, block.BlockType()) + assert.Equal(t, "plan-1", block.ID()) + assert.Equal(t, "Research Plan", block.Title) + require.Len(t, block.Tasks, 2) + assert.Equal(t, "task-1", block.Tasks[0].TaskID) + assert.Equal(t, TaskCardStatusComplete, block.Tasks[0].Status) + require.Len(t, block.Tasks[0].Sources, 1) + assert.Equal(t, "task-2", block.Tasks[1].TaskID) + require.NotNil(t, block.Tasks[1].Details) + + marshalled, err := json.Marshal(block) + require.NoError(t, err) + + var expected, actual map[string]interface{} + err = json.Unmarshal([]byte(payload), &expected) + require.NoError(t, err) + err = json.Unmarshal(marshalled, &actual) + require.NoError(t, err) + + assert.Equal(t, expected, actual) +} + +func TestPlanBlockUnmarshalViaBlocks(t *testing.T) { + payload := `[ + { + "type": "plan", + "title": "Agent Plan", + "tasks": [ + { + "type": "task_card", + "task_id": "t1", + "title": "Step 1", + "status": "pending" + } + ] + } + ]` + + var blocks Blocks + err := json.Unmarshal([]byte(payload), &blocks) + require.NoError(t, err) + require.Len(t, blocks.BlockSet, 1) + + plan, ok := blocks.BlockSet[0].(*PlanBlock) + require.True(t, ok, "expected *PlanBlock, got %T", blocks.BlockSet[0]) + assert.Equal(t, MBTPlan, plan.BlockType()) + assert.Equal(t, "Agent Plan", plan.Title) + require.Len(t, plan.Tasks, 1) + assert.Equal(t, "t1", plan.Tasks[0].TaskID) + assert.Equal(t, TaskCardStatusPending, plan.Tasks[0].Status) +} diff --git a/block_task_card.go b/block_task_card.go new file mode 100644 index 000000000..2053a82d6 --- /dev/null +++ b/block_task_card.go @@ -0,0 +1,101 @@ +package slack + +// TaskCardStatus defines the status of a task card block. +type TaskCardStatus string + +const ( + TaskCardStatusPending TaskCardStatus = "pending" + TaskCardStatusInProgress TaskCardStatus = "in_progress" + TaskCardStatusComplete TaskCardStatus = "complete" + TaskCardStatusError TaskCardStatus = "error" +) + +// TaskCardSource represents a URL reference in a task card block. +type TaskCardSource struct { + Type string `json:"type"` + URL string `json:"url"` + Text string `json:"text"` +} + +// NewTaskCardSource creates a new TaskCardSource with type "url". +func NewTaskCardSource(url, text string) TaskCardSource { + return TaskCardSource{ + Type: "url", + URL: url, + Text: text, + } +} + +// TaskCardBlock defines a block of type task_card used by AI agents +// to display thinking steps and task execution. +// +// More Information: https://docs.slack.dev/reference/block-kit/blocks/task-card-block/ +type TaskCardBlock struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + TaskID string `json:"task_id"` + Title string `json:"title"` + Status TaskCardStatus `json:"status,omitempty"` + Details *RichTextBlock `json:"details,omitempty"` + Output *RichTextBlock `json:"output,omitempty"` + Sources []TaskCardSource `json:"sources,omitempty"` +} + +// BlockType returns the type of the block +func (s TaskCardBlock) BlockType() MessageBlockType { + return s.Type +} + +// ID returns the ID of the block +func (s TaskCardBlock) ID() string { + return s.BlockID +} + +// TaskCardBlockOption allows configuration of options for a new task card block +type TaskCardBlockOption func(*TaskCardBlock) + +// TaskCardBlockOptionBlockID sets the block ID for the task card block +func TaskCardBlockOptionBlockID(blockID string) TaskCardBlockOption { + return func(block *TaskCardBlock) { + block.BlockID = blockID + } +} + +// NewTaskCardBlock returns a new instance of a task card block +func NewTaskCardBlock(taskID, title string, options ...TaskCardBlockOption) *TaskCardBlock { + block := TaskCardBlock{ + Type: MBTTaskCard, + TaskID: taskID, + Title: title, + } + + for _, option := range options { + option(&block) + } + + return &block +} + +// WithStatus sets the status for the TaskCardBlock +func (s *TaskCardBlock) WithStatus(status TaskCardStatus) *TaskCardBlock { + s.Status = status + return s +} + +// WithDetails sets the details rich text block for the TaskCardBlock +func (s *TaskCardBlock) WithDetails(details *RichTextBlock) *TaskCardBlock { + s.Details = details + return s +} + +// WithOutput sets the output rich text block for the TaskCardBlock +func (s *TaskCardBlock) WithOutput(output *RichTextBlock) *TaskCardBlock { + s.Output = output + return s +} + +// WithSources sets the sources for the TaskCardBlock +func (s *TaskCardBlock) WithSources(sources ...TaskCardSource) *TaskCardBlock { + s.Sources = sources + return s +} diff --git a/block_task_card_test.go b/block_task_card_test.go new file mode 100644 index 000000000..656da826c --- /dev/null +++ b/block_task_card_test.go @@ -0,0 +1,155 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewTaskCardBlock(t *testing.T) { + block := NewTaskCardBlock("task-1", "Search the web", TaskCardBlockOptionBlockID("block-1")) + + assert.Equal(t, MBTTaskCard, block.BlockType()) + assert.Equal(t, "task_card", string(block.Type)) + assert.Equal(t, "block-1", block.ID()) + assert.Equal(t, "task-1", block.TaskID) + assert.Equal(t, "Search the web", block.Title) +} + +func TestTaskCardBlockChainableMethods(t *testing.T) { + details := &RichTextBlock{ + Type: MBTRichText, + Elements: []RichTextElement{ + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "Searching..."}, + }, + }, + }, + } + + output := &RichTextBlock{ + Type: MBTRichText, + Elements: []RichTextElement{ + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "Found 3 results"}, + }, + }, + }, + } + + block := NewTaskCardBlock("task-1", "Search the web"). + WithStatus(TaskCardStatusComplete). + WithDetails(details). + WithOutput(output). + WithSources( + NewTaskCardSource("https://example.com", "Example"), + NewTaskCardSource("https://other.com", "Other"), + ) + + assert.Equal(t, TaskCardStatusComplete, block.Status) + assert.Equal(t, details, block.Details) + assert.Equal(t, output, block.Output) + assert.Len(t, block.Sources, 2) + assert.Equal(t, "url", block.Sources[0].Type) + assert.Equal(t, "https://example.com", block.Sources[0].URL) + assert.Equal(t, "Example", block.Sources[0].Text) +} + +func TestTaskCardBlockJSONRoundTrip(t *testing.T) { + payload := `{ + "type": "task_card", + "block_id": "block-1", + "task_id": "task-1", + "title": "Search the web", + "status": "in_progress", + "details": { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Searching for results" + } + ] + } + ] + }, + "output": { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Found 3 results" + } + ] + } + ] + }, + "sources": [ + { + "type": "url", + "url": "https://example.com", + "text": "Example" + } + ] + }` + + var block TaskCardBlock + err := json.Unmarshal([]byte(payload), &block) + require.NoError(t, err) + + assert.Equal(t, MBTTaskCard, block.BlockType()) + assert.Equal(t, "block-1", block.ID()) + assert.Equal(t, "task-1", block.TaskID) + assert.Equal(t, "Search the web", block.Title) + assert.Equal(t, TaskCardStatusInProgress, block.Status) + require.NotNil(t, block.Details) + require.NotNil(t, block.Output) + require.Len(t, block.Sources, 1) + assert.Equal(t, "https://example.com", block.Sources[0].URL) + + marshalled, err := json.Marshal(block) + require.NoError(t, err) + + var expected, actual map[string]interface{} + err = json.Unmarshal([]byte(payload), &expected) + require.NoError(t, err) + err = json.Unmarshal(marshalled, &actual) + require.NoError(t, err) + + assert.Equal(t, expected, actual) +} + +func TestTaskCardBlockUnmarshalViaBlocks(t *testing.T) { + payload := `[ + { + "type": "task_card", + "task_id": "task-1", + "title": "Analyze data", + "status": "complete" + } + ]` + + var blocks Blocks + err := json.Unmarshal([]byte(payload), &blocks) + require.NoError(t, err) + require.Len(t, blocks.BlockSet, 1) + + taskCard, ok := blocks.BlockSet[0].(*TaskCardBlock) + require.True(t, ok, "expected *TaskCardBlock, got %T", blocks.BlockSet[0]) + assert.Equal(t, MBTTaskCard, taskCard.BlockType()) + assert.Equal(t, "task-1", taskCard.TaskID) + assert.Equal(t, "Analyze data", taskCard.Title) + assert.Equal(t, TaskCardStatusComplete, taskCard.Status) +} From 777ee2f7113d914f07fe80dd6e2b629143e8cadd Mon Sep 17 00:00:00 2001 From: Oleg Bespalov Date: Thu, 26 Feb 2026 23:20:45 +0100 Subject: [PATCH 485/606] feat: add optional HTTP retry for Web API (429, 5xx, connection errors) (#1532) ## What? This PR adds optional HTTP retries for the Web API client. Retries are off by default. You can turn them on with `OptionRetry(n)` for simple 429-only retries or `OptionRetryConfig(cfg)` when you want full control. It's also possible to opt-in 5xx retries via NewServerErrorRetryHandler(cfg). For 5xx and connection errors, we also use exponential backoff from the config. Multipart and other streaming requests are left as-is and are not retried. ## Why? Without retries, a single 429, 5xx, or connection blip often forces callers to implement their retry and backoff. Enabling retries via one option improves reliability under rate limits, server issues, and flaky networks without pushing that logic onto every user. Defaulting to 429-only keeps behavior predictable; users who want connection or 5xx retries can add the right handlers. Closes https://github.com/slack-go/slack/issues/1531 --- README.md | 8 + misc.go | 18 +- retry.go | 307 +++++++++++++++++++ retry_test.go | 834 ++++++++++++++++++++++++++++++++++++++++++++++++++ slack.go | 33 ++ 5 files changed, 1194 insertions(+), 6 deletions(-) create mode 100644 retry.go create mode 100644 retry_test.go diff --git a/README.md b/README.md index 7d0ed91eb..3458079d1 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,14 @@ func main() { } ``` +### HTTP retries + +Retries are off by default. Use **OptionRetry(n)** for 429-only retries, or **OptionRetryConfig(cfg)** for full control (connection, 429, opt-in 5xx). With a custom client, pass retry options after `OptionHTTPClient`. See package `slack` doc for handler details. + +```golang +api := slack.New("YOUR_TOKEN_HERE", slack.OptionRetry(3)) +``` + ## Minimal Socket Mode usage: See https://github.com/slack-go/slack/blob/master/examples/socketmode/socketmode.go diff --git a/misc.go b/misc.go index da7a8484b..dc7fb9fdb 100644 --- a/misc.go +++ b/misc.go @@ -378,26 +378,32 @@ func doPost(client httpClient, req *http.Request, parser responseParser, d Debug } // post JSON. -func postJSON(ctx context.Context, client httpClient, endpoint, token string, json []byte, intf any, d Debug) error { - reqBody := bytes.NewBuffer(json) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, reqBody) +func postJSON(ctx context.Context, client httpClient, endpoint, token string, jsonBody []byte, intf any, d Debug) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(jsonBody)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - + // allow retry client to re-send the request body on 429/5xx. + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(jsonBody)), nil + } return doPost(client, req, newJSONParser(intf), d) } // post a url encoded form. func postForm(ctx context.Context, client httpClient, endpoint string, values url.Values, intf any, d Debug) error { - reqBody := strings.NewReader(values.Encode()) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, reqBody) + body := values.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + // allow retry client to re-send the request body on 429/5xx. + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader(body)), nil + } return doPost(client, req, newJSONParser(intf), d) } diff --git a/retry.go b/retry.go new file mode 100644 index 000000000..d2f62117d --- /dev/null +++ b/retry.go @@ -0,0 +1,307 @@ +package slack + +// Optional HTTP retries improve reliability when Slack is busy or the network is flaky. +// Retries are off by default; use OptionRetry or OptionRetryConfig to turn them on. +// +// Retry behavior is driven by pluggable handlers (parity with the Python SDK: +// https://github.com/slackapi/python-slack-sdk). When Handlers is nil, only rate limit +// (429) is retried (NewRateLimitErrorRetryHandler). Use +// AllBuiltinRetryHandlers(cfg) for connection + 429; ConnectionOnlyRetryHandlers(cfg) for +// connection-only; add NewServerErrorRetryHandler(cfg) to also retry 5xx. +// +// File uploads and other requests that stream the body cannot be retried (the body is sent once). +// Regular API calls (form or JSON) are retried when a handler matches (429, 5xx, or connection error). + +import ( + "context" + "errors" + "fmt" + "io" + "math/rand/v2" + "net/http" + "strconv" + "strings" + "time" + + "github.com/slack-go/slack/internal/backoff" +) + +// minRetryAfter429 is the minimum wait before retrying after 429 when Retry-After is missing +// or zero, to avoid tight retry loops when using a partial RetryConfig. +const minRetryAfter429 = time.Second + +// RetryState holds the current attempt and max retries; passed to handlers. +// Backoff is set by retryClient for use by handlers that want exponential backoff (e.g. connection, server error). +// Handlers may call Backoff.Duration() when retrying; each call advances the backoff for the next retry. +type RetryState struct { + Attempt int // current attempt (0-based) + MaxRetries int + Backoff *backoff.Backoff // optional; used by connection/server handlers for exponential backoff +} + +// RetryHandler decides whether to retry a request and how long to wait. +// The first handler that returns (true, wait) wins. resp may be nil (connection failure); err may be nil (got response). +type RetryHandler interface { + ShouldRetry(state *RetryState, req *http.Request, resp *http.Response, err error) (retry bool, wait time.Duration) +} + +// RetryConfig configures HTTP retry behavior. +// When MaxRetries is 0, retries are disabled. +// If Handlers is nil, only rate limit (429) is retried (see DefaultRetryHandlers). +type RetryConfig struct { + // MaxRetries is the maximum number of retry attempts (0 = no retries, 1 = one retry, etc.). + MaxRetries int + // Handlers is the list of handlers to consult; nil means 429 only (DefaultRetryHandlers). + Handlers []RetryHandler + // RetryAfterDuration is used for 429 when the Retry-After header is missing or invalid. + RetryAfterDuration time.Duration + // RetryAfterJitter adds random jitter [0, RetryAfterJitter] to 429 wait to avoid thundering herd (0 = no jitter). + RetryAfterJitter time.Duration + // BackoffInitial is the initial backoff for 5xx and connection errors. + BackoffInitial time.Duration + // BackoffMax caps the backoff duration. + BackoffMax time.Duration + // BackoffJitter adds random jitter [0, BackoffJitter] to backoff to avoid thundering herd (0 to disable). + BackoffJitter time.Duration +} + +// DefaultRetryConfig returns a retry config with sensible defaults. +func DefaultRetryConfig() RetryConfig { + return RetryConfig{ + MaxRetries: 3, + RetryAfterDuration: 60 * time.Second, + RetryAfterJitter: 1 * time.Second, + BackoffInitial: 100 * time.Millisecond, + BackoffMax: 30 * time.Second, + BackoffJitter: 50 * time.Millisecond, + } +} + +// connectionErrorRetryHandler retries on connection errors (e.g. connection reset). +type connectionErrorRetryHandler struct{} + +// NewConnectionErrorRetryHandler returns a handler that retries on connection errors. +func NewConnectionErrorRetryHandler() RetryHandler { + return &connectionErrorRetryHandler{} +} + +func (h *connectionErrorRetryHandler) ShouldRetry(state *RetryState, req *http.Request, resp *http.Response, err error) (bool, time.Duration) { + if err == nil || resp != nil { + return false, 0 + } + if !isRetryableConnError(err) || !requestRetryable(req) { + return false, 0 + } + if state.Attempt >= state.MaxRetries { + return false, 0 + } + // Backoff is always set by retryClient.Do(). + wait := state.Backoff.Duration() + return true, wait +} + +// rateLimitErrorRetryHandler retries on 429 Too Many Requests using Retry-After or config. +type rateLimitErrorRetryHandler struct { + cfg RetryConfig +} + +// NewRateLimitErrorRetryHandler returns a handler that retries on 429. +func NewRateLimitErrorRetryHandler(cfg RetryConfig) RetryHandler { + return &rateLimitErrorRetryHandler{cfg: cfg} +} + +func (h *rateLimitErrorRetryHandler) ShouldRetry(state *RetryState, req *http.Request, resp *http.Response, err error) (bool, time.Duration) { + if resp == nil || resp.StatusCode != http.StatusTooManyRequests || !requestRetryable(req) { + return false, 0 + } + if state.Attempt >= state.MaxRetries { + return false, 0 + } + dur := h.cfg.RetryAfterDuration + if s := resp.Header.Get("Retry-After"); s != "" { + // Parsing only integer seconds is appropriate for Slack (API sends seconds; RFC 7231 also allows HTTP-date). + if sec, parseErr := strconv.ParseInt(strings.TrimSpace(s), 10, 64); parseErr == nil { + if sec > 0 { + dur = time.Duration(sec) * time.Second + } else { + dur = minRetryAfter429 // Retry-After: 0 means use minimum delay + } + } + } + dur = max(dur, minRetryAfter429) + if h.cfg.RetryAfterJitter > 0 { + dur += time.Duration(rand.IntN(int(h.cfg.RetryAfterJitter))) + } + return true, dur +} + +// serverErrorRetryHandler retries on 5xx server errors (opt-in). +type serverErrorRetryHandler struct{} + +// NewServerErrorRetryHandler returns a handler that retries on 5xx. Opt-in; not in DefaultRetryHandlers, ConnectionOnlyRetryHandlers, or AllBuiltinRetryHandlers. +func NewServerErrorRetryHandler(cfg RetryConfig) RetryHandler { + return &serverErrorRetryHandler{} +} + +func (h *serverErrorRetryHandler) ShouldRetry(state *RetryState, req *http.Request, resp *http.Response, err error) (bool, time.Duration) { + if resp == nil || resp.StatusCode < http.StatusInternalServerError || !requestRetryable(req) { + return false, 0 + } + if state.Attempt >= state.MaxRetries { + return false, 0 + } + // Backoff is always set by retryClient.Do(). + wait := state.Backoff.Duration() + return true, wait +} + +// DefaultRetryHandlers returns the default handler when retries are on: rate limit (429) only. +// Used when Handlers is nil. Use AllBuiltinRetryHandlers(cfg) for connection + 429. +func DefaultRetryHandlers(cfg RetryConfig) []RetryHandler { + return []RetryHandler{NewRateLimitErrorRetryHandler(cfg)} +} + +// ConnectionOnlyRetryHandlers returns connection-only handlers (no 429 retries). +func ConnectionOnlyRetryHandlers() []RetryHandler { + return []RetryHandler{NewConnectionErrorRetryHandler()} +} + +// AllBuiltinRetryHandlers returns connection + rate limit (429) handlers; no 5xx. +func AllBuiltinRetryHandlers(cfg RetryConfig) []RetryHandler { + return []RetryHandler{ + NewConnectionErrorRetryHandler(), + NewRateLimitErrorRetryHandler(cfg), + } +} + +// retryClient wraps an httpClient and retries according to config.Handlers. +type retryClient struct { + client httpClient + config RetryConfig + debug Debug // optional; when set and Debug() is true, retries are logged +} + +var _ httpClient = (*retryClient)(nil) + +// handlers returns the list of retry handlers. Empty Handlers slice is treated like nil (default 429 only). +func (c *retryClient) handlers() []RetryHandler { + if len(c.config.Handlers) > 0 { + return c.config.Handlers + } + return DefaultRetryHandlers(c.config) +} + +func (c *retryClient) logRetry(attempt int, reason string, detail any) { + if c.debug == nil || !c.debug.Debug() { + return + } + c.debug.Debugf("slack retry: %s (attempt %d/%d), detail: %v", reason, attempt+1, c.config.MaxRetries+1, detail) +} + +// requestRetryable reports whether the request can be safely retried: either it has no body +// (e.g. GET) or the body can be replayed via GetBody (e.g. POST with GetBody set). +// Requests with a non-nil body and nil GetBody (e.g. streaming uploads) must not be retried. +func requestRetryable(req *http.Request) bool { + return req.Body == nil || req.GetBody != nil +} + +// sleepWithContext sleeps for up to d, or until ctx is done. Returns true if the full duration +// elapsed, false if ctx was cancelled (caller should return ctx.Err()). +func sleepWithContext(ctx context.Context, d time.Duration) bool { + if d <= 0 { + return true + } + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-timer.C: + return true + case <-ctx.Done(): + return false + } +} + +func (c *retryClient) Do(req *http.Request) (*http.Response, error) { + handlers := c.handlers() + bo := &backoff.Backoff{ + Initial: c.config.BackoffInitial, + Max: c.config.BackoffMax, + Jitter: c.config.BackoffJitter, + } + var lastErr error + maxAttempts := c.config.MaxRetries + 1 + + for attempt := range maxAttempts { + state := &RetryState{Attempt: attempt, MaxRetries: c.config.MaxRetries, Backoff: bo} + + // Rewind body for retries (POST/PUT with GetBody). + if attempt > 0 && req.GetBody != nil { + newBody, err := req.GetBody() + if err != nil { + return nil, err + } + req.Body = newBody + } + + resp, err := c.client.Do(req) + if err != nil { + lastErr = err + } + + // Success: got response and status is not 429/5xx. + if err == nil && resp.StatusCode != http.StatusTooManyRequests && resp.StatusCode < http.StatusInternalServerError { + return resp, nil + } + + // No retries left or request body cannot be replayed — return now. + if attempt >= c.config.MaxRetries || !requestRetryable(req) { + if err != nil { + return nil, err + } + return resp, nil + } + + // First handler that wants to retry wins. + var wait time.Duration + retry := false + for _, h := range handlers { + if r, w := h.ShouldRetry(state, req, resp, err); r { + retry, wait = true, w + break + } + } + if !retry { + if err != nil { + return nil, err + } + return resp, nil + } + + // Log, discard response body if we have one, sleep, then next attempt. + if err != nil { + c.logRetry(attempt, "connection error", err) + } else { + reason := fmt.Sprintf("%d %s", resp.StatusCode, http.StatusText(resp.StatusCode)) + c.logRetry(attempt, reason, wait) + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + } + if !sleepWithContext(req.Context(), wait) { + return nil, req.Context().Err() + } + } + + return nil, lastErr +} + +func isRetryableConnError(err error) bool { + for ; err != nil; err = errors.Unwrap(err) { + s := err.Error() + if strings.Contains(s, "connection reset") || + strings.Contains(s, "connection refused") || + strings.Contains(s, "EOF") { + return true + } + } + return false +} diff --git a/retry_test.go b/retry_test.go new file mode 100644 index 000000000..b74c4b0be --- /dev/null +++ b/retry_test.go @@ -0,0 +1,834 @@ +package slack + +import ( + "bytes" + "context" + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// TestRetryOn429ThenSuccess verifies that 429 responses are retried (using Retry-After or config) +// until success; call count = 2×429 + 1×200 = 3. +func TestRetryOn429ThenSuccess(t *testing.T) { + t.Parallel() + + var callCount int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&callCount, 1) + if n <= 2 { + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusTooManyRequests) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + cfg := DefaultRetryConfig() + cfg.MaxRetries = 3 + cfg.RetryAfterDuration = 1 * time.Millisecond + cfg.BackoffInitial = time.Millisecond + cfg.BackoffMax = 5 * time.Millisecond + cfg.Handlers = AllBuiltinRetryHandlers(cfg) + + api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetryConfig(cfg)) + var out SlackResponse + err := api.postMethod(context.Background(), "auth.test", nil, &out) + if err != nil { + t.Fatalf("postMethod: %v", err) + } + if !out.Ok { + t.Errorf("want ok=true, got ok=%v", out.Ok) + } + if got := atomic.LoadInt32(&callCount); got != 3 { + t.Errorf("want 3 calls (2x 429 + 1x 200), got %d", got) + } +} + +// TestRetryOn500ThenSuccess verifies that 5xx responses are retried with backoff until success. +func TestRetryOn500ThenSuccess(t *testing.T) { + t.Parallel() + + var callCount int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&callCount, 1) + if n <= 2 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + cfg := DefaultRetryConfig() + cfg.MaxRetries = 3 + cfg.BackoffInitial = time.Millisecond + cfg.BackoffMax = 5 * time.Millisecond + cfg.Handlers = append(AllBuiltinRetryHandlers(cfg), NewServerErrorRetryHandler(cfg)) + + api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetryConfig(cfg)) + var out SlackResponse + err := api.postMethod(context.Background(), "auth.test", nil, &out) + if err != nil { + t.Fatalf("postMethod: %v", err) + } + if !out.Ok { + t.Errorf("want ok=true, got ok=%v", out.Ok) + } + if got := atomic.LoadInt32(&callCount); got != 3 { + t.Errorf("want 3 calls (2x 500 + 1x 200), got %d", got) + } +} + +// TestRetryExhaustedReturnsLastError verifies that when all 5xx retries are exhausted we return +// StatusCodeError and the server was called MaxRetries+1 times (3 for MaxRetries=2). +func TestRetryExhaustedReturnsLastError(t *testing.T) { + t.Parallel() + + var callCount int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&callCount, 1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + cfg := DefaultRetryConfig() + cfg.MaxRetries = 2 + cfg.BackoffInitial = time.Millisecond + cfg.BackoffMax = time.Millisecond + cfg.Handlers = append(AllBuiltinRetryHandlers(cfg), NewServerErrorRetryHandler(cfg)) + + api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetryConfig(cfg)) + var out SlackResponse + err := api.postMethod(context.Background(), "auth.test", nil, &out) + if err == nil { + t.Fatal("expected error after exhausting retries") + } + if _, ok := err.(StatusCodeError); !ok { + t.Errorf("expected StatusCodeError, got %T: %v", err, err) + } + if got := atomic.LoadInt32(&callCount); got != 3 { + t.Errorf("want 3 calls (all 500), got %d", got) + } +} + +// TestRetryExhausted5xxResponseBodyReadable verifies that when retries are exhausted on 5xx, +// the response body is left readable so checkStatusCode/logResponse can dump it for debug. +func TestRetryExhausted5xxResponseBodyReadable(t *testing.T) { + t.Parallel() + + const bodyContent = "custom error body from server" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(bodyContent)) + })) + defer srv.Close() + + buf := bytes.NewBuffer(nil) + cfg := DefaultRetryConfig() + cfg.MaxRetries = 2 + cfg.BackoffInitial = time.Millisecond + cfg.BackoffMax = time.Millisecond + cfg.Handlers = append(AllBuiltinRetryHandlers(cfg), NewServerErrorRetryHandler(cfg)) + + api := New("token", + OptionAPIURL(srv.URL+"/"), + OptionRetryConfig(cfg), + OptionDebug(true), + OptionLog(log.New(buf, "", 0)), + ) + var out SlackResponse + err := api.postMethod(context.Background(), "auth.test", nil, &out) + if err == nil { + t.Fatal("expected error after exhausting retries") + } + if _, ok := err.(StatusCodeError); !ok { + t.Errorf("expected StatusCodeError, got %T: %v", err, err) + } + logged := buf.String() + if !strings.Contains(logged, bodyContent) { + t.Errorf("debug log should contain response body %q (so checkStatusCode/logResponse could read it); got: %s", bodyContent, logged) + } +} + +// TestRetryExhausted429ResponseBodyReadable verifies that when retries are exhausted on 429 +// without Retry-After, the response body is left readable so logResponse can dump it for debug. +func TestRetryExhausted429ResponseBodyReadable(t *testing.T) { + t.Parallel() + + const bodyContent = "rate limit message from server" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + w.Write([]byte(bodyContent)) + })) + defer srv.Close() + + buf := bytes.NewBuffer(nil) + cfg := DefaultRetryConfig() + cfg.MaxRetries = 2 + cfg.RetryAfterDuration = time.Millisecond + cfg.RetryAfterJitter = 0 + cfg.Handlers = AllBuiltinRetryHandlers(cfg) + + api := New("token", + OptionAPIURL(srv.URL+"/"), + OptionRetryConfig(cfg), + OptionDebug(true), + OptionLog(log.New(buf, "", 0)), + ) + var out SlackResponse + err := api.postMethod(context.Background(), "auth.test", nil, &out) + if err == nil { + t.Fatal("expected error after exhausting 429 retries") + } + // 429 without Retry-After returns StatusCodeError and calls logResponse + if _, ok := err.(StatusCodeError); !ok { + t.Errorf("expected StatusCodeError (429 without Retry-After), got %T: %v", err, err) + } + logged := buf.String() + if !strings.Contains(logged, bodyContent) { + t.Errorf("debug log should contain response body %q; got: %s", bodyContent, logged) + } +} + +// TestRetryExhausted429ReturnsError verifies that when 429 retries are exhausted (with Retry-After +// set) we return *RateLimitedError and the server was called MaxRetries+1 times. +func TestRetryExhausted429ReturnsError(t *testing.T) { + t.Parallel() + + var callCount int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&callCount, 1) + w.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusTooManyRequests) + })) + defer srv.Close() + + cfg := DefaultRetryConfig() + cfg.MaxRetries = 2 + cfg.RetryAfterDuration = time.Millisecond + cfg.RetryAfterJitter = 0 + cfg.Handlers = AllBuiltinRetryHandlers(cfg) + + api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetryConfig(cfg)) + var out SlackResponse + err := api.postMethod(context.Background(), "auth.test", nil, &out) + if err == nil { + t.Fatal("expected error after exhausting 429 retries") + } + if _, ok := err.(*RateLimitedError); !ok { + t.Errorf("expected *RateLimitedError, got %T: %v", err, err) + } + if got := atomic.LoadInt32(&callCount); got != 3 { + t.Errorf("want 3 calls (all 429), got %d", got) + } +} + +// TestOptionRetryZeroDisablesRetry verifies OptionRetryConfig(RetryConfig{MaxRetries: 0}) does not +// wrap the client (no retry layer). +func TestOptionRetryZeroDisablesRetry(t *testing.T) { + t.Parallel() + + api := New("token", OptionRetryConfig(RetryConfig{MaxRetries: 0})) + if _, ok := api.httpclient.(*retryClient); ok { + t.Error("OptionRetryConfig with MaxRetries=0 should not wrap client") + } +} + +// TestOptionRetryNonPositiveDisablesRetry verifies OptionRetry(0), OptionRetry(-1), etc. do not +// wrap the client. +func TestOptionRetryNonPositiveDisablesRetry(t *testing.T) { + t.Parallel() + + for _, maxRetries := range []int{0, -1, -10} { + api := New("token", OptionRetry(maxRetries)) + if _, ok := api.httpclient.(*retryClient); ok { + t.Errorf("OptionRetry(%d) should not wrap client", maxRetries) + } + } +} + +// TestRetryOn503ThenSuccess verifies 503 is retried like other 5xx until success. +func TestRetryOn503ThenSuccess(t *testing.T) { + t.Parallel() + + var callCount int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&callCount, 1) + if n <= 2 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + cfg := DefaultRetryConfig() + cfg.MaxRetries = 3 + cfg.BackoffInitial = time.Millisecond + cfg.BackoffMax = 5 * time.Millisecond + cfg.Handlers = append(AllBuiltinRetryHandlers(cfg), NewServerErrorRetryHandler(cfg)) + + api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetryConfig(cfg)) + var out SlackResponse + err := api.postMethod(context.Background(), "auth.test", nil, &out) + if err != nil { + t.Fatalf("postMethod: %v", err) + } + if !out.Ok { + t.Errorf("want ok=true, got ok=%v", out.Ok) + } + if got := atomic.LoadInt32(&callCount); got != 3 { + t.Errorf("want 3 calls (2x 503 + 1x 200), got %d", got) + } +} + +// TestRetryOnConnectionErrorThenSuccess verifies connection errors (e.g. "connection reset") are +// retried until success; call count = 2×error + 1×200 = 3. +func TestRetryOnConnectionErrorThenSuccess(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + base := &http.Client{} + wrapped := &failingThenOKClient{httpClient: base, failAttempts: 2} + + cfg := DefaultRetryConfig() + cfg.MaxRetries = 3 + cfg.BackoffInitial = time.Millisecond + cfg.BackoffMax = 5 * time.Millisecond + cfg.Handlers = AllBuiltinRetryHandlers(cfg) // connection + 429; default is 429 only + + api := New("token", OptionAPIURL(srv.URL+"/"), OptionHTTPClient(wrapped), OptionRetryConfig(cfg)) + var out SlackResponse + err := api.postMethod(context.Background(), "auth.test", nil, &out) + if err != nil { + t.Fatalf("postMethod: %v", err) + } + if !out.Ok { + t.Errorf("want ok=true, got ok=%v", out.Ok) + } + if got := atomic.LoadInt32(&wrapped.attempts); got != 3 { + t.Errorf("want 3 calls (2x connection error + 1x 200), got %d", got) + } +} + +// TestRetryOnConnectionErrorExhausted verifies that when connection errors persist for all attempts +// we return the last error (no response) and the underlying client was called MaxRetries+1 times. +func TestRetryOnConnectionErrorExhausted(t *testing.T) { + t.Parallel() + + failClient := &connectionFailingClient{} + cfg := DefaultRetryConfig() + cfg.MaxRetries = 2 + cfg.BackoffInitial = time.Millisecond + cfg.BackoffMax = time.Millisecond + cfg.Handlers = AllBuiltinRetryHandlers(cfg) // connection + 429; default is 429 only + + rc := &retryClient{client: failClient, config: cfg, debug: nil} + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://localhost/", strings.NewReader("body")) + resp, err := rc.Do(req) + if err == nil { + t.Fatal("expected error when all connection attempts fail") + } + if resp != nil { + t.Errorf("expected nil response when error returned, got %v", resp) + } + if got := atomic.LoadInt32(&failClient.attempts); got != 3 { + t.Errorf("want 3 attempts (MaxRetries+1), got %d", got) + } +} + +// TestNoRetryWhenGetBodyNil verifies that when a request has a body but no GetBody we do not +// retry on 429/5xx, so we never send an empty body; the underlying client is called exactly once. +func TestNoRetryWhenGetBodyNil(t *testing.T) { + t.Parallel() + + mock := &countAndRespondClient{code: http.StatusTooManyRequests} + cfg := DefaultRetryConfig() + cfg.MaxRetries = 3 + cfg.RetryAfterDuration = time.Millisecond + cfg.RetryAfterJitter = 0 + + rc := &retryClient{client: mock, config: cfg, debug: nil} + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://localhost/", io.NopCloser(strings.NewReader("body"))) + if err != nil { + t.Fatal(err) + } + req.GetBody = nil // request body cannot be replayed; retry must not send empty body + + resp, err := rc.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + resp.Body.Close() + if got := atomic.LoadInt32(&mock.count); got != 1 { + t.Errorf("when body present and GetBody is nil should not retry, got %d calls", got) + } +} + +// TestRetryGetRequestWithNilBody verifies that GET requests (or any request with nil body and +// no GetBody, as created by getResource) are retried on 429 when using AllBuiltinRetryHandlers. +func TestRetryGetRequestWithNilBody(t *testing.T) { + t.Parallel() + + cfg := DefaultRetryConfig() + cfg.MaxRetries = 3 + cfg.RetryAfterDuration = time.Millisecond + cfg.RetryAfterJitter = 0 + cfg.Handlers = AllBuiltinRetryHandlers(cfg) + + // Return 429 twice then 200 on third call. + wrapped := &retryCountThenSuccessClient{needFail: 2} + rc := &retryClient{client: wrapped, config: cfg, debug: nil} + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost/", nil) + if err != nil { + t.Fatal(err) + } + // Body and GetBody are both nil, as with getResource — should still retry. + resp, err := rc.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + resp.Body.Close() + if got := atomic.LoadInt32(&wrapped.calls); got != 3 { + t.Errorf("GET with nil body should retry on 429 (2x 429 + 1x 200), got %d calls", got) + } +} + +// contextCancelClient returns context.Canceled when the request context is already cancelled. +type contextCancelClient struct { + httpClient + calls int32 +} + +func (c *contextCancelClient) Do(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&c.calls, 1) + if err := req.Context().Err(); err != nil { + return nil, err + } + return c.httpClient.Do(req) +} + +// TestRetryRespectsContextCancelation verifies that when the request context is already +// cancelled before the first attempt, we return the context error without retrying +// (underlying client is called once and gets context.Canceled). +func TestRetryRespectsContextCancelation(t *testing.T) { + t.Parallel() + + mock := &countAndRespondClient{code: http.StatusTooManyRequests} + wrapped := &contextCancelClient{httpClient: mock} + cfg := DefaultRetryConfig() + cfg.MaxRetries = 2 + cfg.RetryAfterDuration = 60 * time.Second + cfg.RetryAfterJitter = 0 + cfg.Handlers = AllBuiltinRetryHandlers(cfg) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel before first attempt + + rc := &retryClient{client: wrapped, config: cfg, debug: nil} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://localhost/", strings.NewReader("body")) + if err != nil { + t.Fatal(err) + } + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("body")), nil + } + resp, err := rc.Do(req) + if err == nil { + t.Fatal("expected error when context is cancelled") + } + if resp != nil { + resp.Body.Close() + t.Errorf("expected nil response when context cancelled, got %v", resp) + } + if err != context.Canceled { + t.Errorf("expected context.Canceled, got %v", err) + } + if got := atomic.LoadInt32(&wrapped.calls); got != 1 { + t.Errorf("expected 1 call, got %d", got) + } +} + +// TestRetryContextCanceledDuringSleep verifies that when the context is cancelled during +// the retry wait (after a 429), we return immediately with the context error instead of +// sleeping the full duration, and we do not perform a second request. +func TestRetryContextCanceledDuringSleep(t *testing.T) { + t.Parallel() + + mock := &countAndRespondClient{code: http.StatusTooManyRequests} + cfg := DefaultRetryConfig() + cfg.MaxRetries = 2 + cfg.RetryAfterDuration = 60 * time.Second // long sleep; cancel during this + cfg.RetryAfterJitter = 0 + cfg.Handlers = AllBuiltinRetryHandlers(cfg) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + body := "body" + rc := &retryClient{client: mock, config: cfg, debug: nil} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://localhost/", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader(body)), nil + } + + // Cancel context shortly after first attempt returns 429, so we exit during sleep. + go func() { + time.Sleep(20 * time.Millisecond) + cancel() + }() + + resp, err := rc.Do(req) + if err == nil { + t.Fatal("expected error when context is cancelled during sleep") + } + if resp != nil { + resp.Body.Close() + t.Errorf("expected nil response when context cancelled, got %v", resp) + } + if err != context.Canceled { + t.Errorf("expected context.Canceled, got %v", err) + } + if got := atomic.LoadInt32(&mock.count); got != 1 { + t.Errorf("expected 1 call (cancel during sleep before retry), got %d", got) + } +} + +// TestDefaultRetryConfig verifies DefaultRetryConfig returns the documented defaults. +func TestDefaultRetryConfig(t *testing.T) { + t.Parallel() + + cfg := DefaultRetryConfig() + if cfg.MaxRetries != 3 { + t.Errorf("MaxRetries: got %d, want 3", cfg.MaxRetries) + } + if cfg.RetryAfterDuration != 60*time.Second { + t.Errorf("RetryAfterDuration: got %v, want 60s", cfg.RetryAfterDuration) + } + if cfg.RetryAfterJitter != 1*time.Second { + t.Errorf("RetryAfterJitter: got %v, want 1s", cfg.RetryAfterJitter) + } + if cfg.BackoffInitial != 100*time.Millisecond { + t.Errorf("BackoffInitial: got %v, want 100ms", cfg.BackoffInitial) + } + if cfg.BackoffMax != 30*time.Second { + t.Errorf("BackoffMax: got %v, want 30s", cfg.BackoffMax) + } + if cfg.BackoffJitter != 50*time.Millisecond { + t.Errorf("BackoffJitter: got %v, want 50ms", cfg.BackoffJitter) + } +} + +// TestConnectionOnlyRetryHandlersOnlyRetriesConnection verifies that ConnectionOnlyRetryHandlers +// (connection only) does not retry 429 or 5xx; one call each, then error. +func TestConnectionOnlyRetryHandlersOnlyRetriesConnection(t *testing.T) { + t.Parallel() + + t.Run("429_not_retried", func(t *testing.T) { + t.Parallel() + var callCount int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&callCount, 1) + w.WriteHeader(http.StatusTooManyRequests) + })) + defer srv.Close() + cfg := DefaultRetryConfig() + cfg.MaxRetries = 3 + cfg.Handlers = ConnectionOnlyRetryHandlers() + api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetryConfig(cfg)) + var out SlackResponse + err := api.postMethod(context.Background(), "auth.test", nil, &out) + if err == nil { + t.Fatal("expected error on 429 with connection-only handlers") + } + if got := atomic.LoadInt32(&callCount); got != 1 { + t.Errorf("429 should not be retried with ConnectionOnlyRetryHandlers, got %d calls", got) + } + }) + + t.Run("5xx_not_retried", func(t *testing.T) { + t.Parallel() + var callCount int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&callCount, 1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + cfg := DefaultRetryConfig() + cfg.MaxRetries = 3 + cfg.Handlers = ConnectionOnlyRetryHandlers() + api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetryConfig(cfg)) + var out SlackResponse + err := api.postMethod(context.Background(), "auth.test", nil, &out) + if err == nil { + t.Fatal("expected error on 500 with connection-only handlers") + } + if got := atomic.LoadInt32(&callCount); got != 1 { + t.Errorf("5xx should not be retried with ConnectionOnlyRetryHandlers, got %d calls", got) + } + }) +} + +// TestOptionRetryRetries429Not5xx verifies OptionRetry(n) uses DefaultRetryHandlers (429 only): 429 retried, 5xx not retried. +func TestOptionRetryRetries429Not5xx(t *testing.T) { + t.Parallel() + + t.Run("429_retried", func(t *testing.T) { + t.Parallel() + var callCount int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&callCount, 1) + if n <= 2 { + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusTooManyRequests) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetry(3)) + var out SlackResponse + err := api.postMethod(context.Background(), "auth.test", nil, &out) + if err != nil { + t.Fatalf("postMethod: %v", err) + } + if got := atomic.LoadInt32(&callCount); got != 3 { + t.Errorf("OptionRetry should retry 429, want 3 calls, got %d", got) + } + }) + + t.Run("5xx_not_retried", func(t *testing.T) { + t.Parallel() + var callCount int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&callCount, 1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetry(3)) + var out SlackResponse + err := api.postMethod(context.Background(), "auth.test", nil, &out) + if err == nil { + t.Fatal("expected error on 500 with OptionRetry (no 5xx handler)") + } + if got := atomic.LoadInt32(&callCount); got != 1 { + t.Errorf("OptionRetry should not retry 5xx by default, got %d calls", got) + } + }) +} + +// TestOptionRetryConfigWithNilHandlersDefaultsTo429Only verifies that OptionRetryConfig(cfg) +// with cfg.Handlers == nil defaults to DefaultRetryHandlers (429 only); 429 is retried until success. +func TestOptionRetryConfigWithNilHandlersDefaultsTo429Only(t *testing.T) { + t.Parallel() + + var callCount int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&callCount, 1) + if n <= 2 { + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusTooManyRequests) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + cfg := DefaultRetryConfig() + cfg.MaxRetries = 3 + // Handlers explicitly nil; OptionRetryConfig sets DefaultRetryHandlers(cfg) (429 only). + api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetryConfig(cfg)) + var out SlackResponse + err := api.postMethod(context.Background(), "auth.test", nil, &out) + if err != nil { + t.Fatalf("postMethod: %v", err) + } + if !out.Ok { + t.Errorf("want ok=true, got ok=%v", out.Ok) + } + if got := atomic.LoadInt32(&callCount); got != 3 { + t.Errorf("with nil Handlers default is DefaultRetryHandlers (429 only), 429 should retry; want 3 calls, got %d", got) + } +} + +// TestOptionRetryConfigWithNilHandlersDoesNotRetryConnection verifies that with default (429 only) +// handlers, connection errors are not retried; one call then error. +func TestOptionRetryConfigWithNilHandlersDoesNotRetryConnection(t *testing.T) { + t.Parallel() + + base := &http.Client{} + wrapped := &failingThenOKClient{httpClient: base, failAttempts: 2} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + cfg := DefaultRetryConfig() + cfg.MaxRetries = 3 + // Handlers nil = DefaultRetryHandlers (429 only); connection errors not retried + api := New("token", OptionAPIURL(srv.URL+"/"), OptionHTTPClient(wrapped), OptionRetryConfig(cfg)) + var out SlackResponse + err := api.postMethod(context.Background(), "auth.test", nil, &out) + if err == nil { + t.Fatal("expected error when connection fails with default (429-only) handlers") + } + if got := atomic.LoadInt32(&wrapped.attempts); got != 1 { + t.Errorf("with default (429 only) handlers connection should not be retried, got %d calls", got) + } +} + +// TestServerErrorRetryHandlerOptIn verifies that adding NewServerErrorRetryHandler retries 5xx. +func TestServerErrorRetryHandlerOptIn(t *testing.T) { + t.Parallel() + + var callCount int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&callCount, 1) + if n <= 2 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + cfg := DefaultRetryConfig() + cfg.MaxRetries = 3 + cfg.BackoffInitial = time.Millisecond + cfg.BackoffMax = 5 * time.Millisecond + cfg.Handlers = append(AllBuiltinRetryHandlers(cfg), NewServerErrorRetryHandler(cfg)) + api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetryConfig(cfg)) + var out SlackResponse + err := api.postMethod(context.Background(), "auth.test", nil, &out) + if err != nil { + t.Fatalf("postMethod: %v", err) + } + if !out.Ok { + t.Errorf("want ok=true, got ok=%v", out.Ok) + } + if got := atomic.LoadInt32(&callCount); got != 3 { + t.Errorf("with ServerErrorRetryHandler want 3 calls (2x 500 + 1x 200), got %d", got) + } +} + +// TestIsRetryableConnError verifies which errors are treated as retryable connection failures. +func TestIsRetryableConnError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"connection reset", errConnectionReset, true}, + {"connection refused", errConnectionRefused, true}, + {"EOF", errEOF, true}, + {"other", errOther, false}, + {"wrapped connection reset", fmt.Errorf("request failed: %w", errConnectionReset), true}, + {"wrapped other", fmt.Errorf("request failed: %w", errOther), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := isRetryableConnError(tt.err) + if got != tt.want { + t.Errorf("isRetryableConnError(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} + +// --- Test helpers --- + +// failingThenOKClient fails with a retryable connection error the first N times, then delegates. +type failingThenOKClient struct { + httpClient + failAttempts int32 + attempts int32 +} + +func (c *failingThenOKClient) Do(req *http.Request) (*http.Response, error) { + n := atomic.AddInt32(&c.attempts, 1) + if n <= c.failAttempts { + return nil, &mockErr{msg: "connection reset by peer"} + } + return c.httpClient.Do(req) +} + +// countAndRespondClient counts Do calls and returns a fixed HTTP status code. +type countAndRespondClient struct { + count int32 + code int +} + +func (c *countAndRespondClient) Do(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&c.count, 1) + return &http.Response{ + StatusCode: c.code, + Body: io.NopCloser(strings.NewReader("")), + Header: make(http.Header), + }, nil +} + +// retryCountThenSuccessClient returns 429 for the first needFail Do calls, then 200. +type retryCountThenSuccessClient struct { + needFail int32 + calls int32 +} + +func (c *retryCountThenSuccessClient) Do(req *http.Request) (*http.Response, error) { + n := atomic.AddInt32(&c.calls, 1) + code := http.StatusTooManyRequests + if n > c.needFail { + code = http.StatusOK + } + return &http.Response{ + StatusCode: code, + Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), + Header: make(http.Header), + }, nil +} + +// connectionFailingClient always returns a retryable connection error; attempts is incremented each Do. +type connectionFailingClient struct { + attempts int32 +} + +func (c *connectionFailingClient) Do(*http.Request) (*http.Response, error) { + atomic.AddInt32(&c.attempts, 1) + return nil, &mockErr{msg: "connection reset by peer"} +} + +var ( + errConnectionReset = &mockErr{msg: "connection reset by peer"} + errConnectionRefused = &mockErr{msg: "connection refused"} + errEOF = &mockErr{msg: "EOF"} + errOther = &mockErr{msg: "something else"} +) + +type mockErr struct{ msg string } + +func (e *mockErr) Error() string { return e.msg } diff --git a/slack.go b/slack.go index ea8808b83..fbf255a19 100644 --- a/slack.go +++ b/slack.go @@ -119,6 +119,39 @@ func OptionConfigRefreshToken(token string) func(*Client) { return func(c *Client) { c.configRefreshToken = token } } +// OptionRetry enables HTTP retries for rate limit (429) only; 5xx and connection errors are not retried. +// Uses DefaultRetryHandlers. Use OptionRetryConfig with AllBuiltinRetryHandlers for connection + 429. +// If maxRetries is zero or negative, the client is not wrapped (no retries). +// When using a custom HTTP client, pass OptionRetry after OptionHTTPClient so the retry wrapper is applied to it. +func OptionRetry(maxRetries int) func(*Client) { + return func(c *Client) { + if maxRetries <= 0 { + return + } + cfg := DefaultRetryConfig() + cfg.MaxRetries = maxRetries + cfg.Handlers = DefaultRetryHandlers(cfg) + c.httpclient = &retryClient{client: c.httpclient, config: cfg, debug: c} + } +} + +// OptionRetryConfig enables HTTP retries with a custom config. +// If config.MaxRetries is 0, the client is not wrapped (no retries). +// If config.Handlers is nil, DefaultRetryHandlers(cfg) is used (429 only). +// When using a custom HTTP client, pass OptionRetryConfig after OptionHTTPClient so the retry wrapper is applied to it. +func OptionRetryConfig(config RetryConfig) func(*Client) { + return func(c *Client) { + if config.MaxRetries <= 0 { + return + } + cfg := config + if cfg.Handlers == nil { + cfg.Handlers = DefaultRetryHandlers(cfg) + } + c.httpclient = &retryClient{client: c.httpclient, config: cfg, debug: c} + } +} + // New builds a slack client from the provided token and options. func New(token string, options ...Option) *Client { s := &Client{ From eaddf77e2c8911f8027a29259ec1321651327e07 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Wed, 4 Mar 2026 22:36:38 +0100 Subject: [PATCH 486/606] chore: v0.19.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a58ce529b..787a209cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.0] - 2026-03-04 + +### Added + +- **Optional HTTP retry for Web API** — Retries are off by default. Enable with `OptionRetry(n)` for 429-only retries or `OptionRetryConfig(cfg)` for full control including 5xx and connection errors with exponential backoff. ([#1532]) +- **`task_card` and `plan` agent blocks** — New block types for task cards and plan agent blocks. ([#1536]) + +### Changed + +- CI: bumped `actions/stale` from 10.1.1 to 10.2.0. ([#1534]) +- Use `golangci-lint` in Makefile. ([#1533]) + ## [0.18.0] - 2026-02-21 ### Added @@ -155,8 +167,13 @@ for details. [#1528]: https://github.com/slack-go/slack/pull/1528 [#1529]: https://github.com/slack-go/slack/pull/1529 [#1530]: https://github.com/slack-go/slack/pull/1530 +[#1532]: https://github.com/slack-go/slack/pull/1532 +[#1533]: https://github.com/slack-go/slack/pull/1533 +[#1534]: https://github.com/slack-go/slack/pull/1534 +[#1536]: https://github.com/slack-go/slack/pull/1536 -[Unreleased]: https://github.com/slack-go/slack/compare/v0.18.0...HEAD +[Unreleased]: https://github.com/slack-go/slack/compare/v0.19.0...HEAD +[0.19.0]: https://github.com/slack-go/slack/compare/v0.18.0...v0.19.0 [0.18.0]: https://github.com/slack-go/slack/compare/v0.18.0-rc2...v0.18.0 [0.18.0-rc2]: https://github.com/slack-go/slack/releases/tag/v0.18.0-rc2 [0.18.0-rc1]: https://github.com/slack-go/slack/releases/tag/v0.18.0-rc1 From 8917d11e805ce93a28a88e60343cc393185a6f59 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 7 Mar 2026 15:27:18 +0100 Subject: [PATCH 487/606] docs: update README with links and remove old warning --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 3458079d1..53dd912d3 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,7 @@ Slack API in Go [![Go Reference](https://pkg.go.dev/badge/github.com/slack-go/slack.svg)](https://pkg.go.dev/github.com/slack-go/slack) [![CI](https://github.com/slack-go/slack/actions/workflows/test.yml/badge.svg)](https://github.com/slack-go/slack/actions/workflows/test.yml) =============== -This is the original Slack library for Go created by Norberto Lopes, transferred to a GitHub organization. - -You can also chat with us on the #slack-go, #slack-go-ja Slack channel on the Gophers Slack. +You can chat with us on the [#slack-go](https://gophers.slack.com/archives/C02JQ98JHNC), [#slack-go-ja](https://gophers.slack.com/archives/C02HNL8EN3H) Slack channel on the [Gophers Slack](https://gophers.slack.com). ![logo](logo.png "icon") From 891d7e55c42f82fb6b21c1de8338a9f577a8eee6 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 7 Mar 2026 15:27:36 +0100 Subject: [PATCH 488/606] chore: add golangci-lint to mise --- mise.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/mise.toml b/mise.toml index ac7816d1f..ce5d07a9e 100644 --- a/mise.toml +++ b/mise.toml @@ -1,2 +1,3 @@ [tools] go = "1.25" +golangci-lint = "2.10.1" From 8e62a1a03eba80387fa633dca0d921337a810786 Mon Sep 17 00:00:00 2001 From: Clay Allsopp Date: Sat, 21 Mar 2026 14:14:47 -0400 Subject: [PATCH 489/606] Add WorkflowID to message (#1537) Hey team, this adds the `workflow_id` to the message struct. I can't find this field documented anywhere, but it is on the messages we see that originate from Workflows - let me know if there's something different/else you'd need! --- messages.go | 1 + messages_test.go | 25 +++++++++++++++++++++++++ slackevents/inner_events.go | 7 ++++--- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/messages.go b/messages.go index c53809fda..63945c09b 100644 --- a/messages.go +++ b/messages.go @@ -87,6 +87,7 @@ type Msg struct { Username string `json:"username,omitempty"` Icons *Icon `json:"icons,omitempty"` BotProfile *BotProfile `json:"bot_profile,omitempty"` + WorkflowID string `json:"workflow_id,omitempty"` // channel_join, group_join Inviter string `json:"inviter,omitempty"` diff --git a/messages_test.go b/messages_test.go index 62fd759fa..40af28088 100644 --- a/messages_test.go +++ b/messages_test.go @@ -285,6 +285,31 @@ var botMessage = `{ "ts": "1358877455.000010" }` +var workflowBotMessage = `{ + "type": "message", + "subtype": "bot_message", + "text": "Can you create a TODO.md file", + "bot_id": "BB12033", + "username": "Test Workflow", + "workflow_id": "Wf0123456789", + "blocks": [], + "channel": "C2147483705", + "ts": "1358877455.000010" +}` + +func TestWorkflowBotMessage(t *testing.T) { + message, err := unmarshalMessage(workflowBotMessage) + assert.Nil(t, err) + assert.NotNil(t, message) + assert.Equal(t, "message", message.Type) + assert.Equal(t, MsgSubTypeBotMessage, message.SubType) + assert.Equal(t, "BB12033", message.BotID) + assert.Equal(t, "Test Workflow", message.Username) + assert.Equal(t, "Wf0123456789", message.WorkflowID) + assert.Equal(t, "C2147483705", message.Channel) + assert.Equal(t, "1358877455.000010", message.Timestamp) +} + func TestBotMessage(t *testing.T) { message, err := unmarshalMessage(botMessage) assert.Nil(t, err) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 058a5725c..35ec0e985 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -320,9 +320,10 @@ type MessageEvent struct { SubType string `json:"subtype,omitempty"` // bot_message (https://api.slack.com/events/message/bot_message) - BotID string `json:"bot_id,omitempty"` - Username string `json:"username,omitempty"` - Icons *Icon `json:"icons,omitempty"` + BotID string `json:"bot_id,omitempty"` + Username string `json:"username,omitempty"` + Icons *Icon `json:"icons,omitempty"` + WorkflowID string `json:"workflow_id,omitempty"` // AssistantThread contains action token for Data Access API queries in message events AssistantThread *AssistantThreadActionToken `json:"assistant_thread,omitempty"` From 561f12fedb98338eb77297657dad6b9491c9a1c8 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 21 Mar 2026 18:23:32 +0000 Subject: [PATCH 490/606] feat: add `TriggerID` to `Message` It seems that some types of messages, e.g: `bot_message`, can carry `trigger_id` and `workflow_id`. I've tested this myself in my workspaces and, for example, workflows that are triggered that post a message, will add the trigger id and workflow id. None of `trigger_id` and `workflow_id` are in any documentation or in any of the official libraries, so exercise caution if you use these. --- CHANGELOG.md | 9 + messages.go | 11 +- messages_test.go | 742 ++++++++++++++++++++++++----------------------- 3 files changed, 391 insertions(+), 371 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 787a209cd..7441a58bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +[!IMPORTANT] +`trigger_id` and `workflow_id` are NOT in any documentation or in any of the official +libraries, so exercise caution if you use these. + +### Added + +- **`workflow_id` and `trigger_id` in `Message`** It seems that some types of messages, + e.g: `bot_message`, can carry `trigger_id` and `workflow_id`. + ## [0.19.0] - 2026-03-04 ### Added diff --git a/messages.go b/messages.go index 63945c09b..332e15e99 100644 --- a/messages.go +++ b/messages.go @@ -87,7 +87,16 @@ type Msg struct { Username string `json:"username,omitempty"` Icons *Icon `json:"icons,omitempty"` BotProfile *BotProfile `json:"bot_profile,omitempty"` - WorkflowID string `json:"workflow_id,omitempty"` + + // These tend to be present in some of the messages, especially when triggered through + // a workflow. The API documentation is not clear about which ones are present in + // which messages, so we make them all optional. + // + // I'm adding them here for completeness but none of the Slack official libraries seem + // to support these fields. Be warned that they may be removed in future versions of + // the API, and that they may not be present in all messages. + TriggerID string `json:"trigger_id,omitempty"` + WorkflowID string `json:"workflow_id,omitempty"` // channel_join, group_join Inviter string `json:"inviter,omitempty"` diff --git a/messages_test.go b/messages_test.go index 40af28088..1cca21325 100644 --- a/messages_test.go +++ b/messages_test.go @@ -9,11 +9,11 @@ import ( ) var simpleMessage = `{ - "type": "message", - "channel": "C2147483705", - "user": "U2147483697", - "text": "Hello world", - "ts": "1355517523.000005" + "type": "message", + "channel": "C2147483705", + "user": "U2147483697", + "text": "Hello world", + "ts": "1355517523.000005" }` func unmarshalMessage(j string) (*Message, error) { @@ -36,12 +36,12 @@ func TestSimpleMessage(t *testing.T) { } var starredMessage = `{ - "text": "is testing", - "type": "message", - "subtype": "me_message", - "user": "U2147483697", - "ts": "1433314126.000003", - "is_starred": true + "text": "is testing", + "type": "message", + "subtype": "me_message", + "user": "U2147483697", + "ts": "1433314126.000003", + "is_starred": true }` func TestStarredMessage(t *testing.T) { @@ -57,14 +57,14 @@ func TestStarredMessage(t *testing.T) { } var editedMessage = `{ - "type": "message", - "user": "U2147483697", - "text": "hello edited", - "edited": { - "user": "U2147483697", - "ts": "1433314416.000000" - }, - "ts": "1433314408.000004" + "type": "message", + "user": "U2147483697", + "text": "hello edited", + "edited": { + "user": "U2147483697", + "ts": "1433314416.000000" + }, + "ts": "1433314408.000004" }` func TestEditedMessage(t *testing.T) { @@ -81,54 +81,54 @@ func TestEditedMessage(t *testing.T) { } var uploadedFile = `{ - "type": "message", - "subtype": "file_share", - "text": "<@U2147483697|tester> uploaded a file: and commented: test comment here", - "files": [{ - "id": "abc", - "created": 1433314757, - "timestamp": 1433314757, - "name": "test.txt", - "title": "test.txt", - "mimetype": "text\/plain", - "filetype": "text", - "pretty_type": "Plain Text", - "user": "U2147483697", - "editable": true, - "size": 5, - "mode": "snippet", - "is_external": false, - "external_type": "", - "is_public": true, - "public_url_shared": false, - "url": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/test.txt", - "url_download": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/download\/test.txt", - "url_private": "https:\/\/files.slack.com\/files-pri\/abc-def\/test.txt", - "url_private_download": "https:\/\/files.slack.com\/files-pri\/abc-def\/download\/test.txt", - "permalink": "https:\/\/test.slack.com\/files\/tester\/abc\/test.txt", - "permalink_public": "https:\/\/slack-files.com\/abc-def-ghi", - "edit_link": "https:\/\/test.slack.com\/files\/tester\/abc\/test.txt\/edit", - "preview": "test\n", - "preview_highlight": "
    test<\/pre><\/div>\n
    <\/pre><\/div>\n<\/div>",
    -        "lines": 2,
    -        "lines_more": 0,
    -        "channels": [
    -            "C2147483705"
    -        ],
    -        "groups": [],
    -        "ims": [],
    -        "comments_count": 1,
    -        "initial_comment": {
    -            "id": "Fc066YLGKH",
    -            "created": 1433314757,
    -            "timestamp": 1433314757,
    -            "user": "U2147483697",
    -            "comment": "test comment here"
    -        }
    -    }],
    -    "user": "U2147483697",
    -    "upload": true,
    -    "ts": "1433314757.000006"
    +	"type": "message",
    +	"subtype": "file_share",
    +	"text": "<@U2147483697|tester> uploaded a file:  and commented: test comment here",
    +	"files": [{
    +		"id": "abc",
    +		"created": 1433314757,
    +		"timestamp": 1433314757,
    +		"name": "test.txt",
    +		"title": "test.txt",
    +		"mimetype": "text\/plain",
    +		"filetype": "text",
    +		"pretty_type": "Plain Text",
    +		"user": "U2147483697",
    +		"editable": true,
    +		"size": 5,
    +		"mode": "snippet",
    +		"is_external": false,
    +		"external_type": "",
    +		"is_public": true,
    +		"public_url_shared": false,
    +		"url": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/test.txt",
    +		"url_download": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/download\/test.txt",
    +		"url_private": "https:\/\/files.slack.com\/files-pri\/abc-def\/test.txt",
    +		"url_private_download": "https:\/\/files.slack.com\/files-pri\/abc-def\/download\/test.txt",
    +		"permalink": "https:\/\/test.slack.com\/files\/tester\/abc\/test.txt",
    +		"permalink_public": "https:\/\/slack-files.com\/abc-def-ghi",
    +		"edit_link": "https:\/\/test.slack.com\/files\/tester\/abc\/test.txt\/edit",
    +		"preview": "test\n",
    +		"preview_highlight": "
    test<\/pre><\/div>\n
    <\/pre><\/div>\n<\/div>",
    +		"lines": 2,
    +		"lines_more": 0,
    +		"channels": [
    +			"C2147483705"
    +		],
    +		"groups": [],
    +		"ims": [],
    +		"comments_count": 1,
    +		"initial_comment": {
    +			"id": "Fc066YLGKH",
    +			"created": 1433314757,
    +			"timestamp": 1433314757,
    +			"user": "U2147483697",
    +			"comment": "test comment here"
    +		}
    +	}],
    +	"user": "U2147483697",
    +	"upload": true,
    +	"ts": "1433314757.000006"
     }`
     
     func TestUploadedFile(t *testing.T) {
    @@ -145,44 +145,44 @@ func TestUploadedFile(t *testing.T) {
     }
     
     var testPost = `{
    -    "type": "message",
    -    "subtype": "file_share",
    -    "text": "<@U2147483697|tester> shared a file: ",
    -    "files": [{
    -        "id": "abc",
    -        "created": 1433315398,
    -        "timestamp": 1433315398,
    -        "name": "test_post",
    -        "title": "test post",
    -        "mimetype": "text\/plain",
    -        "filetype": "post",
    -        "pretty_type": "Post",
    -        "user": "U2147483697",
    -        "editable": true,
    -        "size": 14,
    -        "mode": "post",
    -        "is_external": false,
    -        "external_type": "",
    -        "is_public": true,
    -        "public_url_shared": false,
    -        "url": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/test_post",
    -        "url_download": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/download\/test_post",
    -        "url_private": "https:\/\/files.slack.com\/files-pri\/abc-def\/test_post",
    -        "url_private_download": "https:\/\/files.slack.com\/files-pri\/abc-def\/download\/test_post",
    -        "permalink": "https:\/\/test.slack.com\/files\/tester\/abc\/test_post",
    -        "permalink_public": "https:\/\/slack-files.com\/abc-def-ghi",
    -        "edit_link": "https:\/\/test.slack.com\/files\/tester\/abc\/test_post\/edit",
    -        "preview": "test post body",
    -        "channels": [
    -            "C2147483705"
    -        ],
    -        "groups": [],
    -        "ims": [],
    -        "comments_count": 1
    -    }],
    -    "user": "U2147483697",
    -    "upload": false,
    -    "ts": "1433315416.000008"
    +	"type": "message",
    +	"subtype": "file_share",
    +	"text": "<@U2147483697|tester> shared a file: ",
    +	"files": [{
    +		"id": "abc",
    +		"created": 1433315398,
    +		"timestamp": 1433315398,
    +		"name": "test_post",
    +		"title": "test post",
    +		"mimetype": "text\/plain",
    +		"filetype": "post",
    +		"pretty_type": "Post",
    +		"user": "U2147483697",
    +		"editable": true,
    +		"size": 14,
    +		"mode": "post",
    +		"is_external": false,
    +		"external_type": "",
    +		"is_public": true,
    +		"public_url_shared": false,
    +		"url": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/test_post",
    +		"url_download": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/download\/test_post",
    +		"url_private": "https:\/\/files.slack.com\/files-pri\/abc-def\/test_post",
    +		"url_private_download": "https:\/\/files.slack.com\/files-pri\/abc-def\/download\/test_post",
    +		"permalink": "https:\/\/test.slack.com\/files\/tester\/abc\/test_post",
    +		"permalink_public": "https:\/\/slack-files.com\/abc-def-ghi",
    +		"edit_link": "https:\/\/test.slack.com\/files\/tester\/abc\/test_post\/edit",
    +		"preview": "test post body",
    +		"channels": [
    +			"C2147483705"
    +		],
    +		"groups": [],
    +		"ims": [],
    +		"comments_count": 1
    +	}],
    +	"user": "U2147483697",
    +	"upload": false,
    +	"ts": "1433315416.000008"
     }`
     
     func TestPost(t *testing.T) {
    @@ -199,49 +199,49 @@ func TestPost(t *testing.T) {
     }
     
     var testComment = `{
    -    "type": "message",
    -    "subtype": "file_comment",
    -    "text": "<@U2147483697|tester> commented on <@U2147483697|tester>'s file : another comment",
    -    "files": [{
    -        "id": "abc",
    -        "created": 1433315398,
    -        "timestamp": 1433315398,
    -        "name": "test_post",
    -        "title": "test post",
    -        "mimetype": "text\/plain",
    -        "filetype": "post",
    -        "pretty_type": "Post",
    -        "user": "U2147483697",
    -        "editable": true,
    -        "size": 14,
    -        "mode": "post",
    -        "is_external": false,
    -        "external_type": "",
    -        "is_public": true,
    -        "public_url_shared": false,
    -        "url": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/test_post",
    -        "url_download": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/download\/test_post",
    -        "url_private": "https:\/\/files.slack.com\/files-pri\/abc-def\/test_post",
    -        "url_private_download": "https:\/\/files.slack.com\/files-pri\/abc-def\/download\/test_post",
    -        "permalink": "https:\/\/test.slack.com\/files\/tester\/abc\/test_post",
    -        "permalink_public": "https:\/\/slack-files.com\/abc-def-ghi",
    -        "edit_link": "https:\/\/test.slack.com\/files\/tester\/abc\/test_post\/edit",
    -        "preview": "test post body",
    -        "channels": [
    -            "C2147483705"
    -        ],
    -        "groups": [],
    -        "ims": [],
    -        "comments_count": 2
    -    }],
    -    "comment": {
    -        "id": "xyz",
    -        "created": 1433316360,
    -        "timestamp": 1433316360,
    -        "user": "U2147483697",
    -        "comment": "another comment"
    -    },
    -    "ts": "1433316360.000009"
    +	"type": "message",
    +	"subtype": "file_comment",
    +	"text": "<@U2147483697|tester> commented on <@U2147483697|tester>'s file : another comment",
    +	"files": [{
    +		"id": "abc",
    +		"created": 1433315398,
    +		"timestamp": 1433315398,
    +		"name": "test_post",
    +		"title": "test post",
    +		"mimetype": "text\/plain",
    +		"filetype": "post",
    +		"pretty_type": "Post",
    +		"user": "U2147483697",
    +		"editable": true,
    +		"size": 14,
    +		"mode": "post",
    +		"is_external": false,
    +		"external_type": "",
    +		"is_public": true,
    +		"public_url_shared": false,
    +		"url": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/test_post",
    +		"url_download": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/download\/test_post",
    +		"url_private": "https:\/\/files.slack.com\/files-pri\/abc-def\/test_post",
    +		"url_private_download": "https:\/\/files.slack.com\/files-pri\/abc-def\/download\/test_post",
    +		"permalink": "https:\/\/test.slack.com\/files\/tester\/abc\/test_post",
    +		"permalink_public": "https:\/\/slack-files.com\/abc-def-ghi",
    +		"edit_link": "https:\/\/test.slack.com\/files\/tester\/abc\/test_post\/edit",
    +		"preview": "test post body",
    +		"channels": [
    +			"C2147483705"
    +		],
    +		"groups": [],
    +		"ims": [],
    +		"comments_count": 2
    +	}],
    +	"comment": {
    +		"id": "xyz",
    +		"created": 1433316360,
    +		"timestamp": 1433316360,
    +		"user": "U2147483697",
    +		"comment": "another comment"
    +	},
    +	"ts": "1433316360.000009"
     }`
     
     func TestComment(t *testing.T) {
    @@ -258,43 +258,44 @@ func TestComment(t *testing.T) {
     }
     
     var botMessage = `{
    -    "type": "message",
    -    "subtype": "bot_message",
    -    "text": "Pushing is the answer",
    -    "suppress_notification": false,
    -    "bot_id": "BB12033",
    -    "username": "github",
    -    "icons": {},
    -    "team": "T01A9CUMPQA",
    -    "bot_profile": {
    -        "id": "BB12033",
    -        "deleted": false,
    -        "name": "github",
    -        "updated": 1599574335,
    -        "app_id": "A6DB2SWUW",
    -        "icons": {
    -            "image_36": "https://slack-files2.s3-us-west-2.amazonaws.com/avatars/2017-10-24/261138718469_ada58732a18da119678d_36.png",
    -            "image_48": "https://slack-files2.s3-us-west-2.amazonaws.com/avatars/2017-10-24/261138718469_ada58732a18da119678d_48.png",
    -            "image_72": "https://slack-files2.s3-us-west-2.amazonaws.com/avatars/2017-10-24/261138718469_ada58732a18da119678d_72.png"
    -        },
    -        "team_id": "T01A9CUMPQA"
    -    },
    -    "blocks": [],
    -    "channel": "C01AZ844Z32",
    -    "event_ts": "1358877455.000010",
    -    "ts": "1358877455.000010"
    +	"type": "message",
    +	"subtype": "bot_message",
    +	"text": "Pushing is the answer",
    +	"suppress_notification": false,
    +	"bot_id": "BB12033",
    +	"username": "github",
    +	"icons": {},
    +	"team": "T01A9CUMPQA",
    +	"bot_profile": {
    +		"id": "BB12033",
    +		"deleted": false,
    +		"name": "github",
    +		"updated": 1599574335,
    +		"app_id": "A6DB2SWUW",
    +		"icons": {
    +			"image_36": "https://slack-files2.s3-us-west-2.amazonaws.com/avatars/2017-10-24/261138718469_ada58732a18da119678d_36.png",
    +			"image_48": "https://slack-files2.s3-us-west-2.amazonaws.com/avatars/2017-10-24/261138718469_ada58732a18da119678d_48.png",
    +			"image_72": "https://slack-files2.s3-us-west-2.amazonaws.com/avatars/2017-10-24/261138718469_ada58732a18da119678d_72.png"
    +		},
    +		"team_id": "T01A9CUMPQA"
    +	},
    +	"blocks": [],
    +	"channel": "C01AZ844Z32",
    +	"event_ts": "1358877455.000010",
    +	"ts": "1358877455.000010"
     }`
     
     var workflowBotMessage = `{
    -    "type": "message",
    -    "subtype": "bot_message",
    -    "text": "Can you create a TODO.md file",
    -    "bot_id": "BB12033",
    -    "username": "Test Workflow",
    -    "workflow_id": "Wf0123456789",
    -    "blocks": [],
    -    "channel": "C2147483705",
    -    "ts": "1358877455.000010"
    +	"type": "message",
    +	"subtype": "bot_message",
    +	"text": "Can you create a TODO.md file",
    +	"bot_id": "BB12033",
    +	"username": "Test Workflow",
    +	"workflow_id": "Wf0123456789",
    +	"trigger_id": "Ft0123456789",
    +	"blocks": [],
    +	"channel": "C2147483705",
    +	"ts": "1358877455.000010"
     }`
     
     func TestWorkflowBotMessage(t *testing.T) {
    @@ -306,6 +307,7 @@ func TestWorkflowBotMessage(t *testing.T) {
     	assert.Equal(t, "BB12033", message.BotID)
     	assert.Equal(t, "Test Workflow", message.Username)
     	assert.Equal(t, "Wf0123456789", message.WorkflowID)
    +	assert.Equal(t, "Ft0123456789", message.TriggerID)
     	assert.Equal(t, "C2147483705", message.Channel)
     	assert.Equal(t, "1358877455.000010", message.Timestamp)
     }
    @@ -337,12 +339,12 @@ func TestBotMessage(t *testing.T) {
     }
     
     var meMessage = `{
    -    "type": "message",
    -    "subtype": "me_message",
    -    "channel": "C2147483705",
    -    "user": "U2147483697",
    -    "text": "is doing that thing",
    -    "ts": "1355517523.000005"
    +	"type": "message",
    +	"subtype": "me_message",
    +	"channel": "C2147483705",
    +	"user": "U2147483697",
    +	"text": "is doing that thing",
    +	"ts": "1355517523.000005"
     }`
     
     func TestMeMessage(t *testing.T) {
    @@ -358,21 +360,21 @@ func TestMeMessage(t *testing.T) {
     }
     
     var messageChangedMessage = `{
    -    "type": "message",
    -    "subtype": "message_changed",
    -    "hidden": true,
    -    "channel": "C2147483705",
    -    "ts": "1358878755.000001",
    -    "message": {
    -        "type": "message",
    -        "user": "U2147483697",
    -        "text": "Hello, world!",
    -        "ts": "1355517523.000005",
    -        "edited": {
    -            "user": "U2147483697",
    -            "ts": "1358878755.000001"
    -        }
    -    }
    +	"type": "message",
    +	"subtype": "message_changed",
    +	"hidden": true,
    +	"channel": "C2147483705",
    +	"ts": "1358878755.000001",
    +	"message": {
    +		"type": "message",
    +		"user": "U2147483697",
    +		"text": "Hello, world!",
    +		"ts": "1355517523.000005",
    +		"edited": {
    +			"user": "U2147483697",
    +			"ts": "1358878755.000001"
    +		}
    +	}
     }`
     
     func TestMessageChangedMessage(t *testing.T) {
    @@ -395,12 +397,12 @@ func TestMessageChangedMessage(t *testing.T) {
     }
     
     var messageDeletedMessage = `{
    -    "type": "message",
    -    "subtype": "message_deleted",
    -    "hidden": true,
    -    "channel": "C2147483705",
    -    "ts": "1358878755.000001",
    -    "deleted_ts": "1358878749.000002"
    +	"type": "message",
    +	"subtype": "message_deleted",
    +	"hidden": true,
    +	"channel": "C2147483705",
    +	"ts": "1358878755.000001",
    +	"deleted_ts": "1358878749.000002"
     }`
     
     func TestMessageDeletedMessage(t *testing.T) {
    @@ -416,11 +418,11 @@ func TestMessageDeletedMessage(t *testing.T) {
     }
     
     var channelJoinMessage = `{
    -    "type": "message",
    -    "subtype": "channel_join",
    -    "ts": "1358877458.000011",
    -    "user": "U2147483828",
    -    "text": "<@U2147483828|cal> has joined the channel"
    +	"type": "message",
    +	"subtype": "channel_join",
    +	"ts": "1358877458.000011",
    +	"user": "U2147483828",
    +	"text": "<@U2147483828|cal> has joined the channel"
     }`
     
     func TestChannelJoinMessage(t *testing.T) {
    @@ -435,11 +437,11 @@ func TestChannelJoinMessage(t *testing.T) {
     }
     
     var channelJoinInvitedMessage = `{
    -    "type": "message",
    -    "subtype": "channel_join",
    -    "ts": "1358877458.000011",
    -    "user": "U2147483828",
    -    "text": "<@U2147483828|cal> has joined the channel",
    +	"type": "message",
    +	"subtype": "channel_join",
    +	"ts": "1358877458.000011",
    +	"user": "U2147483828",
    +	"text": "<@U2147483828|cal> has joined the channel",
     		"inviter": "U2147483829"
     }`
     
    @@ -456,11 +458,11 @@ func TestChannelJoinInvitedMessage(t *testing.T) {
     }
     
     var channelLeaveMessage = `{
    -    "type": "message",
    -    "subtype": "channel_leave",
    -    "ts": "1358877455.000010",
    -    "user": "U2147483828",
    -    "text": "<@U2147483828|cal> has left the channel"
    +	"type": "message",
    +	"subtype": "channel_leave",
    +	"ts": "1358877455.000010",
    +	"user": "U2147483828",
    +	"text": "<@U2147483828|cal> has left the channel"
     }`
     
     func TestChannelLeaveMessage(t *testing.T) {
    @@ -475,12 +477,12 @@ func TestChannelLeaveMessage(t *testing.T) {
     }
     
     var channelTopicMessage = `{
    -    "type": "message",
    -    "subtype": "channel_topic",
    -    "ts": "1358877455.000010",
    -    "user": "U2147483828",
    -    "topic": "hello world",
    -    "text": "<@U2147483828|cal> set the channel topic: hello world"
    +	"type": "message",
    +	"subtype": "channel_topic",
    +	"ts": "1358877455.000010",
    +	"user": "U2147483828",
    +	"topic": "hello world",
    +	"text": "<@U2147483828|cal> set the channel topic: hello world"
     }`
     
     func TestChannelTopicMessage(t *testing.T) {
    @@ -496,12 +498,12 @@ func TestChannelTopicMessage(t *testing.T) {
     }
     
     var channelPurposeMessage = `{
    -    "type": "message",
    -    "subtype": "channel_purpose",
    -    "ts": "1358877455.000010",
    -    "user": "U2147483828",
    -    "purpose": "whatever",
    -    "text": "<@U2147483828|cal> set the channel purpose: whatever"
    +	"type": "message",
    +	"subtype": "channel_purpose",
    +	"ts": "1358877455.000010",
    +	"user": "U2147483828",
    +	"purpose": "whatever",
    +	"text": "<@U2147483828|cal> set the channel purpose: whatever"
     }`
     
     func TestChannelPurposeMessage(t *testing.T) {
    @@ -517,13 +519,13 @@ func TestChannelPurposeMessage(t *testing.T) {
     }
     
     var channelNameMessage = `{
    -    "type": "message",
    -    "subtype": "channel_name",
    -    "ts": "1358877455.000010",
    -    "user": "U2147483828",
    -    "old_name": "random",
    -    "name": "watercooler",
    -    "text": "<@U2147483828|cal> has renamed the channel from \"random\" to \"watercooler\""
    +	"type": "message",
    +	"subtype": "channel_name",
    +	"ts": "1358877455.000010",
    +	"user": "U2147483828",
    +	"old_name": "random",
    +	"name": "watercooler",
    +	"text": "<@U2147483828|cal> has renamed the channel from \"random\" to \"watercooler\""
     }`
     
     func TestChannelNameMessage(t *testing.T) {
    @@ -540,12 +542,12 @@ func TestChannelNameMessage(t *testing.T) {
     }
     
     var channelArchiveMessage = `{
    -    "type": "message",
    -    "subtype": "channel_archive",
    -    "ts": "1361482916.000003",
    -    "text": " archived the channel",
    -    "user": "U1234",
    -    "members": ["U1234", "U5678"]
    +	"type": "message",
    +	"subtype": "channel_archive",
    +	"ts": "1361482916.000003",
    +	"text": " archived the channel",
    +	"user": "U1234",
    +	"members": ["U1234", "U5678"]
     }`
     
     func TestChannelArchiveMessage(t *testing.T) {
    @@ -562,11 +564,11 @@ func TestChannelArchiveMessage(t *testing.T) {
     }
     
     var channelUnarchiveMessage = `{
    -    "type": "message",
    -    "subtype": "channel_unarchive",
    -    "ts": "1361482916.000003",
    -    "text": " un-archived the channel",
    -    "user": "U1234"
    +	"type": "message",
    +	"subtype": "channel_unarchive",
    +	"ts": "1361482916.000003",
    +	"text": " un-archived the channel",
    +	"user": "U1234"
     }`
     
     func TestChannelUnarchiveMessage(t *testing.T) {
    @@ -581,25 +583,25 @@ func TestChannelUnarchiveMessage(t *testing.T) {
     }
     
     var channelRepliesParentMessage = `{
    -    "type": "message",
    -    "user": "U1234",
    -    "text": "test",
    -    "thread_ts": "1493305433.915644",
    -    "reply_count": 2,
    -    "replies": [
    -        {
    -            "user": "U5678",
    -            "ts": "1493305444.920992"
    -        },
    -        {
    -            "user": "U9012",
    -            "ts": "1493305894.133936"
    -        }
    -    ],
    -    "subscribed": true,
    -    "last_read": "1493305894.133936",
    -    "unread_count": 0,
    -    "ts": "1493305433.915644"
    +	"type": "message",
    +	"user": "U1234",
    +	"text": "test",
    +	"thread_ts": "1493305433.915644",
    +	"reply_count": 2,
    +	"replies": [
    +		{
    +			"user": "U5678",
    +			"ts": "1493305444.920992"
    +		},
    +		{
    +			"user": "U9012",
    +			"ts": "1493305894.133936"
    +		}
    +	],
    +	"subscribed": true,
    +	"last_read": "1493305894.133936",
    +	"unread_count": 0,
    +	"ts": "1493305433.915644"
     }`
     
     func TestChannelRepliesParentMessage(t *testing.T) {
    @@ -619,12 +621,12 @@ func TestChannelRepliesParentMessage(t *testing.T) {
     }
     
     var channelRepliesChildMessage = `{
    -    "type": "message",
    -    "user": "U5678",
    -    "text": "foo",
    -    "thread_ts": "1493305433.915644",
    -    "parent_user_id": "U1234",
    -    "ts": "1493305444.920992"
    +	"type": "message",
    +	"user": "U5678",
    +	"text": "foo",
    +	"thread_ts": "1493305433.915644",
    +	"parent_user_id": "U1234",
    +	"ts": "1493305444.920992"
     }`
     
     func TestChannelRepliesChildMessage(t *testing.T) {
    @@ -640,11 +642,11 @@ func TestChannelRepliesChildMessage(t *testing.T) {
     }
     
     var groupJoinMessage = `{
    -    "type": "message",
    -    "subtype": "group_join",
    -    "ts": "1358877458.000011",
    -    "user": "U2147483828",
    -    "text": "<@U2147483828|cal> has joined the group"
    +	"type": "message",
    +	"subtype": "group_join",
    +	"ts": "1358877458.000011",
    +	"user": "U2147483828",
    +	"text": "<@U2147483828|cal> has joined the group"
     }`
     
     func TestGroupJoinMessage(t *testing.T) {
    @@ -659,11 +661,11 @@ func TestGroupJoinMessage(t *testing.T) {
     }
     
     var groupJoinInvitedMessage = `{
    -    "type": "message",
    -    "subtype": "group_join",
    -    "ts": "1358877458.000011",
    -    "user": "U2147483828",
    -    "text": "<@U2147483828|cal> has joined the group",
    +	"type": "message",
    +	"subtype": "group_join",
    +	"ts": "1358877458.000011",
    +	"user": "U2147483828",
    +	"text": "<@U2147483828|cal> has joined the group",
     		"inviter": "U2147483829"
     }`
     
    @@ -680,11 +682,11 @@ func TestGroupJoinInvitedMessage(t *testing.T) {
     }
     
     var groupLeaveMessage = `{
    -    "type": "message",
    -    "subtype": "group_leave",
    -    "ts": "1358877455.000010",
    -    "user": "U2147483828",
    -    "text": "<@U2147483828|cal> has left the group"
    +	"type": "message",
    +	"subtype": "group_leave",
    +	"ts": "1358877455.000010",
    +	"user": "U2147483828",
    +	"text": "<@U2147483828|cal> has left the group"
     }`
     
     func TestGroupLeaveMessage(t *testing.T) {
    @@ -699,12 +701,12 @@ func TestGroupLeaveMessage(t *testing.T) {
     }
     
     var groupTopicMessage = `{
    -    "type": "message",
    -    "subtype": "group_topic",
    -    "ts": "1358877455.000010",
    -    "user": "U2147483828",
    -    "topic": "hello world",
    -    "text": "<@U2147483828|cal> set the group topic: hello world"
    +	"type": "message",
    +	"subtype": "group_topic",
    +	"ts": "1358877455.000010",
    +	"user": "U2147483828",
    +	"topic": "hello world",
    +	"text": "<@U2147483828|cal> set the group topic: hello world"
     }`
     
     func TestGroupTopicMessage(t *testing.T) {
    @@ -720,12 +722,12 @@ func TestGroupTopicMessage(t *testing.T) {
     }
     
     var groupPurposeMessage = `{
    -    "type": "message",
    -    "subtype": "group_purpose",
    -    "ts": "1358877455.000010",
    -    "user": "U2147483828",
    -    "purpose": "whatever",
    -    "text": "<@U2147483828|cal> set the group purpose: whatever"
    +	"type": "message",
    +	"subtype": "group_purpose",
    +	"ts": "1358877455.000010",
    +	"user": "U2147483828",
    +	"purpose": "whatever",
    +	"text": "<@U2147483828|cal> set the group purpose: whatever"
     }`
     
     func TestGroupPurposeMessage(t *testing.T) {
    @@ -741,13 +743,13 @@ func TestGroupPurposeMessage(t *testing.T) {
     }
     
     var groupNameMessage = `{
    -    "type": "message",
    -    "subtype": "group_name",
    -    "ts": "1358877455.000010",
    -    "user": "U2147483828",
    -    "old_name": "random",
    -    "name": "watercooler",
    -    "text": "<@U2147483828|cal> has renamed the group from \"random\" to \"watercooler\""
    +	"type": "message",
    +	"subtype": "group_name",
    +	"ts": "1358877455.000010",
    +	"user": "U2147483828",
    +	"old_name": "random",
    +	"name": "watercooler",
    +	"text": "<@U2147483828|cal> has renamed the group from \"random\" to \"watercooler\""
     }`
     
     func TestGroupNameMessage(t *testing.T) {
    @@ -764,12 +766,12 @@ func TestGroupNameMessage(t *testing.T) {
     }
     
     var groupArchiveMessage = `{
    -    "type": "message",
    -    "subtype": "group_archive",
    -    "ts": "1361482916.000003",
    -    "text": " archived the group",
    -    "user": "U1234",
    -    "members": ["U1234", "U5678"]
    +	"type": "message",
    +	"subtype": "group_archive",
    +	"ts": "1361482916.000003",
    +	"text": " archived the group",
    +	"user": "U1234",
    +	"members": ["U1234", "U5678"]
     }`
     
     func TestGroupArchiveMessage(t *testing.T) {
    @@ -786,11 +788,11 @@ func TestGroupArchiveMessage(t *testing.T) {
     }
     
     var groupUnarchiveMessage = `{
    -    "type": "message",
    -    "subtype": "group_unarchive",
    -    "ts": "1361482916.000003",
    -    "text": " un-archived the group",
    -    "user": "U1234"
    +	"type": "message",
    +	"subtype": "group_unarchive",
    +	"ts": "1361482916.000003",
    +	"text": " un-archived the group",
    +	"user": "U1234"
     }`
     
     func TestGroupUnarchiveMessage(t *testing.T) {
    @@ -805,52 +807,52 @@ func TestGroupUnarchiveMessage(t *testing.T) {
     }
     
     var fileShareMessage = `{
    -    "type": "message",
    -    "subtype": "file_share",
    -    "ts": "1358877455.000010",
    -    "text": "<@cal> uploaded a file: ",
    -    "files": [{
    -        "id" : "F2147483862",
    -        "created" : 1356032811,
    -        "timestamp" : 1356032811,
    -        "name" : "file.htm",
    -        "title" : "My HTML file",
    -        "mimetype" : "text\/plain",
    -        "filetype" : "text",
    -        "pretty_type": "Text",
    -        "user" : "U2147483697",
    -        "mode" : "hosted",
    -        "editable" : true,
    -        "is_external": false,
    -        "external_type": "",
    -        "size" : 12345,
    -        "url": "https:\/\/slack-files.com\/files-pub\/T024BE7LD-F024BERPE-09acb6\/1.png",
    -        "url_download": "https:\/\/slack-files.com\/files-pub\/T024BE7LD-F024BERPE-09acb6\/download\/1.png",
    -        "url_private": "https:\/\/slack.com\/files-pri\/T024BE7LD-F024BERPE\/1.png",
    -        "url_private_download": "https:\/\/slack.com\/files-pri\/T024BE7LD-F024BERPE\/download\/1.png",
    -        "thumb_64": "https:\/\/slack-files.com\/files-tmb\/T024BE7LD-F024BERPE-c66246\/1_64.png",
    -        "thumb_80": "https:\/\/slack-files.com\/files-tmb\/T024BE7LD-F024BERPE-c66246\/1_80.png",
    -        "thumb_360": "https:\/\/slack-files.com\/files-tmb\/T024BE7LD-F024BERPE-c66246\/1_360.png",
    -        "thumb_360_gif": "https:\/\/slack-files.com\/files-tmb\/T024BE7LD-F024BERPE-c66246\/1_360.gif",
    -        "thumb_360_w": 100,
    -        "thumb_360_h": 100,
    -        "permalink" : "https:\/\/tinyspeck.slack.com\/files\/cal\/F024BERPE\/1.png",
    -        "edit_link" : "https:\/\/tinyspeck.slack.com\/files\/cal\/F024BERPE\/1.png/edit",
    -        "preview" : "<!DOCTYPE html>\n<html>\n<meta charset='utf-8'>",
    -        "preview_highlight" : "<div class=\"sssh-code\"><div class=\"sssh-line\"><pre><!DOCTYPE html...",
    -        "lines" : 123,
    -        "lines_more": 118,
    -        "is_public": true,
    -        "public_url_shared": false,
    -        "channels": ["C024BE7LT"],
    -        "groups": ["G12345"],
    -        "ims": ["D12345"],
    -        "initial_comment": {},
    -        "num_stars": 7,
    -        "is_starred": true
    -    }],
    -    "user": "U2147483697",
    -    "upload": true
    +	"type": "message",
    +	"subtype": "file_share",
    +	"ts": "1358877455.000010",
    +	"text": "<@cal> uploaded a file: ",
    +	"files": [{
    +		"id" : "F2147483862",
    +		"created" : 1356032811,
    +		"timestamp" : 1356032811,
    +		"name" : "file.htm",
    +		"title" : "My HTML file",
    +		"mimetype" : "text\/plain",
    +		"filetype" : "text",
    +		"pretty_type": "Text",
    +		"user" : "U2147483697",
    +		"mode" : "hosted",
    +		"editable" : true,
    +		"is_external": false,
    +		"external_type": "",
    +		"size" : 12345,
    +		"url": "https:\/\/slack-files.com\/files-pub\/T024BE7LD-F024BERPE-09acb6\/1.png",
    +		"url_download": "https:\/\/slack-files.com\/files-pub\/T024BE7LD-F024BERPE-09acb6\/download\/1.png",
    +		"url_private": "https:\/\/slack.com\/files-pri\/T024BE7LD-F024BERPE\/1.png",
    +		"url_private_download": "https:\/\/slack.com\/files-pri\/T024BE7LD-F024BERPE\/download\/1.png",
    +		"thumb_64": "https:\/\/slack-files.com\/files-tmb\/T024BE7LD-F024BERPE-c66246\/1_64.png",
    +		"thumb_80": "https:\/\/slack-files.com\/files-tmb\/T024BE7LD-F024BERPE-c66246\/1_80.png",
    +		"thumb_360": "https:\/\/slack-files.com\/files-tmb\/T024BE7LD-F024BERPE-c66246\/1_360.png",
    +		"thumb_360_gif": "https:\/\/slack-files.com\/files-tmb\/T024BE7LD-F024BERPE-c66246\/1_360.gif",
    +		"thumb_360_w": 100,
    +		"thumb_360_h": 100,
    +		"permalink" : "https:\/\/tinyspeck.slack.com\/files\/cal\/F024BERPE\/1.png",
    +		"edit_link" : "https:\/\/tinyspeck.slack.com\/files\/cal\/F024BERPE\/1.png/edit",
    +		"preview" : "<!DOCTYPE html>\n<html>\n<meta charset='utf-8'>",
    +		"preview_highlight" : "<div class=\"sssh-code\"><div class=\"sssh-line\"><pre><!DOCTYPE html...",
    +		"lines" : 123,
    +		"lines_more": 118,
    +		"is_public": true,
    +		"public_url_shared": false,
    +		"channels": ["C024BE7LT"],
    +		"groups": ["G12345"],
    +		"ims": ["D12345"],
    +		"initial_comment": {},
    +		"num_stars": 7,
    +		"is_starred": true
    +	}],
    +	"user": "U2147483697",
    +	"upload": true
     }`
     
     func TestFileShareMessage(t *testing.T) {
    
    From c93b9164a2e92ff603be9410b6d11a195c0a7644 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sat, 21 Mar 2026 19:48:01 +0100
    Subject: [PATCH 491/606] Revise warning for `trigger_id` and `workflow_id`
    
    Updated warning about `trigger_id` and `workflow_id` in CHANGELOG.
    ---
     CHANGELOG.md | 4 ++--
     1 file changed, 2 insertions(+), 2 deletions(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 7441a58bb..1c9b700ba 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -7,8 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ## [Unreleased]
     
    -[!IMPORTANT]
    -`trigger_id` and `workflow_id` are NOT in any documentation or in any of the official
    +> [!WARNING]
    +> `trigger_id` and `workflow_id` are NOT in any documentation or in any of the official
     libraries, so exercise caution if you use these.
     
     ### Added
    
    From 46d7b49c2a05741a0ba2365e3aad1c8a8b069298 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sat, 21 Mar 2026 20:59:59 +0000
    Subject: [PATCH 492/606] feat!: add Border to RichTextQuote and Language to
     RichTextPreformatted
    
    RichTextQuote is not a standalone struct instead of a type alias of RichTextSection.
    Direct type conversations between the two no longer work.
    ---
     block_rich_text.go      | 32 +++++++++++++++++++++++++-------
     block_rich_text_test.go | 26 +++++++++++++-------------
     2 files changed, 38 insertions(+), 20 deletions(-)
    
    diff --git a/block_rich_text.go b/block_rich_text.go
    index 5df775991..b3714d80e 100644
    --- a/block_rich_text.go
    +++ b/block_rich_text.go
    @@ -129,7 +129,7 @@ func NewRichTextList(style RichTextListElementType, indent int, elements ...Rich
     	}
     }
     
    -// ElementType returns the type of the Element
    +// RichTextElementType returns the type of the Element
     func (s RichTextList) RichTextElementType() RichTextElementType {
     	return s.Type
     }
    @@ -262,7 +262,9 @@ func (e *RichTextSection) UnmarshalJSON(b []byte) error {
     	return nil
     }
     
    -// NewRichTextSectionBlockElement .
    +// NewRichTextSection creates a new rich text section from the provided elements. The
    +// section type will default to "rich_text_section", as it's the only currently supported
    +// section type.
     func NewRichTextSection(elements ...RichTextSectionElement) *RichTextSection {
     	return &RichTextSection{
     		Type:     RTESection,
    @@ -491,7 +493,11 @@ func (r RichTextSectionUnknownElement) RichTextSectionElementType() RichTextSect
     }
     
     // RichTextQuote represents rich_text_quote element type.
    -type RichTextQuote RichTextSection
    +type RichTextQuote struct {
    +	Type     RichTextElementType      `json:"type"`
    +	Elements []RichTextSectionElement `json:"elements"`
    +	Border   int                      `json:"border,omitempty"`
    +}
     
     // RichTextElementType returns the type of the Element
     func (s *RichTextQuote) RichTextElementType() RichTextElementType {
    @@ -504,15 +510,25 @@ func (s *RichTextQuote) UnmarshalJSON(b []byte) error {
     	if err := json.Unmarshal(b, &rts); err != nil {
     		return err
     	}
    -	*s = RichTextQuote(rts)
    -	s.Type = RTEQuote
    +	var standalone struct {
    +		Border int `json:"border"`
    +	}
    +	if err := json.Unmarshal(b, &standalone); err != nil {
    +		return err
    +	}
    +	*s = RichTextQuote{
    +		Type:     RTEQuote,
    +		Elements: rts.Elements,
    +		Border:   standalone.Border,
    +	}
     	return nil
     }
     
     // RichTextPreformatted represents rich_text_quote element type.
     type RichTextPreformatted struct {
     	RichTextSection
    -	Border int `json:"border"`
    +	Border   int    `json:"border"`
    +	Language string `json:"language,omitempty"`
     }
     
     // RichTextElementType returns the type of the Element
    @@ -536,7 +552,8 @@ func (s *RichTextPreformatted) UnmarshalJSON(b []byte) error {
     	// original struct, which may become a maintenance burden (i.e. update the
     	// fields in two places, should it ever change).
     	var standalone struct {
    -		Border int `json:"border"`
    +		Border   int    `json:"border"`
    +		Language string `json:"language"`
     	}
     	if err := json.Unmarshal(b, &standalone); err != nil {
     		return err
    @@ -544,6 +561,7 @@ func (s *RichTextPreformatted) UnmarshalJSON(b []byte) error {
     	*s = RichTextPreformatted{
     		RichTextSection: rts,
     		Border:          standalone.Border,
    +		Language:        standalone.Language,
     	}
     	s.Type = RTEPreformatted
     	return nil
    diff --git a/block_rich_text_test.go b/block_rich_text_test.go
    index 5854602e8..d9ed38dc6 100644
    --- a/block_rich_text_test.go
    +++ b/block_rich_text_test.go
    @@ -13,19 +13,19 @@ const (
       "type":"rich_text",
       "block_id":"FaYCD",
       "elements": [
    -    {
    -      "type":"rich_text_section",
    -      "elements": [
    -        {
    -          "type":"channel",
    -          "channel_id":"C012345678"
    -        },
    -        {
    -          "type":"text",
    -          "text":"dummy_text"
    -        }
    -      ]
    -    }
    +	{
    +	  "type":"rich_text_section",
    +	  "elements": [
    +		{
    +		  "type":"channel",
    +		  "channel_id":"C012345678"
    +		},
    +		{
    +		  "type":"text",
    +		  "text":"dummy_text"
    +		}
    +	  ]
    +	}
       ]
     }`
     
    
    From 0b4eb259379b260f103fce1181ec749f49fe86f9 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sat, 21 Mar 2026 21:30:54 +0000
    Subject: [PATCH 493/606] feat!: flatten RichTextPreformatted to remove
     RichTextSection embedding
    
    RichTextPreformatted should have always been like this, so this is more of a fix than a
    feat perhaps but who's checking.
    ---
     block_rich_text.go      | 15 ++++++++-------
     block_rich_text_test.go | 12 +++++-------
     2 files changed, 13 insertions(+), 14 deletions(-)
    
    diff --git a/block_rich_text.go b/block_rich_text.go
    index b3714d80e..f0c1eb22e 100644
    --- a/block_rich_text.go
    +++ b/block_rich_text.go
    @@ -526,9 +526,10 @@ func (s *RichTextQuote) UnmarshalJSON(b []byte) error {
     
     // RichTextPreformatted represents rich_text_quote element type.
     type RichTextPreformatted struct {
    -	RichTextSection
    -	Border   int    `json:"border"`
    -	Language string `json:"language,omitempty"`
    +	Type     RichTextElementType      `json:"type"`
    +	Elements []RichTextSectionElement `json:"elements"`
    +	Border   int                      `json:"border"`
    +	Language string                   `json:"language,omitempty"`
     }
     
     // RichTextElementType returns the type of the Element
    @@ -559,10 +560,10 @@ func (s *RichTextPreformatted) UnmarshalJSON(b []byte) error {
     		return err
     	}
     	*s = RichTextPreformatted{
    -		RichTextSection: rts,
    -		Border:          standalone.Border,
    -		Language:        standalone.Language,
    +		Type:     RTEPreformatted,
    +		Elements: rts.Elements,
    +		Border:   standalone.Border,
    +		Language: standalone.Language,
     	}
    -	s.Type = RTEPreformatted
     	return nil
     }
    diff --git a/block_rich_text_test.go b/block_rich_text_test.go
    index d9ed38dc6..fd302d735 100644
    --- a/block_rich_text_test.go
    +++ b/block_rich_text_test.go
    @@ -143,10 +143,10 @@ func TestRichTextBlock_UnmarshalJSON(t *testing.T) {
     				BlockID: "G7G",
     				Elements: []RichTextElement{
     					&RichTextSection{Type: RTESection, Elements: []RichTextSectionElement{&RichTextSectionTextElement{Type: RTSEText, Text: "Holy moly\n\n"}}},
    -					&RichTextPreformatted{RichTextSection: RichTextSection{Type: RTEPreformatted, Elements: []RichTextSectionElement{&RichTextSectionTextElement{Type: RTSEText, Text: "Preformatted\n\n"}}}, Border: 2},
    +					&RichTextPreformatted{Type: RTEPreformatted, Elements: []RichTextSectionElement{&RichTextSectionTextElement{Type: RTSEText, Text: "Preformatted\n\n"}}, Border: 2},
     					&RichTextQuote{Type: RTEQuote, Elements: []RichTextSectionElement{&RichTextSectionTextElement{Type: RTSEText, Text: "Quote\n\n"}}},
     					&RichTextQuote{Type: RTEQuote, Elements: []RichTextSectionElement{&RichTextSectionTextElement{Type: RTSEText, Text: "Another quote"}}},
    -					&RichTextPreformatted{RichTextSection: RichTextSection{Type: RTEPreformatted, Elements: []RichTextSectionElement{&RichTextSectionTextElement{Type: RTSEText, Text: "Another preformatted\n\n"}}}, Border: 42},
    +					&RichTextPreformatted{Type: RTEPreformatted, Elements: []RichTextSectionElement{&RichTextSectionTextElement{Type: RTSEText, Text: "Another preformatted\n\n"}}, Border: 42},
     				},
     			},
     			nil,
    @@ -372,11 +372,9 @@ func TestRichTextQuote_Marshal(t *testing.T) {
     	t.Run("rich_text_preformatted", func(t *testing.T) {
     		const rawRTP = "{\"type\":\"rich_text_preformatted\",\"elements\":[{\"type\":\"text\",\"text\":\"Some other text\"}],\"border\":2}"
     		want := RichTextPreformatted{
    -			RichTextSection: RichTextSection{
    -				Type:     RTEPreformatted,
    -				Elements: []RichTextSectionElement{&RichTextSectionTextElement{Type: RTSEText, Text: "Some other text"}},
    -			},
    -			Border: 2,
    +			Type:     RTEPreformatted,
    +			Elements: []RichTextSectionElement{&RichTextSectionTextElement{Type: RTSEText, Text: "Some other text"}},
    +			Border:   2,
     		}
     		var got RichTextPreformatted
     		if err := json.Unmarshal([]byte(rawRTP), &got); err != nil {
    
    From 442f33085b571f26b7ac1dbde4134c83dc1d02e9 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sat, 21 Mar 2026 21:37:24 +0000
    Subject: [PATCH 494/606] chore: update CHANGELOG
    
    ---
     CHANGELOG.md | 10 +++++++++-
     1 file changed, 9 insertions(+), 1 deletion(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 1c9b700ba..4ba2903cd 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -13,8 +13,16 @@ libraries, so exercise caution if you use these.
     
     ### Added
     
    -- **`workflow_id` and `trigger_id` in `Message`** It seems that some types of messages,
    +- **`workflow_id` and `trigger_id` in `Message`** — It seems that some types of messages,
         e.g: `bot_message`, can carry `trigger_id` and `workflow_id`.
    +- **`RichTextQuote.Border` field** — optional border toggle (matches the docs now)
    +- **`RichTextPreformatted.Language` field** — enables syntax highlighting for preformatted
    +  blocks
    +
    +### Fixed
    +- **Remove embedding of `RichTextSection`** — `RichTextQuote` and `RichTextPreformatted`
    +  are now flattened as they should have always been. This is a breaking change for anyone
    +  using these structs directly.
     
     ## [0.19.0] - 2026-03-04
     
    
    From 449e3413856f09a2e9101916dc26f189db033d24 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sat, 21 Mar 2026 22:15:00 +0000
    Subject: [PATCH 495/606] chore: v0.20.0
    
    ---
     CHANGELOG.md | 6 +++++-
     1 file changed, 5 insertions(+), 1 deletion(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 4ba2903cd..5419408b0 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ## [Unreleased]
     
    +## [0.20.0] - 2026-03-21
    +
     > [!WARNING]
     > `trigger_id` and `workflow_id` are NOT in any documentation or in any of the official
     libraries, so exercise caution if you use these.
    @@ -20,6 +22,7 @@ libraries, so exercise caution if you use these.
       blocks
     
     ### Fixed
    +
     - **Remove embedding of `RichTextSection`** — `RichTextQuote` and `RichTextPreformatted`
       are now flattened as they should have always been. This is a breaking change for anyone
       using these structs directly.
    @@ -189,7 +192,8 @@ for details.
     [#1534]: https://github.com/slack-go/slack/pull/1534
     [#1536]: https://github.com/slack-go/slack/pull/1536
     
    -[Unreleased]: https://github.com/slack-go/slack/compare/v0.19.0...HEAD
    +[Unreleased]: https://github.com/slack-go/slack/compare/v0.20.0...HEAD
    +[0.20.0]: https://github.com/slack-go/slack/compare/v0.19.0...v0.20.0
     [0.19.0]: https://github.com/slack-go/slack/compare/v0.18.0...v0.19.0
     [0.18.0]: https://github.com/slack-go/slack/compare/v0.18.0-rc2...v0.18.0
     [0.18.0-rc2]: https://github.com/slack-go/slack/releases/tag/v0.18.0-rc2
    
    From 99499e02fce4463f10713bb116efa4615ff0aca9 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 22 Mar 2026 17:46:48 +0000
    Subject: [PATCH 496/606] feat: add a few missing fields to User and
     UserProfile
    
    We added to `User`:
    - `is_connector_bot`
    - `is_workflow_bot`
    
    We added to `UserProfile`:
    - `guest_invited_by`
    
    Also adjusted the users example to allow listing all users (easier to troubleshoot).
    ---
     CHANGELOG.md            |  6 +++++
     examples/users/users.go | 50 ++++++++++++++++++++++++++++++++++++-----
     users.go                |  5 ++++-
     3 files changed, 55 insertions(+), 6 deletions(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 5419408b0..1a475d852 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ## [Unreleased]
     
    +### Added
    +
    +- **`IsConnectorBot` and `IsWorkflowBot` in `User`** — boolean flags for connector and
    +  workflow bot users
    +- **`GuestInvitedBy` in `UserProfile`** — user ID of whoever invited a guest user
    +
     ## [0.20.0] - 2026-03-21
     
     > [!WARNING]
    diff --git a/examples/users/users.go b/examples/users/users.go
    index d82bf86f3..4d4835613 100644
    --- a/examples/users/users.go
    +++ b/examples/users/users.go
    @@ -1,35 +1,75 @@
     package main
     
     import (
    +	"encoding/json"
     	"flag"
     	"fmt"
    +	"log"
     	"os"
     
     	"github.com/slack-go/slack"
     )
     
     func main() {
    -	userID := flag.String("user", "", "User ID (required)")
    +	userID := flag.String("user", "", "User ID to fetch info for")
    +	list := flag.Bool("list", false, "List all users")
    +	teamID := flag.String("team", "", "Team ID (required for Enterprise Grid)")
     	flag.Parse()
     
    -	// Get token from environment variable
     	userToken := os.Getenv("SLACK_USER_TOKEN")
     	if userToken == "" {
     		fmt.Fprintf(os.Stderr, "SLACK_USER_TOKEN environment variable is required\n")
     		os.Exit(1)
     	}
     
    -	// Get user ID from flag
    -	if *userID == "" {
    -		fmt.Println("User ID is required: use -user flag")
    +	if *userID == "" && !*list {
    +		fmt.Fprintf(os.Stderr, "Use -user  to fetch a user or -list to list all users\n")
     		os.Exit(1)
     	}
     
     	api := slack.New(userToken)
    +
    +	if *list {
    +		var opts []slack.GetUsersOption
    +		if *teamID != "" {
    +			opts = append(opts, slack.GetUsersOptionTeamID(*teamID))
    +		}
    +		listUsers(api, opts...)
    +		return
    +	}
    +
     	user, err := api.GetUserInfo(*userID)
     	if err != nil {
     		fmt.Printf("%s\n", err)
     		return
     	}
    +	b, err := json.MarshalIndent(user, "", "  ")
    +	if err != nil {
    +		log.Fatal(err)
    +	}
    +
    +	fmt.Println(string(b))
     	fmt.Printf("ID: %s, Fullname: %s, Email: %s\n", user.ID, user.Profile.RealName, user.Profile.Email)
     }
    +
    +func listUsers(api *slack.Client, opts ...slack.GetUsersOption) {
    +	users, err := api.GetUsers(opts...)
    +	if err != nil {
    +		log.Fatal(err)
    +	}
    +
    +	for _, user := range users {
    +		guestType := ""
    +		if user.IsUltraRestricted {
    +			guestType = " (single-channel guest)"
    +		} else if user.IsRestricted {
    +			guestType = " (multi-channel guest)"
    +		}
    +
    +		fmt.Printf("%-12s %-30s %s%s\n", user.ID, user.Profile.RealName, user.Profile.Email, guestType)
    +
    +		if (user.IsRestricted || user.IsUltraRestricted) && user.Profile.GuestInvitedBy != "" {
    +			fmt.Printf("  invited_by: %s\n", user.Profile.GuestInvitedBy)
    +		}
    +	}
    +}
    diff --git a/users.go b/users.go
    index 7f1676052..aa8a5c0ce 100644
    --- a/users.go
    +++ b/users.go
    @@ -49,6 +49,7 @@ type UserProfile struct {
     	HuddleState             string                              `json:"huddle_state,omitempty"`
     	HuddleStateExpirationTS int                                 `json:"huddle_state_expiration_ts,omitempty"`
     	StartDate               string                              `json:"start_date,omitempty"`
    +	GuestInvitedBy          string                              `json:"guest_invited_by,omitempty"`
     	Team                    string                              `json:"team"`
     	Fields                  UserProfileCustomFields             `json:"fields,omitempty"`
     }
    @@ -137,6 +138,8 @@ type User struct {
     	IsUltraRestricted      bool           `json:"is_ultra_restricted"`
     	IsStranger             bool           `json:"is_stranger"`
     	IsAppUser              bool           `json:"is_app_user"`
    +	IsConnectorBot         bool           `json:"is_connector_bot"`
    +	IsWorkflowBot          bool           `json:"is_workflow_bot"`
     	IsInvitedUser          bool           `json:"is_invited_user"`
     	IsEmailConfirmed       bool           `json:"is_email_confirmed"`
     	Has2FA                 bool           `json:"has_2fa"`
    @@ -178,7 +181,7 @@ type UserIdentity struct {
     }
     
     // EnterpriseUser is present when a user is part of Slack Enterprise Grid
    -// https://api.slack.com/types/user#enterprise_grid_user_objects
    +// https://docs.slack.dev/reference/objects/user-object/#fields
     type EnterpriseUser struct {
     	ID             string   `json:"id"`
     	EnterpriseID   string   `json:"enterprise_id"`
    
    From e02a262c5bf34a166b3a48b2108d2eed92f2b181 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 22 Mar 2026 19:33:54 +0000
    Subject: [PATCH 497/606] feat(workflows): add featured workflows API support
    
    ---
     CHANGELOG.md                                  |   3 +
     .../workflows_featured/workflows_featured.go  | 153 ++++++++++++
     workflows_featured.go                         | 143 ++++++++++++
     workflows_featured_test.go                    | 221 ++++++++++++++++++
     4 files changed, 520 insertions(+)
     create mode 100644 examples/workflows_featured/workflows_featured.go
     create mode 100644 workflows_featured.go
     create mode 100644 workflows_featured_test.go
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 1a475d852..3c7bbcbec 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ### Added
     
    +- **`workflows.featured` API support** — add, list, remove, and set featured workflows on
    +  channels via `WorkflowsFeaturedAdd`, `WorkflowsFeaturedList`, `WorkflowsFeaturedRemove`,
    +  and `WorkflowsFeaturedSet`
     - **`IsConnectorBot` and `IsWorkflowBot` in `User`** — boolean flags for connector and
       workflow bot users
     - **`GuestInvitedBy` in `UserProfile`** — user ID of whoever invited a guest user
    diff --git a/examples/workflows_featured/workflows_featured.go b/examples/workflows_featured/workflows_featured.go
    new file mode 100644
    index 000000000..07e267084
    --- /dev/null
    +++ b/examples/workflows_featured/workflows_featured.go
    @@ -0,0 +1,153 @@
    +package main
    +
    +import (
    +	"context"
    +	"flag"
    +	"fmt"
    +	"os"
    +	"strings"
    +
    +	"github.com/slack-go/slack"
    +)
    +
    +func main() {
    +	action := flag.String("action", "", "Action to perform: list, add, remove, set (required)")
    +	channel := flag.String("channel", "", "Channel ID (required for add, remove, set)")
    +	channels := flag.String("channels", "", "Comma-separated channel IDs (required for list)")
    +	triggers := flag.String("triggers", "", "Comma-separated trigger IDs (required for add, remove, set)")
    +	flag.Parse()
    +
    +	token := os.Getenv("SLACK_BOT_TOKEN")
    +	if token == "" {
    +		fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN environment variable is required\n")
    +		os.Exit(1)
    +	}
    +
    +	if *action == "" {
    +		fmt.Fprintf(os.Stderr, "Error: -action flag is required (list, add, remove, set)\n")
    +		os.Exit(1)
    +	}
    +
    +	api := slack.New(token)
    +
    +	switch *action {
    +	case "list":
    +		listFeatured(api, *channels)
    +	case "add":
    +		addFeatured(api, *channel, *triggers)
    +	case "remove":
    +		removeFeatured(api, *channel, *triggers)
    +	case "set":
    +		setFeatured(api, *channel, *triggers)
    +	default:
    +		fmt.Fprintf(os.Stderr, "Error: unknown action %q (must be list, add, remove, set)\n", *action)
    +		os.Exit(1)
    +	}
    +}
    +
    +func splitCSV(s string) []string {
    +	if s == "" {
    +		return nil
    +	}
    +	parts := strings.Split(s, ",")
    +	for i := range parts {
    +		parts[i] = strings.TrimSpace(parts[i])
    +	}
    +	return parts
    +}
    +
    +func listFeatured(api *slack.Client, channelsFlag string) {
    +	channelIDs := splitCSV(channelsFlag)
    +	if len(channelIDs) == 0 {
    +		fmt.Fprintf(os.Stderr, "Error: -channels flag is required for list action\n")
    +		os.Exit(1)
    +	}
    +
    +	resp, err := api.WorkflowsFeaturedList(context.Background(), &slack.WorkflowsFeaturedListInput{
    +		ChannelIDs: channelIDs,
    +	})
    +	if err != nil {
    +		fmt.Fprintf(os.Stderr, "Error listing featured workflows: %s\n", err)
    +		os.Exit(1)
    +	}
    +
    +	for _, fw := range resp.FeaturedWorkflows {
    +		fmt.Printf("Channel %s:\n", fw.ChannelID)
    +		if len(fw.Triggers) == 0 {
    +			fmt.Println("  (no featured workflows)")
    +			continue
    +		}
    +		for _, t := range fw.Triggers {
    +			fmt.Printf("  - %s (ID: %s)\n", t.Title, t.ID)
    +		}
    +	}
    +}
    +
    +func addFeatured(api *slack.Client, channelID, triggersFlag string) {
    +	if channelID == "" {
    +		fmt.Fprintf(os.Stderr, "Error: -channel flag is required for add action\n")
    +		os.Exit(1)
    +	}
    +	triggerIDs := splitCSV(triggersFlag)
    +	if len(triggerIDs) == 0 {
    +		fmt.Fprintf(os.Stderr, "Error: -triggers flag is required for add action\n")
    +		os.Exit(1)
    +	}
    +
    +	err := api.WorkflowsFeaturedAdd(context.Background(), &slack.WorkflowsFeaturedAddInput{
    +		ChannelID:  channelID,
    +		TriggerIDs: triggerIDs,
    +	})
    +	if err != nil {
    +		fmt.Fprintf(os.Stderr, "Error adding featured workflows: %s\n", err)
    +		os.Exit(1)
    +	}
    +
    +	fmt.Println("Featured workflows added successfully")
    +}
    +
    +func removeFeatured(api *slack.Client, channelID, triggersFlag string) {
    +	if channelID == "" {
    +		fmt.Fprintf(os.Stderr, "Error: -channel flag is required for remove action\n")
    +		os.Exit(1)
    +	}
    +	triggerIDs := splitCSV(triggersFlag)
    +	if len(triggerIDs) == 0 {
    +		fmt.Fprintf(os.Stderr, "Error: -triggers flag is required for remove action\n")
    +		os.Exit(1)
    +	}
    +
    +	err := api.WorkflowsFeaturedRemove(context.Background(), &slack.WorkflowsFeaturedRemoveInput{
    +		ChannelID:  channelID,
    +		TriggerIDs: triggerIDs,
    +	})
    +	if err != nil {
    +		fmt.Fprintf(os.Stderr, "Error removing featured workflows: %s\n", err)
    +		os.Exit(1)
    +	}
    +
    +	fmt.Println("Featured workflows removed successfully")
    +}
    +
    +func setFeatured(api *slack.Client, channelID, triggersFlag string) {
    +	if channelID == "" {
    +		fmt.Fprintf(os.Stderr, "Error: -channel flag is required for set action\n")
    +		os.Exit(1)
    +	}
    +	triggerIDs := splitCSV(triggersFlag)
    +	if len(triggerIDs) == 0 {
    +		fmt.Fprintf(os.Stderr, "Error: -triggers flag is required for set action\n")
    +		os.Exit(1)
    +	}
    +
    +	err := api.WorkflowsFeaturedSet(context.Background(), &slack.WorkflowsFeaturedSetInput{
    +		ChannelID:  channelID,
    +		TriggerIDs: triggerIDs,
    +	})
    +	if err != nil {
    +		fmt.Fprintf(os.Stderr, "Error setting featured workflows: %s\n", err)
    +		os.Exit(1)
    +	}
    +
    +	fmt.Println("Featured workflows set successfully")
    +}
    diff --git a/workflows_featured.go b/workflows_featured.go
    new file mode 100644
    index 000000000..1d22530c0
    --- /dev/null
    +++ b/workflows_featured.go
    @@ -0,0 +1,143 @@
    +package slack
    +
    +import (
    +	"context"
    +	"encoding/json"
    +	"fmt"
    +)
    +
    +type (
    +	FeaturedWorkflowTrigger struct {
    +		ID    string `json:"id"`
    +		Title string `json:"title"`
    +	}
    +
    +	FeaturedWorkflow struct {
    +		ChannelID string                    `json:"channel_id"`
    +		Triggers  []FeaturedWorkflowTrigger `json:"triggers"`
    +	}
    +
    +	WorkflowsFeaturedAddInput struct {
    +		ChannelID  string   `json:"channel_id"`
    +		TriggerIDs []string `json:"trigger_ids"`
    +	}
    +
    +	WorkflowsFeaturedListInput struct {
    +		ChannelIDs []string `json:"channel_ids"`
    +	}
    +
    +	WorkflowsFeaturedListOutput struct {
    +		FeaturedWorkflows []FeaturedWorkflow `json:"featured_workflows"`
    +	}
    +
    +	WorkflowsFeaturedRemoveInput struct {
    +		ChannelID  string   `json:"channel_id"`
    +		TriggerIDs []string `json:"trigger_ids"`
    +	}
    +
    +	WorkflowsFeaturedSetInput struct {
    +		ChannelID  string   `json:"channel_id"`
    +		TriggerIDs []string `json:"trigger_ids"`
    +	}
    +)
    +
    +// WorkflowsFeaturedAdd adds featured workflows to a channel.
    +//
    +// Slack API Docs:https://api.slack.com/methods/workflows.featured.add
    +func (api *Client) WorkflowsFeaturedAdd(ctx context.Context, input *WorkflowsFeaturedAddInput) error {
    +	response := struct {
    +		SlackResponse
    +	}{}
    +
    +	jsonPayload, err := json.Marshal(input)
    +	if err != nil {
    +		return fmt.Errorf("failed to marshal WorkflowsFeaturedAddInput: %w", err)
    +	}
    +
    +	err = postJSON(ctx, api.httpclient, api.endpoint+"workflows.featured.add", api.token, jsonPayload, &response, api)
    +	if err != nil {
    +		return err
    +	}
    +
    +	if err := response.Err(); err != nil {
    +		return err
    +	}
    +
    +	return nil
    +}
    +
    +// WorkflowsFeaturedList lists featured workflows for the given channels.
    +//
    +// Slack API Docs:https://api.slack.com/methods/workflows.featured.list
    +func (api *Client) WorkflowsFeaturedList(ctx context.Context, input *WorkflowsFeaturedListInput) (*WorkflowsFeaturedListOutput, error) {
    +	response := struct {
    +		SlackResponse
    +		*WorkflowsFeaturedListOutput
    +	}{}
    +
    +	jsonPayload, err := json.Marshal(input)
    +	if err != nil {
    +		return nil, fmt.Errorf("failed to marshal WorkflowsFeaturedListInput: %w", err)
    +	}
    +
    +	err = postJSON(ctx, api.httpclient, api.endpoint+"workflows.featured.list", api.token, jsonPayload, &response, api)
    +	if err != nil {
    +		return nil, err
    +	}
    +
    +	if err := response.Err(); err != nil {
    +		return nil, err
    +	}
    +
    +	return response.WorkflowsFeaturedListOutput, nil
    +}
    +
    +// WorkflowsFeaturedRemove removes featured workflows from a channel.
    +//
    +// Slack API Docs:https://api.slack.com/methods/workflows.featured.remove
    +func (api *Client) WorkflowsFeaturedRemove(ctx context.Context, input *WorkflowsFeaturedRemoveInput) error {
    +	response := struct {
    +		SlackResponse
    +	}{}
    +
    +	jsonPayload, err := json.Marshal(input)
    +	if err != nil {
    +		return fmt.Errorf("failed to marshal WorkflowsFeaturedRemoveInput: %w", err)
    +	}
    +
    +	err = postJSON(ctx, api.httpclient, api.endpoint+"workflows.featured.remove", api.token, jsonPayload, &response, api)
    +	if err != nil {
    +		return err
    +	}
    +
    +	if err := response.Err(); err != nil {
    +		return err
    +	}
    +
    +	return nil
    +}
    +
    +// WorkflowsFeaturedSet replaces all featured workflows in a channel with the given triggers.
    +//
    +// Slack API Docs:https://api.slack.com/methods/workflows.featured.set
    +func (api *Client) WorkflowsFeaturedSet(ctx context.Context, input *WorkflowsFeaturedSetInput) error {
    +	response := struct {
    +		SlackResponse
    +	}{}
    +
    +	jsonPayload, err := json.Marshal(input)
    +	if err != nil {
    +		return fmt.Errorf("failed to marshal WorkflowsFeaturedSetInput: %w", err)
    +	}
    +
    +	err = postJSON(ctx, api.httpclient, api.endpoint+"workflows.featured.set", api.token, jsonPayload, &response, api)
    +	if err != nil {
    +		return err
    +	}
    +
    +	if err := response.Err(); err != nil {
    +		return err
    +	}
    +
    +	return nil
    +}
    diff --git a/workflows_featured_test.go b/workflows_featured_test.go
    new file mode 100644
    index 000000000..3502afa8f
    --- /dev/null
    +++ b/workflows_featured_test.go
    @@ -0,0 +1,221 @@
    +package slack
    +
    +import (
    +	"context"
    +	"net/http"
    +	"reflect"
    +	"testing"
    +)
    +
    +type workflowsFeaturedHandler struct {
    +	rawResponse string
    +}
    +
    +func (h *workflowsFeaturedHandler) handler(w http.ResponseWriter, r *http.Request) {
    +	w.Header().Set("Content-Type", "application/json")
    +	w.Write([]byte(h.rawResponse))
    +}
    +
    +func TestSlack_WorkflowsFeaturedAdd(t *testing.T) {
    +	once.Do(startServer)
    +	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
    +
    +	cases := []struct {
    +		caseName    string
    +		input       *WorkflowsFeaturedAddInput
    +		rawResp     string
    +		expectedErr error
    +	}{
    +		{
    +			caseName: "success",
    +			input: &WorkflowsFeaturedAddInput{
    +				ChannelID:  "C012345678",
    +				TriggerIDs: []string{"Ft1234", "Ft5678"},
    +			},
    +			rawResp:     `{"ok": true}`,
    +			expectedErr: nil,
    +		},
    +	}
    +
    +	h := &workflowsFeaturedHandler{}
    +	http.HandleFunc("/workflows.featured.add", h.handler)
    +	for _, c := range cases {
    +		t.Run(c.caseName, func(t *testing.T) {
    +			h.rawResponse = c.rawResp
    +
    +			err := api.WorkflowsFeaturedAdd(context.Background(), c.input)
    +			if c.expectedErr == nil && err != nil {
    +				t.Fatalf("unexpected error: %s\n", err)
    +			}
    +			if c.expectedErr != nil && err == nil {
    +				t.Fatalf("expected %s, but did not raise an error", c.expectedErr)
    +			}
    +			if c.expectedErr != nil && err != nil && c.expectedErr.Error() != err.Error() {
    +				t.Fatalf("expected %s as error but got %s\n", c.expectedErr, err)
    +			}
    +		})
    +	}
    +}
    +
    +func TestSlack_WorkflowsFeaturedList(t *testing.T) {
    +	once.Do(startServer)
    +	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
    +
    +	cases := []struct {
    +		caseName     string
    +		input        *WorkflowsFeaturedListInput
    +		rawResp      string
    +		expectedResp *WorkflowsFeaturedListOutput
    +		expectedErr  error
    +	}{
    +		{
    +			caseName: "success",
    +			input: &WorkflowsFeaturedListInput{
    +				ChannelIDs: []string{"C012345678", "C987654321"},
    +			},
    +			rawResp: `{
    +				"ok": true,
    +				"featured_workflows": [
    +					{
    +						"channel_id": "C012345678",
    +						"triggers": [
    +							{"id": "Ft1234", "title": "Tabby workflow"},
    +							{"id": "Ft5678", "title": "Tortoise workflow"}
    +						]
    +					},
    +					{
    +						"channel_id": "C987654321",
    +						"triggers": [
    +							{"id": "Ft1234", "title": "Ragdoll workflow"}
    +						]
    +					}
    +				]
    +			}`,
    +			expectedResp: &WorkflowsFeaturedListOutput{
    +				FeaturedWorkflows: []FeaturedWorkflow{
    +					{
    +						ChannelID: "C012345678",
    +						Triggers: []FeaturedWorkflowTrigger{
    +							{ID: "Ft1234", Title: "Tabby workflow"},
    +							{ID: "Ft5678", Title: "Tortoise workflow"},
    +						},
    +					},
    +					{
    +						ChannelID: "C987654321",
    +						Triggers: []FeaturedWorkflowTrigger{
    +							{ID: "Ft1234", Title: "Ragdoll workflow"},
    +						},
    +					},
    +				},
    +			},
    +			expectedErr: nil,
    +		},
    +	}
    +
    +	h := &workflowsFeaturedHandler{}
    +	http.HandleFunc("/workflows.featured.list", h.handler)
    +	for _, c := range cases {
    +		t.Run(c.caseName, func(t *testing.T) {
    +			h.rawResponse = c.rawResp
    +
    +			resp, err := api.WorkflowsFeaturedList(context.Background(), c.input)
    +			if c.expectedErr == nil && err != nil {
    +				t.Fatalf("unexpected error: %s\n", err)
    +			}
    +			if c.expectedErr != nil && err == nil {
    +				t.Fatalf("expected %s, but did not raise an error", c.expectedErr)
    +			}
    +			if c.expectedErr != nil && err != nil && c.expectedErr.Error() != err.Error() {
    +				t.Fatalf("expected %s as error but got %s\n", c.expectedErr, err)
    +			}
    +			if resp == nil || c.expectedResp == nil {
    +				return
    +			}
    +			if !reflect.DeepEqual(resp, c.expectedResp) {
    +				t.Fatalf("expected:\n\t%v\n but got:\n\t%v\n", c.expectedResp, resp)
    +			}
    +		})
    +	}
    +}
    +
    +func TestSlack_WorkflowsFeaturedRemove(t *testing.T) {
    +	once.Do(startServer)
    +	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
    +
    +	cases := []struct {
    +		caseName    string
    +		input       *WorkflowsFeaturedRemoveInput
    +		rawResp     string
    +		expectedErr error
    +	}{
    +		{
    +			caseName: "success",
    +			input: &WorkflowsFeaturedRemoveInput{
    +				ChannelID:  "C012345678",
    +				TriggerIDs: []string{"Ft1234"},
    +			},
    +			rawResp:     `{"ok": true}`,
    +			expectedErr: nil,
    +		},
    +	}
    +
    +	h := &workflowsFeaturedHandler{}
    +	http.HandleFunc("/workflows.featured.remove", h.handler)
    +	for _, c := range cases {
    +		t.Run(c.caseName, func(t *testing.T) {
    +			h.rawResponse = c.rawResp
    +
    +			err := api.WorkflowsFeaturedRemove(context.Background(), c.input)
    +			if c.expectedErr == nil && err != nil {
    +				t.Fatalf("unexpected error: %s\n", err)
    +			}
    +			if c.expectedErr != nil && err == nil {
    +				t.Fatalf("expected %s, but did not raise an error", c.expectedErr)
    +			}
    +			if c.expectedErr != nil && err != nil && c.expectedErr.Error() != err.Error() {
    +				t.Fatalf("expected %s as error but got %s\n", c.expectedErr, err)
    +			}
    +		})
    +	}
    +}
    +
    +func TestSlack_WorkflowsFeaturedSet(t *testing.T) {
    +	once.Do(startServer)
    +	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
    +
    +	cases := []struct {
    +		caseName    string
    +		input       *WorkflowsFeaturedSetInput
    +		rawResp     string
    +		expectedErr error
    +	}{
    +		{
    +			caseName: "success",
    +			input: &WorkflowsFeaturedSetInput{
    +				ChannelID:  "C012345678",
    +				TriggerIDs: []string{"Ft1234", "Ft5678"},
    +			},
    +			rawResp:     `{"ok": true}`,
    +			expectedErr: nil,
    +		},
    +	}
    +
    +	h := &workflowsFeaturedHandler{}
    +	http.HandleFunc("/workflows.featured.set", h.handler)
    +	for _, c := range cases {
    +		t.Run(c.caseName, func(t *testing.T) {
    +			h.rawResponse = c.rawResp
    +
    +			err := api.WorkflowsFeaturedSet(context.Background(), c.input)
    +			if c.expectedErr == nil && err != nil {
    +				t.Fatalf("unexpected error: %s\n", err)
    +			}
    +			if c.expectedErr != nil && err == nil {
    +				t.Fatalf("expected %s, but did not raise an error", c.expectedErr)
    +			}
    +			if c.expectedErr != nil && err != nil && c.expectedErr.Error() != err.Error() {
    +				t.Fatalf("expected %s as error but got %s\n", c.expectedErr, err)
    +			}
    +		})
    +	}
    +}
    
    From ddce7fc8ee90a084ae30c9172590bbf5a98922a6 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 22 Mar 2026 22:31:06 +0000
    Subject: [PATCH 498/606] fix(blocks): resolve nil pointer issue with
     NewBlockHeader
    
    Fixes #1236.
    ---
     block_header.go      |  4 +++-
     block_header_test.go | 10 ++++++++++
     2 files changed, 13 insertions(+), 1 deletion(-)
    
    diff --git a/block_header.go b/block_header.go
    index 3afb4c95f..4277057f7 100644
    --- a/block_header.go
    +++ b/block_header.go
    @@ -36,7 +36,9 @@ func NewHeaderBlock(textObj *TextBlockObject, options ...HeaderBlockOption) *Hea
     	}
     
     	for _, option := range options {
    -		option(&block)
    +		if option != nil {
    +			option(&block)
    +		}
     	}
     
     	return &block
    diff --git a/block_header_test.go b/block_header_test.go
    index 6e6df8adf..0dcbf8780 100644
    --- a/block_header_test.go
    +++ b/block_header_test.go
    @@ -17,3 +17,13 @@ func TestNewHeaderBlock(t *testing.T) {
     	assert.Equal(t, headerBlock.Text.Type, "plain_text")
     	assert.Contains(t, headerBlock.Text.Text, "quite the header")
     }
    +
    +// TestNewHeaderBlockWithNilOption reproduces issue #1236: passing nil as an
    +// option to NewHeaderBlock causes a nil pointer dereference panic.
    +func TestNewHeaderBlockWithNilOption(t *testing.T) {
    +	textInfo := NewTextBlockObject("plain_text", "Header text", false, false)
    +
    +	assert.NotPanics(t, func() {
    +		NewHeaderBlock(textInfo, nil)
    +	}, "NewHeaderBlock should not panic when nil is passed as an option")
    +}
    
    From bfd40a15d7b01313f3400369a181b59ce7e0f1fb Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 22 Mar 2026 22:35:20 +0000
    Subject: [PATCH 499/606] chore: update CHANGELOG
    
    ---
     CHANGELOG.md | 4 ++++
     1 file changed, 4 insertions(+)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 3c7bbcbec..e9add76dd 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
       workflow bot users
     - **`GuestInvitedBy` in `UserProfile`** — user ID of whoever invited a guest user
     
    +### Fixed
    +
    +- **`NewBlockHeader` nil pointer dereference** — passing a nil text object no longer panics. ([#1236])
    +
     ## [0.20.0] - 2026-03-21
     
     > [!WARNING]
    
    From 7310ea3fb8b5a8d2b3f91e7ec10b848dbc25551e Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Mon, 23 Mar 2026 08:28:52 +0000
    Subject: [PATCH 500/606] feat(events): add `Blocks` field to `MessageEvent`
    
    Previously, block data in webhook payloads was only accessible through the nested
    `Message` field (`event.Message.Blocks`). This adds `Blocks` directly to `MessageEvent`
    for consistency with other top-level fields like `Text`.
    
    Closes #1257.
    ---
     CHANGELOG.md                      |  4 +++
     examples/eventsapi/events.go      | 10 +++++-
     examples/socketmode/socketmode.go |  8 +++++
     slackevents/inner_events.go       | 19 ++++++------
     slackevents/inner_events_test.go  | 51 +++++++++++++++++++++++++++++++
     5 files changed, 82 insertions(+), 10 deletions(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index e9add76dd..824b78643 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     - **`IsConnectorBot` and `IsWorkflowBot` in `User`** — boolean flags for connector and
       workflow bot users
     - **`GuestInvitedBy` in `UserProfile`** — user ID of whoever invited a guest user
    +- **`Blocks` field on `MessageEvent`** — block data from webhook payloads is now directly
    +  accessible via `event.Blocks` instead of only through `event.Message.Blocks`. ([#1257])
     
     ### Fixed
     
    @@ -167,6 +169,8 @@ Previous release. See [GitHub releases](https://github.com/slack-go/slack/releas
     for details.
     
     [#897]: https://github.com/slack-go/slack/issues/897
    +[#1236]: https://github.com/slack-go/slack/issues/1236
    +[#1257]: https://github.com/slack-go/slack/issues/1257
     [#1144]: https://github.com/slack-go/slack/issues/1144
     [#1329]: https://github.com/slack-go/slack/issues/1329
     [#1343]: https://github.com/slack-go/slack/issues/1343
    diff --git a/examples/eventsapi/events.go b/examples/eventsapi/events.go
    index 5a33b6586..2656caa94 100644
    --- a/examples/eventsapi/events.go
    +++ b/examples/eventsapi/events.go
    @@ -59,9 +59,17 @@ func main() {
     			switch ev := innerEvent.Data.(type) {
     			case *slackevents.AppMentionEvent:
     				api.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false))
    +			case *slackevents.MessageEvent:
    +				fmt.Printf("[INFO] Message from %s: %s\n", ev.User, ev.Text)
    +				if len(ev.Blocks.BlockSet) > 0 {
    +					fmt.Printf("[INFO] Message contains %d block(s):\n", len(ev.Blocks.BlockSet))
    +					for i, block := range ev.Blocks.BlockSet {
    +						fmt.Printf("[INFO]   Block %d: type=%s\n", i, block.BlockType())
    +					}
    +				}
     			}
     		}
     	})
    -	fmt.Println("[INFO] Server listening")
    +	fmt.Println("[INFO] Server listening on :3000")
     	http.ListenAndServe(":3000", nil)
     }
    diff --git a/examples/socketmode/socketmode.go b/examples/socketmode/socketmode.go
    index 399c6f3ae..f85104a3e 100644
    --- a/examples/socketmode/socketmode.go
    +++ b/examples/socketmode/socketmode.go
    @@ -77,6 +77,14 @@ func main() {
     						if err != nil {
     							fmt.Printf("failed posting message: %v", err)
     						}
    +					case *slackevents.MessageEvent:
    +						fmt.Printf("Message from %s: %s\n", ev.User, ev.Text)
    +						if len(ev.Blocks.BlockSet) > 0 {
    +							fmt.Printf("Message contains %d block(s):\n", len(ev.Blocks.BlockSet))
    +							for i, block := range ev.Blocks.BlockSet {
    +								fmt.Printf("  Block %d: type=%s\n", i, block.BlockType())
    +							}
    +						}
     					case *slackevents.MemberJoinedChannelEvent:
     						fmt.Printf("user %q joined to channel %q", ev.User, ev.Channel)
     					}
    diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go
    index 35ec0e985..5cf6f4938 100644
    --- a/slackevents/inner_events.go
    +++ b/slackevents/inner_events.go
    @@ -288,15 +288,16 @@ type SharedLinks struct {
     // TODO: Improve this so that it is not required to manually parse ChannelType
     type MessageEvent struct {
     	// Basic Message Event - https://api.slack.com/events/message
    -	ClientMsgID     string `json:"client_msg_id"`
    -	Type            string `json:"type"`
    -	User            string `json:"user"`
    -	Text            string `json:"text"`
    -	ThreadTimeStamp string `json:"thread_ts"`
    -	TimeStamp       string `json:"ts"`
    -	Channel         string `json:"channel"`
    -	ChannelType     string `json:"channel_type"`
    -	EventTimeStamp  string `json:"event_ts"`
    +	ClientMsgID     string       `json:"client_msg_id"`
    +	Type            string       `json:"type"`
    +	User            string       `json:"user"`
    +	Text            string       `json:"text"`
    +	Blocks          slack.Blocks `json:"blocks,omitempty"`
    +	ThreadTimeStamp string       `json:"thread_ts"`
    +	TimeStamp       string       `json:"ts"`
    +	Channel         string       `json:"channel"`
    +	ChannelType     string       `json:"channel_type"`
    +	EventTimeStamp  string       `json:"event_ts"`
     
     	// When Message comes from a channel that is shared between workspaces
     	UserTeam   string `json:"user_team,omitempty"`
    diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go
    index 78f6c84d1..c30c803a1 100644
    --- a/slackevents/inner_events_test.go
    +++ b/slackevents/inner_events_test.go
    @@ -503,6 +503,57 @@ func TestBotMessageEvent(t *testing.T) {
     	}
     }
     
    +func TestMessageEventWithBlocks(t *testing.T) {
    +	rawE := []byte(`
    +		{
    +			"type": "message",
    +			"channel": "C024BE91L",
    +			"user": "U2147483697",
    +			"text": "ERROR",
    +			"ts": "1355517523.000005",
    +			"event_ts": "1355517523.000005",
    +			"channel_type": "channel",
    +			"blocks": [
    +				{
    +					"type": "section",
    +					"text": {
    +						"type": "mrkdwn",
    +						"text": "> Danny Torrence left the following review for your property:"
    +					}
    +				}
    +			]
    +		}
    +	`)
    +	var e MessageEvent
    +	err := json.Unmarshal(rawE, &e)
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	if e.Text != "ERROR" {
    +		t.Errorf("expected e.Text ERROR, got %s", e.Text)
    +	}
    +
    +	// Blocks should be directly accessible on MessageEvent
    +	if len(e.Blocks.BlockSet) != 1 {
    +		t.Fatalf("expected 1 block in e.Blocks, got %d", len(e.Blocks.BlockSet))
    +	}
    +	if e.Blocks.BlockSet[0].BlockType() != slack.MBTSection {
    +		t.Errorf("expected section block, got %s", e.Blocks.BlockSet[0].BlockType())
    +	}
    +
    +	// Blocks should also be accessible via Message (populated by UnmarshalJSON)
    +	if e.Message == nil {
    +		t.Fatal("expected e.Message to be non-nil")
    +	}
    +	if len(e.Message.Blocks.BlockSet) != 1 {
    +		t.Fatalf("expected 1 block in e.Message.Blocks, got %d", len(e.Message.Blocks.BlockSet))
    +	}
    +	if e.Message.Blocks.BlockSet[0].BlockType() != slack.MBTSection {
    +		t.Errorf("expected section block in e.Message.Blocks, got %s", e.Message.Blocks.BlockSet[0].BlockType())
    +	}
    +}
    +
     func TestThreadBroadcastEvent(t *testing.T) {
     	rawE := []byte(`
     			{
    
    From ec784975a0e030e0cce1603cce152acb8d52d44f Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Mon, 23 Mar 2026 17:32:36 +0000
    Subject: [PATCH 501/606] fix(webhooks)!: make sure unfurl_links and
     unfurl_media can be false
    
    ---
     CHANGELOG.md     | 23 ++++++++++++++++++++
     webhooks.go      |  4 ++--
     webhooks_test.go | 56 ++++++++++++++++++++++++++++++++++++++++++++++++
     3 files changed, 81 insertions(+), 2 deletions(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 824b78643..abf808dd6 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -18,6 +18,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     - **`Blocks` field on `MessageEvent`** — block data from webhook payloads is now directly
       accessible via `event.Blocks` instead of only through `event.Message.Blocks`. ([#1257])
     
    +### Changed
    +
    +- **`WebhookMessage.UnfurlLinks` and `UnfurlMedia` are now `*bool`** — Previously these
    +  were `bool` with `omitempty`, which meant `false` was silently stripped from the JSON
    +  payload. Users could not explicitly disable link or media unfurling via webhooks. The
    +  fields are now `*bool` so that `nil` (omit), `false`, and `true` all serialize correctly.
    +  ([#1231])
    +
    +  > [!WARNING]
    +  > **Breaking change.** Code that sets these fields directly must be updated:
    +  >
    +  > ```go
    +  > // Before
    +  > msg := slack.WebhookMessage{UnfurlLinks: true}
    +  >
    +  > // After — use a helper or a variable
    +  > t := true
    +  > msg := slack.WebhookMessage{UnfurlLinks: &t}
    +  > ```
    +  >
    +  > Leaving the fields unset (`nil`) preserves the previous default behavior — Slack's
    +  > server-side defaults apply (`unfurl_links=false`, `unfurl_media=true`).
    +
     ### Fixed
     
     - **`NewBlockHeader` nil pointer dereference** — passing a nil text object no longer panics. ([#1236])
    diff --git a/webhooks.go b/webhooks.go
    index 5a854f38b..729bce401 100644
    --- a/webhooks.go
    +++ b/webhooks.go
    @@ -23,8 +23,8 @@ type WebhookMessage struct {
     	ReplaceOriginal bool         `json:"replace_original"`
     	DeleteOriginal  bool         `json:"delete_original"`
     	ReplyBroadcast  bool         `json:"reply_broadcast,omitempty"`
    -	UnfurlLinks     bool         `json:"unfurl_links,omitempty"`
    -	UnfurlMedia     bool         `json:"unfurl_media,omitempty"`
    +	UnfurlLinks     *bool        `json:"unfurl_links,omitempty"`
    +	UnfurlMedia     *bool        `json:"unfurl_media,omitempty"`
     }
     
     func PostWebhook(url string, msg *WebhookMessage) error {
    diff --git a/webhooks_test.go b/webhooks_test.go
    index 6870918a4..b885f4d81 100644
    --- a/webhooks_test.go
    +++ b/webhooks_test.go
    @@ -4,6 +4,7 @@ import (
     	"encoding/json"
     	"net/http"
     	"reflect"
    +	"strings"
     	"testing"
     
     	"github.com/stretchr/testify/assert"
    @@ -85,6 +86,61 @@ func TestPostWebhook_MessageLimitExceeded(t *testing.T) {
     	assert.IsType(t, StatusCodeError{}, err)
     }
     
    +func boolPtr(b bool) *bool { return &b }
    +
    +func TestWebhookMessage_UnfurlFields(t *testing.T) {
    +	t.Run("nil omits fields", func(t *testing.T) {
    +		msg := WebhookMessage{Text: "hello"}
    +		raw, err := json.Marshal(msg)
    +		assert.NoError(t, err)
    +		assert.False(t, strings.Contains(string(raw), "unfurl_links"))
    +		assert.False(t, strings.Contains(string(raw), "unfurl_media"))
    +	})
    +
    +	t.Run("false is preserved", func(t *testing.T) {
    +		msg := WebhookMessage{
    +			Text:        "hello",
    +			UnfurlLinks: boolPtr(false),
    +			UnfurlMedia: boolPtr(false),
    +		}
    +		raw, err := json.Marshal(msg)
    +		assert.NoError(t, err)
    +		assert.Contains(t, string(raw), `"unfurl_links":false`)
    +		assert.Contains(t, string(raw), `"unfurl_media":false`)
    +	})
    +
    +	t.Run("true is preserved", func(t *testing.T) {
    +		msg := WebhookMessage{
    +			Text:        "hello",
    +			UnfurlLinks: boolPtr(true),
    +			UnfurlMedia: boolPtr(true),
    +		}
    +		raw, err := json.Marshal(msg)
    +		assert.NoError(t, err)
    +		assert.Contains(t, string(raw), `"unfurl_links":true`)
    +		assert.Contains(t, string(raw), `"unfurl_media":true`)
    +	})
    +
    +	t.Run("round-trip preserves values", func(t *testing.T) {
    +		original := WebhookMessage{
    +			Text:        "hello",
    +			UnfurlLinks: boolPtr(false),
    +			UnfurlMedia: boolPtr(true),
    +		}
    +		raw, err := json.Marshal(original)
    +		assert.NoError(t, err)
    +
    +		var decoded WebhookMessage
    +		err = json.Unmarshal(raw, &decoded)
    +		assert.NoError(t, err)
    +		assert.Equal(t, original.Text, decoded.Text)
    +		assert.NotNil(t, decoded.UnfurlLinks)
    +		assert.False(t, *decoded.UnfurlLinks)
    +		assert.NotNil(t, decoded.UnfurlMedia)
    +		assert.True(t, *decoded.UnfurlMedia)
    +	})
    +}
    +
     func TestWebhookMessage_WithBlocks(t *testing.T) {
     	textBlockObject := NewTextBlockObject("plain_text", "text", false, false)
     	sectionBlock := NewSectionBlock(textBlockObject, nil, nil)
    
    From 81e1a1435bb45480aa3baa81c911e3bb9a654750 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Tue, 24 Mar 2026 21:57:14 +0000
    Subject: [PATCH 502/606] fix(blocks): add `workflow_button` to `UnmarshalJSON`
    
    Also added a few others that were missing in `block_conv.go`.
    
    Closes #1539
    ---
     CHANGELOG.md       |   4 ++
     block_conv.go      |  20 +++++++
     block_conv_test.go | 144 +++++++++++++++++++++++++++++++++++++++++++++
     3 files changed, 168 insertions(+)
     create mode 100644 block_conv_test.go
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index abf808dd6..254a80b0a 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -43,6 +43,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ### Fixed
     
    +- **`WorkflowButtonBlockElement` missing from `UnmarshalJSON`** — `workflow_button` blocks
    +  now unmarshal correctly through `BlockElements`, `InputBlock`, and `Accessory` paths.
    +  Also adds missing `multi_*_select` and `file_input` cases to `BlockElements.UnmarshalJSON`,
    +  and fixes `toBlockElement` for `RichTextInputElement` and `WorkflowButtonElement`. ([#1539])
     - **`NewBlockHeader` nil pointer dereference** — passing a nil text object no longer panics. ([#1236])
     
     ## [0.20.0] - 2026-03-21
    diff --git a/block_conv.go b/block_conv.go
    index cba651fc7..3089486fe 100644
    --- a/block_conv.go
    +++ b/block_conv.go
    @@ -153,6 +153,8 @@ func (b *InputBlock) UnmarshalJSON(data []byte) error {
     		e = &FeedbackButtonsBlockElement{}
     	case "icon_button":
     		e = &IconButtonBlockElement{}
    +	case "workflow_button":
    +		e = &WorkflowButtonBlockElement{}
     	default:
     		return fmt.Errorf("unsupported block element type %v", s.TypeVal)
     	}
    @@ -231,12 +233,18 @@ func (b *BlockElements) UnmarshalJSON(data []byte) error {
     			blockElement = &RadioButtonsBlockElement{}
     		case "static_select", "external_select", "users_select", "conversations_select", "channels_select":
     			blockElement = &SelectBlockElement{}
    +		case "multi_static_select", "multi_external_select", "multi_users_select", "multi_conversations_select", "multi_channels_select":
    +			blockElement = &MultiSelectBlockElement{}
     		case "number_input":
     			blockElement = &NumberInputBlockElement{}
    +		case "file_input":
    +			blockElement = &FileInputBlockElement{}
     		case "feedback_buttons":
     			blockElement = &FeedbackButtonsBlockElement{}
     		case "icon_button":
     			blockElement = &IconButtonBlockElement{}
    +		case "workflow_button":
    +			blockElement = &WorkflowButtonBlockElement{}
     		default:
     			return fmt.Errorf("unsupported block element type %v", blockElementType)
     		}
    @@ -357,6 +365,12 @@ func (a *Accessory) UnmarshalJSON(data []byte) error {
     			return err
     		}
     		a.CheckboxGroupsBlockElement = element.(*CheckboxGroupsBlockElement)
    +	case "workflow_button":
    +		element, err := unmarshalBlockElement(r, &WorkflowButtonBlockElement{})
    +		if err != nil {
    +			return err
    +		}
    +		a.WorkflowButtonElement = element.(*WorkflowButtonBlockElement)
     	default:
     		element, err := unmarshalBlockElement(r, &UnknownBlockElement{})
     		if err != nil {
    @@ -407,6 +421,12 @@ func toBlockElement(element *Accessory) BlockElement {
     	if element.MultiSelectElement != nil {
     		return element.MultiSelectElement
     	}
    +	if element.RichTextInputElement != nil {
    +		return element.RichTextInputElement
    +	}
    +	if element.WorkflowButtonElement != nil {
    +		return element.WorkflowButtonElement
    +	}
     
     	return nil
     }
    diff --git a/block_conv_test.go b/block_conv_test.go
    new file mode 100644
    index 000000000..ff58b50b3
    --- /dev/null
    +++ b/block_conv_test.go
    @@ -0,0 +1,144 @@
    +package slack
    +
    +import (
    +	"encoding/json"
    +	"fmt"
    +	"testing"
    +
    +	"github.com/stretchr/testify/assert"
    +	"github.com/stretchr/testify/require"
    +)
    +
    +func TestWorkflowButtonBlockElementUnmarshal(t *testing.T) {
    +	workflowButtonJSON := `{
    +		"type": "workflow_button",
    +		"text": {"type": "plain_text", "text": "Run Workflow"},
    +		"workflow": {"trigger": {"url": "https://slack.com/shortcuts/Ft0123ABC/xyz"}},
    +		"action_id": "start_workflow"
    +	}`
    +
    +	t.Run("BlockElements", func(t *testing.T) {
    +		elementsJSON := fmt.Sprintf("[%s]", workflowButtonJSON)
    +		var elements BlockElements
    +		err := json.Unmarshal([]byte(elementsJSON), &elements)
    +		require.NoError(t, err)
    +		require.Len(t, elements.ElementSet, 1)
    +		assert.IsType(t, &WorkflowButtonBlockElement{}, elements.ElementSet[0])
    +
    +		wb := elements.ElementSet[0].(*WorkflowButtonBlockElement)
    +		assert.Equal(t, METWorkflowButton, wb.Type)
    +		assert.Equal(t, "Run Workflow", wb.Text.Text)
    +		assert.Equal(t, "start_workflow", wb.ActionID)
    +		assert.Equal(t, "https://slack.com/shortcuts/Ft0123ABC/xyz", wb.Workflow.Trigger.URL)
    +	})
    +
    +	t.Run("InputBlock", func(t *testing.T) {
    +		inputJSON := fmt.Sprintf(`{
    +			"type": "input",
    +			"label": {"type": "plain_text", "text": "Workflow"},
    +			"element": %s
    +		}`, workflowButtonJSON)
    +		var input InputBlock
    +		err := json.Unmarshal([]byte(inputJSON), &input)
    +		require.NoError(t, err)
    +		require.NotNil(t, input.Element)
    +		assert.IsType(t, &WorkflowButtonBlockElement{}, input.Element)
    +	})
    +
    +	t.Run("Accessory", func(t *testing.T) {
    +		var accessory Accessory
    +		err := json.Unmarshal([]byte(workflowButtonJSON), &accessory)
    +		require.NoError(t, err)
    +		require.NotNil(t, accessory.WorkflowButtonElement)
    +		assert.Equal(t, METWorkflowButton, accessory.WorkflowButtonElement.Type)
    +	})
    +}
    +
    +// TestAllBlockElementTypesUnmarshal ensures every known MET* element type can be
    +// unmarshalled through BlockElements.UnmarshalJSON without error. This test acts
    +// as a safety net: when a new element type constant is added to block_element.go
    +// but its case is not added to the switch statement, this test will fail.
    +func TestAllBlockElementTypesUnmarshal(t *testing.T) {
    +	allTypes := []string{
    +		string(METCheckboxGroups),
    +		string(METImage),
    +		string(METButton),
    +		string(METOverflow),
    +		string(METDatepicker),
    +		string(METTimepicker),
    +		string(METDatetimepicker),
    +		string(METPlainTextInput),
    +		string(METRadioButtons),
    +		string(METRichTextInput),
    +		string(METEmailTextInput),
    +		string(METURLTextInput),
    +		string(METNumber),
    +		string(METFileInput),
    +		string(METFeedbackButtons),
    +		string(METIconButton),
    +		string(METWorkflowButton),
    +		OptTypeStatic,
    +		OptTypeExternal,
    +		OptTypeUser,
    +		OptTypeConversations,
    +		OptTypeChannels,
    +		MultiOptTypeStatic,
    +		MultiOptTypeExternal,
    +		MultiOptTypeUser,
    +		MultiOptTypeConversations,
    +		MultiOptTypeChannels,
    +	}
    +
    +	for _, typ := range allTypes {
    +		t.Run(typ, func(t *testing.T) {
    +			elemJSON := fmt.Sprintf(`[{"type": "%s"}]`, typ)
    +			var elements BlockElements
    +			err := json.Unmarshal([]byte(elemJSON), &elements)
    +			require.NoError(t, err, "BlockElements.UnmarshalJSON should handle type %q", typ)
    +			require.Len(t, elements.ElementSet, 1)
    +		})
    +	}
    +}
    +
    +// TestAllAccessoryTypesRoundTrip ensures every Accessory field can survive a
    +// marshal→unmarshal round trip. When a new field is added to the Accessory struct
    +// and wired into NewAccessory but not into Accessory.UnmarshalJSON, this test
    +// will fail.
    +func TestAllAccessoryTypesRoundTrip(t *testing.T) {
    +	text := NewTextBlockObject("plain_text", "test", false, false)
    +	workflow := &Workflow{Trigger: &WorkflowTrigger{URL: "https://example.com"}}
    +
    +	cases := []struct {
    +		name    string
    +		element BlockElement
    +		check   func(t *testing.T, a *Accessory)
    +	}{
    +		{"image", &ImageBlockElement{Type: METImage}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.ImageElement) }},
    +		{"button", &ButtonBlockElement{Type: METButton}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.ButtonElement) }},
    +		{"overflow", &OverflowBlockElement{Type: METOverflow}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.OverflowElement) }},
    +		{"datepicker", &DatePickerBlockElement{Type: METDatepicker}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.DatePickerElement) }},
    +		{"timepicker", &TimePickerBlockElement{Type: METTimepicker}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.TimePickerElement) }},
    +		{"plain_text_input", &PlainTextInputBlockElement{Type: METPlainTextInput}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.PlainTextInputElement) }},
    +		{"rich_text_input", &RichTextInputBlockElement{Type: METRichTextInput}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.RichTextInputElement) }},
    +		{"radio_buttons", &RadioButtonsBlockElement{Type: METRadioButtons}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.RadioButtonsElement) }},
    +		{"static_select", &SelectBlockElement{Type: OptTypeStatic}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.SelectElement) }},
    +		{"multi_static_select", &MultiSelectBlockElement{Type: MultiOptTypeStatic}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.MultiSelectElement) }},
    +		{"checkboxes", &CheckboxGroupsBlockElement{Type: METCheckboxGroups}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.CheckboxGroupsBlockElement) }},
    +		{"workflow_button", NewWorkflowButtonBlockElement(text, workflow, "action1"), func(t *testing.T, a *Accessory) { assert.NotNil(t, a.WorkflowButtonElement) }},
    +	}
    +
    +	for _, tc := range cases {
    +		t.Run(tc.name, func(t *testing.T) {
    +			accessory := NewAccessory(tc.element)
    +
    +			data, err := json.Marshal(accessory)
    +			require.NoError(t, err, "Marshal failed for %s", tc.name)
    +
    +			var unmarshalled Accessory
    +			err = json.Unmarshal(data, &unmarshalled)
    +			require.NoError(t, err, "Unmarshal failed for %s", tc.name)
    +
    +			tc.check(t, &unmarshalled)
    +		})
    +	}
    +}
    
    From 79d222bb20752d4af1465fd7bd46384a41c8f0c7 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 29 Mar 2026 19:27:54 +0200
    Subject: [PATCH 503/606] fix(views): add guard to ensure we don't compare
     empty BlockIDs
    
    It's totally ok to have multiple blocks with empty IDs. But because we check for validity
    of uniqueness without ignoring empty IDs, the validity check fails when they're empty.
    
    Fixes #1184.
    ---
     views.go      |  3 +++
     views_test.go | 15 +++++++++++++++
     2 files changed, 18 insertions(+)
    
    diff --git a/views.go b/views.go
    index 94c7dae78..cd4271384 100644
    --- a/views.go
    +++ b/views.go
    @@ -167,6 +167,9 @@ func ValidateUniqueBlockID(view ModalViewRequest) bool {
     
     	for _, b := range view.Blocks.BlockSet {
     		if inputBlock, ok := b.(*InputBlock); ok {
    +			if inputBlock.BlockID == "" {
    +				continue
    +			}
     			if _, ok := uniqueBlockID[inputBlock.BlockID]; ok {
     				return false
     			}
    diff --git a/views_test.go b/views_test.go
    index 9d767f1ee..c6de663e2 100644
    --- a/views_test.go
    +++ b/views_test.go
    @@ -61,6 +61,21 @@ func TestSlack_OpenView(t *testing.T) {
     			expectedResp: nil,
     			expectedErr:  ErrBlockIDNotUnique,
     		},
    +		{
    +			caseName:  "allow multiple blocks with empty block IDs",
    +			triggerID: "dummy_trigger_id",
    +			modalViewRequest: ModalViewRequest{
    +				Blocks: Blocks{
    +					BlockSet: []Block{
    +						&InputBlock{BlockID: ""},
    +						&InputBlock{BlockID: ""},
    +					},
    +				},
    +			},
    +			rawResp:      `{"ok": true, "view": {}}`,
    +			expectedResp: &ViewResponse{SlackResponse{Ok: true}, View{}},
    +			expectedErr:  nil,
    +		},
     		{
     			caseName:         "raise an error from Slack API",
     			triggerID:        "dummy_trigger_id",
    
    From 71f30770968ae9a7cce55ee0942a3eb3c2a63e34 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 29 Mar 2026 21:26:25 +0200
    Subject: [PATCH 504/606] fix(users): add Username to User
    
    As an example, in interaction payloads, there will be a username field, and therefore this
    catches it.
    
    Fixes #1218.
    ---
     interactions_test.go | 19 +++++++++++++++++++
     users.go             |  1 +
     2 files changed, 20 insertions(+)
    
    diff --git a/interactions_test.go b/interactions_test.go
    index a8f268722..9e2743449 100644
    --- a/interactions_test.go
    +++ b/interactions_test.go
    @@ -495,6 +495,25 @@ func TestInteractionCallback_InteractionTypeBlockActions_Unmarshal(t *testing.T)
     		[]string{"G12345"})
     }
     
    +func TestInteractionCallback_UserUsername(t *testing.T) {
    +	raw := []byte(`{
    +		"type": "block_actions",
    +		"user": {
    +			"id": "UA8RXUSPL",
    +			"username": "jtorrance",
    +			"name": "jtorrance",
    +			"team_id": "T9TK3CUKW"
    +		},
    +		"actions": []
    +	}`)
    +	var cb InteractionCallback
    +	assert.NoError(t, json.Unmarshal(raw, &cb))
    +	assert.Equal(t, "UA8RXUSPL", cb.User.ID)
    +	assert.Equal(t, "jtorrance", cb.User.Username)
    +	assert.Equal(t, "jtorrance", cb.User.Name)
    +	assert.Equal(t, "T9TK3CUKW", cb.User.TeamID)
    +}
    +
     func TestInteractionCallback_Container_Marshal_And_Unmarshal(t *testing.T) {
     	// Contrived - you generally won't see all of the fields set in a single message
     	raw := []byte(
    diff --git a/users.go b/users.go
    index aa8a5c0ce..316ca6bd9 100644
    --- a/users.go
    +++ b/users.go
    @@ -123,6 +123,7 @@ type User struct {
     	ID                     string         `json:"id"`
     	TeamID                 string         `json:"team_id"`
     	Name                   string         `json:"name"`
    +	Username               string         `json:"username,omitempty"`
     	Deleted                bool           `json:"deleted"`
     	Color                  string         `json:"color"`
     	RealName               string         `json:"real_name"`
    
    From 384450bdeb6fe18a6da7be644b65c44e804cd1ab Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 29 Mar 2026 22:36:44 +0200
    Subject: [PATCH 505/606] feat(events): add Blocks, Attachments, Files to
     AppMentionEvent
    
    Fixes #961.
    ---
     slackevents/inner_events.go      |  6 +++
     slackevents/inner_events_test.go | 63 ++++++++++++++++++++++++++++++++
     socketmode/socketmode_test.go    | 24 ++++++++++++
     3 files changed, 93 insertions(+)
    
    diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go
    index 5cf6f4938..30d0db211 100644
    --- a/slackevents/inner_events.go
    +++ b/slackevents/inner_events.go
    @@ -65,6 +65,12 @@ type AppMentionEvent struct {
     	// BotID is filled out when a bot triggers the app_mention event
     	BotID string `json:"bot_id,omitempty"`
     
    +	// Fields shared with message events
    +	Blocks      slack.Blocks       `json:"blocks,omitempty"`
    +	Attachments []slack.Attachment `json:"attachments,omitempty"`
    +	Files       []slack.File       `json:"files,omitempty"`
    +	Upload      bool               `json:"upload,omitempty"`
    +
     	// When the app is mentioned in the edited message
     	Edited *Edited `json:"edited,omitempty"`
     
    diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go
    index c30c803a1..ed52ad858 100644
    --- a/slackevents/inner_events_test.go
    +++ b/slackevents/inner_events_test.go
    @@ -115,6 +115,69 @@ func TestAppMentionWithAssistantThread(t *testing.T) {
     	}
     }
     
    +func TestAppMentionWithBlocksFilesAttachments(t *testing.T) {
    +	rawE := []byte(`{
    +		"type": "app_mention",
    +		"user": "U061F7AUR",
    +		"text": "<@U0LAN0Z89> check this file",
    +		"ts": "1628259917.003000",
    +		"thread_ts": "1628259917.003000",
    +		"channel": "C0LAN2Q65",
    +		"event_ts": "1628259917.003000",
    +		"bot_id": "B12345",
    +		"blocks": [
    +			{
    +				"type": "section",
    +				"block_id": "HNku",
    +				"text": {
    +					"type": "mrkdwn",
    +					"text": "<@U0LAN0Z89> check this file"
    +				}
    +			}
    +		],
    +		"files": [
    +			{
    +				"id": "F12345",
    +				"name": "test.png",
    +				"mimetype": "image/png",
    +				"filetype": "png"
    +			}
    +		],
    +		"upload": true,
    +		"attachments": [
    +			{
    +				"color": "29AF7B",
    +				"fallback": "[no preview available]",
    +				"id": 1,
    +				"text": "attachment text"
    +			}
    +		]
    +	}`)
    +	var event AppMentionEvent
    +	if err := json.Unmarshal(rawE, &event); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	if len(event.Blocks.BlockSet) != 1 {
    +		t.Errorf("Expected 1 block, got %d", len(event.Blocks.BlockSet))
    +	}
    +	if len(event.Files) != 1 {
    +		t.Errorf("Expected 1 file, got %d", len(event.Files))
    +	}
    +	if event.Files[0].ID != "F12345" {
    +		t.Errorf("Expected file ID 'F12345', got %s", event.Files[0].ID)
    +	}
    +	if !event.Upload {
    +		t.Error("Expected Upload to be true")
    +	}
    +	if len(event.Attachments) != 1 {
    +		t.Errorf("Expected 1 attachment, got %d", len(event.Attachments))
    +	}
    +	if event.Attachments[0].Text != "attachment text" {
    +		t.Errorf("Expected attachment text 'attachment text', got %s", event.Attachments[0].Text)
    +	}
    +}
    +
     func TestAppUninstalled(t *testing.T) {
     	rawE := []byte(`
     		{
    diff --git a/socketmode/socketmode_test.go b/socketmode/socketmode_test.go
    index 5fadea8f4..3ff5e623c 100644
    --- a/socketmode/socketmode_test.go
    +++ b/socketmode/socketmode_test.go
    @@ -7,6 +7,7 @@ import (
     	"reflect"
     	"testing"
     
    +	"github.com/slack-go/slack"
     	"github.com/slack-go/slack/slackevents"
     )
     
    @@ -237,6 +238,29 @@ func TestEventParsing(t *testing.T) {
     						ThreadTimeStamp: "",
     						Channel:         "redacted",
     						EventTimeStamp:  "1610927831.000200",
    +						Blocks: slack.Blocks{
    +							BlockSet: []slack.Block{
    +								&slack.RichTextBlock{
    +									Type:    slack.MBTRichText,
    +									BlockID: "2Le",
    +									Elements: []slack.RichTextElement{
    +										&slack.RichTextSection{
    +											Type: slack.RTESection,
    +											Elements: []slack.RichTextSectionElement{
    +												&slack.RichTextSectionUserElement{
    +													Type:   slack.RTSEUser,
    +													UserID: "redacted",
    +												},
    +												&slack.RichTextSectionTextElement{
    +													Type: slack.RTSEText,
    +													Text: " test39",
    +												},
    +											},
    +										},
    +									},
    +								},
    +							},
    +						},
     					},
     				},
     			},
    
    From 21504491c4f186e28ef8f910a6950580144e3301 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 29 Mar 2026 22:59:10 +0200
    Subject: [PATCH 506/606] fix(users)!: change Has2FA from bool to *bool
    
    When using a bot token, Slack's users.list API omits the has_2fa field entirely. With a
    plain bool, this was indistinguishable from explicitly false, causing all users to appear
    as not having 2FA enabled.
    
    BREAKING CHANGE: User.Has2FA is now *bool. Callers must handle nil and dereference the pointer.
    
    Fixes #1121.
    ---
     users.go      |  2 +-
     users_test.go | 34 +++++++++++++++++++++++++++++++++-
     2 files changed, 34 insertions(+), 2 deletions(-)
    
    diff --git a/users.go b/users.go
    index 316ca6bd9..32e1d6b6b 100644
    --- a/users.go
    +++ b/users.go
    @@ -143,7 +143,7 @@ type User struct {
     	IsWorkflowBot          bool           `json:"is_workflow_bot"`
     	IsInvitedUser          bool           `json:"is_invited_user"`
     	IsEmailConfirmed       bool           `json:"is_email_confirmed"`
    -	Has2FA                 bool           `json:"has_2fa"`
    +	Has2FA                 *bool          `json:"has_2fa,omitempty"`
     	TwoFactorType          *string        `json:"two_factor_type"`
     	HasFiles               bool           `json:"has_files"`
     	Presence               string         `json:"presence"`
    diff --git a/users_test.go b/users_test.go
    index 97b313551..c591fe958 100644
    --- a/users_test.go
    +++ b/users_test.go
    @@ -79,7 +79,7 @@ func getTestUserWithId(id string) User {
     		IsRestricted:      false,
     		IsUltraRestricted: false,
     		Updated:           1555425715,
    -		Has2FA:            false,
    +		Has2FA:            boolPtr(false),
     	}
     }
     
    @@ -889,6 +889,38 @@ func TestUserUnmarshalJSON(t *testing.T) {
     	}
     }
     
    +func TestUserHas2FA_ThreeStates(t *testing.T) {
    +	// When has_2fa is present and true
    +	withTrue := []byte(`{"id":"U1","has_2fa":true}`)
    +	var u1 User
    +	if err := json.Unmarshal(withTrue, &u1); err != nil {
    +		t.Fatal(err)
    +	}
    +	if u1.Has2FA == nil || *u1.Has2FA != true {
    +		t.Fatalf("expected Has2FA=true, got %v", u1.Has2FA)
    +	}
    +
    +	// When has_2fa is present and false
    +	withFalse := []byte(`{"id":"U2","has_2fa":false}`)
    +	var u2 User
    +	if err := json.Unmarshal(withFalse, &u2); err != nil {
    +		t.Fatal(err)
    +	}
    +	if u2.Has2FA == nil || *u2.Has2FA != false {
    +		t.Fatalf("expected Has2FA=false, got %v", u2.Has2FA)
    +	}
    +
    +	// When has_2fa is absent (bot token response)
    +	withoutField := []byte(`{"id":"U3"}`)
    +	var u3 User
    +	if err := json.Unmarshal(withoutField, &u3); err != nil {
    +		t.Fatal(err)
    +	}
    +	if u3.Has2FA != nil {
    +		t.Fatalf("expected Has2FA=nil, got %v", *u3.Has2FA)
    +	}
    +}
    +
     func TestGetUsersReturnsServerError(t *testing.T) {
     	http.DefaultServeMux = new(http.ServeMux)
     	http.HandleFunc("/users.list", func(w http.ResponseWriter, r *http.Request) {
    
    From 5daf9915f4015242dc1c7e58aa61b44de3440d72 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 29 Mar 2026 23:02:19 +0200
    Subject: [PATCH 507/606] chore: update CHANGELOG
    
    ---
     CHANGELOG.md | 24 ++++++++++++++++++++++++
     1 file changed, 24 insertions(+)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 254a80b0a..d3b6e5e47 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -17,6 +17,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     - **`GuestInvitedBy` in `UserProfile`** — user ID of whoever invited a guest user
     - **`Blocks` field on `MessageEvent`** — block data from webhook payloads is now directly
       accessible via `event.Blocks` instead of only through `event.Message.Blocks`. ([#1257])
    +- **`Username` field on `User`** — Slack's interaction payloads (block_actions, shortcuts)
    +  include a `username` field in the user object that was previously dropped during
    +  unmarshalling. ([#1218])
    +- **`Blocks`, `Attachments`, `Files`, `Upload` fields on `AppMentionEvent`** — these fields
    +  are sent by Slack in `app_mention` event payloads but were silently dropped. ([#961])
     
     ### Changed
     
    @@ -41,6 +46,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
       > Leaving the fields unset (`nil`) preserves the previous default behavior — Slack's
       > server-side defaults apply (`unfurl_links=false`, `unfurl_media=true`).
     
    +- **`User.Has2FA` is now `*bool`** — When using a bot token, Slack's `users.list` API omits
    +  `has_2fa` entirely. With a plain `bool`, this was indistinguishable from explicitly `false`.
    +  Now `nil` means absent/unknown, `false` means explicitly disabled, `true` means enabled.
    +  ([#1121])
    +
    +  > [!WARNING]
    +  > **Breaking change.** Code that reads `Has2FA` must handle the pointer:
    +  >
    +  > ```go
    +  > // Before
    +  > if user.Has2FA { ... }
    +  >
    +  > // After
    +  > if user.Has2FA != nil && *user.Has2FA { ... }
    +  > ```
    +
     ### Fixed
     
     - **`WorkflowButtonBlockElement` missing from `UnmarshalJSON`** — `workflow_button` blocks
    @@ -48,6 +69,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
       Also adds missing `multi_*_select` and `file_input` cases to `BlockElements.UnmarshalJSON`,
       and fixes `toBlockElement` for `RichTextInputElement` and `WorkflowButtonElement`. ([#1539])
     - **`NewBlockHeader` nil pointer dereference** — passing a nil text object no longer panics. ([#1236])
    +- **`ValidateUniqueBlockID` rejects empty block IDs** — multiple input blocks with no
    +  explicit `block_id` set (empty string) were incorrectly flagged as duplicates, causing
    +  `OpenView` to fail. ([#1184])
     
     ## [0.20.0] - 2026-03-21
     
    
    From 1e576848c88cd8eb7adada5835a549652e061302 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Mon, 30 Mar 2026 22:53:00 +0200
    Subject: [PATCH 508/606] feat: add ability to get the warnings (#1540)
    
    Supersedes https://github.com/slack-go/slack/pull/913.
    ---
     apps.go               |   2 +-
     dialog.go             |   3 +-
     function_execute.go   |   6 +-
     misc.go               |  38 +++++++++
     slack.go              |  46 ++++++++++-
     socket_mode.go        |   2 +-
     views.go              |  12 +--
     warnings_test.go      | 183 ++++++++++++++++++++++++++++++++++++++++++
     workflows_featured.go |   8 +-
     workflows_triggers.go |   8 +-
     10 files changed, 282 insertions(+), 26 deletions(-)
     create mode 100644 warnings_test.go
    
    diff --git a/apps.go b/apps.go
    index 7322b15b9..c75569fb6 100644
    --- a/apps.go
    +++ b/apps.go
    @@ -35,7 +35,7 @@ func (api *Client) ListEventAuthorizationsContext(ctx context.Context, eventCont
     		"event_context": eventContext,
     	})
     
    -	err := postJSON(ctx, api.httpclient, api.endpoint+"apps.event.authorizations.list", api.appLevelToken, request, &resp, api)
    +	err := api.postJSONMethod(ctx, "apps.event.authorizations.list", api.appLevelToken, request, &resp)
     
     	if err != nil {
     		return nil, err
    diff --git a/dialog.go b/dialog.go
    index f94113f4d..4c507fcdf 100644
    --- a/dialog.go
    +++ b/dialog.go
    @@ -106,8 +106,7 @@ func (api *Client) OpenDialogContext(ctx context.Context, triggerID string, dial
     	}
     
     	response := &DialogOpenResponse{}
    -	endpoint := api.endpoint + "dialog.open"
    -	if err := postJSON(ctx, api.httpclient, endpoint, api.token, encoded, response, api); err != nil {
    +	if err := api.postJSONMethod(ctx, "dialog.open", api.token, encoded, response); err != nil {
     		return err
     	}
     
    diff --git a/function_execute.go b/function_execute.go
    index 4ec8f9f4c..97bc7e150 100644
    --- a/function_execute.go
    +++ b/function_execute.go
    @@ -43,14 +43,13 @@ func (api *Client) FunctionCompleteSuccessContext(ctx context.Context, functionE
     		option(r)
     	}
     
    -	endpoint := api.endpoint + "functions.completeSuccess"
     	jsonData, err := json.Marshal(r)
     	if err != nil {
     		return err
     	}
     
     	response := &SlackResponse{}
    -	if err := postJSON(ctx, api.httpclient, endpoint, api.token, jsonData, response, api); err != nil {
    +	if err := api.postJSONMethod(ctx, "functions.completeSuccess", api.token, jsonData, response); err != nil {
     		return err
     	}
     
    @@ -74,14 +73,13 @@ func (api *Client) FunctionCompleteErrorContext(ctx context.Context, functionExe
     	}
     	r.Error = errorMessage
     
    -	endpoint := api.endpoint + "functions.completeError"
     	jsonData, err := json.Marshal(r)
     	if err != nil {
     		return err
     	}
     
     	response := &SlackResponse{}
    -	if err := postJSON(ctx, api.httpclient, endpoint, api.token, jsonData, response, api); err != nil {
    +	if err := api.postJSONMethod(ctx, "functions.completeError", api.token, jsonData, response); err != nil {
     		return err
     	}
     
    diff --git a/misc.go b/misc.go
    index dc7fb9fdb..07150f6e3 100644
    --- a/misc.go
    +++ b/misc.go
    @@ -114,16 +114,42 @@ func (e *SlackResponseErrors) UnmarshalJSON(data []byte) error {
     type SlackResponse struct {
     	Ok               bool                  `json:"ok"`
     	Error            string                `json:"error"`
    +	Warning          string                `json:"warning"`
     	Errors           []SlackResponseErrors `json:"errors,omitempty"`
     	ResponseMetadata ResponseMetadata      `json:"response_metadata"`
     }
     
    +// Warn returns warning information from the API response, or nil if there
    +// are no warnings.
    +func (t SlackResponse) Warn() *Warning {
    +	if t.Warning == "" && len(t.ResponseMetadata.Warnings) == 0 {
    +		return nil
    +	}
    +	return &Warning{
    +		Codes:    strings.Split(t.Warning, ","),
    +		Warnings: t.ResponseMetadata.Warnings,
    +	}
    +}
    +
    +// warner is satisfied by any response type that can report warnings.
    +type warner interface {
    +	Warn() *Warning
    +}
    +
    +// Warning provides warning information from the web API.
    +// https://docs.slack.dev/apis/web-api/#responses
    +type Warning struct {
    +	Codes    []string
    +	Warnings []string
    +}
    +
     // KickUserFromConversationSlackResponse is a variant of SlackResponse that can handle the case where
     // "errors" can be either an empty object {} or an array of errors.
     // This addresses issue #1446 where conversations.kick endpoint returns {"ok":true,"errors":{}}
     type KickUserFromConversationSlackResponse struct {
     	Ok               bool                  `json:"ok"`
     	Error            string                `json:"error"`
    +	Warning          string                `json:"warning"`
     	Errors           []SlackResponseErrors `json:"-"`
     	ResponseMetadata ResponseMetadata      `json:"response_metadata"`
     }
    @@ -164,6 +190,18 @@ func (s *KickUserFromConversationSlackResponse) UnmarshalJSON(data []byte) error
     	return nil
     }
     
    +// Warn returns warning information from the API response, or nil if there
    +// are no warnings.
    +func (s KickUserFromConversationSlackResponse) Warn() *Warning {
    +	if s.Warning == "" && len(s.ResponseMetadata.Warnings) == 0 {
    +		return nil
    +	}
    +	return &Warning{
    +		Codes:    strings.Split(s.Warning, ","),
    +		Warnings: s.ResponseMetadata.Warnings,
    +	}
    +}
    +
     // Err returns any API error present in the response.
     func (s KickUserFromConversationSlackResponse) Err() error {
     	if s.Ok {
    diff --git a/slack.go b/slack.go
    index fbf255a19..1ba1c7aec 100644
    --- a/slack.go
    +++ b/slack.go
    @@ -68,6 +68,7 @@ type Client struct {
     	debug              bool
     	log                ilogger
     	httpclient         httpClient
    +	onWarning          func(path string, request any, w *Warning)
     }
     
     // Option defines an option for a Client
    @@ -94,6 +95,25 @@ func OptionLog(l logger) func(*Client) {
     	}
     }
     
    +// OptionOnWarning sets a callback invoked whenever an API response contains
    +// warnings. The callback receives the API method path (e.g.
    +// "conversations.join"), the request payload ([url.Values] for form-encoded
    +// requests or []byte for JSON requests), and a [Warning] with the warning
    +// codes and messages.
    +//
    +// Example:
    +//
    +//	api := slack.New("YOUR_TOKEN",
    +//		slack.OptionOnWarning(func(path string, request any, w *slack.Warning) {
    +//			log.Printf("slack warnings for %s: codes=%v warnings=%v", path, w.Codes, w.Warnings)
    +//		}),
    +//	)
    +func OptionOnWarning(fn func(path string, request any, w *Warning)) func(*Client) {
    +	return func(c *Client) {
    +		c.onWarning = fn
    +	}
    +}
    +
     // OptionAPIURL set the url for the client. only useful for testing.
     func OptionAPIURL(u string) func(*Client) {
     	return func(c *Client) { c.endpoint = u }
    @@ -207,10 +227,32 @@ func (api *Client) Debug() bool {
     
     // post to a slack web method.
     func (api *Client) postMethod(ctx context.Context, path string, values url.Values, intf any) error {
    -	return postForm(ctx, api.httpclient, api.endpoint+path, values, intf, api)
    +	err := postForm(ctx, api.httpclient, api.endpoint+path, values, intf, api)
    +	api.checkWarnings(intf, path, values)
    +	return err
     }
     
     // get a slack web method.
     func (api *Client) getMethod(ctx context.Context, path string, token string, values url.Values, intf any) error {
    -	return getResource(ctx, api.httpclient, api.endpoint+path, token, values, intf, api)
    +	err := getResource(ctx, api.httpclient, api.endpoint+path, token, values, intf, api)
    +	api.checkWarnings(intf, path, values)
    +	return err
    +}
    +
    +// postJSONMethod posts JSON to a slack web method.
    +func (api *Client) postJSONMethod(ctx context.Context, path string, token string, jsonBody []byte, intf any) error {
    +	err := postJSON(ctx, api.httpclient, api.endpoint+path, token, jsonBody, intf, api)
    +	api.checkWarnings(intf, path, jsonBody)
    +	return err
    +}
    +
    +func (api *Client) checkWarnings(intf any, path string, request any) {
    +	if api.onWarning == nil {
    +		return
    +	}
    +	if w, ok := intf.(warner); ok {
    +		if warning := w.Warn(); warning != nil {
    +			api.onWarning(path, request, warning)
    +		}
    +	}
     }
    diff --git a/socket_mode.go b/socket_mode.go
    index ea9ea3b7a..d9e7b5863 100644
    --- a/socket_mode.go
    +++ b/socket_mode.go
    @@ -22,7 +22,7 @@ type openResponseFull struct {
     // To have a fully managed Socket Mode connection, use `socketmode.New()`, and call `Run()` on it.
     func (api *Client) StartSocketModeContext(ctx context.Context) (info *SocketModeConnection, websocketURL string, err error) {
     	response := &openResponseFull{}
    -	err = postJSON(ctx, api.httpclient, api.endpoint+"apps.connections.open", api.appLevelToken, nil, response, api)
    +	err = api.postJSONMethod(ctx, "apps.connections.open", api.appLevelToken, nil, response)
     	if err != nil {
     		return nil, "", err
     	}
    diff --git a/views.go b/views.go
    index cd4271384..bd9c9a03a 100644
    --- a/views.go
    +++ b/views.go
    @@ -203,9 +203,8 @@ func (api *Client) OpenViewContext(
     	if err != nil {
     		return nil, err
     	}
    -	endpoint := api.endpoint + "views.open"
     	resp := &ViewResponse{}
    -	err = postJSON(ctx, api.httpclient, endpoint, api.token, encoded, resp, api)
    +	err = api.postJSONMethod(ctx, "views.open", api.token, encoded, resp)
     	if err != nil {
     		return nil, err
     	}
    @@ -235,9 +234,8 @@ func (api *Client) PublishViewContext(
     	if err != nil {
     		return nil, err
     	}
    -	endpoint := api.endpoint + "views.publish"
     	resp := &ViewResponse{}
    -	err = postJSON(ctx, api.httpclient, endpoint, api.token, encoded, resp, api)
    +	err = api.postJSONMethod(ctx, "views.publish", api.token, encoded, resp)
     	if err != nil {
     		return nil, err
     	}
    @@ -268,9 +266,8 @@ func (api *Client) PushViewContext(
     	if err != nil {
     		return nil, err
     	}
    -	endpoint := api.endpoint + "views.push"
     	resp := &ViewResponse{}
    -	err = postJSON(ctx, api.httpclient, endpoint, api.token, encoded, resp, api)
    +	err = api.postJSONMethod(ctx, "views.push", api.token, encoded, resp)
     	if err != nil {
     		return nil, err
     	}
    @@ -304,9 +301,8 @@ func (api *Client) UpdateViewContext(
     	if err != nil {
     		return nil, err
     	}
    -	endpoint := api.endpoint + "views.update"
     	resp := &ViewResponse{}
    -	err = postJSON(ctx, api.httpclient, endpoint, api.token, encoded, resp, api)
    +	err = api.postJSONMethod(ctx, "views.update", api.token, encoded, resp)
     	if err != nil {
     		return nil, err
     	}
    diff --git a/warnings_test.go b/warnings_test.go
    new file mode 100644
    index 000000000..d57e88fef
    --- /dev/null
    +++ b/warnings_test.go
    @@ -0,0 +1,183 @@
    +package slack
    +
    +import (
    +	"net/http"
    +	"net/http/httptest"
    +	"net/url"
    +	"testing"
    +)
    +
    +func TestWarn(t *testing.T) {
    +	t.Run("top-level only", func(t *testing.T) {
    +		resp := SlackResponse{Warning: "missing_charset"}
    +		got := resp.Warn()
    +		if got == nil {
    +			t.Fatal("expected warning, got nil")
    +		}
    +		if len(got.Codes) != 1 || got.Codes[0] != "missing_charset" {
    +			t.Fatalf("expected codes [missing_charset], got %v", got.Codes)
    +		}
    +	})
    +
    +	t.Run("metadata only", func(t *testing.T) {
    +		resp := SlackResponse{
    +			ResponseMetadata: ResponseMetadata{
    +				Warnings: []string{"superfluous_charset"},
    +			},
    +		}
    +		got := resp.Warn()
    +		if got == nil {
    +			t.Fatal("expected warning, got nil")
    +		}
    +		if len(got.Warnings) != 1 || got.Warnings[0] != "superfluous_charset" {
    +			t.Fatalf("expected warnings [superfluous_charset], got %v", got.Warnings)
    +		}
    +	})
    +
    +	t.Run("both sources", func(t *testing.T) {
    +		resp := SlackResponse{
    +			Warning: "missing_charset,deprecated",
    +			ResponseMetadata: ResponseMetadata{
    +				Warnings: []string{"missing_charset", "other"},
    +			},
    +		}
    +		got := resp.Warn()
    +		if got == nil {
    +			t.Fatal("expected warning, got nil")
    +		}
    +		if len(got.Codes) != 2 {
    +			t.Fatalf("expected 2 codes, got %v", got.Codes)
    +		}
    +		if len(got.Warnings) != 2 {
    +			t.Fatalf("expected 2 warnings, got %v", got.Warnings)
    +		}
    +	})
    +
    +	t.Run("no warnings", func(t *testing.T) {
    +		resp := SlackResponse{Ok: true}
    +		got := resp.Warn()
    +		if got != nil {
    +			t.Fatalf("expected nil, got %+v", got)
    +		}
    +	})
    +}
    +
    +func TestOptionOnWarning(t *testing.T) {
    +	t.Run("callback fires on warning", func(t *testing.T) {
    +		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +			w.Header().Set("Content-Type", "application/json")
    +			w.Write([]byte(`{
    +				"ok": true,
    +				"warning": "missing_charset",
    +				"response_metadata": {
    +					"warnings": ["missing_charset"]
    +				}
    +			}`))
    +		}))
    +		defer ts.Close()
    +
    +		var gotWarning *Warning
    +		var gotPath string
    +		var gotRequest any
    +		api := New("test-token",
    +			OptionAPIURL(ts.URL+"/"),
    +			OptionOnWarning(func(path string, request any, w *Warning) {
    +				gotWarning = w
    +				gotPath = path
    +				gotRequest = request
    +			}),
    +		)
    +
    +		_, err := api.AuthTestContext(t.Context())
    +		if err != nil {
    +			t.Fatalf("unexpected error: %v", err)
    +		}
    +		if gotWarning == nil {
    +			t.Fatal("expected warning, got nil")
    +		}
    +		if len(gotWarning.Codes) != 1 || gotWarning.Codes[0] != "missing_charset" {
    +			t.Fatalf("expected codes [missing_charset], got %v", gotWarning.Codes)
    +		}
    +		if gotPath != "auth.test" {
    +			t.Fatalf("expected path auth.test, got %s", gotPath)
    +		}
    +		if _, ok := gotRequest.(url.Values); !ok {
    +			t.Fatalf("expected url.Values request, got %T", gotRequest)
    +		}
    +	})
    +
    +	t.Run("callback not fired when no warnings", func(t *testing.T) {
    +		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +			w.Header().Set("Content-Type", "application/json")
    +			w.Write([]byte(`{"ok": true}`))
    +		}))
    +		defer ts.Close()
    +
    +		called := false
    +		api := New("test-token",
    +			OptionAPIURL(ts.URL+"/"),
    +			OptionOnWarning(func(path string, request any, w *Warning) {
    +				called = true
    +			}),
    +		)
    +
    +		_, err := api.AuthTestContext(t.Context())
    +		if err != nil {
    +			t.Fatalf("unexpected error: %v", err)
    +		}
    +		if called {
    +			t.Fatal("callback should not have been called")
    +		}
    +	})
    +
    +	t.Run("callback fires on error response with warnings", func(t *testing.T) {
    +		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +			w.Header().Set("Content-Type", "application/json")
    +			w.Write([]byte(`{
    +				"ok": false,
    +				"error": "invalid_auth",
    +				"warning": "missing_charset",
    +				"response_metadata": {
    +					"warnings": ["missing_charset"]
    +				}
    +			}`))
    +		}))
    +		defer ts.Close()
    +
    +		var gotWarning *Warning
    +		api := New("test-token",
    +			OptionAPIURL(ts.URL+"/"),
    +			OptionOnWarning(func(path string, request any, w *Warning) {
    +				gotWarning = w
    +			}),
    +		)
    +
    +		_, err := api.AuthTestContext(t.Context())
    +		if err == nil {
    +			t.Fatal("expected error")
    +		}
    +		if gotWarning == nil {
    +			t.Fatal("expected warning, got nil")
    +		}
    +		if len(gotWarning.Codes) != 1 || gotWarning.Codes[0] != "missing_charset" {
    +			t.Fatalf("expected codes [missing_charset], got %v", gotWarning.Codes)
    +		}
    +	})
    +
    +	t.Run("no callback registered is safe", func(t *testing.T) {
    +		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +			w.Header().Set("Content-Type", "application/json")
    +			w.Write([]byte(`{
    +				"ok": true,
    +				"warning": "missing_charset"
    +			}`))
    +		}))
    +		defer ts.Close()
    +
    +		api := New("test-token", OptionAPIURL(ts.URL+"/"))
    +		_, err := api.AuthTestContext(t.Context())
    +		if err != nil {
    +			t.Fatalf("unexpected error: %v", err)
    +		}
    +	})
    +}
    diff --git a/workflows_featured.go b/workflows_featured.go
    index 1d22530c0..08b339868 100644
    --- a/workflows_featured.go
    +++ b/workflows_featured.go
    @@ -54,7 +54,7 @@ func (api *Client) WorkflowsFeaturedAdd(ctx context.Context, input *WorkflowsFea
     		return fmt.Errorf("failed to marshal WorkflowsFeaturedAddInput: %w", err)
     	}
     
    -	err = postJSON(ctx, api.httpclient, api.endpoint+"workflows.featured.add", api.token, jsonPayload, &response, api)
    +	err = api.postJSONMethod(ctx, "workflows.featured.add", api.token, jsonPayload, &response)
     	if err != nil {
     		return err
     	}
    @@ -80,7 +80,7 @@ func (api *Client) WorkflowsFeaturedList(ctx context.Context, input *WorkflowsFe
     		return nil, fmt.Errorf("failed to marshal WorkflowsFeaturedListInput: %w", err)
     	}
     
    -	err = postJSON(ctx, api.httpclient, api.endpoint+"workflows.featured.list", api.token, jsonPayload, &response, api)
    +	err = api.postJSONMethod(ctx, "workflows.featured.list", api.token, jsonPayload, &response)
     	if err != nil {
     		return nil, err
     	}
    @@ -105,7 +105,7 @@ func (api *Client) WorkflowsFeaturedRemove(ctx context.Context, input *Workflows
     		return fmt.Errorf("failed to marshal WorkflowsFeaturedRemoveInput: %w", err)
     	}
     
    -	err = postJSON(ctx, api.httpclient, api.endpoint+"workflows.featured.remove", api.token, jsonPayload, &response, api)
    +	err = api.postJSONMethod(ctx, "workflows.featured.remove", api.token, jsonPayload, &response)
     	if err != nil {
     		return err
     	}
    @@ -130,7 +130,7 @@ func (api *Client) WorkflowsFeaturedSet(ctx context.Context, input *WorkflowsFea
     		return fmt.Errorf("failed to marshal WorkflowsFeaturedSetInput: %w", err)
     	}
     
    -	err = postJSON(ctx, api.httpclient, api.endpoint+"workflows.featured.set", api.token, jsonPayload, &response, api)
    +	err = api.postJSONMethod(ctx, "workflows.featured.set", api.token, jsonPayload, &response)
     	if err != nil {
     		return err
     	}
    diff --git a/workflows_triggers.go b/workflows_triggers.go
    index f9a7cd90c..348348061 100644
    --- a/workflows_triggers.go
    +++ b/workflows_triggers.go
    @@ -84,7 +84,7 @@ func (api *Client) WorkflowsTriggersPermissionsAdd(ctx context.Context, input *W
     		return nil, fmt.Errorf("failed to marshal WorkflowsTriggersPermissionsAddInput: %w", err)
     	}
     
    -	err = postJSON(ctx, api.httpclient, api.endpoint+"workflows.triggers.permissions.add", api.token, jsonPayload, &response, api)
    +	err = api.postJSONMethod(ctx, "workflows.triggers.permissions.add", api.token, jsonPayload, &response)
     	if err != nil {
     		return nil, err
     	}
    @@ -111,7 +111,7 @@ func (api *Client) WorkflowsTriggersPermissionsList(ctx context.Context, input *
     		return nil, fmt.Errorf("failed to marshal WorkflowsTriggersPermissionsListInput: %w", err)
     	}
     
    -	err = postJSON(ctx, api.httpclient, api.endpoint+"workflows.triggers.permissions.list", api.token, jsonPayload, &response, api)
    +	err = api.postJSONMethod(ctx, "workflows.triggers.permissions.list", api.token, jsonPayload, &response)
     	if err != nil {
     		return nil, err
     	}
    @@ -138,7 +138,7 @@ func (api *Client) WorkflowsTriggersPermissionsRemove(ctx context.Context, input
     		return nil, fmt.Errorf("failed to marshal WorkflowsTriggersPermissionsRemoveInput: %w", err)
     	}
     
    -	err = postJSON(ctx, api.httpclient, api.endpoint+"workflows.triggers.permissions.remove", api.token, jsonPayload, &response, api)
    +	err = api.postJSONMethod(ctx, "workflows.triggers.permissions.remove", api.token, jsonPayload, &response)
     	if err != nil {
     		return nil, err
     	}
    @@ -164,7 +164,7 @@ func (api *Client) WorkflowsTriggersPermissionsSet(ctx context.Context, input *W
     		return nil, fmt.Errorf("failed to marshal WorkflowsTriggersPermissionsSetInput: %w", err)
     	}
     
    -	err = postJSON(ctx, api.httpclient, api.endpoint+"workflows.triggers.permissions.set", api.token, jsonPayload, &response, api)
    +	err = api.postJSONMethod(ctx, "workflows.triggers.permissions.set", api.token, jsonPayload, &response)
     	if err != nil {
     		return nil, err
     	}
    
    From 0c45bf14f9ded22bb8ea2fec2bca2e6facceaf65 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Mon, 30 Mar 2026 15:47:20 +0200
    Subject: [PATCH 509/606] fix(reactions)!: move reactions.list to use
     cursor/limit
    
    - count/page are dead: even though slack accepts the params, it kinda ignores
    them (afaict)
    - no paging element in response
    - responses only seem to return response_metadata
    
    Closes #825.
    ---
     CHANGELOG.md                    | 22 ++++++++++++++++++++
     examples/reactions/reactions.go |  4 ++--
     reactions.go                    | 36 +++++++++++++++------------------
     reactions_test.go               | 30 +++++++++++++--------------
     4 files changed, 54 insertions(+), 38 deletions(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index d3b6e5e47..d39223cb5 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -62,6 +62,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
       > if user.Has2FA != nil && *user.Has2FA { ... }
       > ```
     
    +- **`ListReactions` now uses cursor-based pagination** — `ListReactionsParameters` replaces
    +  `Count`/`Page` with `Cursor`/`Limit`, and `ListReactions`/`ListReactionsContext` now return
    +  `([]ReactedItem, string, error)` where the string is the next cursor, instead of
    +  `([]ReactedItem, *Paging, error)`. ([#825])
    +
    +  > [!WARNING]
    +  > **Breaking change.** Both the parameters and return signature have changed:
    +  >
    +  > ```go
    +  > // Before
    +  > params := slack.NewListReactionsParameters()
    +  > params.Count = 100
    +  > params.Page = 2
    +  > items, paging, err := api.ListReactions(params)
    +  >
    +  > // After
    +  > params := slack.NewListReactionsParameters()
    +  > params.Limit = 100
    +  > items, nextCursor, err := api.ListReactions(params)
    +  > // Use nextCursor for the next page: params.Cursor = nextCursor
    +  > ```
    +
     ### Fixed
     
     - **`WorkflowButtonBlockElement` missing from `UnmarshalJSON`** — `workflow_button` blocks
    diff --git a/examples/reactions/reactions.go b/examples/reactions/reactions.go
    index 89abe06e3..eede3d413 100644
    --- a/examples/reactions/reactions.go
    +++ b/examples/reactions/reactions.go
    @@ -78,8 +78,8 @@ func main() {
     
     	// List all of the users reactions.
     	listParams := slack.NewListReactionsParameters()
    -	fmt.Printf("Listing reactions with params: User=%q, TeamID=%q, Count=%d, Page=%d, Full=%v\n",
    -		listParams.User, listParams.TeamID, listParams.Count, listParams.Page, listParams.Full)
    +	fmt.Printf("Listing reactions with params: User=%q, TeamID=%q, Cursor=%q, Limit=%d, Full=%v\n",
    +		listParams.User, listParams.TeamID, listParams.Cursor, listParams.Limit, listParams.Full)
     	listReactions, _, err := api.ListReactions(listParams)
     	if err != nil {
     		fmt.Printf("Error listing reactions: %v\n", err)
    diff --git a/reactions.go b/reactions.go
    index 743b22a1f..18befa699 100644
    --- a/reactions.go
    +++ b/reactions.go
    @@ -70,18 +70,16 @@ func (res getReactionsResponseFull) extractReactedItem() ReactedItem {
     }
     
     const (
    -	DEFAULT_REACTIONS_USER  = ""
    -	DEFAULT_REACTIONS_COUNT = 100
    -	DEFAULT_REACTIONS_PAGE  = 1
    -	DEFAULT_REACTIONS_FULL  = false
    +	DEFAULT_REACTIONS_USER = ""
    +	DEFAULT_REACTIONS_FULL = false
     )
     
     // ListReactionsParameters is the inputs to find all reactions by a user.
     type ListReactionsParameters struct {
     	User   string
     	TeamID string
    -	Count  int
    -	Page   int
    +	Cursor string
    +	Limit  int
     	Full   bool
     }
     
    @@ -89,10 +87,8 @@ type ListReactionsParameters struct {
     // performed by a user.
     func NewListReactionsParameters() ListReactionsParameters {
     	return ListReactionsParameters{
    -		User:  DEFAULT_REACTIONS_USER,
    -		Count: DEFAULT_REACTIONS_COUNT,
    -		Page:  DEFAULT_REACTIONS_PAGE,
    -		Full:  DEFAULT_REACTIONS_FULL,
    +		User: DEFAULT_REACTIONS_USER,
    +		Full: DEFAULT_REACTIONS_FULL,
     	}
     }
     
    @@ -112,8 +108,8 @@ type listReactionsResponseFull struct {
     			Reactions []ItemReaction
     		} `json:"comment"`
     	}
    -	Paging `json:"paging"`
     	SlackResponse
    +	ResponseMetadata `json:"response_metadata"`
     }
     
     func (res listReactionsResponseFull) extractReactedItems() []ReactedItem {
    @@ -253,13 +249,13 @@ func (api *Client) GetReactionsContext(ctx context.Context, item ItemRef, params
     
     // ListReactions returns information about the items a user reacted to.
     // For more details, see ListReactionsContext documentation.
    -func (api *Client) ListReactions(params ListReactionsParameters) ([]ReactedItem, *Paging, error) {
    +func (api *Client) ListReactions(params ListReactionsParameters) ([]ReactedItem, string, error) {
     	return api.ListReactionsContext(context.Background(), params)
     }
     
     // ListReactionsContext returns information about the items a user reacted to with a custom context.
     // Slack API docs: https://api.slack.com/methods/reactions.list
    -func (api *Client) ListReactionsContext(ctx context.Context, params ListReactionsParameters) ([]ReactedItem, *Paging, error) {
    +func (api *Client) ListReactionsContext(ctx context.Context, params ListReactionsParameters) ([]ReactedItem, string, error) {
     	values := url.Values{
     		"token": {api.token},
     	}
    @@ -269,11 +265,11 @@ func (api *Client) ListReactionsContext(ctx context.Context, params ListReaction
     	if params.TeamID != "" {
     		values.Add("team_id", params.TeamID)
     	}
    -	if params.Count != DEFAULT_REACTIONS_COUNT {
    -		values.Add("count", strconv.Itoa(params.Count))
    +	if params.Cursor != "" {
    +		values.Add("cursor", params.Cursor)
     	}
    -	if params.Page != DEFAULT_REACTIONS_PAGE {
    -		values.Add("page", strconv.Itoa(params.Page))
    +	if params.Limit != 0 {
    +		values.Add("limit", strconv.Itoa(params.Limit))
     	}
     	if params.Full {
     		values.Add("full", strconv.FormatBool(params.Full))
    @@ -282,12 +278,12 @@ func (api *Client) ListReactionsContext(ctx context.Context, params ListReaction
     	response := &listReactionsResponseFull{}
     	err := api.postMethod(ctx, "reactions.list", values, response)
     	if err != nil {
    -		return nil, nil, err
    +		return nil, "", err
     	}
     
     	if err := response.Err(); err != nil {
    -		return nil, nil, err
    +		return nil, "", err
     	}
     
    -	return response.extractReactedItems(), &response.Paging, nil
    +	return response.extractReactedItems(), response.ResponseMetadata.Cursor, nil
     }
    diff --git a/reactions_test.go b/reactions_test.go
    index 9cec7e565..57eece5d3 100644
    --- a/reactions_test.go
    +++ b/reactions_test.go
    @@ -27,12 +27,12 @@ func (rh *reactionsHandler) accumulateFormValue(k string, r *http.Request) {
     
     func (rh *reactionsHandler) handler(w http.ResponseWriter, r *http.Request) {
     	rh.accumulateFormValue("channel", r)
    -	rh.accumulateFormValue("count", r)
    +	rh.accumulateFormValue("cursor", r)
     	rh.accumulateFormValue("file", r)
     	rh.accumulateFormValue("file_comment", r)
     	rh.accumulateFormValue("full", r)
    +	rh.accumulateFormValue("limit", r)
     	rh.accumulateFormValue("name", r)
    -	rh.accumulateFormValue("page", r)
     	rh.accumulateFormValue("timestamp", r)
     	rh.accumulateFormValue("user", r)
     	w.Header().Set("Content-Type", "application/json")
    @@ -429,11 +429,8 @@ func TestSlack_ListReactions(t *testing.T) {
                 }
             }
         ],
    -    "paging": {
    -        "count": 100,
    -        "total": 4,
    -        "page": 1,
    -        "pages": 1
    +    "response_metadata": {
    +        "next_cursor": "dXNlcjpVMDYxTkZUVDI="
         }}`
     	want := []ReactedItem{
     		{
    @@ -463,17 +460,18 @@ func TestSlack_ListReactions(t *testing.T) {
     		},
     	}
     	wantParams := map[string]string{
    -		"user":  "User",
    -		"count": "200",
    -		"page":  "2",
    -		"full":  "true",
    +		"user":   "User",
    +		"cursor": "somecursor",
    +		"limit":  "200",
    +		"full":   "true",
     	}
    +	wantCursor := "dXNlcjpVMDYxTkZUVDI="
     	params := NewListReactionsParameters()
     	params.User = "User"
    -	params.Count = 200
    -	params.Page = 2
    +	params.Cursor = "somecursor"
    +	params.Limit = 200
     	params.Full = true
    -	got, paging, err := api.ListReactions(params)
    +	got, nextCursor, err := api.ListReactions(params)
     	if err != nil {
     		t.Fatalf("Unexpected error: %s", err)
     	}
    @@ -490,7 +488,7 @@ func TestSlack_ListReactions(t *testing.T) {
     	if !reflect.DeepEqual(rh.gotParams, wantParams) {
     		t.Errorf("Got params %#v, want %#v", rh.gotParams, wantParams)
     	}
    -	if reflect.DeepEqual(paging, Paging{}) {
    -		t.Errorf("Want paging data, got empty struct")
    +	if nextCursor != wantCursor {
    +		t.Errorf("Got cursor %q, want %q", nextCursor, wantCursor)
     	}
     }
    
    From 5025544208a438a342e73fced43c195b0f869818 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Mon, 30 Mar 2026 22:21:33 +0200
    Subject: [PATCH 510/606] fix(stars)!: move stars.list to use cursor/limit
    
    - count/page are dead: even though slack accepts the params, it kinda ignores
      them (afaict)
    - no paging element in response
    - responses only seem to return response_metadata
    ---
     CHANGELOG.md            | 22 +++++++++++++++
     examples/stars/stars.go | 20 ++++++++++----
     stars.go                | 60 ++++++++++++++++++++---------------------
     stars_test.go           | 35 ++++++++++++------------
     4 files changed, 84 insertions(+), 53 deletions(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index d39223cb5..0a431e9f3 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -84,6 +84,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
       > // Use nextCursor for the next page: params.Cursor = nextCursor
       > ```
     
    +- **`ListStars`/`GetStarred` now use cursor-based pagination** — `StarsParameters` replaces
    +  `Count`/`Page` with `Cursor`/`Limit` (and adds `TeamID`), and `ListStars`/`ListStarsContext`/
    +  `GetStarred`/`GetStarredContext` now return `string` (next cursor) instead of `*Paging`.
    +  Slack's `stars.list` API no longer returns `paging` data — only `response_metadata.next_cursor`.
    +
    +  > [!WARNING]
    +  > **Breaking change.** Both the parameters and return signature have changed:
    +  >
    +  > ```go
    +  > // Before
    +  > params := slack.NewStarsParameters()
    +  > params.Count = 100
    +  > params.Page = 2
    +  > items, paging, err := api.ListStars(params)
    +  >
    +  > // After
    +  > params := slack.NewStarsParameters()
    +  > params.Limit = 100
    +  > items, nextCursor, err := api.ListStars(params)
    +  > // Use nextCursor for the next page: params.Cursor = nextCursor
    +  > ```
    +
     ### Fixed
     
     - **`WorkflowButtonBlockElement` missing from `UnmarshalJSON`** — `workflow_button` blocks
    diff --git a/examples/stars/stars.go b/examples/stars/stars.go
    index cc3f9bacb..72f615b5c 100644
    --- a/examples/stars/stars.go
    +++ b/examples/stars/stars.go
    @@ -3,27 +3,37 @@ package main
     import (
     	"flag"
     	"fmt"
    +	"os"
     
     	"github.com/slack-go/slack"
     )
     
     func main() {
     	var (
    -		apiToken string
    -		debug    bool
    +		debug bool
    +		team  string
     	)
     
    -	flag.StringVar(&apiToken, "token", "YOUR_TOKEN_HERE", "Your Slack API Token")
    +	// Get token from environment variable
    +	apiToken := os.Getenv("SLACK_USER_TOKEN")
    +	if apiToken == "" {
    +		fmt.Println("SLACK_USER_TOKEN environment variable is required")
    +		os.Exit(1)
    +	}
    +
     	flag.BoolVar(&debug, "debug", false, "Show JSON output")
    +	flag.StringVar(&team, "team", "", "Team ID (required for Enterprise Grid)")
     	flag.Parse()
     
     	api := slack.New(apiToken, slack.OptionDebug(debug))
     
    -	// Get all stars for the usr.
    +	// Get all stars for the user.
     	params := slack.NewStarsParameters()
    +	params.TeamID = team
    +
     	starredItems, _, err := api.GetStarred(params)
     	if err != nil {
    -		fmt.Printf("Error getting stars: %s\n", err)
    +		fmt.Printf("Error getting stars: %v\n", err)
     		return
     	}
     	for _, s := range starredItems {
    diff --git a/stars.go b/stars.go
    index 51926854e..0adb28c53 100644
    --- a/stars.go
    +++ b/stars.go
    @@ -8,31 +8,28 @@ import (
     )
     
     const (
    -	DEFAULT_STARS_USER  = ""
    -	DEFAULT_STARS_COUNT = 100
    -	DEFAULT_STARS_PAGE  = 1
    +	DEFAULT_STARS_USER = ""
     )
     
     type StarsParameters struct {
    -	User  string
    -	Count int
    -	Page  int
    +	User   string
    +	Cursor string
    +	Limit  int
    +	TeamID string
     }
     
     type StarredItem Item
     
     type listResponseFull struct {
    -	Items  []Item `json:"items"`
    -	Paging `json:"paging"`
    +	Items []Item `json:"items"`
     	SlackResponse
    +	ResponseMetadata `json:"response_metadata"`
     }
     
     // NewStarsParameters initialises StarsParameters with default values
     func NewStarsParameters() StarsParameters {
     	return StarsParameters{
    -		User:  DEFAULT_STARS_USER,
    -		Count: DEFAULT_STARS_COUNT,
    -		Page:  DEFAULT_STARS_PAGE,
    +		User: DEFAULT_STARS_USER,
     	}
     }
     
    @@ -100,37 +97,40 @@ func (api *Client) RemoveStarContext(ctx context.Context, channel string, item I
     
     // ListStars returns information about the stars a user added.
     // For more information see the ListStarsContext documentation.
    -func (api *Client) ListStars(params StarsParameters) ([]Item, *Paging, error) {
    +func (api *Client) ListStars(params StarsParameters) ([]Item, string, error) {
     	return api.ListStarsContext(context.Background(), params)
     }
     
     // ListStarsContext returns information about the stars a user added with a custom context.
     // Slack API docs: https://api.slack.com/methods/stars.list
    -func (api *Client) ListStarsContext(ctx context.Context, params StarsParameters) ([]Item, *Paging, error) {
    +func (api *Client) ListStarsContext(ctx context.Context, params StarsParameters) ([]Item, string, error) {
     	values := url.Values{
     		"token": {api.token},
     	}
     	if params.User != DEFAULT_STARS_USER {
     		values.Add("user", params.User)
     	}
    -	if params.Count != DEFAULT_STARS_COUNT {
    -		values.Add("count", strconv.Itoa(params.Count))
    +	if params.Cursor != "" {
    +		values.Add("cursor", params.Cursor)
    +	}
    +	if params.Limit != 0 {
    +		values.Add("limit", strconv.Itoa(params.Limit))
     	}
    -	if params.Page != DEFAULT_STARS_PAGE {
    -		values.Add("page", strconv.Itoa(params.Page))
    +	if params.TeamID != "" {
    +		values.Add("team_id", params.TeamID)
     	}
     
     	response := &listResponseFull{}
     	err := api.postMethod(ctx, "stars.list", values, response)
     	if err != nil {
    -		return nil, nil, err
    +		return nil, "", err
     	}
     
     	if err := response.Err(); err != nil {
    -		return nil, nil, err
    +		return nil, "", err
     	}
     
    -	return response.Items, &response.Paging, nil
    +	return response.Items, response.ResponseMetadata.Cursor, nil
     }
     
     // GetStarred returns a list of StarredItem items.
    @@ -139,31 +139,31 @@ func (api *Client) ListStarsContext(ctx context.Context, params StarsParameters)
     // be looking at according to what is in the Type:
     //
     //	for _, item := range items {
    -//	    switch c.Type {
    -//	    case "file_comment":
    -//	        log.Println(c.Comment)
    -//	    case "file":
    -//	        ...
    +//		switch c.Type {
    +//		case "file_comment":
    +//			log.Println(c.Comment)
    +//		case "file":
    +//			...
     //	}
     //
     // This function still exists to maintain backwards compatibility.
     // I exposed it as returning []StarredItem, so it shall stay as StarredItem.
    -func (api *Client) GetStarred(params StarsParameters) ([]StarredItem, *Paging, error) {
    +func (api *Client) GetStarred(params StarsParameters) ([]StarredItem, string, error) {
     	return api.GetStarredContext(context.Background(), params)
     }
     
     // GetStarredContext returns a list of StarredItem items with a custom context
     // For more details see GetStarred
    -func (api *Client) GetStarredContext(ctx context.Context, params StarsParameters) ([]StarredItem, *Paging, error) {
    -	items, paging, err := api.ListStarsContext(ctx, params)
    +func (api *Client) GetStarredContext(ctx context.Context, params StarsParameters) ([]StarredItem, string, error) {
    +	items, nextCursor, err := api.ListStarsContext(ctx, params)
     	if err != nil {
    -		return nil, nil, err
    +		return nil, "", err
     	}
     	starredItems := make([]StarredItem, len(items))
     	for i, item := range items {
     		starredItems[i] = StarredItem(item)
     	}
    -	return starredItems, paging, nil
    +	return starredItems, nextCursor, nil
     }
     
     type listResponsePaginated struct {
    diff --git a/stars_test.go b/stars_test.go
    index 53f0e28bc..1b53a7a26 100644
    --- a/stars_test.go
    +++ b/stars_test.go
    @@ -28,10 +28,13 @@ func (sh *starsHandler) accumulateFormValue(k string, r *http.Request) {
     func (sh *starsHandler) handler(w http.ResponseWriter, r *http.Request) {
     	sh.accumulateFormValue("user", r)
     	sh.accumulateFormValue("count", r)
    +	sh.accumulateFormValue("cursor", r)
     	sh.accumulateFormValue("channel", r)
     	sh.accumulateFormValue("file", r)
     	sh.accumulateFormValue("file_comment", r)
    +	sh.accumulateFormValue("limit", r)
     	sh.accumulateFormValue("page", r)
    +	sh.accumulateFormValue("team_id", r)
     	sh.accumulateFormValue("timestamp", r)
     	w.Header().Set("Content-Type", "application/json")
     	w.Write([]byte(sh.response))
    @@ -187,11 +190,8 @@ func TestSlack_ListStars(t *testing.T) {
                 }
             }
         ],
    -    "paging": {
    -        "count": 100,
    -        "total": 4,
    -        "page": 1,
    -        "pages": 1
    +    "response_metadata": {
    +        "next_cursor": "dXNlcjpVMDYxTkZUVDI="
         }}`
     	want := []Item{
     		NewMessageItem("C1", &Message{Msg: Msg{
    @@ -209,13 +209,14 @@ func TestSlack_ListStars(t *testing.T) {
     		wantStarred[i] = StarredItem(item)
     	}
     	wantParams := map[string]string{
    -		"count": "200",
    -		"page":  "2",
    +		"cursor": "somecursor",
    +		"limit":  "200",
     	}
    +	wantCursor := "dXNlcjpVMDYxTkZUVDI="
     	params := NewStarsParameters()
    -	params.Count = 200
    -	params.Page = 2
    -	got, paging, err := api.ListStars(params)
    +	params.Cursor = "somecursor"
    +	params.Limit = 200
    +	got, nextCursor, err := api.ListStars(params)
     	if err != nil {
     		t.Fatalf("Unexpected error: %s", err)
     	}
    @@ -231,11 +232,12 @@ func TestSlack_ListStars(t *testing.T) {
     	if !reflect.DeepEqual(rh.gotParams, wantParams) {
     		t.Errorf("Got params %#v, want %#v", rh.gotParams, wantParams)
     	}
    -	if reflect.DeepEqual(paging, Paging{}) {
    -		t.Errorf("Want paging data, got empty struct")
    +	if nextCursor != wantCursor {
    +		t.Errorf("Got cursor %q, want %q", nextCursor, wantCursor)
     	}
     	// Test GetStarred
    -	gotStarred, paging, err := api.GetStarred(params)
    +	rh.gotParams = make(map[string]string) // reset
    +	gotStarred, nextCursor, err := api.GetStarred(params)
     	if err != nil {
     		t.Fatalf("Unexpected error: %s", err)
     	}
    @@ -248,10 +250,7 @@ func TestSlack_ListStars(t *testing.T) {
     			fmt.Printf("Comment  %#v\n", item.Comment)
     		}
     	}
    -	if !reflect.DeepEqual(rh.gotParams, wantParams) {
    -		t.Errorf("Got params %#v, want %#v", rh.gotParams, wantParams)
    -	}
    -	if reflect.DeepEqual(paging, Paging{}) {
    -		t.Errorf("Want paging data, got empty struct")
    +	if nextCursor != wantCursor {
    +		t.Errorf("Got cursor %q, want %q", nextCursor, wantCursor)
     	}
     }
    
    From 8497ee811f15efc583bc113f340317c301d17f50 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Mon, 30 Mar 2026 23:12:40 +0200
    Subject: [PATCH 511/606] fix(team)!: move team.accessLogs to use cursor/limit
    
    ---
     CHANGELOG.md | 23 +++++++++++++++++++++++
     team.go      | 36 ++++++++++++++++--------------------
     team_test.go | 27 +++++++++------------------
     3 files changed, 48 insertions(+), 38 deletions(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 0a431e9f3..1f10ae2ed 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -106,6 +106,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
       > // Use nextCursor for the next page: params.Cursor = nextCursor
       > ```
     
    +- **`GetAccessLogs` now uses cursor-based pagination** — `AccessLogParameters` replaces
    +  `Count`/`Page` with `Cursor`/`Limit` (and adds `Before`), and `GetAccessLogs`/
    +  `GetAccessLogsContext` now return `string` (next cursor) instead of `*Paging`.
    +  Slack's `team.accessLogs` API warns `use_cursor_pagination_instead` when using the old
    +  parameters.
    +
    +  > [!WARNING]
    +  > **Breaking change.** Both the parameters and return signature have changed:
    +  >
    +  > ```go
    +  > // Before
    +  > params := slack.NewAccessLogParameters()
    +  > params.Count = 100
    +  > params.Page = 2
    +  > logins, paging, err := api.GetAccessLogs(params)
    +  >
    +  > // After
    +  > params := slack.NewAccessLogParameters()
    +  > params.Limit = 100
    +  > logins, nextCursor, err := api.GetAccessLogs(params)
    +  > // Use nextCursor for the next page: params.Cursor = nextCursor
    +  > ```
    +
     ### Fixed
     
     - **`WorkflowButtonBlockElement` missing from `UnmarshalJSON`** — `workflow_button` blocks
    diff --git a/team.go b/team.go
    index 35b699271..55364a9b4 100644
    --- a/team.go
    +++ b/team.go
    @@ -6,11 +6,6 @@ import (
     	"strconv"
     )
     
    -const (
    -	DEFAULT_LOGINS_COUNT = 100
    -	DEFAULT_LOGINS_PAGE  = 1
    -)
    -
     type TeamResponse struct {
     	Team TeamInfo `json:"team"`
     	SlackResponse
    @@ -46,8 +41,8 @@ type TeamProfileField struct {
     
     type LoginResponse struct {
     	Logins []Login `json:"logins"`
    -	Paging `json:"paging"`
     	SlackResponse
    +	ResponseMetadata `json:"response_metadata"`
     }
     
     type Login struct {
    @@ -75,16 +70,14 @@ type BillingActive struct {
     // AccessLogParameters contains all the parameters necessary (including the optional ones) for a GetAccessLogs() request
     type AccessLogParameters struct {
     	TeamID string
    -	Count  int
    -	Page   int
    +	Cursor string
    +	Limit  int
    +	Before int
     }
     
     // NewAccessLogParameters provides an instance of AccessLogParameters with all the sane default values set
     func NewAccessLogParameters() AccessLogParameters {
    -	return AccessLogParameters{
    -		Count: DEFAULT_LOGINS_COUNT,
    -		Page:  DEFAULT_LOGINS_PAGE,
    -	}
    +	return AccessLogParameters{}
     }
     
     func (api *Client) teamRequest(ctx context.Context, path string, values url.Values) (*TeamResponse, error) {
    @@ -193,31 +186,34 @@ func (api *Client) GetTeamProfileContext(ctx context.Context, teamID ...string)
     
     // GetAccessLogs retrieves a page of logins according to the parameters given.
     // For more information see the GetAccessLogsContext documentation.
    -func (api *Client) GetAccessLogs(params AccessLogParameters) ([]Login, *Paging, error) {
    +func (api *Client) GetAccessLogs(params AccessLogParameters) ([]Login, string, error) {
     	return api.GetAccessLogsContext(context.Background(), params)
     }
     
     // GetAccessLogsContext retrieves a page of logins according to the parameters given with a custom context.
     // Slack API docs: https://api.slack.com/methods/team.accessLogs
    -func (api *Client) GetAccessLogsContext(ctx context.Context, params AccessLogParameters) ([]Login, *Paging, error) {
    +func (api *Client) GetAccessLogsContext(ctx context.Context, params AccessLogParameters) ([]Login, string, error) {
     	values := url.Values{
     		"token": {api.token},
     	}
     	if params.TeamID != "" {
     		values.Add("team_id", params.TeamID)
     	}
    -	if params.Count != DEFAULT_LOGINS_COUNT {
    -		values.Add("count", strconv.Itoa(params.Count))
    +	if params.Cursor != "" {
    +		values.Add("cursor", params.Cursor)
    +	}
    +	if params.Limit != 0 {
    +		values.Add("limit", strconv.Itoa(params.Limit))
     	}
    -	if params.Page != DEFAULT_LOGINS_PAGE {
    -		values.Add("page", strconv.Itoa(params.Page))
    +	if params.Before != 0 {
    +		values.Add("before", strconv.Itoa(params.Before))
     	}
     
     	response, err := api.accessLogsRequest(ctx, "team.accessLogs", values)
     	if err != nil {
    -		return nil, nil, err
    +		return nil, "", err
     	}
    -	return response.Logins, &response.Paging, nil
    +	return response.Logins, response.ResponseMetadata.Cursor, nil
     }
     
     type GetBillableInfoParams struct {
    diff --git a/team_test.go b/team_test.go
    index d0055d177..22186d361 100644
    --- a/team_test.go
    +++ b/team_test.go
    @@ -150,11 +150,8 @@ func getTeamAccessLogs(rw http.ResponseWriter, r *http.Request) {
                             "country": null,
                             "region": null
                             }],
    -                        "paging": {
    -    			"count": 2,
    -    			"total": 2,
    -    			"page": 1,
    -    			"pages": 1
    +                        "response_metadata": {
    +    			"next_cursor": "dGVhbV9pZDo5MDAwMTcw"
         			}
       }`)
     	rw.Write(response)
    @@ -166,7 +163,10 @@ func TestGetAccessLogs(t *testing.T) {
     	once.Do(startServer)
     	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
     
    -	logins, paging, err := api.GetAccessLogs(NewAccessLogParameters())
    +	params := NewAccessLogParameters()
    +	params.Limit = 2
    +	params.TeamID = "T12345"
    +	logins, nextCursor, err := api.GetAccessLogs(params)
     	if err != nil {
     		t.Errorf("Unexpected error: %s", err)
     		return
    @@ -222,17 +222,8 @@ func TestGetAccessLogs(t *testing.T) {
     		t.Fatal(ErrIncorrectResponse)
     	}
     
    -	// test the paging
    -	if paging.Count != 2 {
    -		t.Fatal(ErrIncorrectResponse)
    -	}
    -	if paging.Total != 2 {
    -		t.Fatal(ErrIncorrectResponse)
    -	}
    -	if paging.Page != 1 {
    -		t.Fatal(ErrIncorrectResponse)
    -	}
    -	if paging.Pages != 1 {
    -		t.Fatal(ErrIncorrectResponse)
    +	// test the cursor
    +	if nextCursor != "dGVhbV9pZDo5MDAwMTcw" {
    +		t.Fatalf("Expected cursor %q, got %q", "dGVhbV9pZDo5MDAwMTcw", nextCursor)
     	}
     }
    
    From 6ea0678ab2dbb712742cca53f754731bc0a0b34a Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Mon, 30 Mar 2026 23:28:47 +0200
    Subject: [PATCH 512/606] feat(websocket): support user_*_changed events
    
    Closes #1541.
    ---
     CHANGELOG.md                   |  7 +++++++
     websocket_managed_conn.go      |  5 ++++-
     websocket_managed_conn_test.go |  2 +-
     websocket_misc.go              | 30 ++++++++++++++++++++++++++++--
     4 files changed, 40 insertions(+), 4 deletions(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 1f10ae2ed..9d4761bc1 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ### Added
     
    +- **RTM support for `user_status_changed`, `user_huddle_changed`, `user_profile_changed`
    +  events** — these events are now mapped in `EventMapping` with dedicated structs
    +  (`UserStatusChangedEvent`, `UserHuddleChangedEvent`, `UserProfileChangedEvent`).
    +  Previously they triggered `UnmarshallingErrorEvent`. ([#1541])
    +- **`CacheTS` and `EventTS` fields on `UserChangeEvent`** — these fields were sent by Slack
    +  but silently dropped during unmarshalling.
     - **`workflows.featured` API support** — add, list, remove, and set featured workflows on
       channels via `WorkflowsFeaturedAdd`, `WorkflowsFeaturedList`, `WorkflowsFeaturedRemove`,
       and `WorkflowsFeaturedSet`
    @@ -326,6 +332,7 @@ for details.
     [#1533]: https://github.com/slack-go/slack/pull/1533
     [#1534]: https://github.com/slack-go/slack/pull/1534
     [#1536]: https://github.com/slack-go/slack/pull/1536
    +[#1541]: https://github.com/slack-go/slack/issues/1541
     
     [Unreleased]: https://github.com/slack-go/slack/compare/v0.20.0...HEAD
     [0.20.0]: https://github.com/slack-go/slack/compare/v0.19.0...v0.20.0
    diff --git a/websocket_managed_conn.go b/websocket_managed_conn.go
    index f107b2a47..55c35d1a8 100644
    --- a/websocket_managed_conn.go
    +++ b/websocket_managed_conn.go
    @@ -582,7 +582,10 @@ var EventMapping = map[string]interface{}{
     
     	"manual_presence_change": ManualPresenceChangeEvent{},
     
    -	"user_change": UserChangeEvent{},
    +	"user_change":          UserChangeEvent{},
    +	"user_status_changed":  UserStatusChangedEvent{},
    +	"user_huddle_changed":  UserHuddleChangedEvent{},
    +	"user_profile_changed": UserProfileChangedEvent{},
     
     	"emoji_changed": EmojiChangedEvent{},
     
    diff --git a/websocket_managed_conn_test.go b/websocket_managed_conn_test.go
    index c7a8b7034..efbbe97ec 100644
    --- a/websocket_managed_conn_test.go
    +++ b/websocket_managed_conn_test.go
    @@ -317,7 +317,7 @@ func TestRTMSingleConnect(t *testing.T) {
     }
     
     func TestRTMUnmappedError(t *testing.T) {
    -	const unmappedEventName = "user_status_changed"
    +	const unmappedEventName = "some_unknown_event"
     	// Set up the test server.
     	testServer := slacktest.NewTestServer()
     	go testServer.Start()
    diff --git a/websocket_misc.go b/websocket_misc.go
    index 65a8bb65d..69e63ef15 100644
    --- a/websocket_misc.go
    +++ b/websocket_misc.go
    @@ -71,8 +71,34 @@ type ManualPresenceChangeEvent struct {
     
     // UserChangeEvent represents the user change event
     type UserChangeEvent struct {
    -	Type string `json:"type"`
    -	User User   `json:"user"`
    +	Type    string `json:"type"`
    +	User    User   `json:"user"`
    +	CacheTS int64  `json:"cache_ts"`
    +	EventTS string `json:"event_ts"`
    +}
    +
    +// UserStatusChangedEvent represents the user status changed event
    +type UserStatusChangedEvent struct {
    +	Type    string `json:"type"`
    +	User    User   `json:"user"`
    +	CacheTS int64  `json:"cache_ts"`
    +	EventTS string `json:"event_ts"`
    +}
    +
    +// UserHuddleChangedEvent represents the user huddle changed event
    +type UserHuddleChangedEvent struct {
    +	Type    string `json:"type"`
    +	User    User   `json:"user"`
    +	CacheTS int64  `json:"cache_ts"`
    +	EventTS string `json:"event_ts"`
    +}
    +
    +// UserProfileChangedEvent represents the user profile changed event
    +type UserProfileChangedEvent struct {
    +	Type    string `json:"type"`
    +	User    User   `json:"user"`
    +	CacheTS int64  `json:"cache_ts"`
    +	EventTS string `json:"event_ts"`
     }
     
     // EmojiChangedEvent represents the emoji changed event
    
    From 2b9b0ccc55bf6df502c4c7c30c5d2a10d3f1de33 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Fri, 3 Apr 2026 15:35:16 +0200
    Subject: [PATCH 513/606] fix(chat)!: allow `MsgOptionBlocks` to send empty
     blocks array
    
    Calling `MsgOptionBlocks()` without arguments or a nil slice used to be a no-op. Which
    meant that if you called the function in that way the blocks parameter never got sent,
    even thought that's probably what you intended.
    
    Slack's `chat.update` API requires an exlicit `blocks=[]` to reliably remove blocks from a
    message. This commit coerces nil to an empty slice therefore calling `MsgOptionBlocks`
    without parameters always sets the blocks value (to an empty array).
    
    BREAKING CHANGE: if you were calling `MsgOptionBlocks()` without arguments, know that it
    will now clear all the blocks! Instead, you shouldn't pass it as an option.
    
    Fixes #1214.
    ---
     chat.go      |  6 ++++--
     chat_test.go | 26 ++++++++++++++++++++++++++
     2 files changed, 30 insertions(+), 2 deletions(-)
    
    diff --git a/chat.go b/chat.go
    index f1832d9a0..209b2010f 100644
    --- a/chat.go
    +++ b/chat.go
    @@ -683,11 +683,13 @@ func MsgOptionAttachments(attachments ...Attachment) MsgOption {
     	}
     }
     
    -// MsgOptionBlocks sets blocks for the message
    +// MsgOptionBlocks sets blocks for the message.
    +// Calling with no arguments or an empty slice sends "blocks=[]" to clear blocks.
    +// To skip setting blocks entirely, do not include this option.
     func MsgOptionBlocks(blocks ...Block) MsgOption {
     	return func(config *sendConfig) error {
     		if blocks == nil {
    -			return nil
    +			blocks = []Block{}
     		}
     
     		config.blocks.BlockSet = append(config.blocks.BlockSet, blocks...)
    diff --git a/chat_test.go b/chat_test.go
    index ac4960542..defdd97af 100644
    --- a/chat_test.go
    +++ b/chat_test.go
    @@ -105,6 +105,32 @@ func TestPostMessage(t *testing.T) {
     				"token":   []string{"testing-token"},
     			},
     		},
    +		"EmptyBlocksExplicit": {
    +			endpoint: "/chat.postMessage",
    +			opt: []MsgOption{
    +				MsgOptionBlocks([]Block{}...),
    +				MsgOptionText("text only", false),
    +			},
    +			expected: url.Values{
    +				"blocks":  []string{"[]"},
    +				"channel": []string{"CXXX"},
    +				"text":    []string{"text only"},
    +				"token":   []string{"testing-token"},
    +			},
    +		},
    +		"EmptyBlocksNoArgs": {
    +			endpoint: "/chat.postMessage",
    +			opt: []MsgOption{
    +				MsgOptionBlocks(),
    +				MsgOptionText("text only", false),
    +			},
    +			expected: url.Values{
    +				"blocks":  []string{"[]"},
    +				"channel": []string{"CXXX"},
    +				"text":    []string{"text only"},
    +				"token":   []string{"testing-token"},
    +			},
    +		},
     		"Attachment": {
     			endpoint: "/chat.postMessage",
     			opt: []MsgOption{
    
    From 53857701c5b110e162d636817d5143d6102c9a03 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Fri, 3 Apr 2026 15:51:38 +0200
    Subject: [PATCH 514/606] chore: update changelog
    
    ---
     CHANGELOG.md | 20 ++++++++++++++++++++
     1 file changed, 20 insertions(+)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 9d4761bc1..92e75e405 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ### Added
     
    +- **API warning callbacks** — Slack API responses may include a `warnings` field with
    +  deprecation notices or usage hints. Use `OptionWarnings(func(warnings []string))` to
    +  register a callback that receives these warnings. ([#1540])
     - **RTM support for `user_status_changed`, `user_huddle_changed`, `user_profile_changed`
       events** — these events are now mapped in `EventMapping` with dedicated structs
       (`UserStatusChangedEvent`, `UserHuddleChangedEvent`, `UserProfileChangedEvent`).
    @@ -137,6 +140,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ### Fixed
     
    +- **`MsgOptionBlocks()` with no arguments now sends `blocks=[]`** — Previously, calling
    +  `MsgOptionBlocks()` with no arguments or a nil spread was a silent no-op, making it
    +  impossible to clear blocks from a message via `chat.update`. The Slack API requires an
    +  explicit `blocks=[]` to reliably remove blocks. ([#1214])
    +
    +  > [!WARNING]
    +  > **Breaking change.** `MsgOptionBlocks()` with no arguments now sends `blocks=[]` instead
    +  > of being a no-op. If you were relying on this to be a no-op, remove the option entirely:
    +  >
    +  > ```go
    +  > // Before — this was a no-op, now it sends blocks=[]
    +  > api.PostMessage(ch, slack.MsgOptionBlocks(), slack.MsgOptionText("text", false))
    +  >
    +  > // After — omit MsgOptionBlocks entirely to not set blocks
    +  > api.PostMessage(ch, slack.MsgOptionText("text", false))
    +  > ```
    +
     - **`WorkflowButtonBlockElement` missing from `UnmarshalJSON`** — `workflow_button` blocks
       now unmarshal correctly through `BlockElements`, `InputBlock`, and `Accessory` paths.
       Also adds missing `multi_*_select` and `file_input` cases to `BlockElements.UnmarshalJSON`,
    
    From 5591f10e6dbbe76b5135e896b126402169b6a8e7 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Fri, 3 Apr 2026 18:02:03 +0200
    Subject: [PATCH 515/606] fix(slackevents): deprecate ParseActionEvent
    
    Added documentation on different approaches as well and marked functions and structs as
    deprecated.
    
    Closes #596.
    ---
     CHANGELOG.md                        |  10 ++
     examples/parse_action_event/main.go | 197 ++++++++++++++++++++++++++++
     slackevents/action_events.go        |   7 +
     slackevents/parsers.go              |  16 +++
     4 files changed, 230 insertions(+)
     create mode 100644 examples/parse_action_event/main.go
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 92e75e405..c6c6b7187 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -138,6 +138,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
       > // Use nextCursor for the next page: params.Cursor = nextCursor
       > ```
     
    +### Deprecated
    +
    +- **`slackevents.ParseActionEvent`** — Cannot parse `block_actions` payloads (returns
    +  unmarshalling error). Use `slack.InteractionCallback` with `json.Unmarshal` instead,
    +  or `slack.InteractionCallbackParse` for HTTP requests. `InteractionCallback` handles
    +  all interaction types. ([#596])
    +- **`slackevents.MessageAction`**, **`MessageActionEntity`**, **`MessageActionResponse`** —
    +  Associated types that only support legacy `interactive_message` payloads.
    +
     ### Fixed
     
     - **`MsgOptionBlocks()` with no arguments now sends `blocks=[]`** — Previously, calling
    @@ -352,6 +361,7 @@ for details.
     [#1533]: https://github.com/slack-go/slack/pull/1533
     [#1534]: https://github.com/slack-go/slack/pull/1534
     [#1536]: https://github.com/slack-go/slack/pull/1536
    +[#596]: https://github.com/slack-go/slack/issues/596
     [#1541]: https://github.com/slack-go/slack/issues/1541
     
     [Unreleased]: https://github.com/slack-go/slack/compare/v0.20.0...HEAD
    diff --git a/examples/parse_action_event/main.go b/examples/parse_action_event/main.go
    new file mode 100644
    index 000000000..ac20d6f9a
    --- /dev/null
    +++ b/examples/parse_action_event/main.go
    @@ -0,0 +1,197 @@
    +// This example shows how to handle both Events API and Interactions API
    +// using HTTP endpoints. It listens for messages (Events API), replies with
    +// a button (Block Kit), and handles the button click (Interactions API).
    +//
    +// This is also a migration guide for users of slackevents.ParseActionEvent,
    +// which is deprecated because it cannot parse block_actions payloads. The
    +// correct approach is to use slack.InteractionCallback directly:
    +//
    +//	// Before (broken for block_actions):
    +//	action, err := slackevents.ParseActionEvent(payload, slackevents.OptionNoVerifyToken())
    +//
    +//	// After (handles all interaction types):
    +//	var ic slack.InteractionCallback
    +//	err := json.Unmarshal([]byte(payload), &ic)
    +//	// Use ic.ActionCallback.BlockActions for block actions
    +//	// Use ic.ActionCallback.AttachmentActions for legacy attachment actions
    +//
    +// Note: block_actions are delivered to your Interactivity Request URL, not
    +// your Events API Request URL. These are two separate endpoints in your
    +// Slack app configuration.
    +//
    +// Setup:
    +//  1. export SLACK_BOT_TOKEN=xoxb-...
    +//  2. export SLACK_SIGNING_SECRET=...
    +//  3. go run ./examples/parse_action_event
    +//  4. Expose port 3000 with ngrok: ngrok http 3000
    +//  5. In your Slack app config:
    +//     - Events API Request URL:    https:///events
    +//     - Interactivity Request URL: https:///interactions
    +//     - Subscribe to bot events: message.channels (so the bot can post)
    +//     - Bot scopes: chat:write, channels:read
    +//  6. Invite the bot to a channel, then send any message — the bot will
    +//     reply with a message containing a block action button.
    +//  7. Click the button and watch the logs.
    +package main
    +
    +import (
    +	"encoding/json"
    +	"fmt"
    +	"io"
    +	"net/http"
    +	"net/url"
    +	"os"
    +
    +	"github.com/slack-go/slack"
    +	"github.com/slack-go/slack/slackevents"
    +)
    +
    +func main() {
    +	botToken := os.Getenv("SLACK_BOT_TOKEN")
    +	signingSecret := os.Getenv("SLACK_SIGNING_SECRET")
    +	if botToken == "" || signingSecret == "" {
    +		fmt.Fprintln(os.Stderr, "Set SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET")
    +		os.Exit(1)
    +	}
    +
    +	api := slack.New(botToken)
    +
    +	// Events API endpoint — receives subscription events (message, app_mention, etc.)
    +	http.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
    +		body, err := io.ReadAll(r.Body)
    +		if err != nil {
    +			w.WriteHeader(http.StatusBadRequest)
    +			return
    +		}
    +
    +		sv, err := slack.NewSecretsVerifier(r.Header, signingSecret)
    +		if err != nil {
    +			w.WriteHeader(http.StatusBadRequest)
    +			return
    +		}
    +		if _, err := sv.Write(body); err != nil {
    +			w.WriteHeader(http.StatusInternalServerError)
    +			return
    +		}
    +		if err := sv.Ensure(); err != nil {
    +			w.WriteHeader(http.StatusUnauthorized)
    +			return
    +		}
    +
    +		eventsAPIEvent, err := slackevents.ParseEvent(json.RawMessage(body), slackevents.OptionNoVerifyToken())
    +		if err != nil {
    +			fmt.Printf("[EVENTS] parse error: %v\n", err)
    +			w.WriteHeader(http.StatusInternalServerError)
    +			return
    +		}
    +
    +		fmt.Printf("[EVENTS] received type=%q\n", eventsAPIEvent.Type)
    +
    +		switch eventsAPIEvent.Type {
    +		case slackevents.URLVerification:
    +			var cr *slackevents.ChallengeResponse
    +			if err := json.Unmarshal(body, &cr); err != nil {
    +				w.WriteHeader(http.StatusInternalServerError)
    +				return
    +			}
    +			w.Header().Set("Content-Type", "text/plain")
    +			w.Write([]byte(cr.Challenge))
    +
    +		case slackevents.CallbackEvent:
    +			innerEvent := eventsAPIEvent.InnerEvent
    +			fmt.Printf("[EVENTS] inner type=%q\n", innerEvent.Type)
    +
    +			switch ev := innerEvent.Data.(type) {
    +			case *slackevents.MessageEvent:
    +				// Ignore bot messages to avoid loops.
    +				if ev.BotID != "" {
    +					return
    +				}
    +				fmt.Printf("[EVENTS] message from user %s: %q\n", ev.User, ev.Text)
    +
    +				// Reply with a message containing a block action button.
    +				_, _, err := api.PostMessage(ev.Channel, slack.MsgOptionBlocks(
    +					slack.NewSectionBlock(
    +						slack.NewTextBlockObject("mrkdwn", "Click the button to test block_actions delivery:", false, false),
    +						nil, nil,
    +					),
    +					slack.NewActionBlock("test_actions_block",
    +						slack.NewButtonBlockElement("test_button", "clicked",
    +							slack.NewTextBlockObject("plain_text", "Click me", false, false),
    +						),
    +					),
    +				))
    +				if err != nil {
    +					fmt.Printf("[EVENTS] PostMessage error: %v\n", err)
    +				}
    +			}
    +		}
    +	})
    +
    +	// Interactions endpoint — receives interactive callbacks (block_actions,
    +	// interactive_message, view_submission, etc.).
    +	//
    +	// If you were previously using slackevents.ParseActionEvent, this is the
    +	// correct replacement: unmarshal into slack.InteractionCallback and switch
    +	// on ic.Type.
    +	http.HandleFunc("/interactions", func(w http.ResponseWriter, r *http.Request) {
    +		body, err := io.ReadAll(r.Body)
    +		if err != nil {
    +			w.WriteHeader(http.StatusBadRequest)
    +			return
    +		}
    +
    +		sv, err := slack.NewSecretsVerifier(r.Header, signingSecret)
    +		if err != nil {
    +			w.WriteHeader(http.StatusBadRequest)
    +			return
    +		}
    +		if _, err := sv.Write(body); err != nil {
    +			w.WriteHeader(http.StatusInternalServerError)
    +			return
    +		}
    +		if err := sv.Ensure(); err != nil {
    +			w.WriteHeader(http.StatusUnauthorized)
    +			return
    +		}
    +
    +		// Interactions come as form-encoded with a "payload" field.
    +		payload, err := url.QueryUnescape(string(body))
    +		if err != nil {
    +			fmt.Printf("[INTERACTIONS] unescape error: %v\n", err)
    +			w.WriteHeader(http.StatusBadRequest)
    +			return
    +		}
    +		// Strip the "payload=" prefix.
    +		const prefix = "payload="
    +		if len(payload) > len(prefix) {
    +			payload = payload[len(prefix):]
    +		}
    +
    +		var ic slack.InteractionCallback
    +		if err := json.Unmarshal([]byte(payload), &ic); err != nil {
    +			fmt.Printf("[INTERACTIONS] parse error: %v\n", err)
    +			w.WriteHeader(http.StatusBadRequest)
    +			return
    +		}
    +
    +		fmt.Printf("[INTERACTIONS] type=%q callback_id=%q\n", ic.Type, ic.CallbackID)
    +		switch ic.Type {
    +		case slack.InteractionTypeBlockActions:
    +			for i, a := range ic.ActionCallback.BlockActions {
    +				fmt.Printf("[INTERACTIONS] block_action[%d]: action_id=%q block_id=%q type=%q value=%q\n",
    +					i, a.ActionID, a.BlockID, a.Type, a.Value)
    +			}
    +		case slack.InteractionTypeInteractionMessage:
    +			for i, a := range ic.ActionCallback.AttachmentActions {
    +				fmt.Printf("[INTERACTIONS] attachment_action[%d]: name=%q type=%q value=%q\n",
    +					i, a.Name, a.Type, a.Value)
    +			}
    +		}
    +	})
    +
    +	fmt.Println("[INFO] Listening on :3000")
    +	fmt.Println("[INFO] Events API:    http://localhost:3000/events")
    +	fmt.Println("[INFO] Interactions:  http://localhost:3000/interactions")
    +	http.ListenAndServe(":3000", nil)
    +}
    diff --git a/slackevents/action_events.go b/slackevents/action_events.go
    index c6016f107..e7e4ed105 100644
    --- a/slackevents/action_events.go
    +++ b/slackevents/action_events.go
    @@ -6,18 +6,25 @@ import (
     	"github.com/slack-go/slack"
     )
     
    +// Deprecated: MessageActionResponse is associated with [MessageAction] which cannot
    +// handle block_actions. Use [slack.InteractionCallback] instead.
     type MessageActionResponse struct {
     	ResponseType    string `json:"response_type"`
     	ReplaceOriginal bool   `json:"replace_original"`
     	Text            string `json:"text"`
     }
     
    +// Deprecated: MessageActionEntity is associated with [MessageAction] which cannot
    +// handle block_actions. Use [slack.InteractionCallback] instead.
     type MessageActionEntity struct {
     	ID     string `json:"id"`
     	Domain string `json:"domain"`
     	Name   string `json:"name"`
     }
     
    +// Deprecated: MessageAction cannot represent block_actions payloads. Use
    +// [slack.InteractionCallback] instead, which handles all interaction types.
    +// See [slack.InteractionCallbackParse] for parsing from an HTTP request.
     type MessageAction struct {
     	Type             string                   `json:"type"`
     	Actions          []slack.AttachmentAction `json:"actions"`
    diff --git a/slackevents/parsers.go b/slackevents/parsers.go
    index 9e8c22b7f..acff4984e 100644
    --- a/slackevents/parsers.go
    +++ b/slackevents/parsers.go
    @@ -262,6 +262,22 @@ func ParseEvent(rawEvent json.RawMessage, opts ...Option) (EventsAPIEvent, error
     	}, nil
     }
     
    +// Deprecated: ParseActionEvent cannot parse block_actions payloads and will return an
    +// unmarshalling error for them. Use [slack.InteractionCallback] with [json.Unmarshal]
    +// instead, or [slack.InteractionCallbackParse] to parse directly from an HTTP request.
    +// InteractionCallback handles all interaction types (block_actions, interactive_message,
    +// view_submission, etc.).
    +//
    +// Migration example:
    +//
    +//	// Before (broken for block_actions):
    +//	action, err := slackevents.ParseActionEvent(payload, slackevents.OptionNoVerifyToken())
    +//
    +//	// After (handles all interaction types):
    +//	var ic slack.InteractionCallback
    +//	err := json.Unmarshal([]byte(payload), &ic)
    +//	// Use ic.ActionCallback.BlockActions for block actions
    +//	// Use ic.ActionCallback.AttachmentActions for legacy attachment actions
     func ParseActionEvent(payloadString string, opts ...Option) (MessageAction, error) {
     	byteString := []byte(payloadString)
     	action := MessageAction{}
    
    From a8520b7cac7194dce8999f52bd520cbc1aa9c25b Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Fri, 3 Apr 2026 19:02:29 +0200
    Subject: [PATCH 516/606] feat(examples): add post markdown text example
    
    Closes #1219.
    ---
     examples/markdown/markdown.go | 65 +++++++++++++++++++++++++++++++++++
     1 file changed, 65 insertions(+)
     create mode 100644 examples/markdown/markdown.go
    
    diff --git a/examples/markdown/markdown.go b/examples/markdown/markdown.go
    new file mode 100644
    index 000000000..c678e5c44
    --- /dev/null
    +++ b/examples/markdown/markdown.go
    @@ -0,0 +1,65 @@
    +package main
    +
    +import (
    +	"flag"
    +	"fmt"
    +	"os"
    +
    +	"github.com/slack-go/slack"
    +)
    +
    +func main() {
    +	channelID := flag.String("channel", "", "Channel ID (required)")
    +	flag.Parse()
    +
    +	token := os.Getenv("SLACK_BOT_TOKEN")
    +	if token == "" {
    +		fmt.Println("SLACK_BOT_TOKEN environment variable is required")
    +		os.Exit(1)
    +	}
    +
    +	if *channelID == "" {
    +		fmt.Println("Channel ID is required: use -channel flag")
    +		os.Exit(1)
    +	}
    +
    +	api := slack.New(token)
    +
    +	// Slack uses its own markdown-like syntax called mrkdwn.
    +	// See https://api.slack.com/reference/surfaces/formatting for the full spec.
    +	mrkdwnText := `*Bold text* and _italic text_ and ~strikethrough~
    +
    +Inline ` + "`code`" + ` and a code block:
    +` + "```" + `
    +func main() {
    +    fmt.Println("Hello, Slack!")
    +}
    +` + "```" + `
    +
    +A link: 
    +
    +> A blockquote for emphasis
    +
    +And a list:
    +• First item
    +• Second item
    +• Third item`
    +
    +	section := slack.NewSectionBlock(
    +		slack.NewTextBlockObject("mrkdwn", mrkdwnText, false, false),
    +		nil,
    +		nil,
    +	)
    +
    +	respChannelID, timestamp, err := api.PostMessage(
    +		*channelID,
    +		slack.MsgOptionText("Markdown formatting example (fallback text)", false),
    +		slack.MsgOptionBlocks(section),
    +	)
    +	if err != nil {
    +		fmt.Printf("Error sending message: %s\n", err)
    +		os.Exit(1)
    +	}
    +
    +	fmt.Printf("Message successfully sent to channel %s at %s\n", respChannelID, timestamp)
    +}
    
    From b96c9b19372cabdaabf182a62788c564e965ffff Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sat, 4 Apr 2026 10:35:47 +0200
    Subject: [PATCH 517/606] fix(socketmode)!: don't silently fail on large `Ack`
     payloads
    
    Two issues caused `Ack()` payloads to be silently dropped:
    
    . `gorilla/websocket`'s default 4KB write buffer fragmented messages in to Websocket
    continuation frames that apparently Slack does not reassemble (this is my best hypothesis)
      I've added a 32KB write buffer to sort that.
    . Slack silently drops socket mode responses at or above 20KB. I changed `Ack`, `Send` and
    `SendCtx` to return an error when the serialized response reaches this limit ([#1196])
    
    > [!WARNING]
    > **Breaking changes.**: `Ack` and `Send` now return `error`. Existing call sites that
    > don't capture the return value will continue to compile though!
    
    Closes #1196.
    ---
     CHANGELOG.md                                  |  28 +++--
     .../large_payload_ack/large_payload_ack.go    | 108 ++++++++++++++++++
     socketmode/response.go                        |   5 +
     socketmode/socket_mode_managed_conn.go        |  65 ++++++++---
     socketmode/socket_mode_managed_conn_test.go   | 108 ++++++++++++++++++
     socketmode/socketmode.go                      |  28 ++++-
     6 files changed, 316 insertions(+), 26 deletions(-)
     create mode 100644 examples/socketmode/large_payload_ack/large_payload_ack.go
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index c6c6b7187..4c50a5119 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ## [Unreleased]
     
    +### Deprecated
    +
    +- **`slackevents.ParseActionEvent`** — Cannot parse `block_actions` payloads (returns
    +  unmarshalling error). Use `slack.InteractionCallback` with `json.Unmarshal` instead,
    +  or `slack.InteractionCallbackParse` for HTTP requests. `InteractionCallback` handles
    +  all interaction types. ([#596])
    +- **`slackevents.MessageAction`**, **`MessageActionEntity`**, **`MessageActionResponse`** —
    +  Associated types that only support legacy `interactive_message` payloads.
    +
     ### Added
     
     - **API warning callbacks** — Slack API responses may include a `warnings` field with
    @@ -138,16 +147,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
       > // Use nextCursor for the next page: params.Cursor = nextCursor
       > ```
     
    -### Deprecated
    +### Fixed
     
    -- **`slackevents.ParseActionEvent`** — Cannot parse `block_actions` payloads (returns
    -  unmarshalling error). Use `slack.InteractionCallback` with `json.Unmarshal` instead,
    -  or `slack.InteractionCallbackParse` for HTTP requests. `InteractionCallback` handles
    -  all interaction types. ([#596])
    -- **`slackevents.MessageAction`**, **`MessageActionEntity`**, **`MessageActionResponse`** —
    -  Associated types that only support legacy `interactive_message` payloads.
    +- **Socket Mode: large Ack payloads no longer silently fail** — Two issues caused `Ack()`
    +  payloads to be silently dropped by Slack. First, gorilla/websocket's default 4KB write
    +  buffer fragmented messages into WebSocket continuation frames that Slack does not
    +  reassemble. The library now uses a 32KB write buffer. Second, Slack silently drops
    +  Socket Mode responses at or above 20KB — `Ack()`, `Send()`, and `SendCtx()` now return
    +  an error when the serialized response reaches this limit. ([#1196])
     
    -### Fixed
    +  > [!WARNING]
    +  > **Breaking change.** `Ack()` and `Send()` now return `error`. Existing call sites that
    +  > don't capture the return value continue to compile without changes.
     
     - **`MsgOptionBlocks()` with no arguments now sends `blocks=[]`** — Previously, calling
       `MsgOptionBlocks()` with no arguments or a nil spread was a silent no-op, making it
    @@ -363,6 +374,7 @@ for details.
     [#1536]: https://github.com/slack-go/slack/pull/1536
     [#596]: https://github.com/slack-go/slack/issues/596
     [#1541]: https://github.com/slack-go/slack/issues/1541
    +[#1196]: https://github.com/slack-go/slack/issues/1196
     
     [Unreleased]: https://github.com/slack-go/slack/compare/v0.20.0...HEAD
     [0.20.0]: https://github.com/slack-go/slack/compare/v0.19.0...v0.20.0
    diff --git a/examples/socketmode/large_payload_ack/large_payload_ack.go b/examples/socketmode/large_payload_ack/large_payload_ack.go
    new file mode 100644
    index 000000000..e8861aa28
    --- /dev/null
    +++ b/examples/socketmode/large_payload_ack/large_payload_ack.go
    @@ -0,0 +1,108 @@
    +// This example demonstrates Socket Mode's behavior with large Ack payloads.
    +//
    +// Slack's Socket Mode silently drops WebSocket responses that are 20KB or
    +// larger. The library detects this and returns an error from Ack().
    +//
    +// To run:
    +//
    +//	export SLACK_APP_TOKEN=xapp-...
    +//	export SLACK_BOT_TOKEN=xoxb-...
    +//	go run examples/socketmode/large_payload_ack/large_payload_ack.go
    +//
    +// Then use your slash command with a byte count as the argument:
    +//
    +//	/your-command 1000     -> small payload, works
    +//	/your-command 19000    -> near the 20KB limit, works
    +//	/your-command 21000    -> over the limit, Ack() returns an error
    +//	/your-command          -> defaults to 100 bytes
    +package main
    +
    +import (
    +	"fmt"
    +	"log"
    +	"os"
    +	"strconv"
    +	"strings"
    +
    +	"github.com/slack-go/slack"
    +	"github.com/slack-go/slack/socketmode"
    +)
    +
    +func main() {
    +	appToken := os.Getenv("SLACK_APP_TOKEN")
    +	if appToken == "" {
    +		fmt.Fprintf(os.Stderr, "SLACK_APP_TOKEN environment variable is required\n")
    +		os.Exit(1)
    +	}
    +
    +	if !strings.HasPrefix(appToken, "xapp-") {
    +		fmt.Fprintf(os.Stderr, "SLACK_APP_TOKEN must have the prefix \"xapp-\"\n")
    +		os.Exit(1)
    +	}
    +
    +	botToken := os.Getenv("SLACK_BOT_TOKEN")
    +	if botToken == "" {
    +		fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN environment variable is required\n")
    +		os.Exit(1)
    +	}
    +
    +	if !strings.HasPrefix(botToken, "xoxb-") {
    +		fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN must have the prefix \"xoxb-\"\n")
    +		os.Exit(1)
    +	}
    +
    +	api := slack.New(
    +		botToken,
    +		slack.OptionDebug(true),
    +		slack.OptionLog(log.New(os.Stdout, "api: ", log.Lshortfile|log.LstdFlags)),
    +		slack.OptionAppLevelToken(appToken),
    +	)
    +
    +	client := socketmode.New(
    +		api,
    +		socketmode.OptionDebug(true),
    +		socketmode.OptionLog(log.New(os.Stdout, "socketmode: ", log.Lshortfile|log.LstdFlags)),
    +	)
    +
    +	go func() {
    +		for evt := range client.Events {
    +			switch evt.Type {
    +			case socketmode.EventTypeConnecting:
    +				fmt.Println("Connecting to Slack with Socket Mode...")
    +			case socketmode.EventTypeConnectionError:
    +				fmt.Println("Connection failed. Retrying later...")
    +			case socketmode.EventTypeConnected:
    +				fmt.Println("Connected to Slack with Socket Mode.")
    +			case socketmode.EventTypeSlashCommand:
    +				cmd, ok := evt.Data.(slack.SlashCommand)
    +				if !ok {
    +					fmt.Printf("Ignored %+v\n", evt)
    +					continue
    +				}
    +
    +				size := 100
    +				if cmd.Text != "" {
    +					if n, err := strconv.Atoi(strings.TrimSpace(cmd.Text)); err == nil && n > 0 {
    +						size = n
    +					}
    +				}
    +
    +				payload := map[string]any{
    +					"text": fmt.Sprintf("[%d bytes] %s", size, strings.Repeat("x", size)),
    +				}
    +
    +				if err := client.Ack(*evt.Request, payload); err != nil {
    +					fmt.Printf("Ack() error: %v\n", err)
    +					fmt.Println("Use the Web API (e.g. chat.PostMessage) for large payloads.")
    +
    +					// Ack without payload so Slack knows we received the event.
    +					client.Ack(*evt.Request)
    +				}
    +
    +			default:
    +			}
    +		}
    +	}()
    +
    +	client.Run()
    +}
    diff --git a/socketmode/response.go b/socketmode/response.go
    index 5c7bfabcf..c1ca625ed 100644
    --- a/socketmode/response.go
    +++ b/socketmode/response.go
    @@ -3,4 +3,9 @@ package socketmode
     type Response struct {
     	EnvelopeID string      `json:"envelope_id"`
     	Payload    interface{} `json:"payload,omitempty"`
    +
    +	// rawJSON holds the pre-marshaled JSON bytes when set by SendCtx.
    +	// This avoids double-marshaling: once for the size check and once for
    +	// the WebSocket write.
    +	rawJSON []byte `json:"-"`
     }
    diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go
    index 94664b237..2f9e15d67 100644
    --- a/socketmode/socket_mode_managed_conn.go
    +++ b/socketmode/socket_mode_managed_conn.go
    @@ -302,7 +302,11 @@ func (smc *Client) openAndDial(ctx context.Context, additionalPingHandler func(s
     	// Only use HTTPS for connections to prevent MITM attacks on the connection.
     	upgradeHeader := http.Header{}
     	upgradeHeader.Add("Origin", "https://api.slack.com")
    -	dialer := websocket.DefaultDialer
    +	dialer := &websocket.Dialer{
    +		Proxy:            http.ProxyFromEnvironment,
    +		HandshakeTimeout: defaultHandshakeTimeout,
    +		WriteBufferSize:  defaultWriteBufferSize,
    +	}
     	if smc.dialer != nil {
     		smc.Debugf("Using custom websocket dialer")
     		dialer = smc.dialer
    @@ -355,6 +359,7 @@ func (smc *Client) runResponseSender(ctx context.Context, conn *websocket.Conn)
     			smc.Debugf("Sending Socket Mode response with envelope ID %q: %v", res.EnvelopeID, res)
     
     			if err := unsafeWriteSocketModeResponse(conn, res); err != nil {
    +				smc.Debugf("failed to write Socket Mode response for envelope ID %q: %v", res.EnvelopeID, err)
     				smc.sendEvent(ctx, newEvent(EventTypeErrorWriteFailed, &ErrorWriteFailed{
     					Cause:    err,
     					Response: res,
    @@ -431,9 +436,15 @@ func unsafeWriteSocketModeResponse(conn *websocket.Conn, res *Response) error {
     		return err
     	}
     
    -	// Remove write deadline regardless of WriteJSON succeeds or not
    +	// Remove write deadline regardless of write succeeding or not
     	defer conn.SetWriteDeadline(time.Time{})
     
    +	// Use pre-marshaled bytes from SendCtx when available to avoid
    +	// marshaling twice. Fall back to WriteJSON for responses that
    +	// bypassed SendCtx.
    +	if res.rawJSON != nil {
    +		return conn.WriteMessage(websocket.TextMessage, res.rawJSON)
    +	}
     	return conn.WriteJSON(res)
     }
     
    @@ -449,21 +460,28 @@ func newEvent(tpe EventType, data interface{}, req ...*Request) Event {
     
     // Ack acknowledges the Socket Mode request with the payload.
     //
    -// This tells Slack that the we have received the request denoted by the envelope ID,
    +// This tells Slack that we have received the request denoted by the envelope ID,
     // by sending back the envelope ID over the WebSocket connection.
    -func (smc *Client) Ack(req Request, payload ...interface{}) {
    +//
    +// Returns an error if the serialized response is 20KB or larger, as Slack
    +// silently drops oversized Socket Mode responses. Use Web API methods (e.g.
    +// chat.PostMessage, views.Push) for large payloads.
    +func (smc *Client) Ack(req Request, payload ...interface{}) error {
     	var pld interface{}
     	if len(payload) > 0 {
     		pld = payload[0]
     	}
     
    -	smc.AckCtx(context.TODO(), req.EnvelopeID, pld)
    +	return smc.AckCtx(context.TODO(), req.EnvelopeID, pld)
     }
     
     // AckCtx acknowledges the Socket Mode request envelope ID with the payload.
     //
    -// This tells Slack that the we have received the request denoted by the request (envelope) ID,
    +// This tells Slack that we have received the request denoted by the request (envelope) ID,
     // by sending back the ID over the WebSocket connection.
    +//
    +// Returns an error if the serialized response is 20KB or larger, as Slack
    +// silently drops oversized Socket Mode responses.
     func (smc *Client) AckCtx(ctx context.Context, reqID string, payload interface{}) error {
     	return smc.SendCtx(ctx, Response{
     		EnvelopeID: reqID,
    @@ -474,25 +492,40 @@ func (smc *Client) AckCtx(ctx context.Context, reqID string, payload interface{}
     // Send sends the Socket Mode response over a WebSocket connection.
     // This is usually used for acknowledging requests, but if you need more control over Client.Ack().
     // It's normally recommended to use Client.Ack() instead of this.
    -func (smc *Client) Send(res Response) {
    -	smc.SendCtx(context.TODO(), res)
    +//
    +// Returns an error if the serialized response is 20KB or larger, as Slack
    +// silently drops oversized Socket Mode responses.
    +func (smc *Client) Send(res Response) error {
    +	return smc.SendCtx(context.TODO(), res)
     }
     
     // SendCtx sends the Socket Mode response over a WebSocket connection.
     // This is usually used for acknowledging requests, but if you need more control
     // it's normally recommended to use Client.AckCtx() instead of this.
    +//
    +// Slack's Socket Mode silently drops WebSocket responses that are 20KB or
    +// larger (the write succeeds but Slack ignores the payload). SendCtx returns an
    +// error if the serialized response reaches this limit. For large payloads, use
    +// Web API methods instead (e.g. chat.PostMessage, views.Push).
     func (smc *Client) SendCtx(ctx context.Context, res Response) error {
    -	if smc.debug {
    -		js, err := json.Marshal(res)
    +	js, err := json.Marshal(res)
    +	if err != nil {
    +		return fmt.Errorf("marshalling socket mode response: %w", err)
    +	}
     
    -		// Log the error so users of `Send` don't see it entirely disappear as that method
    -		// does not return an error and used to panic on failure (with or without debug)
    -		smc.Debugf("Scheduling Socket Mode response (error: %v) for envelope ID %s: %s", err, res.EnvelopeID, js)
    -		if err != nil {
    -			return err
    -		}
    +	if len(js) >= maxResponseSize {
    +		return fmt.Errorf("socket mode response (%d bytes) meets or exceeds Slack's %d-byte WebSocket limit and would be silently dropped; use the Web API for large payloads",
    +			len(js), maxResponseSize)
     	}
     
    +	if smc.debug {
    +		smc.Debugf("Scheduling Socket Mode response for envelope ID %s: %s", res.EnvelopeID, js)
    +	}
    +
    +	// Store pre-marshaled bytes so unsafeWriteSocketModeResponse can use
    +	// WriteMessage instead of WriteJSON, avoiding a second marshal.
    +	res.rawJSON = js
    +
     	select {
     	case <-ctx.Done():
     		return ctx.Err()
    diff --git a/socketmode/socket_mode_managed_conn_test.go b/socketmode/socket_mode_managed_conn_test.go
    index 542ccdb45..178c6028d 100644
    --- a/socketmode/socket_mode_managed_conn_test.go
    +++ b/socketmode/socket_mode_managed_conn_test.go
    @@ -5,7 +5,9 @@ package socketmode
     
     import (
     	"context"
    +	"encoding/json"
     	"errors"
    +	"strings"
     	"testing"
     	"time"
     
    @@ -44,3 +46,109 @@ func Test_passContext(t *testing.T) {
     		assert.EqualError(t, errors.Unwrap(err), context.DeadlineExceeded.Error())
     	})
     }
    +
    +func TestSendCtx_PayloadQueued(t *testing.T) {
    +	api := slack.New("ABCDEFG")
    +	cli := New(api)
    +
    +	err := cli.SendCtx(context.Background(), Response{
    +		EnvelopeID: "test-envelope",
    +		Payload:    "small payload",
    +	})
    +
    +	assert.NoError(t, err)
    +
    +	select {
    +	case res := <-cli.socketModeResponses:
    +		assert.Equal(t, "test-envelope", res.EnvelopeID)
    +	default:
    +		t.Fatal("expected response to be queued on socketModeResponses channel")
    +	}
    +}
    +
    +func TestSendCtx_PayloadAtLimit(t *testing.T) {
    +	api := slack.New("ABCDEFG")
    +	cli := New(api)
    +
    +	// Build a response that serializes to exactly maxResponseSize bytes.
    +	// The envelope and JSON overhead consume some bytes, so we pad the payload.
    +	envelope := "test-envelope"
    +	overhead, _ := json.Marshal(Response{EnvelopeID: envelope, Payload: ""})
    +	padding := strings.Repeat("x", maxResponseSize-len(overhead))
    +
    +	// Verify we hit exactly the limit.
    +	exact, _ := json.Marshal(Response{EnvelopeID: envelope, Payload: padding})
    +	assert.Equal(t, maxResponseSize, len(exact), "test setup: payload should serialize to exactly %d bytes", maxResponseSize)
    +
    +	err := cli.SendCtx(context.Background(), Response{
    +		EnvelopeID: envelope,
    +		Payload:    padding,
    +	})
    +	assert.Error(t, err)
    +	assert.Contains(t, err.Error(), "silently dropped")
    +}
    +
    +func TestSendCtx_PayloadJustUnderLimit(t *testing.T) {
    +	api := slack.New("ABCDEFG")
    +	cli := New(api)
    +
    +	envelope := "test-envelope"
    +	overhead, _ := json.Marshal(Response{EnvelopeID: envelope, Payload: ""})
    +	padding := strings.Repeat("x", maxResponseSize-len(overhead)-1)
    +
    +	// Verify we're one byte under.
    +	under, _ := json.Marshal(Response{EnvelopeID: envelope, Payload: padding})
    +	assert.Equal(t, maxResponseSize-1, len(under), "test setup: payload should serialize to exactly %d bytes", maxResponseSize-1)
    +
    +	err := cli.SendCtx(context.Background(), Response{
    +		EnvelopeID: envelope,
    +		Payload:    padding,
    +	})
    +	assert.NoError(t, err)
    +
    +	select {
    +	case res := <-cli.socketModeResponses:
    +		assert.Equal(t, envelope, res.EnvelopeID)
    +	default:
    +		t.Fatal("expected response to be queued on socketModeResponses channel")
    +	}
    +}
    +
    +func TestSendCtx_MarshalError(t *testing.T) {
    +	api := slack.New("ABCDEFG")
    +	cli := New(api)
    +
    +	err := cli.SendCtx(context.Background(), Response{
    +		EnvelopeID: "test-envelope",
    +		Payload:    func() {},
    +	})
    +
    +	assert.Error(t, err)
    +	assert.Contains(t, err.Error(), "marshalling socket mode response")
    +}
    +
    +func TestAck_ReturnsNoError(t *testing.T) {
    +	api := slack.New("ABCDEFG")
    +	cli := New(api)
    +
    +	err := cli.Ack(Request{EnvelopeID: "test-envelope"}, "payload")
    +	assert.NoError(t, err)
    +
    +	select {
    +	case res := <-cli.socketModeResponses:
    +		assert.Equal(t, "test-envelope", res.EnvelopeID)
    +	default:
    +		t.Fatal("expected response to be queued on socketModeResponses channel")
    +	}
    +}
    +
    +func TestAck_ReturnsErrorWhenPayloadTooLarge(t *testing.T) {
    +	api := slack.New("ABCDEFG")
    +	cli := New(api)
    +
    +	largePayload := strings.Repeat("x", maxResponseSize)
    +	err := cli.Ack(Request{EnvelopeID: "test-envelope"}, largePayload)
    +
    +	assert.Error(t, err)
    +	assert.Contains(t, err.Error(), "silently dropped")
    +}
    diff --git a/socketmode/socketmode.go b/socketmode/socketmode.go
    index 6ca8f487c..86445cb06 100644
    --- a/socketmode/socketmode.go
    +++ b/socketmode/socketmode.go
    @@ -49,9 +49,32 @@ const (
     
     	websocketDefaultTimeout = 10 * time.Second
     	defaultMaxPingInterval  = 30 * time.Second
    +	defaultHandshakeTimeout = 45 * time.Second
    +
    +	// maxResponseSize is Slack's server-side limit for Socket Mode WebSocket response
    +	// messages (20KB). Responses at or above this size on the wire are silently dropped
    +	// by Slack — the WebSocket write succeeds but Slack ignores the payload. Empirically
    +	// verified using both this library and Slack's official Node SDK
    +	// (@slack/socket-mode).
    +	//
    +	// Note: gorilla/websocket's WriteJSON adds a trailing newline (via
    +	// json.Encoder.Encode), so the on-wire size is json.Marshal(response) + 1. The
    +	// pre-flight check in SendCtx uses >= maxResponseSize on the json.Marshal output,
    +	// which is equivalent to > maxResponseSize on the wire minus the newline —
    +	// conservative by exactly 1 byte.
    +	maxResponseSize = 20 * 1024 // 20480 bytes
    +
    +	// defaultWriteBufferSize is the WebSocket write buffer size used when dialing Slack's
    +	// Socket Mode endpoint. gorilla/websocket's default is 4096 bytes; messages exceeding
    +	// the buffer are split into WebSocket continuation frames (I think). Slack's server
    +	// does not reassemble continuation frames, causing silent message drops (this is my
    +	// hypothesis from testing). We set this above Slack's 20KB response limit to ensure
    +	// all valid messages are sent as single frames.
    +	defaultWriteBufferSize = 32 * 1024
     )
     
    -// Open calls the "apps.connections.open" endpoint and returns the provided URL and the full Info block.
    +// Open calls the "apps.connections.open" endpoint and returns the provided URL and the
    +// full Info block.
     //
     // To have a fully managed Websocket connection, use `New`, and call `Run()` on it.
     func (smc *Client) Open() (info *slack.SocketModeConnection, websocketURL string, err error) {
    @@ -61,7 +84,8 @@ func (smc *Client) Open() (info *slack.SocketModeConnection, websocketURL string
     	return smc.StartSocketModeContext(ctx)
     }
     
    -// OpenContext calls the "apps.connections.open" endpoint and returns the provided URL and the full Info block.
    +// OpenContext calls the "apps.connections.open" endpoint and returns the provided URL and
    +// the full Info block.
     //
     // To have a fully managed Websocket connection, use `New`, and call `Run()` on it.
     func (smc *Client) OpenContext(ctx context.Context) (info *slack.SocketModeConnection, websocketURL string, err error) {
    
    From 711cae7a533178f8e4b93df379d45c8cdd20a00f Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sat, 4 Apr 2026 11:33:54 +0200
    Subject: [PATCH 518/606] feat(usergroups): add UpdateUserGroupMembersList
    
    I kinda agree that making the user do a strings.join isn't the most ergonomic thing so
    instead of adding a breaking change I'm instead adding a new call users can use if they
    want that feels slightly more ergonomic.
    
    Closes #1172.
    ---
     CHANGELOG.md  |  4 ++++
     usergroups.go | 14 ++++++++++++++
     2 files changed, 18 insertions(+)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 4c50a5119..5601e3be9 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ### Added
     
    +- **`UpdateUserGroupMembersList` / `UpdateUserGroupMembersListContext`** — Convenience
    +  wrappers around `UpdateUserGroupMembers` that accept `[]string` instead of a
    +  comma-separated string, enabling clean chaining with `GetUserGroupMembers`. ([#1172])
     - **API warning callbacks** — Slack API responses may include a `warnings` field with
       deprecation notices or usage hints. Use `OptionWarnings(func(warnings []string))` to
       register a callback that receives these warnings. ([#1540])
    @@ -374,6 +377,7 @@ for details.
     [#1536]: https://github.com/slack-go/slack/pull/1536
     [#596]: https://github.com/slack-go/slack/issues/596
     [#1541]: https://github.com/slack-go/slack/issues/1541
    +[#1172]: https://github.com/slack-go/slack/issues/1172
     [#1196]: https://github.com/slack-go/slack/issues/1196
     
     [Unreleased]: https://github.com/slack-go/slack/compare/v0.20.0...HEAD
    diff --git a/usergroups.go b/usergroups.go
    index 616bbf158..b5c545457 100644
    --- a/usergroups.go
    +++ b/usergroups.go
    @@ -568,3 +568,17 @@ func (api *Client) UpdateUserGroupMembersContext(ctx context.Context, userGroup
     	}
     	return response.UserGroup, nil
     }
    +
    +// UpdateUserGroupMembersList updates the members of an existing user group,
    +// accepting a slice of user IDs. This is a convenience wrapper around
    +// UpdateUserGroupMembers for use with APIs that return []string (e.g.
    +// GetUserGroupMembers).
    +func (api *Client) UpdateUserGroupMembersList(userGroup string, members []string, options ...UpdateUserGroupMembersOption) (UserGroup, error) {
    +	return api.UpdateUserGroupMembersContext(context.Background(), userGroup, strings.Join(members, ","), options...)
    +}
    +
    +// UpdateUserGroupMembersListContext updates the members of an existing user
    +// group with a custom context, accepting a slice of user IDs.
    +func (api *Client) UpdateUserGroupMembersListContext(ctx context.Context, userGroup string, members []string, options ...UpdateUserGroupMembersOption) (UserGroup, error) {
    +	return api.UpdateUserGroupMembersContext(ctx, userGroup, strings.Join(members, ","), options...)
    +}
    
    From 60d4713e72e32ca1e28b43316e8151df07dd601b Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sat, 4 Apr 2026 20:03:26 +0200
    Subject: [PATCH 519/606] feat: expose HTTP response headers via a callback
     option
    
    Slack API responses can include useful HTTP headers. We already used the X-Ratelimit-*
    ones internally but never exposed any of the headers to the user. This fixes that.
    
    Closes #1076.
    ---
     CHANGELOG.md             |   6 ++
     audit.go                 |   2 +-
     channels.go              |   2 +-
     chat.go                  |   2 +-
     misc.go                  |  50 ++++++++++----
     misc_test.go             |   6 +-
     oauth.go                 |   8 +--
     response_headers_test.go | 137 +++++++++++++++++++++++++++++++++++++++
     slack.go                 |  32 +++++++--
     users.go                 |   2 +-
     10 files changed, 218 insertions(+), 29 deletions(-)
     create mode 100644 response_headers_test.go
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 5601e3be9..e2d19c191 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -18,6 +18,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ### Added
     
    +- **HTTP response headers** — Slack API response headers (e.g. `X-OAuth-Scopes`,
    +  `X-Accepted-OAuth-Scopes`, `X-Ratelimit-*`) are now accessible. `AuthTestResponse`
    +  exposes a `Header` field directly. For all other methods, use
    +  `OptionOnResponseHeaders(func(method string, headers http.Header))` to register a
    +  callback that fires after every API call. ([#1076])
     - **`UpdateUserGroupMembersList` / `UpdateUserGroupMembersListContext`** — Convenience
       wrappers around `UpdateUserGroupMembers` that accept `[]string` instead of a
       comma-separated string, enabling clean chaining with `GetUserGroupMembers`. ([#1172])
    @@ -378,6 +383,7 @@ for details.
     [#596]: https://github.com/slack-go/slack/issues/596
     [#1541]: https://github.com/slack-go/slack/issues/1541
     [#1172]: https://github.com/slack-go/slack/issues/1172
    +[#1076]: https://github.com/slack-go/slack/issues/1076
     [#1196]: https://github.com/slack-go/slack/issues/1196
     
     [Unreleased]: https://github.com/slack-go/slack/compare/v0.20.0...HEAD
    diff --git a/audit.go b/audit.go
    index bb9ae6dc9..135a68d78 100644
    --- a/audit.go
    +++ b/audit.go
    @@ -108,7 +108,7 @@ type AuditLogParameters struct {
     func (api *Client) auditLogsRequest(ctx context.Context, path string, values url.Values) (*AuditLogResponse, error) {
     	response := &AuditLogResponse{}
     	// The Audit Logs API uses a different base URL (api.slack.com instead of slack.com/api)
    -	err := getResource(ctx, api.httpclient, api.auditEndpoint+path, api.token, values, response, api)
    +	_, err := getResource(ctx, api.httpclient, api.auditEndpoint+path, api.token, values, response, api)
     	if err != nil {
     		return nil, err
     	}
    diff --git a/channels.go b/channels.go
    index 88d567bff..d01ce823f 100644
    --- a/channels.go
    +++ b/channels.go
    @@ -28,7 +28,7 @@ type Channel struct {
     
     func (api *Client) channelRequest(ctx context.Context, path string, values url.Values) (*channelResponseFull, error) {
     	response := &channelResponseFull{}
    -	err := postForm(ctx, api.httpclient, api.endpoint+path, values, response, api)
    +	_, err := postForm(ctx, api.httpclient, api.endpoint+path, values, response, api)
     	if err != nil {
     		return nil, err
     	}
    diff --git a/chat.go b/chat.go
    index 209b2010f..d353604d5 100644
    --- a/chat.go
    +++ b/chat.go
    @@ -264,7 +264,7 @@ func (api *Client) SendMessageContext(ctx context.Context, channelID string, opt
     		api.Debugf("Sending request: %s", redactToken(reqBody))
     	}
     
    -	if err = doPost(api.httpclient, req, parser(&response), api); err != nil {
    +	if _, err = doPost(api.httpclient, req, parser(&response), api); err != nil {
     		return "", "", "", err
     	}
     
    diff --git a/misc.go b/misc.go
    index 07150f6e3..46060a720 100644
    --- a/misc.go
    +++ b/misc.go
    @@ -143,6 +143,23 @@ type Warning struct {
     	Warnings []string
     }
     
    +// httpHeaderSetter is satisfied by response types that can store HTTP
    +// response headers. The response parser checks for this interface and
    +// injects headers before JSON decoding.
    +type httpHeaderSetter interface {
    +	setHTTPResponseHeaders(http.Header)
    +}
    +
    +// responseHeaders is a mix-in for internal response types that need to
    +// capture HTTP response headers. Embedded in types like authTestResponseFull
    +// so the parser can store headers that are then propagated to the public
    +// response type (e.g. AuthTestResponse.Header).
    +type responseHeaders struct {
    +	header http.Header
    +}
    +
    +func (r *responseHeaders) setHTTPResponseHeaders(h http.Header) { r.header = h }
    +
     // KickUserFromConversationSlackResponse is a variant of SlackResponse that can handle the case where
     // "errors" can be either an empty object {} or an array of errors.
     // This addresses issue #1446 where conversations.kick endpoint returns {"ok":true,"errors":{}}
    @@ -400,26 +417,25 @@ func createFormFields(mw *multipart.Writer, values url.Values) error {
     	return nil
     }
     
    -func doPost(client httpClient, req *http.Request, parser responseParser, d Debug) error {
    +func doPost(client httpClient, req *http.Request, parser responseParser, d Debug) (http.Header, error) {
     	resp, err := client.Do(req)
     	if err != nil {
    -		return err
    +		return nil, err
     	}
     	defer resp.Body.Close()
     
    -	err = checkStatusCode(resp, d)
    -	if err != nil {
    -		return err
    +	if err = checkStatusCode(resp, d); err != nil {
    +		return nil, err
     	}
     
    -	return parser(resp)
    +	return resp.Header, parser(resp)
     }
     
     // post JSON.
    -func postJSON(ctx context.Context, client httpClient, endpoint, token string, jsonBody []byte, intf any, d Debug) error {
    +func postJSON(ctx context.Context, client httpClient, endpoint, token string, jsonBody []byte, intf any, d Debug) (http.Header, error) {
     	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(jsonBody))
     	if err != nil {
    -		return err
    +		return nil, err
     	}
     	req.Header.Set("Content-Type", "application/json")
     	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
    @@ -431,11 +447,11 @@ func postJSON(ctx context.Context, client httpClient, endpoint, token string, js
     }
     
     // post a url encoded form.
    -func postForm(ctx context.Context, client httpClient, endpoint string, values url.Values, intf any, d Debug) error {
    +func postForm(ctx context.Context, client httpClient, endpoint string, values url.Values, intf any, d Debug) (http.Header, error) {
     	body := values.Encode()
     	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(body))
     	if err != nil {
    -		return err
    +		return nil, err
     	}
     	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
     	// allow retry client to re-send the request body on 429/5xx.
    @@ -445,10 +461,10 @@ func postForm(ctx context.Context, client httpClient, endpoint string, values ur
     	return doPost(client, req, newJSONParser(intf), d)
     }
     
    -func getResource(ctx context.Context, client httpClient, endpoint, token string, values url.Values, intf any, d Debug) error {
    +func getResource(ctx context.Context, client httpClient, endpoint, token string, values url.Values, intf any, d Debug) (http.Header, error) {
     	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
     	if err != nil {
    -		return err
    +		return nil, err
     	}
     	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
     	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
    @@ -460,7 +476,8 @@ func getResource(ctx context.Context, client httpClient, endpoint, token string,
     
     func parseAdminResponse(ctx context.Context, client httpClient, method string, teamName string, values url.Values, intf any, d Debug) error {
     	endpoint := fmt.Sprintf(WEBAPIURLFormat, teamName, method, time.Now().Unix())
    -	return postForm(ctx, client, endpoint, values, intf, d)
    +	_, err := postForm(ctx, client, endpoint, values, intf, d)
    +	return err
     }
     
     func logResponse(resp *http.Response, d Debug) error {
    @@ -508,6 +525,9 @@ func newJSONParser(dst any) responseParser {
     		if dst == nil {
     			return nil
     		}
    +		if hs, ok := dst.(httpHeaderSetter); ok {
    +			hs.setHTTPResponseHeaders(resp.Header.Clone())
    +		}
     		return json.NewDecoder(resp.Body).Decode(dst)
     	}
     }
    @@ -545,6 +565,10 @@ func newContentTypeParser(dst any) responseParser {
     		case "application/json":
     			return newJSONParser(dst)(req)
     		default:
    +			// newTextParser doesn't use dst, so capture headers here.
    +			if hs, ok := dst.(httpHeaderSetter); ok {
    +				hs.setHTTPResponseHeaders(req.Header.Clone())
    +			}
     			return newTextParser(dst)(req)
     		}
     	}
    diff --git a/misc_test.go b/misc_test.go
    index 631115d93..07f32c0fb 100644
    --- a/misc_test.go
    +++ b/misc_test.go
    @@ -45,7 +45,7 @@ func TestParseResponse(t *testing.T) {
     	}
     
     	responsePartial := &SlackResponse{}
    -	err := postForm(context.Background(), http.DefaultClient, APIURL+"parseResponse", values, responsePartial, discard{})
    +	_, err := postForm(context.Background(), http.DefaultClient, APIURL+"parseResponse", values, responsePartial, discard{})
     	if err != nil {
     		t.Errorf("Unexpected error: %s", err)
     	}
    @@ -58,7 +58,7 @@ func TestParseResponseNoToken(t *testing.T) {
     	values := url.Values{}
     
     	responsePartial := &SlackResponse{}
    -	err := postForm(context.Background(), http.DefaultClient, APIURL+"parseResponse", values, responsePartial, discard{})
    +	_, err := postForm(context.Background(), http.DefaultClient, APIURL+"parseResponse", values, responsePartial, discard{})
     	if err != nil {
     		t.Errorf("Unexpected error: %s", err)
     		return
    @@ -78,7 +78,7 @@ func TestParseResponseInvalidToken(t *testing.T) {
     		"token": {"whatever"},
     	}
     	responsePartial := &SlackResponse{}
    -	err := postForm(context.Background(), http.DefaultClient, APIURL+"parseResponse", values, responsePartial, discard{})
    +	_, err := postForm(context.Background(), http.DefaultClient, APIURL+"parseResponse", values, responsePartial, discard{})
     	if err != nil {
     		t.Errorf("Unexpected error: %s", err)
     		return
    diff --git a/oauth.go b/oauth.go
    index 0c77eca40..58ceba844 100644
    --- a/oauth.go
    +++ b/oauth.go
    @@ -127,7 +127,7 @@ func GetOAuthResponseContext(ctx context.Context, client httpClient, clientID, c
     		"redirect_uri":  {redirectURI},
     	}
     	response := &OAuthResponse{}
    -	if err = postForm(ctx, client, APIURL+"oauth.access", values, response, discard{}); err != nil {
    +	if _, err = postForm(ctx, client, APIURL+"oauth.access", values, response, discard{}); err != nil {
     		return nil, err
     	}
     	return response, response.Err()
    @@ -149,7 +149,7 @@ func GetOAuthV2ResponseContext(ctx context.Context, client httpClient, clientID,
     		"redirect_uri":  {redirectURI},
     	}
     	response := &OAuthV2Response{}
    -	if err = postForm(ctx, client, APIURL+"oauth.v2.access", values, response, discard{}); err != nil {
    +	if _, err = postForm(ctx, client, APIURL+"oauth.v2.access", values, response, discard{}); err != nil {
     		return nil, err
     	}
     	return response, response.Err()
    @@ -171,7 +171,7 @@ func RefreshOAuthV2TokenContext(ctx context.Context, client httpClient, clientID
     		"grant_type":    {"refresh_token"},
     	}
     	response := &OAuthV2Response{}
    -	if err = postForm(ctx, client, APIURL+"oauth.v2.access", values, response, discard{}); err != nil {
    +	if _, err = postForm(ctx, client, APIURL+"oauth.v2.access", values, response, discard{}); err != nil {
     		return nil, err
     	}
     	return response, response.Err()
    @@ -193,7 +193,7 @@ func GetOpenIDConnectTokenContext(ctx context.Context, client httpClient, client
     		"redirect_uri":  {redirectURI},
     	}
     	response := &OpenIDConnectResponse{}
    -	if err = postForm(ctx, client, APIURL+"openid.connect.token", values, response, discard{}); err != nil {
    +	if _, err = postForm(ctx, client, APIURL+"openid.connect.token", values, response, discard{}); err != nil {
     		return nil, err
     	}
     	return response, response.Err()
    diff --git a/response_headers_test.go b/response_headers_test.go
    new file mode 100644
    index 000000000..829fb1183
    --- /dev/null
    +++ b/response_headers_test.go
    @@ -0,0 +1,137 @@
    +package slack
    +
    +import (
    +	"net/http"
    +	"net/http/httptest"
    +	"testing"
    +)
    +
    +func TestResponseHeaders(t *testing.T) {
    +	t.Run("AuthTest captures headers", func(t *testing.T) {
    +		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +			w.Header().Set("Content-Type", "application/json")
    +			w.Header().Set("X-OAuth-Scopes", "users:read,channels:read")
    +			w.Header().Set("X-Accepted-OAuth-Scopes", "users:read")
    +			w.Write([]byte(`{"ok":true,"url":"https://example.slack.com","team":"T","user":"U","team_id":"T1","user_id":"U1"}`))
    +		}))
    +		defer ts.Close()
    +
    +		api := New("test-token", OptionAPIURL(ts.URL+"/"))
    +		resp, err := api.AuthTestContext(t.Context())
    +		if err != nil {
    +			t.Fatalf("unexpected error: %v", err)
    +		}
    +		if got := resp.Header.Get("X-OAuth-Scopes"); got != "users:read,channels:read" {
    +			t.Fatalf("expected X-OAuth-Scopes=users:read,channels:read, got %q", got)
    +		}
    +		if got := resp.Header.Get("X-Accepted-OAuth-Scopes"); got != "users:read" {
    +			t.Fatalf("expected X-Accepted-OAuth-Scopes=users:read, got %q", got)
    +		}
    +	})
    +
    +	t.Run("SlackResponse embeds headers via callback", func(t *testing.T) {
    +		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +			w.Header().Set("Content-Type", "application/json")
    +			w.Header().Set("X-OAuth-Scopes", "admin")
    +			w.Write([]byte(`{"ok":true,"url":"https://example.slack.com","team":"T","user":"U","team_id":"T1","user_id":"U1"}`))
    +		}))
    +		defer ts.Close()
    +
    +		var gotHeaders http.Header
    +		api := New("test-token",
    +			OptionAPIURL(ts.URL+"/"),
    +			OptionOnResponseHeaders(func(path string, headers http.Header) {
    +				gotHeaders = headers
    +			}),
    +		)
    +
    +		_, err := api.AuthTestContext(t.Context())
    +		if err != nil {
    +			t.Fatalf("unexpected error: %v", err)
    +		}
    +		if gotHeaders == nil {
    +			t.Fatal("expected headers from SlackResponse, got nil")
    +		}
    +		if got := gotHeaders.Get("X-OAuth-Scopes"); got != "admin" {
    +			t.Fatalf("expected X-OAuth-Scopes=admin, got %q", got)
    +		}
    +	})
    +}
    +
    +func TestOptionOnResponseHeaders(t *testing.T) {
    +	t.Run("callback fires", func(t *testing.T) {
    +		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +			w.Header().Set("Content-Type", "application/json")
    +			w.Header().Set("X-OAuth-Scopes", "users:read")
    +			w.Write([]byte(`{"ok":true,"url":"https://example.slack.com","team":"T","user":"U","team_id":"T1","user_id":"U1"}`))
    +		}))
    +		defer ts.Close()
    +
    +		var gotPath string
    +		var gotHeaders http.Header
    +		api := New("test-token",
    +			OptionAPIURL(ts.URL+"/"),
    +			OptionOnResponseHeaders(func(path string, headers http.Header) {
    +				gotPath = path
    +				gotHeaders = headers
    +			}),
    +		)
    +
    +		_, err := api.AuthTestContext(t.Context())
    +		if err != nil {
    +			t.Fatalf("unexpected error: %v", err)
    +		}
    +		if gotPath != "auth.test" {
    +			t.Fatalf("expected path auth.test, got %q", gotPath)
    +		}
    +		if gotHeaders == nil {
    +			t.Fatal("expected headers, got nil")
    +		}
    +		if got := gotHeaders.Get("X-OAuth-Scopes"); got != "users:read" {
    +			t.Fatalf("expected X-OAuth-Scopes=users:read, got %q", got)
    +		}
    +	})
    +
    +	t.Run("no callback is safe", func(t *testing.T) {
    +		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +			w.Header().Set("Content-Type", "application/json")
    +			w.Header().Set("X-OAuth-Scopes", "users:read")
    +			w.Write([]byte(`{"ok":true,"url":"https://example.slack.com","team":"T","user":"U","team_id":"T1","user_id":"U1"}`))
    +		}))
    +		defer ts.Close()
    +
    +		api := New("test-token", OptionAPIURL(ts.URL+"/"))
    +		_, err := api.AuthTestContext(t.Context())
    +		if err != nil {
    +			t.Fatalf("unexpected error: %v", err)
    +		}
    +	})
    +
    +	t.Run("callback fires on error", func(t *testing.T) {
    +		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +			w.Header().Set("Content-Type", "application/json")
    +			w.Header().Set("X-OAuth-Scopes", "users:read")
    +			w.Write([]byte(`{"ok":false,"error":"invalid_auth"}`))
    +		}))
    +		defer ts.Close()
    +
    +		var gotHeaders http.Header
    +		api := New("test-token",
    +			OptionAPIURL(ts.URL+"/"),
    +			OptionOnResponseHeaders(func(path string, headers http.Header) {
    +				gotHeaders = headers
    +			}),
    +		)
    +
    +		_, err := api.AuthTestContext(t.Context())
    +		if err == nil {
    +			t.Fatal("expected error")
    +		}
    +		if gotHeaders == nil {
    +			t.Fatal("expected headers even on error response, got nil")
    +		}
    +		if got := gotHeaders.Get("X-OAuth-Scopes"); got != "users:read" {
    +			t.Fatalf("expected X-OAuth-Scopes=users:read, got %q", got)
    +		}
    +	})
    +}
    diff --git a/slack.go b/slack.go
    index 1ba1c7aec..45b9cd3bc 100644
    --- a/slack.go
    +++ b/slack.go
    @@ -46,13 +46,15 @@ type AuthTestResponse struct {
     	TeamID string `json:"team_id"`
     	UserID string `json:"user_id"`
     	// EnterpriseID is only returned when an enterprise id present
    -	EnterpriseID string `json:"enterprise_id,omitempty"`
    -	BotID        string `json:"bot_id"`
    +	EnterpriseID string      `json:"enterprise_id,omitempty"`
    +	BotID        string      `json:"bot_id"`
    +	Header       http.Header `json:"-"`
     }
     
     type authTestResponseFull struct {
     	SlackResponse
     	AuthTestResponse
    +	responseHeaders
     }
     
     type ParamOption func(*url.Values)
    @@ -69,6 +71,7 @@ type Client struct {
     	log                ilogger
     	httpclient         httpClient
     	onWarning          func(path string, request any, w *Warning)
    +	onResponseHeaders  func(path string, headers http.Header)
     }
     
     // Option defines an option for a Client
    @@ -114,6 +117,15 @@ func OptionOnWarning(fn func(path string, request any, w *Warning)) func(*Client
     	}
     }
     
    +// OptionOnResponseHeaders sets a callback invoked after every API request
    +// with the API method path and the HTTP response headers. This allows
    +// accessing headers like X-OAuth-Scopes and X-Ratelimit-* for any request.
    +func OptionOnResponseHeaders(fn func(path string, headers http.Header)) func(*Client) {
    +	return func(c *Client) {
    +		c.onResponseHeaders = fn
    +	}
    +}
    +
     // OptionAPIURL set the url for the client. only useful for testing.
     func OptionAPIURL(u string) func(*Client) {
     	return func(c *Client) { c.endpoint = u }
    @@ -203,6 +215,7 @@ func (api *Client) AuthTestContext(ctx context.Context) (response *AuthTestRespo
     		return nil, err
     	}
     
    +	responseFull.AuthTestResponse.Header = responseFull.responseHeaders.header
     	return &responseFull.AuthTestResponse, responseFull.Err()
     }
     
    @@ -227,22 +240,25 @@ func (api *Client) Debug() bool {
     
     // post to a slack web method.
     func (api *Client) postMethod(ctx context.Context, path string, values url.Values, intf any) error {
    -	err := postForm(ctx, api.httpclient, api.endpoint+path, values, intf, api)
    +	headers, err := postForm(ctx, api.httpclient, api.endpoint+path, values, intf, api)
     	api.checkWarnings(intf, path, values)
    +	api.fireResponseHeaders(path, headers)
     	return err
     }
     
     // get a slack web method.
     func (api *Client) getMethod(ctx context.Context, path string, token string, values url.Values, intf any) error {
    -	err := getResource(ctx, api.httpclient, api.endpoint+path, token, values, intf, api)
    +	headers, err := getResource(ctx, api.httpclient, api.endpoint+path, token, values, intf, api)
     	api.checkWarnings(intf, path, values)
    +	api.fireResponseHeaders(path, headers)
     	return err
     }
     
     // postJSONMethod posts JSON to a slack web method.
     func (api *Client) postJSONMethod(ctx context.Context, path string, token string, jsonBody []byte, intf any) error {
    -	err := postJSON(ctx, api.httpclient, api.endpoint+path, token, jsonBody, intf, api)
    +	headers, err := postJSON(ctx, api.httpclient, api.endpoint+path, token, jsonBody, intf, api)
     	api.checkWarnings(intf, path, jsonBody)
    +	api.fireResponseHeaders(path, headers)
     	return err
     }
     
    @@ -256,3 +272,9 @@ func (api *Client) checkWarnings(intf any, path string, request any) {
     		}
     	}
     }
    +
    +func (api *Client) fireResponseHeaders(path string, headers http.Header) {
    +	if api.onResponseHeaders != nil && headers != nil {
    +		api.onResponseHeaders(path, headers)
    +	}
    +}
    diff --git a/users.go b/users.go
    index 32e1d6b6b..b20accdf9 100644
    --- a/users.go
    +++ b/users.go
    @@ -649,7 +649,7 @@ func (api *Client) SetUserCustomFieldsContext(ctx context.Context, userID string
     	}
     
     	response := &userResponseFull{}
    -	if err := postForm(ctx, api.httpclient, APIURL+"users.profile.set", values, response, api); err != nil {
    +	if _, err := postForm(ctx, api.httpclient, APIURL+"users.profile.set", values, response, api); err != nil {
     		return err
     	}
     
    
    From 5271c49854c3a5d9fe1f59c5632701fd12aa0932 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sat, 4 Apr 2026 21:50:10 +0200
    Subject: [PATCH 520/606] feat(dnd): add team_id support to GetDNDInfo and
     GetDNDTeamInfo
    
    Closes #1157.
    ---
     CHANGELOG.md |  5 +++++
     dnd.go       | 36 +++++++++++++++++++++++++-----------
     dnd_test.go  | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++
     3 files changed, 80 insertions(+), 11 deletions(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index e2d19c191..0925264dc 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -23,6 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
       exposes a `Header` field directly. For all other methods, use
       `OptionOnResponseHeaders(func(method string, headers http.Header))` to register a
       callback that fires after every API call. ([#1076])
    +- **`DNDOptionTeamID`** — `GetDNDInfo` and `GetDNDTeamInfo` now accept optional
    +  `ParamOption` arguments. Use `DNDOptionTeamID("T...")` to pass `team_id`, which is
    +  required after workspace migration (Slack returns `missing_argument` without it).
    +  ([#1157])
     - **`UpdateUserGroupMembersList` / `UpdateUserGroupMembersListContext`** — Convenience
       wrappers around `UpdateUserGroupMembers` that accept `[]string` instead of a
       comma-separated string, enabling clean chaining with `GetUserGroupMembers`. ([#1172])
    @@ -384,6 +388,7 @@ for details.
     [#1541]: https://github.com/slack-go/slack/issues/1541
     [#1172]: https://github.com/slack-go/slack/issues/1172
     [#1076]: https://github.com/slack-go/slack/issues/1076
    +[#1157]: https://github.com/slack-go/slack/issues/1157
     [#1196]: https://github.com/slack-go/slack/issues/1196
     
     [Unreleased]: https://github.com/slack-go/slack/compare/v0.20.0...HEAD
    diff --git a/dnd.go b/dnd.go
    index 81eaf5024..4f6b35a58 100644
    --- a/dnd.go
    +++ b/dnd.go
    @@ -7,6 +7,14 @@ import (
     	"strings"
     )
     
    +// DNDOptionTeamID sets the team_id parameter for DND methods. Required after
    +// workspace migration when the API returns missing_argument: team_id.
    +func DNDOptionTeamID(teamID string) ParamOption {
    +	return func(v *url.Values) {
    +		v.Set("team_id", teamID)
    +	}
    +}
    +
     type SnoozeDebug struct {
     	SnoozeEndDate string `json:"snooze_end_date"`
     }
    @@ -52,7 +60,7 @@ func (api *Client) EndDND() error {
     }
     
     // EndDNDContext ends the user's scheduled Do Not Disturb session with a custom context.
    -// Slack API docs: https://api.slack.com/methods/dnd.endDnd
    +// Slack API docs: https://docs.slack.dev/reference/methods/dnd.endDnd
     func (api *Client) EndDNDContext(ctx context.Context) error {
     	values := url.Values{
     		"token": {api.token},
    @@ -74,7 +82,7 @@ func (api *Client) EndSnooze() (*DNDStatus, error) {
     }
     
     // EndSnoozeContext ends the current user's snooze mode with a custom context.
    -// Slack API docs: https://api.slack.com/methods/dnd.endSnooze
    +// Slack API docs: https://docs.slack.dev/reference/methods/dnd.endSnooze
     func (api *Client) EndSnoozeContext(ctx context.Context) (*DNDStatus, error) {
     	values := url.Values{
     		"token": {api.token},
    @@ -89,19 +97,22 @@ func (api *Client) EndSnoozeContext(ctx context.Context) (*DNDStatus, error) {
     
     // GetDNDInfo provides information about a user's current Do Not Disturb settings.
     // For more information see the GetDNDInfoContext documentation.
    -func (api *Client) GetDNDInfo(user *string) (*DNDStatus, error) {
    -	return api.GetDNDInfoContext(context.Background(), user)
    +func (api *Client) GetDNDInfo(user *string, options ...ParamOption) (*DNDStatus, error) {
    +	return api.GetDNDInfoContext(context.Background(), user, options...)
     }
     
     // GetDNDInfoContext provides information about a user's current Do Not Disturb settings with a custom context.
    -// Slack API docs: https://api.slack.com/methods/dnd.info
    -func (api *Client) GetDNDInfoContext(ctx context.Context, user *string) (*DNDStatus, error) {
    +// Slack API docs: https://docs.slack.dev/reference/methods/dnd.info/
    +func (api *Client) GetDNDInfoContext(ctx context.Context, user *string, options ...ParamOption) (*DNDStatus, error) {
     	values := url.Values{
     		"token": {api.token},
     	}
     	if user != nil {
     		values.Set("user", *user)
     	}
    +	for _, opt := range options {
    +		opt(&values)
    +	}
     
     	response, err := api.dndRequest(ctx, "dnd.info", values)
     	if err != nil {
    @@ -112,17 +123,20 @@ func (api *Client) GetDNDInfoContext(ctx context.Context, user *string) (*DNDSta
     
     // GetDNDTeamInfo provides information about a user's current Do Not Disturb settings.
     // For more information see the GetDNDTeamInfoContext documentation.
    -func (api *Client) GetDNDTeamInfo(users []string) (map[string]DNDStatus, error) {
    -	return api.GetDNDTeamInfoContext(context.Background(), users)
    +func (api *Client) GetDNDTeamInfo(users []string, options ...ParamOption) (map[string]DNDStatus, error) {
    +	return api.GetDNDTeamInfoContext(context.Background(), users, options...)
     }
     
     // GetDNDTeamInfoContext provides information about a user's current Do Not Disturb settings with a custom context.
    -// Slack API docs: https://api.slack.com/methods/dnd.teamInfo
    -func (api *Client) GetDNDTeamInfoContext(ctx context.Context, users []string) (map[string]DNDStatus, error) {
    +// Slack API docs: https://docs.slack.dev/reference/methods/dnd.teamInfo
    +func (api *Client) GetDNDTeamInfoContext(ctx context.Context, users []string, options ...ParamOption) (map[string]DNDStatus, error) {
     	values := url.Values{
     		"token": {api.token},
     		"users": {strings.Join(users, ",")},
     	}
    +	for _, opt := range options {
    +		opt(&values)
    +	}
     	response := &dndTeamInfoResponse{}
     
     	if err := api.postMethod(ctx, "dnd.teamInfo", values, response); err != nil {
    @@ -145,7 +159,7 @@ func (api *Client) SetSnooze(minutes int) (*DNDStatus, error) {
     // SetSnoozeContext adjusts the snooze duration for a user's Do Not Disturb settings.
     // If a snooze session is not already active for the user, invoking this method will
     // begin one for the specified duration.
    -// Slack API docs: https://api.slack.com/methods/dnd.setSnooze
    +// Slack API docs: https://docs.slack.dev/reference/methods/dnd.setSnooze
     func (api *Client) SetSnoozeContext(ctx context.Context, minutes int) (*DNDStatus, error) {
     	values := url.Values{
     		"token":       {api.token},
    diff --git a/dnd_test.go b/dnd_test.go
    index 81f20b95c..264f641b8 100644
    --- a/dnd_test.go
    +++ b/dnd_test.go
    @@ -2,6 +2,7 @@ package slack
     
     import (
     	"net/http"
    +	"net/http/httptest"
     	"reflect"
     	"testing"
     )
    @@ -124,6 +125,55 @@ func TestSlack_GetDNDTeamInfo(t *testing.T) {
     	}
     }
     
    +func TestSlack_GetDNDInfoWithTeamID(t *testing.T) {
    +	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +		if err := r.ParseForm(); err != nil {
    +			w.WriteHeader(http.StatusBadRequest)
    +			return
    +		}
    +		if r.FormValue("team_id") != "T12345" {
    +			w.Header().Set("Content-Type", "application/json")
    +			w.Write([]byte(`{"ok":false,"error":"missing_argument"}`))
    +			return
    +		}
    +		w.Header().Set("Content-Type", "application/json")
    +		w.Write([]byte(`{"ok":true,"dnd_enabled":true,"next_dnd_start_ts":1450416600,"next_dnd_end_ts":1450452600}`))
    +	}))
    +	defer ts.Close()
    +
    +	api := New("testing-token", OptionAPIURL(ts.URL+"/"))
    +	_, err := api.GetDNDInfo(nil, DNDOptionTeamID("T12345"))
    +	if err != nil {
    +		t.Fatalf("Unexpected error: %s", err)
    +	}
    +}
    +
    +func TestSlack_GetDNDTeamInfoWithTeamID(t *testing.T) {
    +	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +		if err := r.ParseForm(); err != nil {
    +			w.WriteHeader(http.StatusBadRequest)
    +			return
    +		}
    +		if r.FormValue("team_id") != "T12345" {
    +			w.Header().Set("Content-Type", "application/json")
    +			w.Write([]byte(`{"ok":false,"error":"missing_argument"}`))
    +			return
    +		}
    +		w.Header().Set("Content-Type", "application/json")
    +		w.Write([]byte(`{"ok":true,"users":{"U023BECGF":{"dnd_enabled":true,"next_dnd_start_ts":1450387800,"next_dnd_end_ts":1450423800}}}`))
    +	}))
    +	defer ts.Close()
    +
    +	api := New("testing-token", OptionAPIURL(ts.URL+"/"))
    +	result, err := api.GetDNDTeamInfo(nil, DNDOptionTeamID("T12345"))
    +	if err != nil {
    +		t.Fatalf("Unexpected error: %s", err)
    +	}
    +	if _, ok := result["U023BECGF"]; !ok {
    +		t.Fatal("expected U023BECGF in result")
    +	}
    +}
    +
     func TestSlack_SetSnooze(t *testing.T) {
     	http.HandleFunc("/dnd.setSnooze", func(w http.ResponseWriter, r *http.Request) {
     		w.Header().Set("Content-Type", "application/json")
    
    From badaa652a2b30ee433584f79ebc6786dfb9b4da1 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sat, 4 Apr 2026 22:12:43 +0200
    Subject: [PATCH 521/606] feat(users): add SetUserProfile to set full profile
     in one call
    
    Closes ##1158.
    ---
     CHANGELOG.md  |  4 ++++
     users.go      | 50 +++++++++++++++++++++++++++++++++++++++++++++++++-
     users_test.go | 24 ++++++++++++++++++++++++
     3 files changed, 77 insertions(+), 1 deletion(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 0925264dc..6d9a5b0b1 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -30,6 +30,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     - **`UpdateUserGroupMembersList` / `UpdateUserGroupMembersListContext`** — Convenience
       wrappers around `UpdateUserGroupMembers` that accept `[]string` instead of a
       comma-separated string, enabling clean chaining with `GetUserGroupMembers`. ([#1172])
    +- **`SetUserProfile` / `SetUserProfileContext`** — Set multiple user profile fields in a
    +  single API call by passing a `*UserProfile` struct to `users.profile.set`. Complements
    +  the existing single-field methods (`SetUserRealName`, `SetUserCustomStatus`, etc.).
    +  ([#1158])
     - **API warning callbacks** — Slack API responses may include a `warnings` field with
       deprecation notices or usage hints. Use `OptionWarnings(func(warnings []string))` to
       register a callback that receives these warnings. ([#1540])
    diff --git a/users.go b/users.go
    index b20accdf9..1d724c045 100644
    --- a/users.go
    +++ b/users.go
    @@ -81,7 +81,7 @@ func (fields *UserProfileCustomFields) UnmarshalJSON(b []byte) error {
     // MarshalJSON is the implementation of the json.Marshaler interface.
     func (fields UserProfileCustomFields) MarshalJSON() ([]byte, error) {
     	if len(fields.fields) == 0 {
    -		return []byte("[]"), nil
    +		return []byte("{}"), nil
     	}
     	return json.Marshal(fields.fields)
     }
    @@ -606,6 +606,54 @@ func (api *Client) SetUserRealNameContextWithUser(ctx context.Context, user, rea
     	return response.Err()
     }
     
    +// SetUserProfile sets the profile for the provided user.
    +// For more information see the SetUserProfileContext documentation.
    +func (api *Client) SetUserProfile(user string, profile *UserProfile) error {
    +	return api.SetUserProfileContext(context.Background(), user, profile)
    +}
    +
    +// SetUserProfileContext sets the profile for the provided user with a custom context.
    +//
    +// The profile parameter is serialized as-is. Fields present in the JSON (including
    +// zero-value fields without an omitempty tag, such as RealName and DisplayName) will
    +// be updated by Slack. To avoid unintended changes, retrieve the current profile with
    +// GetUserProfile, modify the desired fields, and pass the result.
    +//
    +// For setting individual fields, prefer the targeted methods: SetUserRealName,
    +// SetUserCustomStatus, SetUserCustomFields.
    +//
    +// If a workspace admin has mapped custom profile fields to standard fields (e.g.
    +// title), the custom field takes precedence. Update the custom field via
    +// SetUserCustomFields instead.
    +//
    +// The user parameter is required when setting another user's profile (admin only,
    +// paid plans). Pass an empty string to modify the authenticated user's own profile.
    +//
    +// Slack API docs: https://docs.slack.dev/reference/methods/users.profile.set/
    +func (api *Client) SetUserProfileContext(ctx context.Context, user string, profile *UserProfile) error {
    +	profileJSON, err := json.Marshal(profile)
    +	if err != nil {
    +		return err
    +	}
    +
    +	values := url.Values{
    +		"token":   {api.token},
    +		"profile": {string(profileJSON)},
    +	}
    +
    +	// optional field. It should not be set if empty
    +	if user != "" {
    +		values["user"] = []string{user}
    +	}
    +
    +	response := &userResponseFull{}
    +	if err = api.postMethod(ctx, "users.profile.set", values, response); err != nil {
    +		return err
    +	}
    +
    +	return response.Err()
    +}
    +
     // SetUserCustomFields sets Custom Profile fields on the provided users account.
     // For more information see the SetUserCustomFieldsContext documentation.
     func (api *Client) SetUserCustomFields(userID string, customFields map[string]UserProfileCustomField) error {
    diff --git a/users_test.go b/users_test.go
    index c591fe958..de42675b1 100644
    --- a/users_test.go
    +++ b/users_test.go
    @@ -329,6 +329,8 @@ func TestUserProfileSet(t *testing.T) {
     
     	up.RealName = "Real Name Test"
     	testSetUserRealName(api, up, t)
    +
    +	testSetUserProfile(api, up, t)
     }
     
     func testSetUserRealName(api *Client, up *UserProfile, t *testing.T) {
    @@ -388,6 +390,28 @@ func testSetUserCustomStatusWithUser(api *Client, user string, up *UserProfile,
     	}
     }
     
    +func testSetUserProfile(api *Client, up *UserProfile, t *testing.T) {
    +	profile := &UserProfile{
    +		RealName:    "Set Profile Test",
    +		DisplayName: "setprofile",
    +		Title:       "Engineer",
    +	}
    +
    +	if err := api.SetUserProfile("U1234567", profile); err != nil {
    +		t.Fatalf("SetUserProfile() = %#v, want ", err)
    +	}
    +
    +	if up.RealName != profile.RealName {
    +		t.Fatalf("UserProfile.RealName = %q, want %q", up.RealName, profile.RealName)
    +	}
    +	if up.DisplayName != profile.DisplayName {
    +		t.Fatalf("UserProfile.DisplayName = %q, want %q", up.DisplayName, profile.DisplayName)
    +	}
    +	if up.Title != profile.Title {
    +		t.Fatalf("UserProfile.Title = %q, want %q", up.Title, profile.Title)
    +	}
    +}
    +
     func testUnsetUserCustomStatus(api *Client, up *UserProfile, t *testing.T) {
     	if err := api.UnsetUserCustomStatus(); err != nil {
     		t.Fatalf(`UnsetUserCustomStatus() = %#v, want `, err)
    
    From b0498259ce795523afc034f51c352a71939d2514 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sat, 4 Apr 2026 23:15:01 +0200
    Subject: [PATCH 522/606] fix(users): test on marshall
    
    ---
     users_test.go | 6 +++---
     1 file changed, 3 insertions(+), 3 deletions(-)
    
    diff --git a/users_test.go b/users_test.go
    index de42675b1..cf59a4ac5 100644
    --- a/users_test.go
    +++ b/users_test.go
    @@ -721,8 +721,8 @@ func TestUserProfileCustomFieldsMarshalJSON(t *testing.T) {
     	if err != nil {
     		t.Fatal(err)
     	}
    -	if string(b) != "[]" {
    -		t.Fatalf(`string(b) = "%s", wanted "[]"`, string(b))
    +	if string(b) != "{}" {
    +		t.Fatalf(`string(b) = "%s", wanted "{}"`, string(b))
     	}
     	fields = getTestUserProfileCustomFields()
     	if _, err := json.Marshal(fields); err != nil {
    @@ -826,7 +826,7 @@ func TestUserUnmarshalJSON(t *testing.T) {
     			"huddle_state_expiration_ts": 1648596421,
     			"start_date": "2022-01-01",
     			"team": "T12345678",
    -			"fields": []
    +			"fields": {}
     		},
     		"is_bot": false,
     		"is_admin": true,
    
    From ace86a6d08dd395ea77ef0f44b4764c05567951c Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 5 Apr 2026 09:41:03 +0200
    Subject: [PATCH 523/606] feat(socketmode): handle
     Handle{View{Submission,Closed},Shortcut}
    
    Dispatch `shortcut`/`message_action`, `view_submission` and `view_closed` interactions by
    `CallbackID`, matching the existing `HandleInteractionBlockAction` and
    `HandleSlashCommand` pattern.
    
    Closes #1161.
    ---
     CHANGELOG.md                          |   4 +
     socketmode/socketmode_handler.go      | 109 +++++++--
     socketmode/socketmode_handler_test.go | 303 +++++++++++++++++++++++++-
     3 files changed, 391 insertions(+), 25 deletions(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 6d9a5b0b1..58c2ca56d 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -56,6 +56,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
       unmarshalling. ([#1218])
     - **`Blocks`, `Attachments`, `Files`, `Upload` fields on `AppMentionEvent`** — these fields
       are sent by Slack in `app_mention` event payloads but were silently dropped. ([#961])
    +- **`HandleShortcut`, `HandleViewSubmission`, `HandleViewClosed` in socketmode handler** —
    +  Level 3 handlers that dispatch `shortcut`/`message_action`, `view_submission`, and
    +  `view_closed` interactions by `CallbackID`, matching the pattern of
    +  `HandleInteractionBlockAction` and `HandleSlashCommand`. ([#1161])
     
     ### Changed
     
    diff --git a/socketmode/socketmode_handler.go b/socketmode/socketmode_handler.go
    index 5b56f2954..97f000e8d 100644
    --- a/socketmode/socketmode_handler.go
    +++ b/socketmode/socketmode_handler.go
    @@ -16,8 +16,11 @@ type SocketmodeHandler struct {
     	InteractionEventMap map[slack.InteractionType][]SocketmodeHandlerFunc
     	EventApiMap         map[slackevents.EventsAPIType][]SocketmodeHandlerFunc
     	//lvl 3 - the most userfriendly way of managing event
    -	InteractionBlockActionEventMap map[string]SocketmodeHandlerFunc
    -	SlashCommandMap                map[string]SocketmodeHandlerFunc
    +	InteractionBlockActionEventMap    map[string]SocketmodeHandlerFunc
    +	InteractionShortcutEventMap       map[string]SocketmodeHandlerFunc
    +	InteractionViewSubmissionEventMap map[string]SocketmodeHandlerFunc
    +	InteractionViewClosedEventMap     map[string]SocketmodeHandlerFunc
    +	SlashCommandMap                   map[string]SocketmodeHandlerFunc
     
     	Default SocketmodeHandlerFunc
     }
    @@ -35,15 +38,21 @@ func NewSocketmodeHandler(client *Client) *SocketmodeHandler {
     	eventApiMap := make(map[slackevents.EventsAPIType][]SocketmodeHandlerFunc)
     
     	interactionBlockActionEventMap := make(map[string]SocketmodeHandlerFunc)
    +	shortcutMap := make(map[string]SocketmodeHandlerFunc)
    +	viewSubmissionMap := make(map[string]SocketmodeHandlerFunc)
    +	viewClosedMap := make(map[string]SocketmodeHandlerFunc)
     	slackCommandMap := make(map[string]SocketmodeHandlerFunc)
     
     	return &SocketmodeHandler{
    -		Client:                         client,
    -		EventMap:                       eventMap,
    -		EventApiMap:                    eventApiMap,
    -		InteractionEventMap:            interactionEventMap,
    -		InteractionBlockActionEventMap: interactionBlockActionEventMap,
    -		SlashCommandMap:                slackCommandMap,
    +		Client:                            client,
    +		EventMap:                          eventMap,
    +		EventApiMap:                       eventApiMap,
    +		InteractionEventMap:               interactionEventMap,
    +		InteractionBlockActionEventMap:    interactionBlockActionEventMap,
    +		InteractionShortcutEventMap:       shortcutMap,
    +		InteractionViewSubmissionEventMap: viewSubmissionMap,
    +		InteractionViewClosedEventMap:     viewClosedMap,
    +		SlashCommandMap:                   slackCommandMap,
     		Default: func(e *Event, c *Client) {
     			c.log.Printf("Unexpected event type received: %v\n", e.Type)
     		},
    @@ -57,11 +66,12 @@ func (r *SocketmodeHandler) Handle(et EventType, f SocketmodeHandlerFunc) {
     }
     
     // Register a middleware or handler for an Interaction
    -// There is several types of interactions, decated functions lets you better handle them
    +// There is several types of interactions, dedicated functions lets you better handle them
     // See
     // * HandleInteractionBlockAction
    -// * (Not Implemented) HandleShortcut
    -// * (Not Implemented) HandleView
    +// * HandleShortcut
    +// * HandleViewSubmission
    +// * HandleViewClosed
     func (r *SocketmodeHandler) HandleInteraction(et slack.InteractionType, f SocketmodeHandlerFunc) {
     	r.InteractionEventMap[et] = append(r.InteractionEventMap[et], f)
     }
    @@ -80,6 +90,48 @@ func (r *SocketmodeHandler) HandleInteractionBlockAction(actionID string, f Sock
     	r.InteractionBlockActionEventMap[actionID] = f
     }
     
    +// Register a middleware or handler for a Shortcut (global or message) referenced by its CallbackID
    +func (r *SocketmodeHandler) HandleShortcut(callbackID string, f SocketmodeHandlerFunc) {
    +	if callbackID == "" {
    +		panic("invalid callbackID cannot be empty")
    +	}
    +	if f == nil {
    +		panic("invalid handler cannot be nil")
    +	}
    +	if _, exist := r.InteractionShortcutEventMap[callbackID]; exist {
    +		panic("multiple registrations for callbackID " + callbackID)
    +	}
    +	r.InteractionShortcutEventMap[callbackID] = f
    +}
    +
    +// Register a middleware or handler for a View Submission referenced by its CallbackID
    +func (r *SocketmodeHandler) HandleViewSubmission(callbackID string, f SocketmodeHandlerFunc) {
    +	if callbackID == "" {
    +		panic("invalid callbackID cannot be empty")
    +	}
    +	if f == nil {
    +		panic("invalid handler cannot be nil")
    +	}
    +	if _, exist := r.InteractionViewSubmissionEventMap[callbackID]; exist {
    +		panic("multiple registrations for callbackID " + callbackID)
    +	}
    +	r.InteractionViewSubmissionEventMap[callbackID] = f
    +}
    +
    +// Register a middleware or handler for a View Closed event referenced by its CallbackID
    +func (r *SocketmodeHandler) HandleViewClosed(callbackID string, f SocketmodeHandlerFunc) {
    +	if callbackID == "" {
    +		panic("invalid callbackID cannot be empty")
    +	}
    +	if f == nil {
    +		panic("invalid handler cannot be nil")
    +	}
    +	if _, exist := r.InteractionViewClosedEventMap[callbackID]; exist {
    +		panic("multiple registrations for callbackID " + callbackID)
    +	}
    +	r.InteractionViewClosedEventMap[callbackID] = f
    +}
    +
     // Register a middleware or handler for an Event (from slackevents)
     func (r *SocketmodeHandler) HandleEvents(et slackevents.EventsAPIType, f SocketmodeHandlerFunc) {
     	r.EventApiMap[et] = append(r.EventApiMap[et], f)
    @@ -193,19 +245,36 @@ func (r *SocketmodeHandler) interactionDispatcher(evt *Event) bool {
     		ishandled = true
     	}
     
    -	// Level 3 - interaction with actionID
    -	blockActions := interaction.ActionCallback.BlockActions
    -	// outmoded approach won`t be implemented
    -	// attachments_actions := interaction.ActionCallback.AttachmentActions
    -
    -	for _, action := range blockActions {
    -		if handler, ok := r.InteractionBlockActionEventMap[action.ActionID]; ok {
    -
    +	// Level 3 - interaction with actionID or callbackID
    +	switch interaction.Type {
    +	case slack.InteractionTypeBlockActions:
    +		blockActions := interaction.ActionCallback.BlockActions
    +		// outmoded approach won`t be implemented
    +		// attachments_actions := interaction.ActionCallback.AttachmentActions
    +
    +		for _, action := range blockActions {
    +			if handler, ok := r.InteractionBlockActionEventMap[action.ActionID]; ok {
    +				go handler(evt, r.Client)
    +				ishandled = true
    +			}
    +		}
    +	case slack.InteractionTypeShortcut, slack.InteractionTypeMessageAction:
    +		if handler, ok := r.InteractionShortcutEventMap[interaction.CallbackID]; ok {
    +			go handler(evt, r.Client)
    +			ishandled = true
    +		}
    +	case slack.InteractionTypeViewSubmission:
    +		if handler, ok := r.InteractionViewSubmissionEventMap[interaction.View.CallbackID]; ok {
    +			go handler(evt, r.Client)
    +			ishandled = true
    +		}
    +	case slack.InteractionTypeViewClosed:
    +		if handler, ok := r.InteractionViewClosedEventMap[interaction.View.CallbackID]; ok {
     			go handler(evt, r.Client)
    -
     			ishandled = true
     		}
     	}
    +
     	return ishandled
     }
     
    diff --git a/socketmode/socketmode_handler_test.go b/socketmode/socketmode_handler_test.go
    index 623e15ef9..194572ced 100644
    --- a/socketmode/socketmode_handler_test.go
    +++ b/socketmode/socketmode_handler_test.go
    @@ -16,17 +16,23 @@ func init_SocketmodeHandler() *SocketmodeHandler {
     	interactioneventMap := make(map[slack.InteractionType][]SocketmodeHandlerFunc)
     	eventApiMap := make(map[slackevents.EventsAPIType][]SocketmodeHandlerFunc)
     	interactionBlockActionEventMap := make(map[string]SocketmodeHandlerFunc)
    +	shortcutMap := make(map[string]SocketmodeHandlerFunc)
    +	viewSubmissionMap := make(map[string]SocketmodeHandlerFunc)
    +	viewClosedMap := make(map[string]SocketmodeHandlerFunc)
     	slashCommandMap := make(map[string]SocketmodeHandlerFunc)
     
     	return &SocketmodeHandler{
     		Client: &Client{
     			log: log.New(os.Stderr, "slack-go/slack/socketmode", log.LstdFlags|log.Lshortfile),
     		},
    -		EventMap:                       eventMap,
    -		EventApiMap:                    eventApiMap,
    -		InteractionEventMap:            interactioneventMap,
    -		InteractionBlockActionEventMap: interactionBlockActionEventMap,
    -		SlashCommandMap:                slashCommandMap,
    +		EventMap:                          eventMap,
    +		EventApiMap:                       eventApiMap,
    +		InteractionEventMap:               interactioneventMap,
    +		InteractionBlockActionEventMap:    interactionBlockActionEventMap,
    +		InteractionShortcutEventMap:       shortcutMap,
    +		InteractionViewSubmissionEventMap: viewSubmissionMap,
    +		InteractionViewClosedEventMap:     viewClosedMap,
    +		SlashCommandMap:                   slashCommandMap,
     	}
     }
     
    @@ -76,6 +82,18 @@ func middleware_slach_command(evt *Event, client *Client) {
     	//do nothing
     }
     
    +func middleware_shortcut(evt *Event, client *Client) {
    +	//do nothing
    +}
    +
    +func middleware_view_submission(evt *Event, client *Client) {
    +	//do nothing
    +}
    +
    +func middleware_view_closed(evt *Event, client *Client) {
    +	//do nothing
    +}
    +
     func TestSocketmodeHandler_Handle(t *testing.T) {
     	type args struct {
     		evt      Event
    @@ -444,6 +462,215 @@ func TestSocketmodeHandler_HandleSlashCommand(t *testing.T) {
     	}
     }
     
    +func TestSocketmodeHandler_HandleShortcut(t *testing.T) {
    +	type args struct {
    +		evt      Event
    +		register func(*SocketmodeHandler, chan<- string)
    +	}
    +	tests := []struct {
    +		name string
    +		args args
    +		want string
    +	}{
    +		{
    +			name: "Global shortcut matches registered function",
    +			args: args{
    +				evt: Event{
    +					Type: EventTypeInteractive,
    +					Data: slack.InteractionCallback{
    +						Type:       slack.InteractionTypeShortcut,
    +						CallbackID: "open_ticket",
    +					},
    +				},
    +				register: func(r *SocketmodeHandler, c chan<- string) {
    +					r.HandleShortcut("open_ticket", testing_wrapper(c, middleware_shortcut))
    +				},
    +			},
    +			want: "github.com/slack-go/slack/socketmode.middleware_shortcut",
    +		}, {
    +			name: "Message action matches registered function",
    +			args: args{
    +				evt: Event{
    +					Type: EventTypeInteractive,
    +					Data: slack.InteractionCallback{
    +						Type:       slack.InteractionTypeMessageAction,
    +						CallbackID: "save_message",
    +					},
    +				},
    +				register: func(r *SocketmodeHandler, c chan<- string) {
    +					r.HandleShortcut("save_message", testing_wrapper(c, middleware_shortcut))
    +				},
    +			},
    +			want: "github.com/slack-go/slack/socketmode.middleware_shortcut",
    +		}, {
    +			name: "Event do not Match any registered function",
    +			args: args{
    +				evt: Event{
    +					Type: EventTypeInteractive,
    +					Data: slack.InteractionCallback{
    +						Type:       slack.InteractionTypeShortcut,
    +						CallbackID: "other_shortcut",
    +					},
    +				},
    +				register: func(r *SocketmodeHandler, c chan<- string) {
    +					r.HandleShortcut("open_ticket", testing_wrapper(c, middleware_shortcut))
    +				},
    +			},
    +			want: "github.com/slack-go/slack/socketmode.defaultmiddleware",
    +		},
    +	}
    +	for _, tt := range tests {
    +		t.Run(tt.name, func(t *testing.T) {
    +			r := init_SocketmodeHandler()
    +
    +			c := make(chan string)
    +
    +			tt.args.register(r, c)
    +			r.HandleDefault(testing_wrapper(c, defaultmiddleware))
    +
    +			r.dispatcher(tt.args.evt)
    +
    +			got := <-c
    +
    +			if got != tt.want {
    +				t.Fatalf("%s was not called for EventTy(\"%v\"), got %v", tt.want, tt.args.evt.Type, got)
    +			}
    +		})
    +	}
    +}
    +
    +func TestSocketmodeHandler_HandleViewSubmission(t *testing.T) {
    +	type args struct {
    +		evt      Event
    +		register func(*SocketmodeHandler, chan<- string)
    +	}
    +	tests := []struct {
    +		name string
    +		args args
    +		want string
    +	}{
    +		{
    +			name: "Event Match registered function",
    +			args: args{
    +				evt: Event{
    +					Type: EventTypeInteractive,
    +					Data: slack.InteractionCallback{
    +						Type: slack.InteractionTypeViewSubmission,
    +						View: slack.View{
    +							CallbackID: "create_ticket",
    +						},
    +					},
    +				},
    +				register: func(r *SocketmodeHandler, c chan<- string) {
    +					r.HandleViewSubmission("create_ticket", testing_wrapper(c, middleware_view_submission))
    +				},
    +			},
    +			want: "github.com/slack-go/slack/socketmode.middleware_view_submission",
    +		}, {
    +			name: "Event do not Match any registered function",
    +			args: args{
    +				evt: Event{
    +					Type: EventTypeInteractive,
    +					Data: slack.InteractionCallback{
    +						Type: slack.InteractionTypeViewSubmission,
    +						View: slack.View{
    +							CallbackID: "other_modal",
    +						},
    +					},
    +				},
    +				register: func(r *SocketmodeHandler, c chan<- string) {
    +					r.HandleViewSubmission("create_ticket", testing_wrapper(c, middleware_view_submission))
    +				},
    +			},
    +			want: "github.com/slack-go/slack/socketmode.defaultmiddleware",
    +		},
    +	}
    +	for _, tt := range tests {
    +		t.Run(tt.name, func(t *testing.T) {
    +			r := init_SocketmodeHandler()
    +
    +			c := make(chan string)
    +
    +			tt.args.register(r, c)
    +			r.HandleDefault(testing_wrapper(c, defaultmiddleware))
    +
    +			r.dispatcher(tt.args.evt)
    +
    +			got := <-c
    +
    +			if got != tt.want {
    +				t.Fatalf("%s was not called for EventTy(\"%v\"), got %v", tt.want, tt.args.evt.Type, got)
    +			}
    +		})
    +	}
    +}
    +
    +func TestSocketmodeHandler_HandleViewClosed(t *testing.T) {
    +	type args struct {
    +		evt      Event
    +		register func(*SocketmodeHandler, chan<- string)
    +	}
    +	tests := []struct {
    +		name string
    +		args args
    +		want string
    +	}{
    +		{
    +			name: "Event Match registered function",
    +			args: args{
    +				evt: Event{
    +					Type: EventTypeInteractive,
    +					Data: slack.InteractionCallback{
    +						Type: slack.InteractionTypeViewClosed,
    +						View: slack.View{
    +							CallbackID: "create_ticket",
    +						},
    +					},
    +				},
    +				register: func(r *SocketmodeHandler, c chan<- string) {
    +					r.HandleViewClosed("create_ticket", testing_wrapper(c, middleware_view_closed))
    +				},
    +			},
    +			want: "github.com/slack-go/slack/socketmode.middleware_view_closed",
    +		}, {
    +			name: "Event do not Match any registered function",
    +			args: args{
    +				evt: Event{
    +					Type: EventTypeInteractive,
    +					Data: slack.InteractionCallback{
    +						Type: slack.InteractionTypeViewClosed,
    +						View: slack.View{
    +							CallbackID: "other_modal",
    +						},
    +					},
    +				},
    +				register: func(r *SocketmodeHandler, c chan<- string) {
    +					r.HandleViewClosed("create_ticket", testing_wrapper(c, middleware_view_closed))
    +				},
    +			},
    +			want: "github.com/slack-go/slack/socketmode.defaultmiddleware",
    +		},
    +	}
    +	for _, tt := range tests {
    +		t.Run(tt.name, func(t *testing.T) {
    +			r := init_SocketmodeHandler()
    +
    +			c := make(chan string)
    +
    +			tt.args.register(r, c)
    +			r.HandleDefault(testing_wrapper(c, defaultmiddleware))
    +
    +			r.dispatcher(tt.args.evt)
    +
    +			got := <-c
    +
    +			if got != tt.want {
    +				t.Fatalf("%s was not called for EventTy(\"%v\"), got %v", tt.want, tt.args.evt.Type, got)
    +			}
    +		})
    +	}
    +}
    +
     func TestSocketmodeHandler_Handle_errors(t *testing.T) {
     	type args struct {
     		register func(*SocketmodeHandler, chan<- string)
    @@ -496,6 +723,72 @@ func TestSocketmodeHandler_Handle_errors(t *testing.T) {
     					r.HandleInteractionBlockAction("action_id", testing_wrapper(c, middleware_interaction_block_action))
     				},
     			},
    +		}, {
    +			name: "Attempt to register empty Shortcut callbackID",
    +			args: args{
    +				register: func(r *SocketmodeHandler, c chan<- string) {
    +					r.HandleShortcut("", testing_wrapper(c, middleware_shortcut))
    +				},
    +			},
    +		}, {
    +			name: "Attempt to register nil Shortcut handler",
    +			args: args{
    +				register: func(r *SocketmodeHandler, c chan<- string) {
    +					r.HandleShortcut("callback_id", nil)
    +				},
    +			},
    +		}, {
    +			name: "Attempt to register duplicate Shortcut callbackID",
    +			args: args{
    +				register: func(r *SocketmodeHandler, c chan<- string) {
    +					r.HandleShortcut("callback_id", testing_wrapper(c, middleware_shortcut))
    +					r.HandleShortcut("callback_id", testing_wrapper(c, middleware_shortcut))
    +				},
    +			},
    +		}, {
    +			name: "Attempt to register empty ViewSubmission callbackID",
    +			args: args{
    +				register: func(r *SocketmodeHandler, c chan<- string) {
    +					r.HandleViewSubmission("", testing_wrapper(c, middleware_view_submission))
    +				},
    +			},
    +		}, {
    +			name: "Attempt to register nil ViewSubmission handler",
    +			args: args{
    +				register: func(r *SocketmodeHandler, c chan<- string) {
    +					r.HandleViewSubmission("callback_id", nil)
    +				},
    +			},
    +		}, {
    +			name: "Attempt to register duplicate ViewSubmission callbackID",
    +			args: args{
    +				register: func(r *SocketmodeHandler, c chan<- string) {
    +					r.HandleViewSubmission("callback_id", testing_wrapper(c, middleware_view_submission))
    +					r.HandleViewSubmission("callback_id", testing_wrapper(c, middleware_view_submission))
    +				},
    +			},
    +		}, {
    +			name: "Attempt to register empty ViewClosed callbackID",
    +			args: args{
    +				register: func(r *SocketmodeHandler, c chan<- string) {
    +					r.HandleViewClosed("", testing_wrapper(c, middleware_view_closed))
    +				},
    +			},
    +		}, {
    +			name: "Attempt to register nil ViewClosed handler",
    +			args: args{
    +				register: func(r *SocketmodeHandler, c chan<- string) {
    +					r.HandleViewClosed("callback_id", nil)
    +				},
    +			},
    +		}, {
    +			name: "Attempt to register duplicate ViewClosed callbackID",
    +			args: args{
    +				register: func(r *SocketmodeHandler, c chan<- string) {
    +					r.HandleViewClosed("callback_id", testing_wrapper(c, middleware_view_closed))
    +					r.HandleViewClosed("callback_id", testing_wrapper(c, middleware_view_closed))
    +				},
    +			},
     		},
     	}
     	for _, tt := range tests {
    
    From b5fb1566d05b2b890261b5db92ec810f5887f693 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 5 Apr 2026 11:37:57 +0200
    Subject: [PATCH 524/606] docs(socketmode): clarify app-level vs bot token
     usage in examples
    
    Closes #941.
    ---
     examples/socketmode/socketmode.go             | 21 ++++++++++++++++++
     .../socketmode_handler/socketmode_handler.go  | 22 +++++++++++++++++++
     2 files changed, 43 insertions(+)
    
    diff --git a/examples/socketmode/socketmode.go b/examples/socketmode/socketmode.go
    index f85104a3e..ee79bbdcc 100644
    --- a/examples/socketmode/socketmode.go
    +++ b/examples/socketmode/socketmode.go
    @@ -1,3 +1,24 @@
    +// This example demonstrates a basic Socket Mode client that listens for
    +// Events API events, interactive components, and slash commands.
    +//
    +// Socket Mode requires two tokens:
    +//
    +//   - App-level token (xapp-…): opens the WebSocket connection via
    +//     apps.connections.open. Generate one in your app settings under
    +//     Basic Information → App-Level Tokens with the connections:write scope.
    +//
    +//   - Bot token (xoxb-…): used for all Web API calls (posting messages,
    +//     opening views, etc.). This is the token you get after installing the
    +//     app to a workspace.
    +//
    +// The bot token is passed to slack.New() as the primary credential; the
    +// app-level token is passed via slack.OptionAppLevelToken().
    +//
    +// To run:
    +//
    +//	export SLACK_APP_TOKEN=xapp-...
    +//	export SLACK_BOT_TOKEN=xoxb-...
    +//	go run examples/socketmode/socketmode.go
     package main
     
     import (
    diff --git a/examples/socketmode_handler/socketmode_handler.go b/examples/socketmode_handler/socketmode_handler.go
    index a12e6d194..c23c059e9 100644
    --- a/examples/socketmode_handler/socketmode_handler.go
    +++ b/examples/socketmode_handler/socketmode_handler.go
    @@ -1,3 +1,25 @@
    +// This example demonstrates the SocketmodeHandler, a higher-level API that
    +// routes Socket Mode events to registered handler functions instead of
    +// requiring a manual event-loop switch.
    +//
    +// Socket Mode requires two tokens:
    +//
    +//   - App-level token (xapp-…): opens the WebSocket connection via
    +//     apps.connections.open. Generate one in your app settings under
    +//     Basic Information → App-Level Tokens with the connections:write scope.
    +//
    +//   - Bot token (xoxb-…): used for all Web API calls (posting messages,
    +//     opening views, etc.). This is the token you get after installing the
    +//     app to a workspace.
    +//
    +// The bot token is passed to slack.New() as the primary credential; the
    +// app-level token is passed via slack.OptionAppLevelToken().
    +//
    +// To run:
    +//
    +//	export SLACK_APP_TOKEN=xapp-...
    +//	export SLACK_BOT_TOKEN=xoxb-...
    +//	go run examples/socketmode_handler/socketmode_handler.go
     package main
     
     import (
    
    From a03edf6e065a9934d65913e897762c77ada8774b Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 5 Apr 2026 12:01:25 +0200
    Subject: [PATCH 525/606] chore(admin)!: adjust error strings to not start with
     uppercase
    
    BREAKING CHANGE: if you are matching the error content in your code, please look at all
    the strings changed in this commit and adjust your code.
    ---
     CHANGELOG.md    |  6 ++++++
     admin.go        | 12 ++++++------
     conversation.go | 14 ++++++++------
     3 files changed, 20 insertions(+), 12 deletions(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 58c2ca56d..acd03c240 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -63,6 +63,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ### Changed
     
    +- Adjusted some `admin` errors that started with uppercase to be lowercase per go
    +  conventions.
    +
    +  > [!WARNING]
    +  > **Breaking change.** If you are matching the error content in your code, this is a
    +  > BREAKING CHANGE.
     - **`WebhookMessage.UnfurlLinks` and `UnfurlMedia` are now `*bool`** — Previously these
       were `bool` with `omitempty`, which meant `false` was silently stripped from the JSON
       payload. Users could not explicitly disable link or media unfurling via webhooks. The
    diff --git a/admin.go b/admin.go
    index d51426b56..1b0d2178a 100644
    --- a/admin.go
    +++ b/admin.go
    @@ -59,7 +59,7 @@ func (api *Client) InviteGuestContext(ctx context.Context, teamName, channel, fi
     
     	err := api.adminRequest(ctx, "invite", teamName, values)
     	if err != nil {
    -		return fmt.Errorf("Failed to invite single-channel guest: %s", err)
    +		return fmt.Errorf("failed to invite single-channel guest: %s", err)
     	}
     
     	return nil
    @@ -86,7 +86,7 @@ func (api *Client) InviteRestrictedContext(ctx context.Context, teamName, channe
     
     	err := api.adminRequest(ctx, "invite", teamName, values)
     	if err != nil {
    -		return fmt.Errorf("Failed to restricted account: %s", err)
    +		return fmt.Errorf("failed to restricted account: %s", err)
     	}
     
     	return nil
    @@ -110,7 +110,7 @@ func (api *Client) InviteToTeamContext(ctx context.Context, teamName, firstName,
     
     	err := api.adminRequest(ctx, "invite", teamName, values)
     	if err != nil {
    -		return fmt.Errorf("Failed to invite to team: %s", err)
    +		return fmt.Errorf("failed to invite to team: %s", err)
     	}
     
     	return nil
    @@ -132,7 +132,7 @@ func (api *Client) SetRegularContext(ctx context.Context, teamName, user string)
     
     	err := api.adminRequest(ctx, "setRegular", teamName, values)
     	if err != nil {
    -		return fmt.Errorf("Failed to change the user (%s) to a regular user: %s", user, err)
    +		return fmt.Errorf("failed to change the user (%s) to a regular user: %s", user, err)
     	}
     
     	return nil
    @@ -154,7 +154,7 @@ func (api *Client) SendSSOBindingEmailContext(ctx context.Context, teamName, use
     
     	err := api.adminRequest(ctx, "sendSSOBind", teamName, values)
     	if err != nil {
    -		return fmt.Errorf("Failed to send SSO binding email for user (%s): %s", user, err)
    +		return fmt.Errorf("failed to send SSO binding email for user (%s): %s", user, err)
     	}
     
     	return nil
    @@ -177,7 +177,7 @@ func (api *Client) SetUltraRestrictedContext(ctx context.Context, teamName, uid,
     
     	err := api.adminRequest(ctx, "setUltraRestricted", teamName, values)
     	if err != nil {
    -		return fmt.Errorf("Failed to ultra-restrict account: %s", err)
    +		return fmt.Errorf("failed to ultra-restrict account: %s", err)
     	}
     
     	return nil
    diff --git a/conversation.go b/conversation.go
    index 2f8e7f42d..1e1985e1f 100644
    --- a/conversation.go
    +++ b/conversation.go
    @@ -353,12 +353,14 @@ func (api *Client) InviteUsersToConversationContext(ctx context.Context, channel
     	return response.Channel, response.Err()
     }
     
    -// The following functions are for inviting users to a channel but setting the `force`
    -// parameter to true. We have added this so that we don't break the existing API.
    -//
    -// IMPORTANT: If we ever get here for _another_ parameter, we should consider refactoring
    -// this to be more flexible.
    -//
    +/**********************************************************************************
    +The following functions are for inviting users to a channel but setting the `force`
    +parameter to true. We have added this so that we don't break the existing API.
    +
    +IMPORTANT: If we ever get here for _another_ parameter, we should consider refactoring
    +this to be more flexible.
    +*/
    +
     // ForceInviteUsersToConversation invites users to a channel but sets the `force`
     // parameter to true.
     //
    
    From 29adca49a8ce3c1043281741f8f5f16ec30b8e53 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 5 Apr 2026 12:04:26 +0200
    Subject: [PATCH 526/606] chore(info)!: remove deprecated nil-returning lookup
     methods
    
    Remove `GetBotByID`, `GetUserByID`, `GetChannelByID`, `GetGroupByID`, and `GetIMByID` from
    Info. These methods were already deprecated and returned nil unconditionally. They were
    returning nil since 2017/2018 so it's time we get rid of them.
    
    I'm classifying this as a BREAKING CHANGE but please, if you were still calling them...
    ---
     CHANGELOG.md |  9 +++++++++
     info.go      | 25 -------------------------
     2 files changed, 9 insertions(+), 25 deletions(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index acd03c240..bbd72b23c 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -16,6 +16,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     - **`slackevents.MessageAction`**, **`MessageActionEntity`**, **`MessageActionResponse`** —
       Associated types that only support legacy `interactive_message` payloads.
     
    +### Removed
    +
    +- **`Info.GetBotByID`, `GetUserByID`, `GetChannelByID`, `GetGroupByID`, `GetIMByID`** —
    +  These methods were deprecated and returned `nil` unconditionally. They have been removed.
    +
    +  > [!WARNING]
    +  > **Breaking change.** If you are calling any of these methods, remove those calls — they
    +  > were already no-ops.
    +
     ### Added
     
     - **HTTP response headers** — Slack API response headers (e.g. `X-OAuth-Scopes`,
    diff --git a/info.go b/info.go
    index a026ab494..8e8ccc49e 100644
    --- a/info.go
    +++ b/info.go
    @@ -452,28 +452,3 @@ type infoResponseFull struct {
     	Info
     	SlackResponse
     }
    -
    -// GetBotByID is deprecated and returns nil
    -func (info Info) GetBotByID(botID string) *Bot {
    -	return nil
    -}
    -
    -// GetUserByID is deprecated and returns nil
    -func (info Info) GetUserByID(userID string) *User {
    -	return nil
    -}
    -
    -// GetChannelByID is deprecated and returns nil
    -func (info Info) GetChannelByID(channelID string) *Channel {
    -	return nil
    -}
    -
    -// GetGroupByID is deprecated and returns nil
    -func (info Info) GetGroupByID(groupID string) *Group {
    -	return nil
    -}
    -
    -// GetIMByID is deprecated and returns nil
    -func (info Info) GetIMByID(imID string) *IM {
    -	return nil
    -}
    
    From a870697ba1c17f44fa60616386826bcfeed401a6 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 5 Apr 2026 12:32:19 +0200
    Subject: [PATCH 527/606] chore(im)!: remote IM struct, move IsUserDelted to
     Conversation
    
    ---
     CHANGELOG.md         |  8 ++++++++
     conversation.go      |  1 +
     conversation_test.go |  8 ++++----
     im.go                | 21 ---------------------
     4 files changed, 13 insertions(+), 25 deletions(-)
     delete mode 100644 im.go
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index bbd72b23c..fd010dc4e 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -18,6 +18,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ### Removed
     
    +- **`IM` struct** — Removed the `IM` struct (and unused internal types `imChannel`,
    +  `imResponseFull`). The `IsUserDeleted` field has been moved to `Conversation`, where it
    +  is populated for IM-type conversations. Code using `IM` should switch to `Conversation`.
    +
    +  > [!NOTE]
    +  > In practice no user should be affected — `IM` was never returned by any public API
    +  > method in this library, so there was no way to obtain one outside of manual construction.
    +
     - **`Info.GetBotByID`, `GetUserByID`, `GetChannelByID`, `GetGroupByID`, `GetIMByID`** —
       These methods were deprecated and returned `nil` unconditionally. They have been removed.
     
    diff --git a/conversation.go b/conversation.go
    index 1e1985e1f..a0ce707b0 100644
    --- a/conversation.go
    +++ b/conversation.go
    @@ -29,6 +29,7 @@ type Conversation struct {
     	IsPrivate          bool     `json:"is_private"`
     	IsReadOnly         bool     `json:"is_read_only"`
     	IsMpIM             bool     `json:"is_mpim"`
    +	IsUserDeleted      bool     `json:"is_user_deleted"`
     	Unlinked           int      `json:"unlinked"`
     	NameNormalized     string   `json:"name_normalized"`
     	NumMembers         int      `json:"num_members"`
    diff --git a/conversation_test.go b/conversation_test.go
    index 488fe9169..1c7a83e93 100644
    --- a/conversation_test.go
    +++ b/conversation_test.go
    @@ -420,8 +420,8 @@ var simpleIM = `{
         "unread_count_display": 0
     }`
     
    -func unmarshalIM(j string) (*IM, error) {
    -	im := &IM{}
    +func unmarshalIM(j string) (*Conversation, error) {
    +	im := &Conversation{}
     	if err := json.Unmarshal([]byte(j), &im); err != nil {
     		return nil, err
     	}
    @@ -434,7 +434,7 @@ func TestSimpleIM(t *testing.T) {
     	assertSimpleIM(t, im)
     }
     
    -func assertSimpleIM(t *testing.T, im *IM) {
    +func assertSimpleIM(t *testing.T, im *Conversation) {
     	assert.NotNil(t, im)
     	assert.Equal(t, "D024BFF1M", im.ID)
     	assert.Equal(t, true, im.IsIM)
    @@ -448,7 +448,7 @@ func assertSimpleIM(t *testing.T, im *IM) {
     }
     
     func TestCreateSimpleIM(t *testing.T) {
    -	im := &IM{}
    +	im := &Conversation{}
     	im.ID = "D024BFF1M"
     	im.IsIM = true
     	im.User = "U024BE7LH"
    diff --git a/im.go b/im.go
    deleted file mode 100644
    index 7c4bc2572..000000000
    --- a/im.go
    +++ /dev/null
    @@ -1,21 +0,0 @@
    -package slack
    -
    -type imChannel struct {
    -	ID string `json:"id"`
    -}
    -
    -type imResponseFull struct {
    -	NoOp          bool      `json:"no_op"`
    -	AlreadyClosed bool      `json:"already_closed"`
    -	AlreadyOpen   bool      `json:"already_open"`
    -	Channel       imChannel `json:"channel"`
    -	IMs           []IM      `json:"ims"`
    -	History
    -	SlackResponse
    -}
    -
    -// IM contains information related to the Direct Message channel
    -type IM struct {
    -	Conversation
    -	IsUserDeleted bool `json:"is_user_deleted"`
    -}
    
    From 2b3c6e20dcbd44dbacdf8962b413bc1914069a81 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 5 Apr 2026 12:38:21 +0200
    Subject: [PATCH 528/606] refactor(blocks): use type switch variable in
     NewAccessory
    
    ---
     block_element.go | 26 +++++++++++++-------------
     1 file changed, 13 insertions(+), 13 deletions(-)
    
    diff --git a/block_element.go b/block_element.go
    index b271f132f..128493407 100644
    --- a/block_element.go
    +++ b/block_element.go
    @@ -67,31 +67,31 @@ type Accessory struct {
     
     // NewAccessory returns a new Accessory for a given block element
     func NewAccessory(element BlockElement) *Accessory {
    -	switch element.(type) {
    +	switch element := element.(type) {
     	case *ImageBlockElement:
    -		return &Accessory{ImageElement: element.(*ImageBlockElement)}
    +		return &Accessory{ImageElement: element}
     	case *ButtonBlockElement:
    -		return &Accessory{ButtonElement: element.(*ButtonBlockElement)}
    +		return &Accessory{ButtonElement: element}
     	case *OverflowBlockElement:
    -		return &Accessory{OverflowElement: element.(*OverflowBlockElement)}
    +		return &Accessory{OverflowElement: element}
     	case *DatePickerBlockElement:
    -		return &Accessory{DatePickerElement: element.(*DatePickerBlockElement)}
    +		return &Accessory{DatePickerElement: element}
     	case *TimePickerBlockElement:
    -		return &Accessory{TimePickerElement: element.(*TimePickerBlockElement)}
    +		return &Accessory{TimePickerElement: element}
     	case *PlainTextInputBlockElement:
    -		return &Accessory{PlainTextInputElement: element.(*PlainTextInputBlockElement)}
    +		return &Accessory{PlainTextInputElement: element}
     	case *RichTextInputBlockElement:
    -		return &Accessory{RichTextInputElement: element.(*RichTextInputBlockElement)}
    +		return &Accessory{RichTextInputElement: element}
     	case *RadioButtonsBlockElement:
    -		return &Accessory{RadioButtonsElement: element.(*RadioButtonsBlockElement)}
    +		return &Accessory{RadioButtonsElement: element}
     	case *SelectBlockElement:
    -		return &Accessory{SelectElement: element.(*SelectBlockElement)}
    +		return &Accessory{SelectElement: element}
     	case *MultiSelectBlockElement:
    -		return &Accessory{MultiSelectElement: element.(*MultiSelectBlockElement)}
    +		return &Accessory{MultiSelectElement: element}
     	case *CheckboxGroupsBlockElement:
    -		return &Accessory{CheckboxGroupsBlockElement: element.(*CheckboxGroupsBlockElement)}
    +		return &Accessory{CheckboxGroupsBlockElement: element}
     	case *WorkflowButtonBlockElement:
    -		return &Accessory{WorkflowButtonElement: element.(*WorkflowButtonBlockElement)}
    +		return &Accessory{WorkflowButtonElement: element}
     	default:
     		return &Accessory{UnknownElement: element.(*UnknownBlockElement)}
     	}
    
    From bb8821c70e7f7633276ee257f97070f2e430a4a5 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 5 Apr 2026 16:03:52 +0200
    Subject: [PATCH 529/606] docs(views): document `ViewSubmissionResponse`
     constructors
    
    Closes #726.
    Closes #1013.
    ---
     CHANGELOG.md  | 12 ++++++++++++
     views.go      | 48 ++++++++++++++++++++++++++++++++++++++++++++----
     views_test.go |  8 ++++----
     3 files changed, 60 insertions(+), 8 deletions(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index fd010dc4e..ddd7637c1 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -78,6 +78,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
       `view_closed` interactions by `CallbackID`, matching the pattern of
       `HandleInteractionBlockAction` and `HandleSlashCommand`. ([#1161])
     
    +### Documentation
    +
    +- **`ViewSubmissionResponse` constructors** — `NewClearViewSubmissionResponse`,
    +  `NewUpdateViewSubmissionResponse`, `NewPushViewSubmissionResponse`, and
    +  `NewErrorsViewSubmissionResponse` now have doc comments explaining the HTTP response
    +  pattern (write JSON and return promptly) and the Socket Mode pattern (pass as Ack
    +  payload). `NewErrorsViewSubmissionResponse` additionally documents that map keys must
    +  be `BlockID`s of `InputBlock` elements. ([#726], [#1013])
    +- **Socket Mode examples** — `examples/socketmode/` and `examples/socketmode_handler/` now
    +  have doc comments explaining the two-token requirement: app-level token (`xapp-`) for the
    +  WebSocket connection and bot token (`xoxb-`) for API calls. ([#941])
    +
     ### Changed
     
     - Adjusted some `admin` errors that started with uppercase to be lowercase per go
    diff --git a/views.go b/views.go
    index bd9c9a03a..c16503c0e 100644
    --- a/views.go
    +++ b/views.go
    @@ -70,12 +70,30 @@ type ViewSubmissionResponse struct {
     	Errors         map[string]string  `json:"errors,omitempty"`
     }
     
    +// NewClearViewSubmissionResponse closes all open modals in the current stack.
    +//
    +// For HTTP-based apps, marshal this to JSON and write it as the HTTP response
    +// body. The response is not sent until the handler returns, so start any slow
    +// work in a goroutine and return promptly.
    +//
    +// For Socket Mode apps, pass this as the payload argument to Ack().
    +//
    +// See https://docs.slack.dev/surfaces/modals#closing_views
     func NewClearViewSubmissionResponse() *ViewSubmissionResponse {
     	return &ViewSubmissionResponse{
     		ResponseAction: RAClear,
     	}
     }
     
    +// NewUpdateViewSubmissionResponse replaces the current modal with a new view.
    +//
    +// For HTTP-based apps, marshal this to JSON and write it as the HTTP response
    +// body. The response is not sent until the handler returns, so start any slow
    +// work in a goroutine and return promptly.
    +//
    +// For Socket Mode apps, pass this as the payload argument to Ack().
    +//
    +// See https://docs.slack.dev/surfaces/modals#updating_views
     func NewUpdateViewSubmissionResponse(view *ModalViewRequest) *ViewSubmissionResponse {
     	return &ViewSubmissionResponse{
     		ResponseAction: RAUpdate,
    @@ -83,6 +101,15 @@ func NewUpdateViewSubmissionResponse(view *ModalViewRequest) *ViewSubmissionResp
     	}
     }
     
    +// NewPushViewSubmissionResponse pushes a new view onto the modal stack.
    +//
    +// For HTTP-based apps, marshal this to JSON and write it as the HTTP response
    +// body. The response is not sent until the handler returns, so start any slow
    +// work in a goroutine and return promptly.
    +//
    +// For Socket Mode apps, pass this as the payload argument to Ack().
    +//
    +// See https://docs.slack.dev/surfaces/modals#pushing_views
     func NewPushViewSubmissionResponse(view *ModalViewRequest) *ViewSubmissionResponse {
     	return &ViewSubmissionResponse{
     		ResponseAction: RAPush,
    @@ -90,6 +117,19 @@ func NewPushViewSubmissionResponse(view *ModalViewRequest) *ViewSubmissionRespon
     	}
     }
     
    +// NewErrorsViewSubmissionResponse displays validation errors on form fields.
    +//
    +// The errors map keys must be the BlockID of an InputBlock in the view. Keys
    +// that reference other block types (e.g. SectionBlock) are silently ignored
    +// by Slack, which shows a generic "trouble connecting" error instead.
    +//
    +// For HTTP-based apps, marshal this to JSON and write it as the HTTP response
    +// body. The response is not sent until the handler returns, so start any slow
    +// work in a goroutine and return promptly.
    +//
    +// For Socket Mode apps, pass this as the payload argument to Ack().
    +//
    +// See https://docs.slack.dev/surfaces/modals/#displaying_errors
     func NewErrorsViewSubmissionResponse(errors map[string]string) *ViewSubmissionResponse {
     	return &ViewSubmissionResponse{
     		ResponseAction: RAErrors,
    @@ -181,7 +221,7 @@ func ValidateUniqueBlockID(view ModalViewRequest) bool {
     }
     
     // OpenViewContext opens a view for a user with a custom context.
    -// Slack API docs: https://api.slack.com/methods/views.open
    +// Slack API docs: https://docs.slack.dev/reference/methods/views.open
     func (api *Client) OpenViewContext(
     	ctx context.Context,
     	triggerID string,
    @@ -222,7 +262,7 @@ func (api *Client) PublishView(userID string, view HomeTabViewRequest, hash stri
     }
     
     // PublishViewContext publishes a static view for a user with a custom context.
    -// Slack API docs: https://api.slack.com/methods/views.publish
    +// Slack API docs: https://docs.slack.dev/reference/methods/views.publish
     func (api *Client) PublishViewContext(
     	ctx context.Context,
     	req PublishViewContextRequest,
    @@ -249,7 +289,7 @@ func (api *Client) PushView(triggerID string, view ModalViewRequest) (*ViewRespo
     }
     
     // PushViewContext pushes a view onto the stack of a root view with a custom context.
    -// Slack API docs: https://api.slack.com/methods/views.push
    +// Slack API docs: https://docs.slack.dev/reference/methods/views.push
     func (api *Client) PushViewContext(
     	ctx context.Context,
     	triggerID string,
    @@ -281,7 +321,7 @@ func (api *Client) UpdateView(view ModalViewRequest, externalID, hash, viewID st
     }
     
     // UpdateViewContext updates an existing view with a custom context.
    -// Slack API docs: https://api.slack.com/methods/views.update
    +// Slack API docs: https://docs.slack.dev/reference/methods/views.update
     func (api *Client) UpdateViewContext(
     	ctx context.Context,
     	view ModalViewRequest,
    diff --git a/views_test.go b/views_test.go
    index c6de663e2..2bb49dd67 100644
    --- a/views_test.go
    +++ b/views_test.go
    @@ -848,14 +848,14 @@ func TestSlack_PushViewSubmissionResponse(t *testing.T) {
     
     func TestSlack_ErrorsViewSubmissionResponse(t *testing.T) {
     	resp := NewErrorsViewSubmissionResponse(map[string]string{
    -		"input_text_action_id": "Please input a name that's at least 6 characters long",
    -		"file_action_id":       "File exceeded size limit of 5 KB",
    +		"name_input_block": "Please input a name that's at least 6 characters long",
    +		"file_input_block": "File exceeded size limit of 5 KB",
     	})
     	rawResp := `{
     		"response_action": "errors",
     		"errors": {
    -			"input_text_action_id": "Please input a name that's at least 6 characters long",
    -			"file_action_id": "File exceeded size limit of 5 KB"
    +			"name_input_block": "Please input a name that's at least 6 characters long",
    +			"file_input_block": "File exceeded size limit of 5 KB"
     		}
     	}`
     
    
    From ca622276fba56c24035e327df4290b4abef8ccac Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 5 Apr 2026 16:30:03 +0200
    Subject: [PATCH 530/606] fix(blocks): preserve unknown rich text elements on
     round-trip (#764)
    
    ---
     block_conv_test.go | 45 +++++++++++++++++++++++++++++++++++++++++++++
     block_rich_text.go |  8 ++++++++
     2 files changed, 53 insertions(+)
    
    diff --git a/block_conv_test.go b/block_conv_test.go
    index ff58b50b3..9bce11507 100644
    --- a/block_conv_test.go
    +++ b/block_conv_test.go
    @@ -100,6 +100,51 @@ func TestAllBlockElementTypesUnmarshal(t *testing.T) {
     	}
     }
     
    +func TestRichTextUnknownRoundTrip(t *testing.T) {
    +	input := `{"type":"rich_text","block_id":"b1","elements":[{"type":"rich_text_unknown_type","key":"val"}]}`
    +	var block RichTextBlock
    +	err := json.Unmarshal([]byte(input), &block)
    +	require.NoError(t, err)
    +	require.Len(t, block.Elements, 1)
    +
    +	u, ok := block.Elements[0].(*RichTextUnknown)
    +	require.True(t, ok, "expected *RichTextUnknown")
    +	assert.Equal(t, RichTextElementType("rich_text_unknown_type"), u.Type)
    +
    +	out, err := json.Marshal(block)
    +	require.NoError(t, err)
    +
    +	var roundTripped map[string]any
    +	err = json.Unmarshal(out, &roundTripped)
    +	require.NoError(t, err)
    +	elems := roundTripped["elements"].([]any)
    +	elem := elems[0].(map[string]any)
    +	assert.Equal(t, "rich_text_unknown_type", elem["type"])
    +	assert.Equal(t, "val", elem["key"])
    +}
    +
    +func TestRichTextSectionUnknownElementRoundTrip(t *testing.T) {
    +	input := `{"type":"rich_text_section","elements":[{"type":"unknown_elem","data":42}]}`
    +	var section RichTextSection
    +	err := json.Unmarshal([]byte(input), §ion)
    +	require.NoError(t, err)
    +	require.Len(t, section.Elements, 1)
    +
    +	_, ok := section.Elements[0].(*RichTextSectionUnknownElement)
    +	require.True(t, ok, "expected *RichTextSectionUnknownElement")
    +
    +	out, err := json.Marshal(section)
    +	require.NoError(t, err)
    +
    +	var roundTripped map[string]any
    +	err = json.Unmarshal(out, &roundTripped)
    +	require.NoError(t, err)
    +	elems := roundTripped["elements"].([]any)
    +	elem := elems[0].(map[string]any)
    +	assert.Equal(t, "unknown_elem", elem["type"])
    +	assert.Equal(t, float64(42), elem["data"])
    +}
    +
     // TestAllAccessoryTypesRoundTrip ensures every Accessory field can survive a
     // marshal→unmarshal round trip. When a new field is added to the Accessory struct
     // and wired into NewAccessory but not into Accessory.UnmarshalJSON, this test
    diff --git a/block_rich_text.go b/block_rich_text.go
    index f0c1eb22e..b0bc87b9b 100644
    --- a/block_rich_text.go
    +++ b/block_rich_text.go
    @@ -103,6 +103,10 @@ func (u RichTextUnknown) RichTextElementType() RichTextElementType {
     	return u.Type
     }
     
    +func (u RichTextUnknown) MarshalJSON() ([]byte, error) {
    +	return []byte(u.Raw), nil
    +}
    +
     type RichTextListElementType string
     
     const (
    @@ -492,6 +496,10 @@ func (r RichTextSectionUnknownElement) RichTextSectionElementType() RichTextSect
     	return r.Type
     }
     
    +func (r RichTextSectionUnknownElement) MarshalJSON() ([]byte, error) {
    +	return []byte(r.Raw), nil
    +}
    +
     // RichTextQuote represents rich_text_quote element type.
     type RichTextQuote struct {
     	Type     RichTextElementType      `json:"type"`
    
    From bfd53c60864174f0884f23639a7a22ec86e4e0f8 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 5 Apr 2026 17:03:36 +0200
    Subject: [PATCH 531/606] feat(oauth): add GetOpenIDConnectUserInfo
    
    Closes #967.
    ---
     CHANGELOG.md  |  3 +++
     oauth.go      | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++
     oauth_test.go | 60 +++++++++++++++++++++++++++++++++++++++++++++
     3 files changed, 131 insertions(+)
     create mode 100644 oauth_test.go
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index ddd7637c1..02f5dbcc5 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -35,6 +35,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ### Added
     
    +- **`GetOpenIDConnectUserInfo` / `GetOpenIDConnectUserInfoContext`** — Returns identity
    +  information about the user associated with the token via `openid.connect.userInfo`.
    +  Complements the existing `GetOpenIDConnectToken` method. ([#967])
     - **HTTP response headers** — Slack API response headers (e.g. `X-OAuth-Scopes`,
       `X-Accepted-OAuth-Scopes`, `X-Ratelimit-*`) are now accessible. `AuthTestResponse`
       exposes a `Header` field directly. For all other methods, use
    diff --git a/oauth.go b/oauth.go
    index 58ceba844..d872ae37d 100644
    --- a/oauth.go
    +++ b/oauth.go
    @@ -177,6 +177,74 @@ func RefreshOAuthV2TokenContext(ctx context.Context, client httpClient, clientID
     	return response, response.Err()
     }
     
    +// OpenIDConnectUserInfoResponse contains the response from openid.connect.userInfo.
    +//
    +// Some of the fields in the response to this method are preceded with https://slack.com/.
    +// These fields are Slack-specific, and they're from the perspective of Slack.
    +type OpenIDConnectUserInfoResponse struct {
    +	Ok bool `json:"ok"`
    +
    +	Sub string `json:"sub"`
    +
    +	UserID string `json:"https://slack.com/user_id"`
    +	TeamID string `json:"https://slack.com/team_id"`
    +
    +	Email             string `json:"email"`
    +	EmailVerified     bool   `json:"email_verified"`
    +	DateEmailVerified int64  `json:"date_email_verified"`
    +
    +	Name       string `json:"name"`
    +	Picture    string `json:"picture"`
    +	GivenName  string `json:"given_name"`
    +	FamilyName string `json:"family_name"`
    +	Locale     string `json:"locale"`
    +
    +	TeamName     string `json:"https://slack.com/team_name"`
    +	TeamDomain   string `json:"https://slack.com/team_domain"`
    +	TeamImage34  string `json:"https://slack.com/team_image_34"`
    +	TeamImage44  string `json:"https://slack.com/team_image_44"`
    +	TeamImage68  string `json:"https://slack.com/team_image_68"`
    +	TeamImage88  string `json:"https://slack.com/team_image_88"`
    +	TeamImage102 string `json:"https://slack.com/team_image_102"`
    +	TeamImage132 string `json:"https://slack.com/team_image_132"`
    +	TeamImage230 string `json:"https://slack.com/team_image_230"`
    +
    +	// `TeamImageDefault` indicates whether the image is a default one (true), or someone
    +	// uploaded their own (false).
    +	TeamImageDefault bool `json:"https://slack.com/team_image_default"`
    +
    +	UserImage24       string `json:"https://slack.com/user_image_24"`
    +	UserImage32       string `json:"https://slack.com/user_image_32"`
    +	UserImage48       string `json:"https://slack.com/user_image_48"`
    +	UserImage72       string `json:"https://slack.com/user_image_72"`
    +	UserImage192      string `json:"https://slack.com/user_image_192"`
    +	UserImage512      string `json:"https://slack.com/user_image_512"`
    +	UserImage1024     string `json:"https://slack.com/user_image_1024"`
    +	UserImageOriginal string `json:"https://slack.com/user_image_original"`
    +
    +	SlackResponse
    +}
    +
    +// GetOpenIDConnectUserInfo returns the user info for the token.
    +// For more details, see GetOpenIDConnectUserInfoContext documentation.
    +func (api *Client) GetOpenIDConnectUserInfo() (*OpenIDConnectUserInfoResponse, error) {
    +	return api.GetOpenIDConnectUserInfoContext(context.Background())
    +}
    +
    +// GetOpenIDConnectUserInfoContext returns identity information about the user associated with the token.
    +// Slack API docs: https://docs.slack.dev/reference/methods/openid.connect.userInfo
    +func (api *Client) GetOpenIDConnectUserInfoContext(ctx context.Context) (*OpenIDConnectUserInfoResponse, error) {
    +	values := url.Values{
    +		"token": {api.token},
    +	}
    +	response := &OpenIDConnectUserInfoResponse{}
    +	err := api.postMethod(ctx, "openid.connect.userInfo", values, response)
    +	if err != nil {
    +		return nil, err
    +	}
    +	return response, response.Err()
    +}
    +
     // GetOpenIDConnectToken exchanges a temporary OAuth verifier code for an access token for Sign in with Slack.
     // For more details, see GetOpenIDConnectTokenContext documentation.
     func GetOpenIDConnectToken(client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OpenIDConnectResponse, err error) {
    diff --git a/oauth_test.go b/oauth_test.go
    new file mode 100644
    index 000000000..1e20b4c0f
    --- /dev/null
    +++ b/oauth_test.go
    @@ -0,0 +1,60 @@
    +package slack
    +
    +import (
    +	"net/http"
    +	"testing"
    +
    +	"github.com/stretchr/testify/assert"
    +	"github.com/stretchr/testify/require"
    +)
    +
    +func getOpenIDConnectUserInfo(rw http.ResponseWriter, r *http.Request) {
    +	rw.Header().Set("Content-Type", "application/json")
    +	rw.Write([]byte(`{
    +		"ok": true,
    +		"sub": "U0R7JM",
    +		"https://slack.com/user_id": "U0R7JM",
    +		"https://slack.com/team_id": "T0R7GR",
    +		"email": "krane@slack-corp.com",
    +		"email_verified": true,
    +		"date_email_verified": 1622128723,
    +		"name": "krane",
    +		"picture": "https://secure.gravatar.com/....png",
    +		"given_name": "Bront",
    +		"family_name": "Kansen",
    +		"locale": "en-US",
    +		"https://slack.com/team_name": "Slack Corp",
    +		"https://slack.com/team_domain": "slackcorp",
    +		"https://slack.com/user_image_24": "...",
    +		"https://slack.com/user_image_32": "...",
    +		"https://slack.com/user_image_48": "...",
    +		"https://slack.com/user_image_72": "...",
    +		"https://slack.com/user_image_192": "...",
    +		"https://slack.com/user_image_512": "...",
    +		"https://slack.com/team_image_default": true
    +	}`))
    +}
    +
    +func TestGetOpenIDConnectUserInfo(t *testing.T) {
    +	http.HandleFunc("/openid.connect.userInfo", getOpenIDConnectUserInfo)
    +
    +	once.Do(startServer)
    +	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
    +
    +	resp, err := api.GetOpenIDConnectUserInfo()
    +	require.NoError(t, err)
    +
    +	assert.Equal(t, "U0R7JM", resp.Sub)
    +	assert.Equal(t, "U0R7JM", resp.UserID)
    +	assert.Equal(t, "T0R7GR", resp.TeamID)
    +	assert.Equal(t, "krane@slack-corp.com", resp.Email)
    +	assert.True(t, resp.EmailVerified)
    +	assert.Equal(t, int64(1622128723), resp.DateEmailVerified)
    +	assert.Equal(t, "krane", resp.Name)
    +	assert.Equal(t, "Bront", resp.GivenName)
    +	assert.Equal(t, "Kansen", resp.FamilyName)
    +	assert.Equal(t, "en-US", resp.Locale)
    +	assert.Equal(t, "Slack Corp", resp.TeamName)
    +	assert.Equal(t, "slackcorp", resp.TeamDomain)
    +	assert.True(t, resp.TeamImageDefault)
    +}
    
    From 9e3d20a82fa0b47a1e754df6dce674ba9045d97a Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 5 Apr 2026 17:36:25 +0200
    Subject: [PATCH 532/606] feat(rtm): add sh_room and channel_updated event
     mappings
    
    - `SHRoomJoinEvent`
    - `SHRoomLeaveEvent`
    - `SHRoomUpdateEvent`
    - `ChannelUpdatedEvent`
    
    Closes #858.
    ---
     CHANGELOG.md                                |   4 +
     examples/rtm_call_events/rtm_call_events.go |  66 ++++++++
     websocket_managed_conn.go                   |   6 +
     websocket_misc.go                           |  88 +++++++++++
     websocket_misc_test.go                      | 167 ++++++++++++++++++++
     5 files changed, 331 insertions(+)
     create mode 100644 examples/rtm_call_events/rtm_call_events.go
     create mode 100644 websocket_misc_test.go
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 02f5dbcc5..d265093bd 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -61,6 +61,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
       events** — these events are now mapped in `EventMapping` with dedicated structs
       (`UserStatusChangedEvent`, `UserHuddleChangedEvent`, `UserProfileChangedEvent`).
       Previously they triggered `UnmarshallingErrorEvent`. ([#1541])
    +- **RTM support for `sh_room_join`, `sh_room_leave`, `sh_room_update`, `channel_updated`
    +  events** — Slack Call/Huddle room events and channel property updates are now mapped with
    +  dedicated structs (`SHRoomJoinEvent`, `SHRoomLeaveEvent`, `SHRoomUpdateEvent`,
    +  `ChannelUpdatedEvent`). ([#858])
     - **`CacheTS` and `EventTS` fields on `UserChangeEvent`** — these fields were sent by Slack
       but silently dropped during unmarshalling.
     - **`workflows.featured` API support** — add, list, remove, and set featured workflows on
    diff --git a/examples/rtm_call_events/rtm_call_events.go b/examples/rtm_call_events/rtm_call_events.go
    new file mode 100644
    index 000000000..4c717698a
    --- /dev/null
    +++ b/examples/rtm_call_events/rtm_call_events.go
    @@ -0,0 +1,66 @@
    +// This example connects via RTM and prints Slack Call/Huddle room events
    +// (sh_room_join, sh_room_leave). Start a call or huddle in a channel where
    +// the bot is present to see the events.
    +//
    +// To run:
    +//
    +//	export SLACK_BOT_TOKEN=xoxb-...
    +//	go run examples/rtm_call_events/rtm_call_events.go
    +package main
    +
    +import (
    +	"fmt"
    +	"log"
    +	"os"
    +
    +	"github.com/slack-go/slack"
    +)
    +
    +func main() {
    +	token := os.Getenv("SLACK_BOT_TOKEN")
    +	if token == "" {
    +		fmt.Fprintln(os.Stderr, "SLACK_BOT_TOKEN environment variable is required")
    +		os.Exit(1)
    +	}
    +
    +	api := slack.New(
    +		token,
    +		slack.OptionDebug(true),
    +		slack.OptionLog(log.New(os.Stdout, "rtm: ", log.Lshortfile|log.LstdFlags)),
    +	)
    +
    +	rtm := api.NewRTM()
    +	go rtm.ManageConnection()
    +
    +	fmt.Println("Listening for call/huddle events... start a call in a channel where this bot is present.")
    +
    +	for msg := range rtm.IncomingEvents {
    +		switch ev := msg.Data.(type) {
    +		case *slack.ConnectedEvent:
    +			fmt.Printf("Connected as %s\n", ev.Info.User.Name)
    +
    +		case *slack.SHRoomJoinEvent:
    +			fmt.Printf("User %s joined call in room %s (channels: %v, participants: %v)\n",
    +				ev.User, ev.Room.ID, ev.Room.Channels, ev.Room.Participants)
    +
    +		case *slack.SHRoomLeaveEvent:
    +			fmt.Printf("User %s left call in room %s (remaining: %v)\n",
    +				ev.User, ev.Room.ID, ev.Room.Participants)
    +
    +		case *slack.SHRoomUpdateEvent:
    +			name := ""
    +			if ev.Room.Name != nil {
    +				name = *ev.Room.Name
    +			}
    +			fmt.Printf("Room %s updated: %q (family: %s, participants: %v)\n",
    +				ev.Room.ID, name, ev.Room.CallFamily, ev.Room.Participants)
    +
    +		case *slack.RTMError:
    +			fmt.Printf("RTM Error: %s\n", ev.Error())
    +
    +		case *slack.InvalidAuthEvent:
    +			fmt.Fprintln(os.Stderr, "Invalid credentials")
    +			return
    +		}
    +	}
    +}
    diff --git a/websocket_managed_conn.go b/websocket_managed_conn.go
    index 55c35d1a8..9c9644f59 100644
    --- a/websocket_managed_conn.go
    +++ b/websocket_managed_conn.go
    @@ -611,4 +611,10 @@ var EventMapping = map[string]interface{}{
     
     	"desktop_notification":       DesktopNotificationEvent{},
     	"mobile_in_app_notification": MobileInAppNotificationEvent{},
    +
    +	"channel_updated": ChannelUpdatedEvent{},
    +
    +	"sh_room_join":   SHRoomJoinEvent{},
    +	"sh_room_leave":  SHRoomLeaveEvent{},
    +	"sh_room_update": SHRoomUpdateEvent{},
     }
    diff --git a/websocket_misc.go b/websocket_misc.go
    index 69e63ef15..06d19f3fb 100644
    --- a/websocket_misc.go
    +++ b/websocket_misc.go
    @@ -165,3 +165,91 @@ type MemberLeftChannelEvent struct {
     	ChannelType string `json:"channel_type"`
     	Team        string `json:"team"`
     }
    +
    +// ChannelUpdatedEvent is fired when a channel's properties are updated (tabs, meeting notes, etc.).
    +type ChannelUpdatedEvent struct {
    +	Type     string         `json:"type"`
    +	Updates  map[string]any `json:"updates"`
    +	Channel  string         `json:"channel"`
    +	Channels []string       `json:"channels"`
    +	EventTS  string         `json:"event_ts"`
    +	TS       string         `json:"ts"`
    +}
    +
    +// SHRoomRecording holds recording metadata for a Slack Call/Huddle room.
    +type SHRoomRecording struct {
    +	CanRecordSummary string `json:"can_record_summary,omitempty"`
    +}
    +
    +// SHRoom represents a Slack Huddle/Call room.
    +type SHRoom struct {
    +	ID                         string                    `json:"id"`
    +	Name                       *string                   `json:"name"` // nullable in Slack's response
    +	MediaServer                string                    `json:"media_server"`
    +	CreatedBy                  string                    `json:"created_by"`
    +	DateStart                  int64                     `json:"date_start"`
    +	DateEnd                    int64                     `json:"date_end"`
    +	Participants               []string                  `json:"participants"`
    +	ParticipantHistory         []string                  `json:"participant_history"`
    +	ParticipantsEvents         map[string]map[string]any `json:"participants_events,omitempty"`
    +	ParticipantsCameraOn       []string                  `json:"participants_camera_on"`
    +	ParticipantsCameraOff      []string                  `json:"participants_camera_off"`
    +	ParticipantsScreenshareOn  []string                  `json:"participants_screenshare_on"`
    +	ParticipantsScreenshareOff []string                  `json:"participants_screenshare_off"`
    +	CanvasThreadTS             string                    `json:"canvas_thread_ts,omitempty"`
    +	ThreadRootTS               string                    `json:"thread_root_ts,omitempty"`
    +	Channels                   []string                  `json:"channels"`
    +	IsDMCall                   bool                      `json:"is_dm_call"`
    +	WasRejected                bool                      `json:"was_rejected"`
    +	WasMissed                  bool                      `json:"was_missed"`
    +	WasAccepted                bool                      `json:"was_accepted"`
    +	HasEnded                   bool                      `json:"has_ended"`
    +	BackgroundID               string                    `json:"background_id,omitempty"`
    +	CanvasBackground           string                    `json:"canvas_background,omitempty"`
    +	IsPrewarmed                bool                      `json:"is_prewarmed,omitempty"`
    +	IsScheduled                bool                      `json:"is_scheduled,omitempty"`
    +	Recording                  *SHRoomRecording          `json:"recording,omitempty"`
    +	Locale                     string                    `json:"locale,omitempty"`
    +	AttachedFileIDs            []string                  `json:"attached_file_ids,omitempty"`
    +	MediaBackendType           string                    `json:"media_backend_type"`
    +	DisplayID                  string                    `json:"display_id,omitempty"`
    +	ExternalUniqueID           string                    `json:"external_unique_id"`
    +	AppID                      string                    `json:"app_id"`
    +	CallFamily                 string                    `json:"call_family,omitempty"`
    +	HuddleLink                 string                    `json:"huddle_link,omitempty"`
    +}
    +
    +// SHRoomHuddle holds the huddle-specific metadata on sh_room events.
    +type SHRoomHuddle struct {
    +	ChannelID string `json:"channel_id"`
    +}
    +
    +// SHRoomJoinEvent is fired when a user joins a Slack Call/Huddle room.
    +type SHRoomJoinEvent struct {
    +	Type    string        `json:"type"`
    +	Room    SHRoom        `json:"room"`
    +	User    string        `json:"user"`
    +	Huddle  *SHRoomHuddle `json:"huddle,omitempty"`
    +	EventTS string        `json:"event_ts"`
    +	TS      string        `json:"ts"`
    +}
    +
    +// SHRoomLeaveEvent is fired when a user leaves a Slack Call/Huddle room.
    +type SHRoomLeaveEvent struct {
    +	Type    string        `json:"type"`
    +	Room    SHRoom        `json:"room"`
    +	User    string        `json:"user"`
    +	Huddle  *SHRoomHuddle `json:"huddle,omitempty"`
    +	EventTS string        `json:"event_ts"`
    +	TS      string        `json:"ts"`
    +}
    +
    +// SHRoomUpdateEvent is fired when a Slack Call/Huddle room is updated.
    +type SHRoomUpdateEvent struct {
    +	Type    string        `json:"type"`
    +	Room    SHRoom        `json:"room"`
    +	User    string        `json:"user"`
    +	Huddle  *SHRoomHuddle `json:"huddle,omitempty"`
    +	EventTS string        `json:"event_ts"`
    +	TS      string        `json:"ts"`
    +}
    diff --git a/websocket_misc_test.go b/websocket_misc_test.go
    new file mode 100644
    index 000000000..b1a27956e
    --- /dev/null
    +++ b/websocket_misc_test.go
    @@ -0,0 +1,167 @@
    +package slack
    +
    +import (
    +	"encoding/json"
    +	"testing"
    +
    +	"github.com/stretchr/testify/assert"
    +	"github.com/stretchr/testify/require"
    +)
    +
    +func TestSHRoomJoinEventUnmarshal(t *testing.T) {
    +	raw := `{
    +		"type": "sh_room_join",
    +		"room": {
    +			"id": "R01XXXBW",
    +			"name": null,
    +			"media_server": "",
    +			"created_by": "U12334",
    +			"date_start": 1607089008,
    +			"date_end": 0,
    +			"participants": ["U12334", "U56789"],
    +			"participant_history": ["U12334", "U56789"],
    +			"participants_camera_on": [],
    +			"participants_camera_off": [],
    +			"participants_screenshare_on": [],
    +			"participants_screenshare_off": [],
    +			"channels": ["C12334"],
    +			"is_dm_call": false,
    +			"was_rejected": false,
    +			"was_missed": false,
    +			"was_accepted": false,
    +			"has_ended": false,
    +			"media_backend_type": "free_willy",
    +			"external_unique_id": "8c92471f-test",
    +			"app_id": "A00"
    +		},
    +		"user": "U12334",
    +		"event_ts": "1607089059.080900",
    +		"ts": "1607089059.080900"
    +	}`
    +
    +	var ev SHRoomJoinEvent
    +	err := json.Unmarshal([]byte(raw), &ev)
    +	require.NoError(t, err)
    +
    +	assert.Equal(t, "sh_room_join", ev.Type)
    +	assert.Equal(t, "U12334", ev.User)
    +	assert.Equal(t, "R01XXXBW", ev.Room.ID)
    +	assert.Nil(t, ev.Room.Name)
    +	assert.Equal(t, "U12334", ev.Room.CreatedBy)
    +	assert.Equal(t, int64(1607089008), ev.Room.DateStart)
    +	assert.Equal(t, []string{"U12334", "U56789"}, ev.Room.Participants)
    +	assert.Equal(t, []string{"C12334"}, ev.Room.Channels)
    +	assert.False(t, ev.Room.IsDMCall)
    +	assert.Equal(t, "free_willy", ev.Room.MediaBackendType)
    +	assert.Equal(t, "1607089059.080900", ev.EventTS)
    +}
    +
    +func TestSHRoomLeaveEventUnmarshal(t *testing.T) {
    +	raw := `{
    +		"type": "sh_room_leave",
    +		"room": {
    +			"id": "R01XXXBW",
    +			"name": null,
    +			"media_server": "",
    +			"created_by": "U12334",
    +			"date_start": 1607089008,
    +			"date_end": 0,
    +			"participants": ["U12334"],
    +			"participant_history": ["U12334", "U56789"],
    +			"participants_camera_on": [],
    +			"participants_camera_off": [],
    +			"participants_screenshare_on": [],
    +			"participants_screenshare_off": [],
    +			"channels": ["C12334"],
    +			"is_dm_call": false,
    +			"was_rejected": false,
    +			"was_missed": false,
    +			"was_accepted": false,
    +			"has_ended": false,
    +			"media_backend_type": "free_willy",
    +			"external_unique_id": "8c92471f-test",
    +			"app_id": "A00"
    +		},
    +		"user": "U56789",
    +		"event_ts": "1607091086.081500",
    +		"ts": "1607091086.081500"
    +	}`
    +
    +	var ev SHRoomLeaveEvent
    +	err := json.Unmarshal([]byte(raw), &ev)
    +	require.NoError(t, err)
    +
    +	assert.Equal(t, "sh_room_leave", ev.Type)
    +	assert.Equal(t, "U56789", ev.User)
    +	assert.Equal(t, "R01XXXBW", ev.Room.ID)
    +	assert.Equal(t, []string{"U12334"}, ev.Room.Participants)
    +	assert.Equal(t, "1607091086.081500", ev.EventTS)
    +}
    +
    +func TestSHRoomUpdateEventUnmarshal(t *testing.T) {
    +	raw := `{
    +		"type": "sh_room_update",
    +		"room": {
    +			"id": "R0AQSG0Q859",
    +			"name": "A sort of topic",
    +			"media_server": "",
    +			"created_by": "U031L4VDD",
    +			"date_start": 1775402709,
    +			"date_end": 0,
    +			"participants": ["U031L4VDD"],
    +			"participant_history": ["U031L4VDD"],
    +			"participants_events": {"U031L4VDD": {"joined": true, "camera_on": false}},
    +			"participants_camera_on": [],
    +			"participants_camera_off": [],
    +			"participants_screenshare_on": [],
    +			"participants_screenshare_off": [],
    +			"canvas_thread_ts": "1775402709.576349",
    +			"thread_root_ts": "1775402709.576349",
    +			"channels": ["C031L4VDP"],
    +			"is_dm_call": false,
    +			"was_rejected": false,
    +			"was_missed": false,
    +			"was_accepted": false,
    +			"has_ended": false,
    +			"background_id": "GRADIENT_02",
    +			"canvas_background": "GRADIENT_02",
    +			"is_prewarmed": true,
    +			"is_scheduled": false,
    +			"recording": {"can_record_summary": "unavailable"},
    +			"locale": "en-US",
    +			"attached_file_ids": [],
    +			"media_backend_type": "free_willy",
    +			"display_id": "",
    +			"external_unique_id": "755e016f-aae1-4d4f-abcc-952b1b872713",
    +			"app_id": "A00",
    +			"call_family": "huddle",
    +			"huddle_link": "https://app.slack.com/huddle/T031L4VD9/C031L4VDP"
    +		},
    +		"user": "U031L4VDD",
    +		"huddle": {"channel_id": "C031L4VDP"},
    +		"event_ts": "1775402785.000200",
    +		"ts": "1775402785.000200"
    +	}`
    +
    +	var ev SHRoomUpdateEvent
    +	err := json.Unmarshal([]byte(raw), &ev)
    +	require.NoError(t, err)
    +
    +	assert.Equal(t, "sh_room_update", ev.Type)
    +	assert.Equal(t, "U031L4VDD", ev.User)
    +	assert.Equal(t, "R0AQSG0Q859", ev.Room.ID)
    +	assert.NotNil(t, ev.Room.Name)
    +	assert.Equal(t, "A sort of topic", *ev.Room.Name)
    +	assert.Equal(t, "huddle", ev.Room.CallFamily)
    +	assert.True(t, ev.Room.IsPrewarmed)
    +	assert.Equal(t, "1775402709.576349", ev.Room.CanvasThreadTS)
    +	assert.Equal(t, "GRADIENT_02", ev.Room.BackgroundID)
    +	assert.Equal(t, "en-US", ev.Room.Locale)
    +	assert.NotNil(t, ev.Room.Recording)
    +	assert.Equal(t, "unavailable", ev.Room.Recording.CanRecordSummary)
    +	assert.Equal(t, "https://app.slack.com/huddle/T031L4VD9/C031L4VDP", ev.Room.HuddleLink)
    +	assert.NotNil(t, ev.Huddle)
    +	assert.Equal(t, "C031L4VDP", ev.Huddle.ChannelID)
    +	assert.NotNil(t, ev.Room.ParticipantsEvents)
    +	assert.Contains(t, ev.Room.ParticipantsEvents, "U031L4VDD")
    +}
    
    From 6a63662365caf9c15d18f0e70ea17ddaaa080bd2 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Fri, 3 Apr 2026 20:04:36 +0200
    Subject: [PATCH 533/606] fix(websocket): add missing events (apps, activity,
     badge count)
    
    Closes #1544.
    ---
     examples/unmapped_events/unmapped_events.go | 74 +++++++++++++++++++++
     websocket_managed_conn.go                   |  4 ++
     websocket_misc.go                           | 26 +++++++-
     3 files changed, 103 insertions(+), 1 deletion(-)
     create mode 100644 examples/unmapped_events/unmapped_events.go
    
    diff --git a/examples/unmapped_events/unmapped_events.go b/examples/unmapped_events/unmapped_events.go
    new file mode 100644
    index 000000000..7fadf405b
    --- /dev/null
    +++ b/examples/unmapped_events/unmapped_events.go
    @@ -0,0 +1,74 @@
    +// This example connects to Slack via RTM and listens for events.
    +//
    +// Before the fix for #1544, the events "apps_uninstalled", "activity", and
    +// "badge_counts_updated" were not mapped and would appear as
    +// UnmarshallingErrorEvent with an "Received unmapped event" message.
    +//
    +// Usage:
    +//
    +//	export SLACK_BOT_TOKEN=xoxb-...
    +//	go run ./examples/unmapped_events/
    +//
    +// Then interact with your workspace (open channels, browse around) and watch
    +// for unmapped event errors in the output. The "activity" and
    +// "badge_counts_updated" events tend to appear during normal workspace usage.
    +// The "apps_uninstalled" event appears when an app is removed.
    +package main
    +
    +import (
    +	"fmt"
    +	"log"
    +	"os"
    +
    +	"github.com/slack-go/slack"
    +)
    +
    +func main() {
    +	token := os.Getenv("SLACK_BOT_TOKEN")
    +	if token == "" {
    +		fmt.Println("SLACK_BOT_TOKEN environment variable is required")
    +		os.Exit(1)
    +	}
    +
    +	api := slack.New(
    +		token,
    +		slack.OptionDebug(true),
    +		slack.OptionLog(log.New(os.Stdout, "slack-bot: ", log.Lshortfile|log.LstdFlags)),
    +	)
    +
    +	rtm := api.NewRTM()
    +	go rtm.ManageConnection()
    +
    +	for msg := range rtm.IncomingEvents {
    +		switch ev := msg.Data.(type) {
    +		case *slack.ConnectedEvent:
    +			fmt.Printf("Connected: %s (connection count: %d)\n", ev.Info.User.ID, ev.ConnectionCount)
    +
    +		case *slack.AppsUninstalledEvent:
    +			fmt.Printf("Apps uninstalled: %+v\n", ev)
    +
    +		case *slack.ActivityEvent:
    +			fmt.Printf("Activity: subtype=%s key=%s\n", ev.SubType, ev.Key)
    +
    +		case *slack.BadgeCountsUpdatedEvent:
    +			fmt.Printf("Badge counts updated: %+v\n", ev)
    +
    +		case *slack.UnmarshallingErrorEvent:
    +			// Before the fix, apps_uninstalled/activity/badge_counts_updated
    +			// end up here as unmapped events.
    +			fmt.Printf("UNMAPPED EVENT ERROR: %v\n", ev.ErrorObj)
    +
    +		case *slack.InvalidAuthEvent:
    +			fmt.Println("Invalid credentials")
    +			return
    +
    +		case *slack.DisconnectedEvent:
    +			if ev.Intentional {
    +				return
    +			}
    +
    +		default:
    +			fmt.Printf("Event: type=%s data=%T\n", msg.Type, ev)
    +		}
    +	}
    +}
    diff --git a/websocket_managed_conn.go b/websocket_managed_conn.go
    index 9c9644f59..da861fa87 100644
    --- a/websocket_managed_conn.go
    +++ b/websocket_managed_conn.go
    @@ -598,6 +598,10 @@ var EventMapping = map[string]interface{}{
     
     	"accounts_changed": AccountsChangedEvent{},
     
    +	"apps_uninstalled":     AppsUninstalledEvent{},
    +	"activity":             ActivityEvent{},
    +	"badge_counts_updated": BadgeCountsUpdatedEvent{},
    +
     	"reconnect_url": ReconnectUrlEvent{},
     
     	"member_joined_channel": MemberJoinedChannelEvent{},
    diff --git a/websocket_misc.go b/websocket_misc.go
    index 06d19f3fb..fb301f5eb 100644
    --- a/websocket_misc.go
    +++ b/websocket_misc.go
    @@ -166,7 +166,8 @@ type MemberLeftChannelEvent struct {
     	Team        string `json:"team"`
     }
     
    -// ChannelUpdatedEvent is fired when a channel's properties are updated (tabs, meeting notes, etc.).
    +// ChannelUpdatedEvent is fired when a channel's properties are updated (tabs, meeting
    +// notes, etc.).
     type ChannelUpdatedEvent struct {
     	Type     string         `json:"type"`
     	Updates  map[string]any `json:"updates"`
    @@ -253,3 +254,26 @@ type SHRoomUpdateEvent struct {
     	EventTS string        `json:"event_ts"`
     	TS      string        `json:"ts"`
     }
    +
    +// AppsUninstalledEvent represents the apps_uninstalled event sent via RTM
    +// when one or more apps are uninstalled from the workspace.
    +type AppsUninstalledEvent struct {
    +	Type string `json:"type"`
    +}
    +
    +// ActivityEvent represents the activity event sent via RTM. This is an
    +// internal Slack event that fires during normal workspace usage (e.g. new
    +// messages, bundle updates).
    +type ActivityEvent struct {
    +	Type           string          `json:"type"`
    +	SubType        string          `json:"subtype"`
    +	Key            string          `json:"key"`
    +	Entry          json.RawMessage `json:"entry"`
    +	EventTimestamp string          `json:"event_ts"`
    +}
    +
    +// BadgeCountsUpdatedEvent represents the badge_counts_updated event sent via
    +// RTM when notification badge counts change.
    +type BadgeCountsUpdatedEvent struct {
    +	Type string `json:"type"`
    +}
    
    From cc21fd11d5b42f81d91a94720bd8d6e9cf47701a Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 5 Apr 2026 19:16:07 +0200
    Subject: [PATCH 534/606] feat(oauth): add OAuthOptionAPIURL for testable OAuth
     functions
    
    Closes #744.
    ---
     CHANGELOG.md  |  5 ++++
     oauth.go      | 68 +++++++++++++++++++++++++++++++++------------------
     oauth_test.go | 35 ++++++++++++++++++++++++++
     3 files changed, 84 insertions(+), 24 deletions(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index d265093bd..fdf25e418 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -35,6 +35,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ### Added
     
    +- **`OAuthOptionAPIURL` for package-level OAuth functions** — All package-level OAuth
    +  functions (`GetOAuthV2Response`, `GetOpenIDConnectToken`, `RefreshOAuthV2Token`, etc.)
    +  now accept variadic `OAuthOption` arguments. Use `OAuthOptionAPIURL(url)` to override
    +  the default Slack API URL, enabling integration tests against a local HTTP server.
    +  Existing callers are unaffected. ([#744])
     - **`GetOpenIDConnectUserInfo` / `GetOpenIDConnectUserInfoContext`** — Returns identity
       information about the user associated with the token via `openid.connect.userInfo`.
       Complements the existing `GetOpenIDConnectToken` method. ([#967])
    diff --git a/oauth.go b/oauth.go
    index d872ae37d..1df1369c6 100644
    --- a/oauth.go
    +++ b/oauth.go
    @@ -79,16 +79,36 @@ type OpenIDConnectResponse struct {
     	SlackResponse
     }
     
    +type oauthConfig struct {
    +	apiURL string
    +}
    +
    +// OAuthOption configures package-level OAuth functions.
    +type OAuthOption func(*oauthConfig)
    +
    +// OAuthOptionAPIURL overrides the default Slack API URL. Useful for testing.
    +func OAuthOptionAPIURL(url string) OAuthOption {
    +	return func(c *oauthConfig) { c.apiURL = url }
    +}
    +
    +func resolveOAuthAPIURL(opts []OAuthOption) string {
    +	c := oauthConfig{apiURL: APIURL}
    +	for _, o := range opts {
    +		o(&c)
    +	}
    +	return c.apiURL
    +}
    +
     // GetOAuthToken retrieves an AccessToken.
     // For more details, see GetOAuthTokenContext documentation.
    -func GetOAuthToken(client httpClient, clientID, clientSecret, code, redirectURI string) (accessToken string, scope string, err error) {
    -	return GetOAuthTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI)
    +func GetOAuthToken(client httpClient, clientID, clientSecret, code, redirectURI string, opts ...OAuthOption) (accessToken string, scope string, err error) {
    +	return GetOAuthTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI, opts...)
     }
     
     // GetOAuthTokenContext retrieves an AccessToken with a custom context.
     // For more details, see GetOAuthResponseContext documentation.
    -func GetOAuthTokenContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (accessToken string, scope string, err error) {
    -	response, err := GetOAuthResponseContext(ctx, client, clientID, clientSecret, code, redirectURI)
    +func GetOAuthTokenContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string, opts ...OAuthOption) (accessToken string, scope string, err error) {
    +	response, err := GetOAuthResponseContext(ctx, client, clientID, clientSecret, code, redirectURI, opts...)
     	if err != nil {
     		return "", "", err
     	}
    @@ -97,14 +117,14 @@ func GetOAuthTokenContext(ctx context.Context, client httpClient, clientID, clie
     
     // GetBotOAuthToken retrieves top-level and bot AccessToken - https://api.slack.com/legacy/oauth#bot_user_access_tokens
     // For more details, see GetBotOAuthTokenContext documentation.
    -func GetBotOAuthToken(client httpClient, clientID, clientSecret, code, redirectURI string) (accessToken string, scope string, bot OAuthResponseBot, err error) {
    -	return GetBotOAuthTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI)
    +func GetBotOAuthToken(client httpClient, clientID, clientSecret, code, redirectURI string, opts ...OAuthOption) (accessToken string, scope string, bot OAuthResponseBot, err error) {
    +	return GetBotOAuthTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI, opts...)
     }
     
     // GetBotOAuthTokenContext retrieves top-level and bot AccessToken with a custom context.
     // For more details, see GetOAuthResponseContext documentation.
    -func GetBotOAuthTokenContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (accessToken string, scope string, bot OAuthResponseBot, err error) {
    -	response, err := GetOAuthResponseContext(ctx, client, clientID, clientSecret, code, redirectURI)
    +func GetBotOAuthTokenContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string, opts ...OAuthOption) (accessToken string, scope string, bot OAuthResponseBot, err error) {
    +	response, err := GetOAuthResponseContext(ctx, client, clientID, clientSecret, code, redirectURI, opts...)
     	if err != nil {
     		return "", "", OAuthResponseBot{}, err
     	}
    @@ -113,13 +133,13 @@ func GetBotOAuthTokenContext(ctx context.Context, client httpClient, clientID, c
     
     // GetOAuthResponse retrieves OAuth response.
     // For more details, see GetOAuthResponseContext documentation.
    -func GetOAuthResponse(client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OAuthResponse, err error) {
    -	return GetOAuthResponseContext(context.Background(), client, clientID, clientSecret, code, redirectURI)
    +func GetOAuthResponse(client httpClient, clientID, clientSecret, code, redirectURI string, opts ...OAuthOption) (resp *OAuthResponse, err error) {
    +	return GetOAuthResponseContext(context.Background(), client, clientID, clientSecret, code, redirectURI, opts...)
     }
     
     // GetOAuthResponseContext retrieves OAuth response with custom context.
     // Slack API docs: https://api.slack.com/methods/oauth.access
    -func GetOAuthResponseContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OAuthResponse, err error) {
    +func GetOAuthResponseContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string, opts ...OAuthOption) (resp *OAuthResponse, err error) {
     	values := url.Values{
     		"client_id":     {clientID},
     		"client_secret": {clientSecret},
    @@ -127,7 +147,7 @@ func GetOAuthResponseContext(ctx context.Context, client httpClient, clientID, c
     		"redirect_uri":  {redirectURI},
     	}
     	response := &OAuthResponse{}
    -	if _, err = postForm(ctx, client, APIURL+"oauth.access", values, response, discard{}); err != nil {
    +	if _, err = postForm(ctx, client, resolveOAuthAPIURL(opts)+"oauth.access", values, response, discard{}); err != nil {
     		return nil, err
     	}
     	return response, response.Err()
    @@ -135,13 +155,13 @@ func GetOAuthResponseContext(ctx context.Context, client httpClient, clientID, c
     
     // GetOAuthV2Response gets a V2 OAuth access token response.
     // For more details, see GetOAuthV2ResponseContext documentation.
    -func GetOAuthV2Response(client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OAuthV2Response, err error) {
    -	return GetOAuthV2ResponseContext(context.Background(), client, clientID, clientSecret, code, redirectURI)
    +func GetOAuthV2Response(client httpClient, clientID, clientSecret, code, redirectURI string, opts ...OAuthOption) (resp *OAuthV2Response, err error) {
    +	return GetOAuthV2ResponseContext(context.Background(), client, clientID, clientSecret, code, redirectURI, opts...)
     }
     
     // GetOAuthV2ResponseContext with a context, gets a V2 OAuth access token response.
     // Slack API docs: https://api.slack.com/methods/oauth.v2.access
    -func GetOAuthV2ResponseContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OAuthV2Response, err error) {
    +func GetOAuthV2ResponseContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string, opts ...OAuthOption) (resp *OAuthV2Response, err error) {
     	values := url.Values{
     		"client_id":     {clientID},
     		"client_secret": {clientSecret},
    @@ -149,7 +169,7 @@ func GetOAuthV2ResponseContext(ctx context.Context, client httpClient, clientID,
     		"redirect_uri":  {redirectURI},
     	}
     	response := &OAuthV2Response{}
    -	if _, err = postForm(ctx, client, APIURL+"oauth.v2.access", values, response, discard{}); err != nil {
    +	if _, err = postForm(ctx, client, resolveOAuthAPIURL(opts)+"oauth.v2.access", values, response, discard{}); err != nil {
     		return nil, err
     	}
     	return response, response.Err()
    @@ -157,13 +177,13 @@ func GetOAuthV2ResponseContext(ctx context.Context, client httpClient, clientID,
     
     // RefreshOAuthV2Token with a context, gets a V2 OAuth access token response.
     // For more details, see RefreshOAuthV2TokenContext documentation.
    -func RefreshOAuthV2Token(client httpClient, clientID, clientSecret, refreshToken string) (resp *OAuthV2Response, err error) {
    -	return RefreshOAuthV2TokenContext(context.Background(), client, clientID, clientSecret, refreshToken)
    +func RefreshOAuthV2Token(client httpClient, clientID, clientSecret, refreshToken string, opts ...OAuthOption) (resp *OAuthV2Response, err error) {
    +	return RefreshOAuthV2TokenContext(context.Background(), client, clientID, clientSecret, refreshToken, opts...)
     }
     
     // RefreshOAuthV2TokenContext with a context, gets a V2 OAuth access token response.
     // Slack API docs: https://api.slack.com/methods/oauth.v2.access
    -func RefreshOAuthV2TokenContext(ctx context.Context, client httpClient, clientID, clientSecret, refreshToken string) (resp *OAuthV2Response, err error) {
    +func RefreshOAuthV2TokenContext(ctx context.Context, client httpClient, clientID, clientSecret, refreshToken string, opts ...OAuthOption) (resp *OAuthV2Response, err error) {
     	values := url.Values{
     		"client_id":     {clientID},
     		"client_secret": {clientSecret},
    @@ -171,7 +191,7 @@ func RefreshOAuthV2TokenContext(ctx context.Context, client httpClient, clientID
     		"grant_type":    {"refresh_token"},
     	}
     	response := &OAuthV2Response{}
    -	if _, err = postForm(ctx, client, APIURL+"oauth.v2.access", values, response, discard{}); err != nil {
    +	if _, err = postForm(ctx, client, resolveOAuthAPIURL(opts)+"oauth.v2.access", values, response, discard{}); err != nil {
     		return nil, err
     	}
     	return response, response.Err()
    @@ -247,13 +267,13 @@ func (api *Client) GetOpenIDConnectUserInfoContext(ctx context.Context) (*OpenID
     
     // GetOpenIDConnectToken exchanges a temporary OAuth verifier code for an access token for Sign in with Slack.
     // For more details, see GetOpenIDConnectTokenContext documentation.
    -func GetOpenIDConnectToken(client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OpenIDConnectResponse, err error) {
    -	return GetOpenIDConnectTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI)
    +func GetOpenIDConnectToken(client httpClient, clientID, clientSecret, code, redirectURI string, opts ...OAuthOption) (resp *OpenIDConnectResponse, err error) {
    +	return GetOpenIDConnectTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI, opts...)
     }
     
     // GetOpenIDConnectTokenContext with a context, gets an access token for Sign in with Slack.
     // Slack API docs: https://api.slack.com/methods/openid.connect.token
    -func GetOpenIDConnectTokenContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OpenIDConnectResponse, err error) {
    +func GetOpenIDConnectTokenContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string, opts ...OAuthOption) (resp *OpenIDConnectResponse, err error) {
     	values := url.Values{
     		"client_id":     {clientID},
     		"client_secret": {clientSecret},
    @@ -261,7 +281,7 @@ func GetOpenIDConnectTokenContext(ctx context.Context, client httpClient, client
     		"redirect_uri":  {redirectURI},
     	}
     	response := &OpenIDConnectResponse{}
    -	if _, err = postForm(ctx, client, APIURL+"openid.connect.token", values, response, discard{}); err != nil {
    +	if _, err = postForm(ctx, client, resolveOAuthAPIURL(opts)+"openid.connect.token", values, response, discard{}); err != nil {
     		return nil, err
     	}
     	return response, response.Err()
    diff --git a/oauth_test.go b/oauth_test.go
    index 1e20b4c0f..62212a911 100644
    --- a/oauth_test.go
    +++ b/oauth_test.go
    @@ -8,6 +8,41 @@ import (
     	"github.com/stretchr/testify/require"
     )
     
    +func getOAuthV2Response(rw http.ResponseWriter, r *http.Request) {
    +	rw.Header().Set("Content-Type", "application/json")
    +	rw.Write([]byte(`{
    +		"ok": true,
    +		"access_token": "xoxb-test-token",
    +		"token_type": "bot",
    +		"scope": "chat:write",
    +		"bot_user_id": "U0KRQLJ9H",
    +		"app_id": "A0KRD7HC3",
    +		"team": {"name": "Test Team", "id": "T0KRQLJ9H"},
    +		"enterprise": {"name": "", "id": ""},
    +		"is_enterprise_install": false,
    +		"authed_user": {"id": "U0KRQLJ9H"}
    +	}`))
    +}
    +
    +func TestGetOAuthV2ResponseWithCustomURL(t *testing.T) {
    +	http.HandleFunc("/oauth.v2.access", getOAuthV2Response)
    +
    +	once.Do(startServer)
    +
    +	resp, err := GetOAuthV2Response(
    +		http.DefaultClient,
    +		"client-id", "client-secret", "code", "http://localhost/callback",
    +		OAuthOptionAPIURL("http://"+serverAddr+"/"),
    +	)
    +	require.NoError(t, err)
    +
    +	assert.Equal(t, "xoxb-test-token", resp.AccessToken)
    +	assert.Equal(t, "bot", resp.TokenType)
    +	assert.Equal(t, "chat:write", resp.Scope)
    +	assert.Equal(t, "U0KRQLJ9H", resp.BotUserID)
    +	assert.Equal(t, "T0KRQLJ9H", resp.Team.ID)
    +}
    +
     func getOpenIDConnectUserInfo(rw http.ResponseWriter, r *http.Request) {
     	rw.Header().Set("Content-Type", "application/json")
     	rw.Write([]byte(`{
    
    From c6a1f342d0481a65f0acb24c5df5dd033bda7f01 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 5 Apr 2026 21:02:55 +0200
    Subject: [PATCH 535/606] feat(admin): add admin.teams.settings.* methods
    
    Closes #960.
    ---
     CHANGELOG.md        |   5 ++
     admin_teams.go      | 153 ++++++++++++++++++++++++++++++++++++++++++++
     admin_teams_test.go | 119 ++++++++++++++++++++++++++++++++++
     3 files changed, 277 insertions(+)
     create mode 100644 admin_teams.go
     create mode 100644 admin_teams_test.go
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index fdf25e418..08e169e98 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -35,6 +35,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ### Added
     
    +- **`admin.teams.settings.*` API support** — `AdminTeamsSettingsInfo`,
    +  `AdminTeamsSettingsSetDefaultChannels`, `AdminTeamsSettingsSetDescription`,
    +  `AdminTeamsSettingsSetDiscoverability`, `AdminTeamsSettingsSetIcon`, and
    +  `AdminTeamsSettingsSetName`. Includes `TeamDiscoverability` enum with `Open`,
    +  `InviteOnly`, `Closed`, and `Unlisted` variants. ([#960])
     - **`OAuthOptionAPIURL` for package-level OAuth functions** — All package-level OAuth
       functions (`GetOAuthV2Response`, `GetOpenIDConnectToken`, `RefreshOAuthV2Token`, etc.)
       now accept variadic `OAuthOption` arguments. Use `OAuthOptionAPIURL(url)` to override
    diff --git a/admin_teams.go b/admin_teams.go
    new file mode 100644
    index 000000000..17ae3df33
    --- /dev/null
    +++ b/admin_teams.go
    @@ -0,0 +1,153 @@
    +package slack
    +
    +import (
    +	"context"
    +	"net/url"
    +	"strings"
    +)
    +
    +// AdminTeamSettings contains workspace settings returned by admin.teams.settings.info.
    +type AdminTeamSettings struct {
    +	ID               string           `json:"id"`
    +	Name             string           `json:"name"`
    +	URL              string           `json:"url"`
    +	Domain           string           `json:"domain"`
    +	EmailDomain      string           `json:"email_domain"`
    +	AvatarBaseURL    string           `json:"avatar_base_url"`
    +	IsVerified       bool             `json:"is_verified"`
    +	Icon             TeamSettingsIcon `json:"icon"`
    +	EnterpriseID     string           `json:"enterprise_id"`
    +	EnterpriseName   string           `json:"enterprise_name"`
    +	EnterpriseDomain string           `json:"enterprise_domain"`
    +	DefaultChannels  []string         `json:"default_channels"`
    +}
    +
    +// TeamSettingsIcon contains team icon URLs and a default flag.
    +type TeamSettingsIcon struct {
    +	ImageDefault bool   `json:"image_default"`
    +	Image34      string `json:"image_34"`
    +	Image44      string `json:"image_44"`
    +	Image68      string `json:"image_68"`
    +	Image88      string `json:"image_88"`
    +	Image102     string `json:"image_102"`
    +	Image132     string `json:"image_132"`
    +	Image230     string `json:"image_230"`
    +}
    +
    +// TeamDiscoverability represents the discoverability setting for a workspace.
    +type TeamDiscoverability string
    +
    +const (
    +	TeamDiscoverabilityOpen       TeamDiscoverability = "open"
    +	TeamDiscoverabilityInviteOnly TeamDiscoverability = "invite_only"
    +	TeamDiscoverabilityClosed     TeamDiscoverability = "closed"
    +	TeamDiscoverabilityUnlisted   TeamDiscoverability = "unlisted"
    +)
    +
    +type adminTeamSettingsInfoResponse struct {
    +	Team AdminTeamSettings `json:"team"`
    +	SlackResponse
    +}
    +
    +// AdminTeamsSettingsInfo returns workspace settings.
    +// Slack API docs: https://docs.slack.dev/reference/methods/admin.teams.settings.info
    +func (api *Client) AdminTeamsSettingsInfo(ctx context.Context, teamID string) (*AdminTeamSettings, error) {
    +	values := url.Values{
    +		"token":   {api.token},
    +		"team_id": {teamID},
    +	}
    +
    +	response := &adminTeamSettingsInfoResponse{}
    +	err := api.postMethod(ctx, "admin.teams.settings.info", values, response)
    +	if err != nil {
    +		return nil, err
    +	}
    +
    +	return &response.Team, response.Err()
    +}
    +
    +// AdminTeamsSettingsSetDefaultChannels sets the default channels for a workspace.
    +// Slack API docs: https://docs.slack.dev/reference/methods/admin.teams.settings.setDefaultChannels
    +func (api *Client) AdminTeamsSettingsSetDefaultChannels(ctx context.Context, teamID string, channelIDs ...string) error {
    +	values := url.Values{
    +		"token":       {api.token},
    +		"team_id":     {teamID},
    +		"channel_ids": {strings.Join(channelIDs, ",")},
    +	}
    +
    +	response := &SlackResponse{}
    +	err := api.postMethod(ctx, "admin.teams.settings.setDefaultChannels", values, response)
    +	if err != nil {
    +		return err
    +	}
    +	return response.Err()
    +}
    +
    +// AdminTeamsSettingsSetDescription sets the description for a workspace.
    +// Slack API docs: https://docs.slack.dev/reference/methods/admin.teams.settings.setDescription
    +func (api *Client) AdminTeamsSettingsSetDescription(ctx context.Context, teamID, description string) error {
    +	values := url.Values{
    +		"token":       {api.token},
    +		"team_id":     {teamID},
    +		"description": {description},
    +	}
    +
    +	response := &SlackResponse{}
    +	err := api.postMethod(ctx, "admin.teams.settings.setDescription", values, response)
    +	if err != nil {
    +		return err
    +	}
    +	return response.Err()
    +}
    +
    +// AdminTeamsSettingsSetDiscoverability sets the discoverability for a workspace.
    +// The discoverability parameter must be one of: open, invite_only, closed, or unlisted.
    +// Slack API docs: https://docs.slack.dev/reference/methods/admin.teams.settings.setDiscoverability
    +func (api *Client) AdminTeamsSettingsSetDiscoverability(ctx context.Context, teamID string, discoverability TeamDiscoverability) error {
    +	values := url.Values{
    +		"token":           {api.token},
    +		"team_id":         {teamID},
    +		"discoverability": {string(discoverability)},
    +	}
    +
    +	response := &SlackResponse{}
    +	err := api.postMethod(ctx, "admin.teams.settings.setDiscoverability", values, response)
    +	if err != nil {
    +		return err
    +	}
    +	return response.Err()
    +}
    +
    +// AdminTeamsSettingsSetIcon sets the icon for a workspace.
    +// Slack API docs: https://docs.slack.dev/reference/methods/admin.teams.settings.setIcon
    +func (api *Client) AdminTeamsSettingsSetIcon(ctx context.Context, teamID, imageURL string) error {
    +	values := url.Values{
    +		"token":     {api.token},
    +		"team_id":   {teamID},
    +		"image_url": {imageURL},
    +	}
    +
    +	response := &SlackResponse{}
    +	err := api.postMethod(ctx, "admin.teams.settings.setIcon", values, response)
    +	if err != nil {
    +		return err
    +	}
    +	return response.Err()
    +}
    +
    +// AdminTeamsSettingsSetName sets the name for a workspace.
    +// Slack API docs: https://docs.slack.dev/reference/methods/admin.teams.settings.setName
    +func (api *Client) AdminTeamsSettingsSetName(ctx context.Context, teamID, name string) error {
    +	values := url.Values{
    +		"token":   {api.token},
    +		"team_id": {teamID},
    +		"name":    {name},
    +	}
    +
    +	response := &SlackResponse{}
    +	err := api.postMethod(ctx, "admin.teams.settings.setName", values, response)
    +	if err != nil {
    +		return err
    +	}
    +	return response.Err()
    +}
    diff --git a/admin_teams_test.go b/admin_teams_test.go
    new file mode 100644
    index 000000000..4a545d842
    --- /dev/null
    +++ b/admin_teams_test.go
    @@ -0,0 +1,119 @@
    +package slack
    +
    +import (
    +	"context"
    +	"net/http"
    +	"testing"
    +
    +	"github.com/stretchr/testify/assert"
    +	"github.com/stretchr/testify/require"
    +)
    +
    +func getAdminTeamsSettingsInfo(rw http.ResponseWriter, r *http.Request) {
    +	rw.Header().Set("Content-Type", "application/json")
    +	rw.Write([]byte(`{
    +		"ok": true,
    +		"team": {
    +			"id": "T12345",
    +			"name": "Test Workspace",
    +			"url": "https://test-workspace.slack.com/",
    +			"domain": "test-workspace",
    +			"email_domain": "example.com",
    +			"avatar_base_url": "https://ca.slack-edge.com/",
    +			"is_verified": false,
    +			"icon": {
    +				"image_default": true,
    +				"image_34": "https://example.com/icon_34.png",
    +				"image_44": "https://example.com/icon_44.png",
    +				"image_68": "https://example.com/icon_68.png",
    +				"image_88": "https://example.com/icon_88.png",
    +				"image_102": "https://example.com/icon_102.png",
    +				"image_132": "https://example.com/icon_132.png",
    +				"image_230": "https://example.com/icon_230.png"
    +			},
    +			"enterprise_id": "E12345",
    +			"enterprise_name": "Test Enterprise",
    +			"enterprise_domain": "test-enterprise",
    +			"default_channels": ["C12345", "C67890"]
    +		}
    +	}`))
    +}
    +
    +func TestAdminTeamsSettingsInfo(t *testing.T) {
    +	http.HandleFunc("/admin.teams.settings.info", getAdminTeamsSettingsInfo)
    +
    +	once.Do(startServer)
    +	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
    +
    +	settings, err := api.AdminTeamsSettingsInfo(context.Background(), "T12345")
    +	require.NoError(t, err)
    +
    +	assert.Equal(t, "T12345", settings.ID)
    +	assert.Equal(t, "Test Workspace", settings.Name)
    +	assert.Equal(t, "https://test-workspace.slack.com/", settings.URL)
    +	assert.Equal(t, "test-workspace", settings.Domain)
    +	assert.Equal(t, "example.com", settings.EmailDomain)
    +	assert.Equal(t, "https://ca.slack-edge.com/", settings.AvatarBaseURL)
    +	assert.False(t, settings.IsVerified)
    +	assert.Equal(t, "E12345", settings.EnterpriseID)
    +	assert.Equal(t, "Test Enterprise", settings.EnterpriseName)
    +	assert.Equal(t, "test-enterprise", settings.EnterpriseDomain)
    +	assert.Equal(t, []string{"C12345", "C67890"}, settings.DefaultChannels)
    +	assert.True(t, settings.Icon.ImageDefault)
    +	assert.Equal(t, "https://example.com/icon_34.png", settings.Icon.Image34)
    +}
    +
    +func okHandler(rw http.ResponseWriter, r *http.Request) {
    +	rw.Header().Set("Content-Type", "application/json")
    +	rw.Write([]byte(`{"ok": true}`))
    +}
    +
    +func TestAdminTeamsSettingsSetDefaultChannels(t *testing.T) {
    +	http.HandleFunc("/admin.teams.settings.setDefaultChannels", okHandler)
    +
    +	once.Do(startServer)
    +	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
    +
    +	err := api.AdminTeamsSettingsSetDefaultChannels(context.Background(), "T12345", "C111", "C222")
    +	require.NoError(t, err)
    +}
    +
    +func TestAdminTeamsSettingsSetDescription(t *testing.T) {
    +	http.HandleFunc("/admin.teams.settings.setDescription", okHandler)
    +
    +	once.Do(startServer)
    +	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
    +
    +	err := api.AdminTeamsSettingsSetDescription(context.Background(), "T12345", "A test workspace")
    +	require.NoError(t, err)
    +}
    +
    +func TestAdminTeamsSettingsSetDiscoverability(t *testing.T) {
    +	http.HandleFunc("/admin.teams.settings.setDiscoverability", okHandler)
    +
    +	once.Do(startServer)
    +	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
    +
    +	err := api.AdminTeamsSettingsSetDiscoverability(context.Background(), "T12345", TeamDiscoverabilityInviteOnly)
    +	require.NoError(t, err)
    +}
    +
    +func TestAdminTeamsSettingsSetIcon(t *testing.T) {
    +	http.HandleFunc("/admin.teams.settings.setIcon", okHandler)
    +
    +	once.Do(startServer)
    +	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
    +
    +	err := api.AdminTeamsSettingsSetIcon(context.Background(), "T12345", "https://example.com/icon.png")
    +	require.NoError(t, err)
    +}
    +
    +func TestAdminTeamsSettingsSetName(t *testing.T) {
    +	http.HandleFunc("/admin.teams.settings.setName", okHandler)
    +
    +	once.Do(startServer)
    +	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
    +
    +	err := api.AdminTeamsSettingsSetName(context.Background(), "T12345", "New Name")
    +	require.NoError(t, err)
    +}
    
    From 063d0136e52a3c7e9de669490159ca03f9d5e564 Mon Sep 17 00:00:00 2001
    From: Tom Ashworth 
    Date: Thu, 13 Nov 2025 11:35:25 +0000
    Subject: [PATCH 536/606] Add support for arbitrary JSON blocks
    
    As Slack introduces new block types, this change allows users to include
    any block type by specifying its JSON representation directly.
    ---
     block_json.go      | 110 +++++++++++++++++++++
     block_json_test.go | 235 +++++++++++++++++++++++++++++++++++++++++++++
     2 files changed, 345 insertions(+)
     create mode 100644 block_json.go
     create mode 100644 block_json_test.go
    
    diff --git a/block_json.go b/block_json.go
    new file mode 100644
    index 000000000..43798f51e
    --- /dev/null
    +++ b/block_json.go
    @@ -0,0 +1,110 @@
    +package slack
    +
    +import (
    +	"encoding/json"
    +	"fmt"
    +)
    +
    +// RawJSONBlock represents a block created from raw JSON that preserves
    +// the original JSON structure. This is useful for testing new Slack block types
    +// before the library has full support, or for using blocks copied from Block Kit Builder.
    +//
    +// The block stores the original JSON and outputs it unchanged during marshalling,
    +// ensuring no data is lost through the unmarshal/marshal cycle.
    +type RawJSONBlock struct {
    +	Type    MessageBlockType `json:"-"`
    +	BlockID string           `json:"-"`
    +	raw     json.RawMessage
    +}
    +
    +// BlockType returns the type of the block
    +func (r RawJSONBlock) BlockType() MessageBlockType {
    +	return r.Type
    +}
    +
    +// ID returns the block_id of the block
    +func (r RawJSONBlock) ID() string {
    +	return r.BlockID
    +}
    +
    +// MarshalJSON outputs the original JSON unchanged
    +func (r RawJSONBlock) MarshalJSON() ([]byte, error) {
    +	return r.raw, nil
    +}
    +
    +// BlockFromJSON creates a RawJSONBlock from a JSON string that preserves
    +// the original JSON. This is useful for quickly testing blocks from Slack's
    +// Block Kit Builder or for incorporating new block types before the library
    +// has full support.
    +//
    +// The JSON can be either a single block object or an array of blocks.
    +// If an array is provided, only the first block is returned.
    +//
    +// The returned block stores the original JSON and outputs it unchanged during
    +// marshalling, ensuring no data is lost.
    +//
    +// Returns an error if the JSON is invalid, empty, or missing required fields.
    +//
    +// Example:
    +//
    +//	block, err := slack.BlockFromJSON(`{"type": "section", "text": {"type": "mrkdwn", "text": "Hello"}}`)
    +//	if err != nil {
    +//	    return err
    +//	}
    +//	blocks = append(blocks, block)
    +func BlockFromJSON(jsonStr string) (Block, error) {
    +	var rawJSON json.RawMessage
    +	var isArray bool
    +
    +	// Try to unmarshal as an array first
    +	var arrayTest []json.RawMessage
    +	if err := json.Unmarshal([]byte(jsonStr), &arrayTest); err == nil && len(arrayTest) > 0 {
    +		rawJSON = arrayTest[0]
    +		isArray = true
    +	} else {
    +		// Try as a single block object
    +		if err := json.Unmarshal([]byte(jsonStr), &rawJSON); err != nil {
    +			return nil, fmt.Errorf("failed to unmarshal block JSON: %w", err)
    +		}
    +		isArray = false
    +	}
    +
    +	if !isArray && len(rawJSON) == 0 {
    +		return nil, fmt.Errorf("no blocks found in JSON")
    +	}
    +
    +	// Extract minimal fields for Block interface
    +	var minimal struct {
    +		Type    string `json:"type"`
    +		BlockID string `json:"block_id"`
    +	}
    +	if err := json.Unmarshal(rawJSON, &minimal); err != nil {
    +		return nil, fmt.Errorf("failed to extract block type: %w", err)
    +	}
    +
    +	if minimal.Type == "" {
    +		return nil, fmt.Errorf("block missing required 'type' field")
    +	}
    +
    +	return RawJSONBlock{
    +		Type:    MessageBlockType(minimal.Type),
    +		BlockID: minimal.BlockID,
    +		raw:     rawJSON,
    +	}, nil
    +}
    +
    +// MustBlockFromJSON creates a Block from a JSON string and panics if there's an error.
    +// This is primarily intended for use in tests or examples where the JSON is known to be valid.
    +// For production code, use BlockFromJSON which returns an error instead.
    +//
    +// Example:
    +//
    +//	block := slack.MustBlockFromJSON(`{"type": "divider"}`)
    +//	msg := slack.NewBlockMessage(block)
    +func MustBlockFromJSON(jsonStr string) Block {
    +	block, err := BlockFromJSON(jsonStr)
    +	if err != nil {
    +		panic(fmt.Sprintf("MustBlockFromJSON: %v", err))
    +	}
    +	return block
    +}
    diff --git a/block_json_test.go b/block_json_test.go
    new file mode 100644
    index 000000000..a99aaf57a
    --- /dev/null
    +++ b/block_json_test.go
    @@ -0,0 +1,235 @@
    +package slack
    +
    +import (
    +	"encoding/json"
    +	"testing"
    +
    +	"github.com/stretchr/testify/assert"
    +)
    +
    +func TestBlockFromJSON(t *testing.T) {
    +	tests := []struct {
    +		name      string
    +		json      string
    +		wantType  MessageBlockType
    +		wantError bool
    +	}{
    +		{
    +			name:     "valid single divider block",
    +			json:     `{"type": "divider"}`,
    +			wantType: MBTDivider,
    +		},
    +		{
    +			name:     "valid section block",
    +			json:     `{"type": "section", "text": {"type": "mrkdwn", "text": "Hello"}}`,
    +			wantType: MBTSection,
    +		},
    +		{
    +			name:     "valid array with single block",
    +			json:     `[{"type": "divider"}]`,
    +			wantType: MBTDivider,
    +		},
    +		{
    +			name:     "valid array with multiple blocks (takes first)",
    +			json:     `[{"type": "divider"}, {"type": "section", "text": {"type": "plain_text", "text": "Hi"}}]`,
    +			wantType: MBTDivider,
    +		},
    +		{
    +			name:      "invalid JSON syntax",
    +			json:      `{"type": "divider"`,
    +			wantError: true,
    +		},
    +		{
    +			name:      "empty JSON object",
    +			json:      `{}`,
    +			wantError: true, // Cannot determine block type without wrapping in array
    +		},
    +		{
    +			name:      "empty array",
    +			json:      `[]`,
    +			wantError: true,
    +		},
    +		{
    +			name:      "null",
    +			json:      `null`,
    +			wantError: true,
    +		},
    +	}
    +
    +	for _, tt := range tests {
    +		t.Run(tt.name, func(t *testing.T) {
    +			block, err := BlockFromJSON(tt.json)
    +
    +			if tt.wantError {
    +				assert.Error(t, err)
    +				assert.Nil(t, block)
    +			} else {
    +				assert.NoError(t, err)
    +				assert.NotNil(t, block)
    +				assert.Equal(t, tt.wantType, block.BlockType())
    +			}
    +		})
    +	}
    +}
    +
    +func TestMustBlockFromJSON(t *testing.T) {
    +	t.Run("valid JSON does not panic", func(t *testing.T) {
    +		assert.NotPanics(t, func() {
    +			block := MustBlockFromJSON(`{"type": "divider"}`)
    +			assert.NotNil(t, block)
    +			assert.Equal(t, MBTDivider, block.BlockType())
    +		})
    +	})
    +
    +	t.Run("invalid JSON panics", func(t *testing.T) {
    +		assert.Panics(t, func() {
    +			MustBlockFromJSON(`invalid json`)
    +		})
    +	})
    +
    +	t.Run("empty array panics", func(t *testing.T) {
    +		assert.Panics(t, func() {
    +			MustBlockFromJSON(`[]`)
    +		})
    +	})
    +}
    +
    +func TestRawJSONBlockRoundTrip(t *testing.T) {
    +	t.Run("simple block preserves all fields", func(t *testing.T) {
    +		originalJSON := `{"type": "section", "text": {"type": "mrkdwn", "text": "Hello World"}, "block_id": "section1"}`
    +
    +		block, err := BlockFromJSON(originalJSON)
    +		assert.NoError(t, err)
    +		assert.Equal(t, MBTSection, block.BlockType())
    +		assert.Equal(t, "section1", block.ID())
    +
    +		// Marshal back to JSON
    +		marshalled, err := json.Marshal(block)
    +		assert.NoError(t, err)
    +
    +		// Unmarshal both to compare (ignoring whitespace differences)
    +		var original, result map[string]interface{}
    +		assert.NoError(t, json.Unmarshal([]byte(originalJSON), &original))
    +		assert.NoError(t, json.Unmarshal(marshalled, &result))
    +
    +		assert.Equal(t, original, result, "Round-trip should preserve all fields")
    +	})
    +
    +	t.Run("complex block with nested elements preserves everything", func(t *testing.T) {
    +		// Using a complex context_actions block as an example
    +		originalJSON := `{
    +			"type": "context_actions",
    +			"block_id": "feedback_block",
    +			"elements": [
    +				{
    +					"type": "feedback_buttons",
    +					"action_id": "ai_feedback",
    +					"positive_button": {
    +						"text": {"type": "plain_text", "text": "👍"},
    +						"value": "positive"
    +					},
    +					"negative_button": {
    +						"text": {"type": "plain_text", "text": "👎"},
    +						"value": "negative"
    +					}
    +				},
    +				{
    +					"type": "icon_button",
    +					"icon": "trash",
    +					"text": {"type": "plain_text", "text": "Delete"},
    +					"action_id": "delete_action",
    +					"value": "delete_response"
    +				}
    +			]
    +		}`
    +
    +		block, err := BlockFromJSON(originalJSON)
    +		assert.NoError(t, err)
    +		assert.Equal(t, MessageBlockType("context_actions"), block.BlockType())
    +		assert.Equal(t, "feedback_block", block.ID())
    +
    +		// Marshal back to JSON
    +		marshalled, err := json.Marshal(block)
    +		assert.NoError(t, err)
    +
    +		// Unmarshal both to compare
    +		var original, result map[string]interface{}
    +		assert.NoError(t, json.Unmarshal([]byte(originalJSON), &original))
    +		assert.NoError(t, json.Unmarshal(marshalled, &result))
    +
    +		assert.Equal(t, original, result, "Complex block should preserve all nested fields")
    +
    +		// Specifically verify elements array is preserved
    +		resultElements, ok := result["elements"].([]interface{})
    +		assert.True(t, ok, "elements should be an array")
    +		assert.Equal(t, 2, len(resultElements), "should have 2 elements")
    +	})
    +}
    +
    +func TestRawJSONBlockInDeepStructure(t *testing.T) {
    +	t.Run("RawJSONBlock in Message with mixed blocks", func(t *testing.T) {
    +		// Create a regular divider block
    +		divider := NewDividerBlock()
    +
    +		// Create a RawJSONBlock with complex content
    +		contextActionsJSON := `{
    +			"type": "context_actions",
    +			"block_id": "feedback_block",
    +			"elements": [
    +				{
    +					"type": "feedback_buttons",
    +					"action_id": "ai_feedback",
    +					"positive_button": {
    +						"text": {"type": "plain_text", "text": "👍"},
    +						"value": "positive"
    +					},
    +					"negative_button": {
    +						"text": {"type": "plain_text", "text": "👎"},
    +						"value": "negative"
    +					}
    +				}
    +			]
    +		}`
    +		rawBlock := MustBlockFromJSON(contextActionsJSON)
    +
    +		// Create a regular section block
    +		sectionText := NewTextBlockObject("mrkdwn", "*Regular Section*", false, false)
    +		section := NewSectionBlock(sectionText, nil, nil)
    +
    +		// Create a Message with all three blocks
    +		msg := NewBlockMessage(divider, rawBlock, section)
    +
    +		// Marshal the entire message
    +		marshalled, err := json.Marshal(msg)
    +		assert.NoError(t, err)
    +
    +		// Unmarshal to verify structure
    +		var result struct {
    +			Blocks []json.RawMessage `json:"blocks"`
    +		}
    +		assert.NoError(t, json.Unmarshal(marshalled, &result))
    +		assert.Equal(t, 3, len(result.Blocks), "should have 3 blocks")
    +
    +		// Verify the middle block (our RawJSONBlock) has all fields preserved
    +		var contextActionsBlock map[string]interface{}
    +		assert.NoError(t, json.Unmarshal(result.Blocks[1], &contextActionsBlock))
    +
    +		assert.Equal(t, "context_actions", contextActionsBlock["type"])
    +		assert.Equal(t, "feedback_block", contextActionsBlock["block_id"])
    +
    +		// Verify elements array is present and intact
    +		elements, ok := contextActionsBlock["elements"].([]interface{})
    +		assert.True(t, ok, "elements should be an array")
    +		assert.Equal(t, 1, len(elements), "should have 1 element")
    +
    +		// Verify the nested feedback_buttons element
    +		firstElement, ok := elements[0].(map[string]interface{})
    +		assert.True(t, ok, "first element should be an object")
    +		assert.Equal(t, "feedback_buttons", firstElement["type"])
    +		assert.Equal(t, "ai_feedback", firstElement["action_id"])
    +
    +		// Verify the nested buttons exist
    +		assert.NotNil(t, firstElement["positive_button"], "should have positive_button")
    +		assert.NotNil(t, firstElement["negative_button"], "should have negative_button")
    +	})
    +}
    
    From 8189cb46290f3dc647530c873dbbf7455e6fba15 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 5 Apr 2026 22:36:36 +0200
    Subject: [PATCH 537/606] docs: update changelog with
     BlockFromJSON/MustBlockFromJSON
    
    ---
     CHANGELOG.md | 5 +++++
     1 file changed, 5 insertions(+)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 08e169e98..ee808d11e 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -94,6 +94,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
       Level 3 handlers that dispatch `shortcut`/`message_action`, `view_submission`, and
       `view_closed` interactions by `CallbackID`, matching the pattern of
       `HandleInteractionBlockAction` and `HandleSlashCommand`. ([#1161])
    +- **`BlockFromJSON` / `MustBlockFromJSON`** — Create blocks from raw JSON strings, enabling
    +  direct use of output from Slack's [Block Kit Builder](https://app.slack.com/block-kit-builder)
    +  or quick adoption of new block types before the library adds typed support. The original
    +  JSON is preserved through marshalling. ([#1497])
     
     ### Documentation
     
    @@ -424,6 +428,7 @@ for details.
     [#1490]: https://github.com/slack-go/slack/pull/1490
     [#1491]: https://github.com/slack-go/slack/issues/1491
     [#1495]: https://github.com/slack-go/slack/pull/1495
    +[#1497]: https://github.com/slack-go/slack/pull/1497
     [#1499]: https://github.com/slack-go/slack/pull/1499
     [#1504]: https://github.com/slack-go/slack/pull/1504
     [#1506]: https://github.com/slack-go/slack/pull/1506
    
    From 2666fa066cb33cb9710e440ecfd8d0e072677010 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 5 Apr 2026 22:46:53 +0200
    Subject: [PATCH 538/606] fix(blocks): preserve raw JSON in UnknownBlock
    
    Closes #1338 (especially after we merged #1497)
    ---
     CHANGELOG.md          |  7 +++++++
     block_conv.go         |  7 ++++++-
     block_unknown.go      | 22 ++++++++++++++++++++--
     block_unknown_test.go | 25 +++++++++++++++++++++++++
     4 files changed, 58 insertions(+), 3 deletions(-)
     create mode 100644 block_unknown_test.go
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index ee808d11e..24b78bf16 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -111,6 +111,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
       have doc comments explaining the two-token requirement: app-level token (`xapp-`) for the
       WebSocket connection and bot token (`xoxb-`) for API calls. ([#941])
     
    +### Fixed
    +
    +- **`UnknownBlock` round-trip data loss** — Unrecognized block types (e.g. new Slack block
    +  types not yet supported by this library) now preserve their full JSON through
    +  unmarshal/marshal cycles. Previously only `type` and `block_id` were retained, silently
    +  discarding all other fields.
    +
     ### Changed
     
     - Adjusted some `admin` errors that started with uppercase to be lowercase per go
    diff --git a/block_conv.go b/block_conv.go
    index 3089486fe..a792f26e3 100644
    --- a/block_conv.go
    +++ b/block_conv.go
    @@ -84,7 +84,12 @@ func (b *Blocks) UnmarshalJSON(data []byte) error {
     		case "plan":
     			block = &PlanBlock{}
     		default:
    -			block = &UnknownBlock{}
    +			b := &UnknownBlock{raw: r}
    +			if err = json.Unmarshal(r, b); err != nil {
    +				return err
    +			}
    +			blocks.BlockSet = append(blocks.BlockSet, b)
    +			continue
     		}
     
     		err = json.Unmarshal(r, block)
    diff --git a/block_unknown.go b/block_unknown.go
    index 7a49a2c8d..71b2a90c9 100644
    --- a/block_unknown.go
    +++ b/block_unknown.go
    @@ -1,10 +1,19 @@
     package slack
     
    -// UnknownBlock represents a block type that is not yet known. This block type exists to prevent Slack from introducing
    -// new and unknown block types that break this library.
    +import "encoding/json"
    +
    +// UnknownBlock represents a block type that is not yet known. This block type
    +// exists to prevent Slack from introducing new and unknown block types that
    +// break this library. It preserves the raw JSON so that unrecognized blocks
    +// survive round-trip marshaling.
    +//
    +// If you encounter an UnknownBlock for a block type that Slack documents,
    +// please open an issue at https://github.com/slack-go/slack/issues so we can
    +// add first-class support for it.
     type UnknownBlock struct {
     	Type    MessageBlockType `json:"type"`
     	BlockID string           `json:"block_id,omitempty"`
    +	raw     json.RawMessage
     }
     
     // BlockType returns the type of the block
    @@ -16,3 +25,12 @@ func (b UnknownBlock) BlockType() MessageBlockType {
     func (s UnknownBlock) ID() string {
     	return s.BlockID
     }
    +
    +// MarshalJSON returns the original raw JSON if available, preserving all fields
    +func (b UnknownBlock) MarshalJSON() ([]byte, error) {
    +	if b.raw != nil {
    +		return b.raw, nil
    +	}
    +	type alias UnknownBlock
    +	return json.Marshal(alias(b))
    +}
    diff --git a/block_unknown_test.go b/block_unknown_test.go
    new file mode 100644
    index 000000000..8686de5e7
    --- /dev/null
    +++ b/block_unknown_test.go
    @@ -0,0 +1,25 @@
    +package slack
    +
    +import (
    +	"encoding/json"
    +	"testing"
    +
    +	"github.com/stretchr/testify/assert"
    +	"github.com/stretchr/testify/require"
    +)
    +
    +func TestUnknownBlockRoundTrip(t *testing.T) {
    +	input := `[{"type":"some_future_block","block_id":"fb1","custom_field":"value","nested":{"key":"val"}}]`
    +
    +	var blocks Blocks
    +	err := json.Unmarshal([]byte(input), &blocks)
    +	require.NoError(t, err)
    +	require.Len(t, blocks.BlockSet, 1)
    +
    +	assert.Equal(t, MessageBlockType("some_future_block"), blocks.BlockSet[0].BlockType())
    +	assert.Equal(t, "fb1", blocks.BlockSet[0].ID())
    +
    +	output, err := json.Marshal(blocks)
    +	require.NoError(t, err)
    +	assert.JSONEq(t, input, string(output))
    +}
    
    From fc49102d910d4072da3dc4430e88b982e7049794 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 5 Apr 2026 23:43:38 +0200
    Subject: [PATCH 539/606] chore: v0.21.0
    
    ---
     CHANGELOG.md | 5 ++++-
     1 file changed, 4 insertions(+), 1 deletion(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 24b78bf16..25d61f3c7 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ## [Unreleased]
     
    +## [0.21.0] - 2026-04-05
    +
     ### Deprecated
     
     - **`slackevents.ParseActionEvent`** — Cannot parse `block_actions` payloads (returns
    @@ -463,7 +465,8 @@ for details.
     [#1157]: https://github.com/slack-go/slack/issues/1157
     [#1196]: https://github.com/slack-go/slack/issues/1196
     
    -[Unreleased]: https://github.com/slack-go/slack/compare/v0.20.0...HEAD
    +[Unreleased]: https://github.com/slack-go/slack/compare/v0.21.0...HEAD
    +[0.21.0]: https://github.com/slack-go/slack/compare/v0.20.0...v0.21.0
     [0.20.0]: https://github.com/slack-go/slack/compare/v0.19.0...v0.20.0
     [0.19.0]: https://github.com/slack-go/slack/compare/v0.18.0...v0.19.0
     [0.18.0]: https://github.com/slack-go/slack/compare/v0.18.0-rc2...v0.18.0
    
    From 86049e88a2be5a0b7999a68674f2f8c9922ac91f Mon Sep 17 00:00:00 2001
    From: Mo Abukar <76791648+moabukar@users.noreply.github.com>
    Date: Wed, 8 Apr 2026 13:44:48 +0100
    Subject: [PATCH 540/606] fix: move attachment and block serialization to
     formSender (#1547)
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    ## What
    
    Fixes the `FIXME` in `MsgOptionAttachments` - attachments and blocks
    were being serialized twice (once for the JSON path, once for the form
    path). Same issue existed in `MsgOptionBlocks`.
    
    ## How
    
    Moved form-path serialization into `formSender.BuildRequestContext`
    instead of doing it eagerly in the option functions. Now each sender
    handles its own serialization:
    - `responseURLSender` → reads typed struct fields (unchanged)
    - `formSender` → marshals into form values at send time
    
    ## Worth noting
    
    - `UnsafeApplyMsgOptions` returns `config.values` directly — after this
    change, `attachments`/`blocks` won't be in there since marshaling is
    deferred. That function is documented as unsupported though.
    - `MsgOptionMetadata` has the same pattern — left it out to keep scope
    small, happy to follow up.
    
    ## Tests
    
    All existing tests pass. Covered by `TestPostMessage` (attachments,
    blocks, empty blocks) and
    `TestPostMessageWithBlocksWhenMsgOptionResponseURLApplied`.
    ---
     chat.go | 51 +++++++++++++++++++++++++++++----------------------
     1 file changed, 29 insertions(+), 22 deletions(-)
    
    diff --git a/chat.go b/chat.go
    index d353604d5..8d4066cf0 100644
    --- a/chat.go
    +++ b/chat.go
    @@ -375,13 +375,15 @@ func (t sendConfig) BuildRequestContext(ctx context.Context, token, channelID st
     			deleteOriginal:  t.deleteOriginal,
     		}.BuildRequestContext(ctx)
     	default:
    -		return formSender{endpoint: t.endpoint, values: t.values}.BuildRequestContext(ctx)
    +		return formSender{endpoint: t.endpoint, values: t.values, attachments: t.attachments, blocks: t.blocks}.BuildRequestContext(ctx)
     	}
     }
     
     type formSender struct {
    -	endpoint string
    -	values   url.Values
    +	endpoint    string
    +	values      url.Values
    +	attachments []Attachment
    +	blocks      Blocks
     }
     
     func (t formSender) BuildRequest() (*http.Request, func(*chatResponseFull) responseParser, error) {
    @@ -389,6 +391,22 @@ func (t formSender) BuildRequest() (*http.Request, func(*chatResponseFull) respo
     }
     
     func (t formSender) BuildRequestContext(ctx context.Context) (*http.Request, func(*chatResponseFull) responseParser, error) {
    +	if t.attachments != nil {
    +		attachmentBytes, err := json.Marshal(t.attachments)
    +		if err != nil {
    +			return nil, nil, err
    +		}
    +		t.values.Set("attachments", string(attachmentBytes))
    +	}
    +
    +	if t.blocks.BlockSet != nil {
    +		blockBytes, err := json.Marshal(t.blocks.BlockSet)
    +		if err != nil {
    +			return nil, nil, err
    +		}
    +		t.values.Set("blocks", string(blockBytes))
    +	}
    +
     	req, err := formReq(ctx, t.endpoint, t.values)
     	return req, func(resp *chatResponseFull) responseParser {
     		return newJSONParser(resp)
    @@ -670,16 +688,7 @@ func MsgOptionAttachments(attachments ...Attachment) MsgOption {
     
     		config.attachments = attachments
     
    -		// FIXME: We are setting the attachments on the message twice: above for
    -		// the json version, and below for the html version.  The marshalled bytes
    -		// we put into config.values below don't work directly in the Msg version.
    -
    -		attachmentBytes, err := json.Marshal(attachments)
    -		if err == nil {
    -			config.values.Set("attachments", string(attachmentBytes))
    -		}
    -
    -		return err
    +		return nil
     	}
     }
     
    @@ -688,17 +697,15 @@ func MsgOptionAttachments(attachments ...Attachment) MsgOption {
     // To skip setting blocks entirely, do not include this option.
     func MsgOptionBlocks(blocks ...Block) MsgOption {
     	return func(config *sendConfig) error {
    -		if blocks == nil {
    -			blocks = []Block{}
    +		if len(blocks) == 0 {
    +			// Explicitly set to empty slice (not nil) so the sender
    +			// knows to marshal "[]" and clear blocks on the message.
    +			config.blocks.BlockSet = []Block{}
    +		} else {
    +			config.blocks.BlockSet = append(config.blocks.BlockSet, blocks...)
     		}
     
    -		config.blocks.BlockSet = append(config.blocks.BlockSet, blocks...)
    -
    -		blocks, err := json.Marshal(blocks)
    -		if err == nil {
    -			config.values.Set("blocks", string(blocks))
    -		}
    -		return err
    +		return nil
     	}
     }
     
    
    From 2a73b4422120d34c1b6edeba05fcc1b4a2fef51a Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Wed, 8 Apr 2026 14:45:26 +0200
    Subject: [PATCH 541/606] docs: add #1547 to changelog
    
    ---
     CHANGELOG.md | 15 +++++++++++++++
     1 file changed, 15 insertions(+)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 25d61f3c7..bf83beb90 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ## [Unreleased]
     
    +### Fixed
    +
    +- **Duplicate attachment/block serialization in `MsgOptionAttachments` / `MsgOptionBlocks`** —
    +  Attachments and blocks were serialized twice: once into typed struct fields (for the JSON
    +  response-URL path) and again into `url.Values` (for the form POST path). Serialization for
    +  the form path now happens inside `formSender.BuildRequestContext`, so each sender owns its
    +  own marshalling. This fixes the long-standing FIXME and eliminates redundant `json.Marshal`
    +  calls in the option functions. ([#1547])
    +
    +  > [!NOTE]
    +  > `UnsafeApplyMsgOptions` returns `config.values` directly. After this change,
    +  > `attachments` and `blocks` keys are no longer present in those values since
    +  > marshalling is deferred to send time. This function is documented as unsupported.
    +
     ## [0.21.0] - 2026-04-05
     
     ### Deprecated
    @@ -464,6 +478,7 @@ for details.
     [#1076]: https://github.com/slack-go/slack/issues/1076
     [#1157]: https://github.com/slack-go/slack/issues/1157
     [#1196]: https://github.com/slack-go/slack/issues/1196
    +[#1547]: https://github.com/slack-go/slack/pull/1547
     
     [Unreleased]: https://github.com/slack-go/slack/compare/v0.21.0...HEAD
     [0.21.0]: https://github.com/slack-go/slack/compare/v0.20.0...v0.21.0
    
    From ecb7c68bebb01aad64ddf5939b3d6cafa1e7fe54 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Wed, 8 Apr 2026 15:05:38 +0200
    Subject: [PATCH 542/606] chore: remove useless TODO
    
    ---
     block_object.go | 1 -
     1 file changed, 1 deletion(-)
    
    diff --git a/block_object.go b/block_object.go
    index fd73b6c4a..c1b64cc4c 100644
    --- a/block_object.go
    +++ b/block_object.go
    @@ -11,7 +11,6 @@ import (
     
     // BlockObject defines an interface that all block object types should
     // implement.
    -// @TODO: Is this interface needed?
     
     // blockObject object types
     const (
    
    From 9f9002ab62500b0b55c60613b09e5e21ad818c81 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Wed, 8 Apr 2026 15:07:42 +0200
    Subject: [PATCH 543/606] chore: remove useless TODO
    
    ---
     websocket_groups.go | 3 ---
     1 file changed, 3 deletions(-)
    
    diff --git a/websocket_groups.go b/websocket_groups.go
    index eb88985c4..c35d5f357 100644
    --- a/websocket_groups.go
    +++ b/websocket_groups.go
    @@ -7,9 +7,6 @@ type GroupCreatedEvent struct {
     	Channel ChannelCreatedInfo `json:"channel"`
     }
     
    -// XXX: Should we really do this? event.Group is probably nicer than event.Channel
    -// even though the api returns "channel"
    -
     // GroupMarkedEvent represents the Group marked event
     type GroupMarkedEvent ChannelInfoEvent
     
    
    From 3500739a17bd61310c4d5825254ed9449f61b37b Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Wed, 8 Apr 2026 15:09:37 +0200
    Subject: [PATCH 544/606] chore: better Icons godoc
    
    ---
     info.go | 2 +-
     1 file changed, 1 insertion(+), 1 deletion(-)
    
    diff --git a/info.go b/info.go
    index 8e8ccc49e..d5276f757 100644
    --- a/info.go
    +++ b/info.go
    @@ -431,7 +431,7 @@ type Team struct {
     	Icons  *Icons `json:"icon,omitempty"`
     }
     
    -// Icons XXX: needs further investigation
    +// Icons contains the image URLs for the team icons in various sizes
     type Icons struct {
     	Image36  string `json:"image_36,omitempty"`
     	Image48  string `json:"image_48,omitempty"`
    
    From 5e0d5c3352e025d2b9f192982739f244bfd45ae5 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Wed, 8 Apr 2026 15:13:20 +0200
    Subject: [PATCH 545/606] chore: remove misleading XXX prefix to an excellent
     comment
    
    ---
     users.go | 16 ++++++++--------
     1 file changed, 8 insertions(+), 8 deletions(-)
    
    diff --git a/users.go b/users.go
    index 1d724c045..541baae78 100644
    --- a/users.go
    +++ b/users.go
    @@ -730,16 +730,16 @@ func (api *Client) SetUserCustomStatusWithUser(user, statusText, statusEmoji str
     //
     // Slack API docs: https://api.slack.com/methods/users.profile.set
     func (api *Client) SetUserCustomStatusContextWithUser(ctx context.Context, user, statusText, statusEmoji string, statusExpiration int64) error {
    -	// XXX(theckman): this anonymous struct is for making requests to the Slack
    -	// API for setting and unsetting a User's Custom Status/Emoji. To change
    -	// these values we must provide a JSON document as the profile POST field.
    +	// This anonymous struct is for making requests to the Slack API for setting and
    +	// unsetting a User's Custom Status/Emoji. To change these values we must provide a
    +	// JSON document as the profile POST field.
     	//
    -	// We use an anonymous struct over UserProfile because to unset the values
    -	// on the User's profile we cannot use the `json:"omitempty"` tag. This is
    -	// because an empty string ("") is what's used to unset the values. Check
    -	// out the API docs for more details:
    +	// We use an anonymous struct over UserProfile because to unset the values on the
    +	// User's profile we cannot use the `json:"omitempty"` tag. This is because an empty
    +	// string ("") is what's used to unset the values. Check out the API docs for more
    +	// details:
     	//
    -	// - https://api.slack.com/docs/presence-and-status#custom_status
    +	// - https://docs.slack.dev/apis/web-api/user-presence-and-status/#custom-status
     	profile, err := json.Marshal(
     		&struct {
     			StatusText       string `json:"status_text"`
    
    From 7ec6a51470a12af130c81a816519c5d30e59e74d Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Wed, 8 Apr 2026 15:16:29 +0200
    Subject: [PATCH 546/606] chore: assert the test file as well
    
    ---
     messages_test.go | 19 ++++++++++++++++++-
     1 file changed, 18 insertions(+), 1 deletion(-)
    
    diff --git a/messages_test.go b/messages_test.go
    index 1cca21325..745da760b 100644
    --- a/messages_test.go
    +++ b/messages_test.go
    @@ -138,7 +138,24 @@ func TestUploadedFile(t *testing.T) {
     	assert.Equal(t, "message", message.Type)
     	assert.Equal(t, MsgSubTypeFileShare, message.SubType)
     	assert.Equal(t, "<@U2147483697|tester> uploaded a file:  and commented: test comment here", message.Text)
    -	// TODO: Assert File
    +	assert.Len(t, message.Files, 1)
    +	file := message.Files[0]
    +	assert.Equal(t, "abc", file.ID)
    +	assert.Equal(t, JSONTime(1433314757), file.Created)
    +	assert.Equal(t, "test.txt", file.Name)
    +	assert.Equal(t, "test.txt", file.Title)
    +	assert.Equal(t, "text/plain", file.Mimetype)
    +	assert.Equal(t, "text", file.Filetype)
    +	assert.Equal(t, "Plain Text", file.PrettyType)
    +	assert.Equal(t, "U2147483697", file.User)
    +	assert.True(t, file.Editable)
    +	assert.Equal(t, 5, file.Size)
    +	assert.Equal(t, "snippet", file.Mode)
    +	assert.False(t, file.IsExternal)
    +	assert.True(t, file.IsPublic)
    +	assert.Equal(t, "https://test.slack.com/files/tester/abc/test.txt", file.Permalink)
    +	assert.Equal(t, []string{"C2147483705"}, file.Channels)
    +	assert.Equal(t, 1, file.CommentsCount)
     	assert.Equal(t, "U2147483697", message.User)
     	assert.True(t, message.Upload)
     	assert.Equal(t, "1433314757.000006", message.Timestamp)
    
    From fa3e1432e726c7f79272ecb0e9a0ac86eecf3615 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Wed, 8 Apr 2026 15:18:31 +0200
    Subject: [PATCH 547/606] chore: assert the test file and comment
    
    ---
     messages_test.go | 26 +++++++++++++++++++++++---
     1 file changed, 23 insertions(+), 3 deletions(-)
    
    diff --git a/messages_test.go b/messages_test.go
    index 745da760b..1704837b8 100644
    --- a/messages_test.go
    +++ b/messages_test.go
    @@ -209,7 +209,14 @@ func TestPost(t *testing.T) {
     	assert.Equal(t, "message", message.Type)
     	assert.Equal(t, MsgSubTypeFileShare, message.SubType)
     	assert.Equal(t, "<@U2147483697|tester> shared a file: ", message.Text)
    -	// TODO: Assert File
    +	assert.Len(t, message.Files, 1)
    +	file := message.Files[0]
    +	assert.Equal(t, "abc", file.ID)
    +	assert.Equal(t, "test_post", file.Name)
    +	assert.Equal(t, "test post", file.Title)
    +	assert.Equal(t, "post", file.Mode)
    +	assert.Equal(t, 14, file.Size)
    +	assert.Equal(t, 1, file.CommentsCount)
     	assert.Equal(t, "U2147483697", message.User)
     	assert.False(t, message.Upload)
     	assert.Equal(t, "1433315416.000008", message.Timestamp)
    @@ -269,8 +276,21 @@ func TestComment(t *testing.T) {
     	assert.Equal(t, "message", message.Type)
     	assert.Equal(t, MsgSubTypeFileComment, message.SubType)
     	assert.Equal(t, "<@U2147483697|tester> commented on <@U2147483697|tester>'s file : another comment", message.Text)
    -	// TODO: Assert File
    -	// TODO: Assert Comment
    +	assert.Len(t, message.Files, 1)
    +	file := message.Files[0]
    +	assert.Equal(t, "abc", file.ID)
    +	assert.Equal(t, "test_post", file.Name)
    +	assert.Equal(t, "test post", file.Title)
    +	assert.Equal(t, "post", file.Mode)
    +	assert.Equal(t, 14, file.Size)
    +	assert.Equal(t, 2, file.CommentsCount)
    +
    +	assert.NotNil(t, message.Comment)
    +	assert.Equal(t, "xyz", message.Comment.ID)
    +	assert.Equal(t, JSONTime(1433316360), message.Comment.Created)
    +	assert.Equal(t, "U2147483697", message.Comment.User)
    +	assert.Equal(t, "another comment", message.Comment.Comment)
    +
     	assert.Equal(t, "1433316360.000009", message.Timestamp)
     }
     
    
    From 17153aabe6bdb8478bc36304ca9dc243d8b9a15c Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Wed, 8 Apr 2026 15:35:41 +0200
    Subject: [PATCH 548/606] chore: remove stale TODO
    
    ---
     users_test.go | 47 ++++++++++++++++++++++++-----------------------
     1 file changed, 24 insertions(+), 23 deletions(-)
    
    diff --git a/users_test.go b/users_test.go
    index cf59a4ac5..9db10482d 100644
    --- a/users_test.go
    +++ b/users_test.go
    @@ -99,28 +99,28 @@ func getUserIdentity(rw http.ResponseWriter, r *http.Request) {
     	response := []byte(`{
       "ok": true,
       "user": {
    -    "id": "UXXXXXXXX",
    -    "name": "Test User",
    -    "email": "test@test.com",
    -    "image_24": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_24.jpg",
    -    "image_32": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_32.jpg",
    -    "image_48": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_48.jpg",
    -    "image_72": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_72.jpg",
    -    "image_192": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_192.jpg",
    -    "image_512": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_512.jpg"
    +	"id": "UXXXXXXXX",
    +	"name": "Test User",
    +	"email": "test@test.com",
    +	"image_24": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_24.jpg",
    +	"image_32": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_32.jpg",
    +	"image_48": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_48.jpg",
    +	"image_72": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_72.jpg",
    +	"image_192": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_192.jpg",
    +	"image_512": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_512.jpg"
       },
       "team": {
    -    "id": "TXXXXXXXX",
    -    "name": "team-name",
    -    "domain": "team-domain",
    -    "image_34": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_34.jpg",
    -    "image_44": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_44.jpg",
    -    "image_68": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_68.jpg",
    -    "image_88": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_88.jpg",
    -    "image_102": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_102.jpg",
    -    "image_132": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_132.jpg",
    -    "image_230": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_230.jpg",
    -    "image_original": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_original.jpg"
    +	"id": "TXXXXXXXX",
    +	"name": "team-name",
    +	"domain": "team-domain",
    +	"image_34": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_34.jpg",
    +	"image_44": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_44.jpg",
    +	"image_68": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_68.jpg",
    +	"image_88": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_88.jpg",
    +	"image_102": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_102.jpg",
    +	"image_132": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_132.jpg",
    +	"image_230": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_230.jpg",
    +	"image_original": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_original.jpg"
       }
     }`)
     	rw.Write(response)
    @@ -215,7 +215,8 @@ func newProfileHandler(up *UserProfile) (setter func(http.ResponseWriter, *http.
     
     		*up = userProfile
     
    -		// TODO(theckman): enhance this to return a full User object
    +		// Response only needs {"ok":true} — this handler tests request parsing, not
    +		// response unmarshalling
     		fmt.Fprint(w, `{"ok":true}`)
     	}
     }
    @@ -702,8 +703,8 @@ func TestUserProfileCustomFieldsUnmarshalJSON(t *testing.T) {
     	}
     	if err := json.Unmarshal([]byte(`{
     	  "Xxxxxx": {
    -	    "value": "test value",
    -	    "alt": ""
    +		"value": "test value",
    +		"alt": ""
     	  }
     	}`), fields); err != nil {
     		t.Fatal(err)
    
    From 923f351f1120978d37f2e9a5f001cd9ef763f833 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Wed, 8 Apr 2026 16:14:33 +0200
    Subject: [PATCH 549/606] feat(slackevents): add `ChannelType` constants and
     helpers
    
    ---
     CHANGELOG.md                     |  7 +++++
     slackevents/inner_events.go      | 15 +++++++++--
     slackevents/inner_events_test.go | 45 ++++++++++++++++++--------------
     slackevents/parsers_test.go      |  2 +-
     4 files changed, 46 insertions(+), 23 deletions(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index bf83beb90..e403a957f 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ## [Unreleased]
     
    +### Added
    +
    +- **`slackevents.ChannelType*` constants and `MessageEvent` helpers** — Added
    +  `ChannelTypeChannel`, `ChannelTypeGroup`, `ChannelTypeIM`, `ChannelTypeMPIM` constants
    +  and `IsChannel()`, `IsGroup()`, `IsIM()`, `IsMpIM()` methods on `MessageEvent` so
    +  callers no longer need to compare raw strings.
    +
     ### Fixed
     
     - **Duplicate attachment/block serialization in `MsgOptionAttachments` / `MsgOptionBlocks`** —
    diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go
    index 30d0db211..676a005e1 100644
    --- a/slackevents/inner_events.go
    +++ b/slackevents/inner_events.go
    @@ -285,13 +285,19 @@ type SharedLinks struct {
     	URL    string `json:"url"`
     }
     
    +const (
    +	ChannelTypeChannel = "channel" // Public channel message
    +	ChannelTypeGroup   = "group"   // Private channel message
    +	ChannelTypeIM      = "im"      // Direct message
    +	ChannelTypeMPIM    = "mpim"    // Multiparty direct message
    +)
    +
     // MessageEvent occurs when a variety of types of messages has been posted.
     // Parse ChannelType to see which
     // if ChannelType = "group", this is a private channel message
     // if ChannelType = "channel", this message was sent to a channel
     // if ChannelType = "im", this is a private message
    -// if ChannelType = "mim", A message was posted in a multiparty direct message channel
    -// TODO: Improve this so that it is not required to manually parse ChannelType
    +// if ChannelType = "mpim", A message was posted in a multiparty direct message channel
     type MessageEvent struct {
     	// Basic Message Event - https://api.slack.com/events/message
     	ClientMsgID     string       `json:"client_msg_id"`
    @@ -341,6 +347,11 @@ type MessageEvent struct {
     	Permalink       string            `json:"permalink,omitempty"`
     }
     
    +func (e *MessageEvent) IsIM() bool      { return e.ChannelType == ChannelTypeIM }
    +func (e *MessageEvent) IsChannel() bool { return e.ChannelType == ChannelTypeChannel }
    +func (e *MessageEvent) IsGroup() bool   { return e.ChannelType == ChannelTypeGroup }
    +func (e *MessageEvent) IsMpIM() bool    { return e.ChannelType == ChannelTypeMPIM }
    +
     // UnmarshalJSON implements the json.Unmarshaler interface for MessageEvent.
     // This custom unmarshaler handles both regular messages and message_changed events
     // by normalizing the message data into the Message field.
    diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go
    index ed52ad858..28eab34a3 100644
    --- a/slackevents/inner_events_test.go
    +++ b/slackevents/inner_events_test.go
    @@ -468,7 +468,9 @@ func TestMessageEvent(t *testing.T) {
     	if e.Message.Text != "Live long and prospect." {
     		t.Error(fmt.Errorf("expected e.Message.Text Live long and prospect., got %s", e.Message.Text))
     	}
    -
    +	if !e.IsChannel() {
    +		t.Error(fmt.Errorf("expected IsChannelMessage true, got false"))
    +	}
     }
     
     func TestMessageEventWithAssistantThread(t *testing.T) {
    @@ -508,6 +510,9 @@ func TestMessageEventWithAssistantThread(t *testing.T) {
     	if e.AssistantThread.ActionToken != "9876543.hijklmnop" {
     		t.Errorf("Expected ActionToken to be '9876543.hijklmnop', got %s", e.AssistantThread.ActionToken)
     	}
    +	if !e.IsIM() {
    +		t.Error(fmt.Errorf("expected IsIM true, got false"))
    +	}
     }
     
     func TestMessageChangedEvent(t *testing.T) {
    @@ -1823,13 +1828,13 @@ func TestDndUpdatedEvent(t *testing.T) {
     func TestDndUpdatedUserEvent(t *testing.T) {
     	rawE := []byte(`
     		{
    -    		"type": "dnd_updated_user",
    -    		"user": "U1234",
    -    		"dnd_status": {
    -        		"dnd_enabled": true,
    -        		"next_dnd_start_ts": 1450387800,
    -        		"next_dnd_end_ts": 1450423800
    -    		}
    +			"type": "dnd_updated_user",
    +			"user": "U1234",
    +			"dnd_status": {
    +				"dnd_enabled": true,
    +				"next_dnd_start_ts": 1450387800,
    +				"next_dnd_end_ts": 1450423800
    +			}
     		}
     	`)
     
    @@ -1881,10 +1886,10 @@ func TestEmailDomainChangedEvent(t *testing.T) {
     func TestGroupHistoryChangedEvent(t *testing.T) {
     	rawE := []byte(`
     		{
    -    		"type": "group_history_changed",
    -    		"latest": "1358877455.000010",
    -    		"ts": "1361482916.000003",
    -    		"event_ts": "1361482916.000004"
    +			"type": "group_history_changed",
    +			"latest": "1358877455.000010",
    +			"ts": "1361482916.000003",
    +			"event_ts": "1361482916.000004"
     		}
     	`)
     
    @@ -1906,9 +1911,9 @@ func TestGroupHistoryChangedEvent(t *testing.T) {
     func TestGroupOpenEvent(t *testing.T) {
     	rawE := []byte(`
     		{
    -    		"type": "group_open",
    -    		"user": "U024BE7LH",
    -    		"channel": "G024BE91L"
    +			"type": "group_open",
    +			"user": "U024BE7LH",
    +			"channel": "G024BE91L"
     		}
     	`)
     
    @@ -2004,10 +2009,10 @@ func TestImCreatedEvent(t *testing.T) {
     func TestImHistoryChangedEvent(t *testing.T) {
     	rawE := []byte(`
     		{
    -    		"type": "im_history_changed",
    -    		"latest": "1358877455.000010",
    -    		"ts": "1361482916.000003",
    -    		"event_ts": "1361482916.000004"
    +			"type": "im_history_changed",
    +			"latest": "1358877455.000010",
    +			"ts": "1361482916.000003",
    +			"event_ts": "1361482916.000004"
     		}
     	`)
     
    @@ -2692,7 +2697,7 @@ func TestChannelUnsharedEvent(t *testing.T) {
     		"type": "channel_unshared",
     		"previously_connected_team_id": "E163Q94DX",
     		"channel": "C123ABC456",
    -        "is_ext_shared": false,
    +		"is_ext_shared": false,
     		"event_ts": "1561064063.001100"
     	}`
     
    diff --git a/slackevents/parsers_test.go b/slackevents/parsers_test.go
    index bacc703a2..4046b0137 100644
    --- a/slackevents/parsers_test.go
    +++ b/slackevents/parsers_test.go
    @@ -386,7 +386,7 @@ func TestParseEventAPIMessageMPIMWithAssistantThread(t *testing.T) {
     					if innerEvent.AssistantThread.ActionToken != "xyz789.multiparty" {
     						t.Errorf("Expected ActionToken to be 'xyz789.multiparty', got %s", innerEvent.AssistantThread.ActionToken)
     					}
    -					if innerEvent.ChannelType != "mpim" {
    +					if !innerEvent.IsMpIM() {
     						t.Errorf("Expected ChannelType to be 'mpim', got %s", innerEvent.ChannelType)
     					}
     				}
    
    From b1e976fe51219d07cc513d72b20b0186f2264ae4 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Wed, 8 Apr 2026 18:03:34 +0200
    Subject: [PATCH 550/606] chore(slackevents): clarify eventsMap priority and
     remove stale TODOs
    
    ---
     slackevents/parsers.go | 15 ++++-----------
     1 file changed, 4 insertions(+), 11 deletions(-)
    
    diff --git a/slackevents/parsers.go b/slackevents/parsers.go
    index acff4984e..80308eae7 100644
    --- a/slackevents/parsers.go
    +++ b/slackevents/parsers.go
    @@ -10,23 +10,16 @@ import (
     	"github.com/slack-go/slack"
     )
     
    -// eventsMap checks both slack.EventsMapping and
    -// and slackevents.EventsAPIInnerEventMapping. If the event
    -// exists, returns the unmarshalled struct instance of
    -// target for the matching event type.
    -// TODO: Consider moving all events into its own package?
    +// eventsMap checks both slackevents.EventsAPIInnerEventMapping and slack.EventMapping
    +// (RTM). EventsAPI mapping is checked first because both define a "message" type, and
    +// EventsAPI's MessageEvent is the correct choice for Events API payloads.
     func eventsMap(t string) (interface{}, bool) {
    -	// Must parse EventsAPI FIRST as both RTM and EventsAPI
    -	// have a type: "Message" event.
    -	// TODO: Handle these cases more explicitly.
    +	// EventsAPI mapping takes precedence over RTM mapping.
     	v, exists := EventsAPIInnerEventMapping[EventsAPIType(t)]
     	if exists {
     		return v, exists
     	}
     	v, exists = slack.EventMapping[t]
    -	if exists {
    -		return v, exists
    -	}
     	return v, exists
     }
     
    
    From 6d77e45e6424b7504b3dddcf5d10d4beb7618811 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Wed, 8 Apr 2026 18:06:41 +0200
    Subject: [PATCH 551/606] chore: v0.21.1
    
    ---
     CHANGELOG.md | 5 ++++-
     1 file changed, 4 insertions(+), 1 deletion(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index e403a957f..d471bbaa4 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ## [Unreleased]
     
    +## [0.21.1] - 2026-04-08
    +
     ### Added
     
     - **`slackevents.ChannelType*` constants and `MessageEvent` helpers** — Added
    @@ -487,7 +489,8 @@ for details.
     [#1196]: https://github.com/slack-go/slack/issues/1196
     [#1547]: https://github.com/slack-go/slack/pull/1547
     
    -[Unreleased]: https://github.com/slack-go/slack/compare/v0.21.0...HEAD
    +[Unreleased]: https://github.com/slack-go/slack/compare/v0.21.1...HEAD
    +[0.21.1]: https://github.com/slack-go/slack/compare/v0.21.0...v0.21.1
     [0.21.0]: https://github.com/slack-go/slack/compare/v0.20.0...v0.21.0
     [0.20.0]: https://github.com/slack-go/slack/compare/v0.19.0...v0.20.0
     [0.19.0]: https://github.com/slack-go/slack/compare/v0.18.0...v0.19.0
    
    From 3298f7020e73e8920ee7d179079dcbe8599a1ac3 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sat, 11 Apr 2026 21:18:33 +0200
    Subject: [PATCH 552/606] feat(assistant): expand `assistant.search.context`
     API surface
    
    Added missing parameters (sort, time range, highlight, context messages, semantic search
    control) and response types (files, channels, context messages).
    
    Also fixed array params to use comma-separated encoding.
    ---
     CHANGELOG.md                                  |  14 ++
     assistant.go                                  | 140 ++++++++++--
     assistant_test.go                             | 210 +++++++++++-------
     examples/assistant_search/assistant_search.go | 113 ++++++++++
     4 files changed, 378 insertions(+), 99 deletions(-)
     create mode 100644 examples/assistant_search/assistant_search.go
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index d471bbaa4..23ad3fa0f 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ## [Unreleased]
     
    +### Added
    +
    +- Added missing parameters to `assistant.search.context` (`Sort`, `SortDir`, `Before`,
    +  `After`, `Highlight`, `IncludeContextMessages`, `IncludeDeletedUsers`,
    +  `IncludeMessageBlocks`, `IncludeArchivedChannels`, `DisableSemanticSearch`, `Modifiers`,
    +  `TermClauses`) and new response types (`AssistantSearchContextFile`,
    +  `AssistantSearchContextChannel`, `AssistantSearchContextMessageContext`) to match the
    +  full Real-Time Search API surface.
    +
    +### Fixed
    +
    +- `ChannelTypes` and `ContentTypes` now send comma-separated values instead of repeated
    +  form keys, matching the convention used by every other method in the library.
    +
     ## [0.21.1] - 2026-04-08
     
     ### Added
    diff --git a/assistant.go b/assistant.go
    index d95e68a52..0f44d31af 100644
    --- a/assistant.go
    +++ b/assistant.go
    @@ -39,30 +39,82 @@ type AssistantThreadsPrompt struct {
     
     // AssistantSearchContextParameters are the parameters for AssistantSearchContext
     type AssistantSearchContextParameters struct {
    -	Query            string   `json:"query"`
    -	ActionToken      string   `json:"action_token,omitempty"`
    -	ChannelTypes     []string `json:"channel_types,omitempty"`
    -	ContentTypes     []string `json:"content_types,omitempty"`
    -	ContextChannelID string   `json:"context_channel_id,omitempty"`
    -	Cursor           string   `json:"cursor,omitempty"`
    -	IncludeBots      bool     `json:"include_bots,omitempty"`
    -	Limit            int      `json:"limit,omitempty"`
    +	Query                   string   `json:"query"`
    +	ActionToken             string   `json:"action_token,omitempty"`
    +	ChannelTypes            []string `json:"channel_types,omitempty"`
    +	ContentTypes            []string `json:"content_types,omitempty"`
    +	ContextChannelID        string   `json:"context_channel_id,omitempty"`
    +	Cursor                  string   `json:"cursor,omitempty"`
    +	IncludeBots             bool     `json:"include_bots,omitempty"`
    +	Limit                   int      `json:"limit,omitempty"`
    +	IncludeDeletedUsers     bool     `json:"include_deleted_users,omitempty"`
    +	Before                  int64    `json:"before,omitempty"`
    +	After                   int64    `json:"after,omitempty"`
    +	IncludeContextMessages  bool     `json:"include_context_messages,omitempty"`
    +	Sort                    string   `json:"sort,omitempty"`
    +	SortDir                 string   `json:"sort_dir,omitempty"`
    +	IncludeMessageBlocks    bool     `json:"include_message_blocks,omitempty"`
    +	Highlight               bool     `json:"highlight,omitempty"`
    +	TermClauses             []string `json:"term_clauses,omitempty"`
    +	Modifiers               string   `json:"modifiers,omitempty"`
    +	IncludeArchivedChannels bool     `json:"include_archived_channels,omitempty"`
    +	DisableSemanticSearch   bool     `json:"disable_semantic_search,omitempty"`
     }
     
     // AssistantSearchContextMessage represents a search result message
     type AssistantSearchContextMessage struct {
    -	AuthorUserID string `json:"author_user_id"`
    -	TeamID       string `json:"team_id"`
    -	ChannelID    string `json:"channel_id"`
    -	MessageTS    string `json:"message_ts"`
    -	Content      string `json:"content"`
    -	IsAuthorBot  bool   `json:"is_author_bot"`
    -	Permalink    string `json:"permalink"`
    +	AuthorUserID    string                                `json:"author_user_id"`
    +	AuthorName      string                                `json:"author_name,omitempty"`
    +	TeamID          string                                `json:"team_id"`
    +	ChannelID       string                                `json:"channel_id"`
    +	ChannelName     string                                `json:"channel_name,omitempty"`
    +	MessageTS       string                                `json:"message_ts"`
    +	Content         string                                `json:"content"`
    +	IsAuthorBot     bool                                  `json:"is_author_bot"`
    +	Permalink       string                                `json:"permalink"`
    +	Blocks          Blocks                                `json:"blocks,omitempty"`
    +	ContextMessages *AssistantSearchContextMessageContext `json:"context_messages,omitempty"`
    +}
    +
    +// AssistantSearchContextMessageContext contains context messages surrounding a search result
    +type AssistantSearchContextMessageContext struct {
    +	Before []AssistantSearchContextMessage `json:"before"`
    +	After  []AssistantSearchContextMessage `json:"after"`
    +}
    +
    +// AssistantSearchContextFile represents a search result file
    +type AssistantSearchContextFile struct {
    +	UploaderUserID string `json:"uploader_user_id"`
    +	AuthorUserID   string `json:"author_user_id"`
    +	AuthorName     string `json:"author_name"`
    +	TeamID         string `json:"team_id"`
    +	FileID         string `json:"file_id"`
    +	DateCreated    int64  `json:"date_created"`
    +	DateUpdated    int64  `json:"date_updated"`
    +	Title          string `json:"title"`
    +	FileType       string `json:"file_type"`
    +	Permalink      string `json:"permalink"`
    +	Content        string `json:"content"`
    +}
    +
    +// AssistantSearchContextChannel represents a search result channel
    +type AssistantSearchContextChannel struct {
    +	TeamID        string `json:"team_id"`
    +	CreatorUserID string `json:"creator_user_id"`
    +	CreatorName   string `json:"creator_name"`
    +	DateCreated   int64  `json:"date_created"`
    +	DateUpdated   int64  `json:"date_updated"`
    +	Name          string `json:"name"`
    +	Topic         string `json:"topic"`
    +	Purpose       string `json:"purpose"`
    +	Permalink     string `json:"permalink"`
     }
     
     // AssistantSearchContextResults contains the search results
     type AssistantSearchContextResults struct {
    -	Messages []AssistantSearchContextMessage `json:"messages"`
    +	Messages []AssistantSearchContextMessage `json:"messages,omitempty"`
    +	Files    []AssistantSearchContextFile    `json:"files,omitempty"`
    +	Channels []AssistantSearchContextChannel `json:"channels,omitempty"`
     }
     
     // AssistantSearchContextResponse is the response from assistant.search.context
    @@ -224,15 +276,11 @@ func (api *Client) SearchAssistantContextContext(ctx context.Context, params Ass
     	}
     
     	if len(params.ChannelTypes) > 0 {
    -		for _, channelType := range params.ChannelTypes {
    -			values.Add("channel_types", channelType)
    -		}
    +		values.Add("channel_types", strings.Join(params.ChannelTypes, ","))
     	}
     
     	if len(params.ContentTypes) > 0 {
    -		for _, contentType := range params.ContentTypes {
    -			values.Add("content_types", contentType)
    -		}
    +		values.Add("content_types", strings.Join(params.ContentTypes, ","))
     	}
     
     	if params.ContextChannelID != "" {
    @@ -251,6 +299,54 @@ func (api *Client) SearchAssistantContextContext(ctx context.Context, params Ass
     		values.Add("limit", strconv.Itoa(params.Limit))
     	}
     
    +	if params.IncludeDeletedUsers {
    +		values.Add("include_deleted_users", "true")
    +	}
    +
    +	if params.Before > 0 {
    +		values.Add("before", strconv.FormatInt(params.Before, 10))
    +	}
    +
    +	if params.After > 0 {
    +		values.Add("after", strconv.FormatInt(params.After, 10))
    +	}
    +
    +	if params.IncludeContextMessages {
    +		values.Add("include_context_messages", "true")
    +	}
    +
    +	if params.Sort != "" {
    +		values.Add("sort", params.Sort)
    +	}
    +
    +	if params.SortDir != "" {
    +		values.Add("sort_dir", params.SortDir)
    +	}
    +
    +	if params.IncludeMessageBlocks {
    +		values.Add("include_message_blocks", "true")
    +	}
    +
    +	if params.Highlight {
    +		values.Add("highlight", "true")
    +	}
    +
    +	if len(params.TermClauses) > 0 {
    +		values.Add("term_clauses", strings.Join(params.TermClauses, ","))
    +	}
    +
    +	if params.Modifiers != "" {
    +		values.Add("modifiers", params.Modifiers)
    +	}
    +
    +	if params.IncludeArchivedChannels {
    +		values.Add("include_archived_channels", "true")
    +	}
    +
    +	if params.DisableSemanticSearch {
    +		values.Add("disable_semantic_search", "true")
    +	}
    +
     	response := &AssistantSearchContextResponse{}
     
     	err := api.postMethod(ctx, "assistant.search.context", values, response)
    diff --git a/assistant_test.go b/assistant_test.go
    index c6895c20f..162516ee3 100644
    --- a/assistant_test.go
    +++ b/assistant_test.go
    @@ -3,7 +3,6 @@ package slack
     import (
     	"encoding/json"
     	"net/http"
    -	"net/url"
     	"testing"
     )
     
    @@ -178,98 +177,155 @@ func TestSearchAssistantContext(t *testing.T) {
     	}
     }
     
    -func assistantSearchContextHandlerWithNewParams(rw http.ResponseWriter, r *http.Request) {
    -	query := r.FormValue("query")
    -	actionToken := r.FormValue("action_token")
    -	contextChannelID := r.FormValue("context_channel_id")
    -	includeBots := r.FormValue("include_bots")
    -
    -	rw.Header().Set("Content-Type", "application/json")
    +func TestSearchAssistantContextExpandedResponse(t *testing.T) {
    +	raw := `{
    +		"ok": true,
    +		"results": {
    +			"messages": [
    +				{
    +					"author_user_id": "U111",
    +					"author_name": "Test User",
    +					"team_id": "T111",
    +					"channel_id": "C111",
    +					"channel_name": "general",
    +					"message_ts": "1234567890.123456",
    +					"content": "Hello world",
    +					"is_author_bot": false,
    +					"permalink": "https://example.slack.com/archives/C111/p1234567890123456",
    +					"context_messages": {
    +						"before": [
    +							{
    +								"author_user_id": "U222",
    +								"team_id": "T111",
    +								"channel_id": "C111",
    +								"message_ts": "1234567889.000000",
    +								"content": "Before message",
    +								"is_author_bot": false,
    +								"permalink": "https://example.slack.com/archives/C111/p1234567889000000"
    +							}
    +						],
    +						"after": [
    +							{
    +								"author_user_id": "U333",
    +								"team_id": "T111",
    +								"channel_id": "C111",
    +								"message_ts": "1234567891.000000",
    +								"content": "After message",
    +								"is_author_bot": true,
    +								"permalink": "https://example.slack.com/archives/C111/p1234567891000000"
    +							}
    +						]
    +					}
    +				}
    +			],
    +			"files": [
    +				{
    +					"uploader_user_id": "U111",
    +					"author_user_id": "U111",
    +					"author_name": "Test User",
    +					"team_id": "T111",
    +					"file_id": "F111",
    +					"date_created": 1700000000,
    +					"date_updated": 1700001000,
    +					"title": "test.pdf",
    +					"file_type": "pdf",
    +					"permalink": "https://example.slack.com/files/U111/F111/test.pdf",
    +					"content": "File content excerpt"
    +				}
    +			],
    +			"channels": [
    +				{
    +					"team_id": "T111",
    +					"creator_user_id": "U111",
    +					"creator_name": "Test User",
    +					"date_created": 1600000000,
    +					"date_updated": 1700000000,
    +					"name": "general",
    +					"topic": "General discussion",
    +					"purpose": "Company-wide announcements",
    +					"permalink": "https://example.slack.com/archives/C111"
    +				}
    +			]
    +		},
    +		"response_metadata": {
    +			"next_cursor": "cursor123"
    +		}
    +	}`
     
    -	if query != "" && actionToken != "" && contextChannelID != "" && includeBots == "true" {
    -		resp, _ := json.Marshal(&AssistantSearchContextResponse{
    -			SlackResponse: SlackResponse{Ok: true},
    -			Results: AssistantSearchContextResults{
    -				Messages: []AssistantSearchContextMessage{
    -					{
    -						AuthorUserID: "U1234567890",
    -						TeamID:       "T0987654321",
    -						ChannelID:    contextChannelID, // Use the provided context channel ID
    -						MessageTS:    "1234567890.123456",
    -						Content:      "This is a test message with new parameters",
    -						IsAuthorBot:  true, // Test with bot message
    -						Permalink:    "https://example.slack.com/archives/" + contextChannelID + "/p1234567890123456",
    -					},
    -				},
    -			},
    -			ResponseMetadata: struct {
    -				NextCursor string `json:"next_cursor"`
    -			}{
    -				NextCursor: "next_cursor_value_new",
    -			},
    -		})
    -		rw.Write(resp)
    -	} else {
    -		rw.Write([]byte(`{ "ok": false, "error": "missing_required_parameters" }`))
    +	var response AssistantSearchContextResponse
    +	if err := json.Unmarshal([]byte(raw), &response); err != nil {
    +		t.Fatalf("Unmarshal error: %s", err)
     	}
    -}
     
    -func TestSearchAssistantContextWithNewParameters(t *testing.T) {
    -	http.HandleFunc("/assistant.search.context.new", assistantSearchContextHandlerWithNewParams)
    -	once.Do(startServer)
    -
    -	// Test with new parameters
    -	params := AssistantSearchContextParameters{
    -		Query:            "test query with new params",
    -		ActionToken:      "action_token_123",
    -		ChannelTypes:     []string{"public_channel"},
    -		ContentTypes:     []string{"messages"},
    -		ContextChannelID: "C0987654321",
    -		IncludeBots:      true,
    -		Limit:            5,
    +	if !response.Ok {
    +		t.Fatalf("Expected Ok to be true")
     	}
     
    -	// We need to temporarily change the API method for this test
    -	// Since we can't easily override the method, we'll test the parameter building logic
    +	// Verify messages
    +	if len(response.Results.Messages) != 1 {
    +		t.Fatalf("Expected 1 message, got %d", len(response.Results.Messages))
    +	}
     
    -	// Create a custom client to test parameter handling
    -	values := url.Values{"token": {"testing-token"}}
    -	values.Add("query", params.Query)
    +	msg := response.Results.Messages[0]
    +	if msg.AuthorName != "Test User" {
    +		t.Errorf("Expected AuthorName 'Test User', got %q", msg.AuthorName)
    +	}
    +	if msg.ChannelName != "general" {
    +		t.Errorf("Expected ChannelName 'general', got %q", msg.ChannelName)
    +	}
     
    -	if params.ActionToken != "" {
    -		values.Add("action_token", params.ActionToken)
    +	// Verify context messages
    +	if msg.ContextMessages == nil {
    +		t.Fatal("Expected ContextMessages to be non-nil")
     	}
    -	if len(params.ChannelTypes) > 0 {
    -		for _, channelType := range params.ChannelTypes {
    -			values.Add("channel_types", channelType)
    -		}
    +	if len(msg.ContextMessages.Before) != 1 {
    +		t.Fatalf("Expected 1 before context message, got %d", len(msg.ContextMessages.Before))
     	}
    -	if len(params.ContentTypes) > 0 {
    -		for _, contentType := range params.ContentTypes {
    -			values.Add("content_types", contentType)
    -		}
    +	if msg.ContextMessages.Before[0].Content != "Before message" {
    +		t.Errorf("Expected before content 'Before message', got %q", msg.ContextMessages.Before[0].Content)
    +	}
    +	if len(msg.ContextMessages.After) != 1 {
    +		t.Fatalf("Expected 1 after context message, got %d", len(msg.ContextMessages.After))
    +	}
    +	if !msg.ContextMessages.After[0].IsAuthorBot {
    +		t.Errorf("Expected after message IsAuthorBot true")
     	}
    -	if params.ContextChannelID != "" {
    -		values.Add("context_channel_id", params.ContextChannelID)
    +
    +	// Verify files
    +	if len(response.Results.Files) != 1 {
    +		t.Fatalf("Expected 1 file, got %d", len(response.Results.Files))
    +	}
    +	file := response.Results.Files[0]
    +	if file.FileID != "F111" {
    +		t.Errorf("Expected FileID 'F111', got %q", file.FileID)
    +	}
    +	if file.Title != "test.pdf" {
    +		t.Errorf("Expected Title 'test.pdf', got %q", file.Title)
     	}
    -	if params.IncludeBots {
    -		values.Add("include_bots", "true")
    +	if file.FileType != "pdf" {
    +		t.Errorf("Expected FileType 'pdf', got %q", file.FileType)
     	}
    -	if params.Limit > 0 {
    -		values.Add("limit", "5")
    +	if file.DateCreated != 1700000000 {
    +		t.Errorf("Expected DateCreated 1700000000, got %d", file.DateCreated)
     	}
     
    -	// Verify all parameters are set correctly
    -	if values.Get("action_token") != "action_token_123" {
    -		t.Errorf("Expected action_token to be 'action_token_123', got %s", values.Get("action_token"))
    +	// Verify channels
    +	if len(response.Results.Channels) != 1 {
    +		t.Fatalf("Expected 1 channel, got %d", len(response.Results.Channels))
     	}
    -	if values.Get("context_channel_id") != "C0987654321" {
    -		t.Errorf("Expected context_channel_id to be 'C0987654321', got %s", values.Get("context_channel_id"))
    +	ch := response.Results.Channels[0]
    +	if ch.Name != "general" {
    +		t.Errorf("Expected Name 'general', got %q", ch.Name)
     	}
    -	if values.Get("include_bots") != "true" {
    -		t.Errorf("Expected include_bots to be 'true', got %s", values.Get("include_bots"))
    +	if ch.Topic != "General discussion" {
    +		t.Errorf("Expected Topic 'General discussion', got %q", ch.Topic)
     	}
    -	if values.Get("limit") != "5" {
    -		t.Errorf("Expected limit to be '5', got %s", values.Get("limit"))
    +	if ch.Purpose != "Company-wide announcements" {
    +		t.Errorf("Expected Purpose 'Company-wide announcements', got %q", ch.Purpose)
    +	}
    +
    +	// Verify cursor
    +	if response.ResponseMetadata.NextCursor != "cursor123" {
    +		t.Errorf("Expected NextCursor 'cursor123', got %q", response.ResponseMetadata.NextCursor)
     	}
     }
    diff --git a/examples/assistant_search/assistant_search.go b/examples/assistant_search/assistant_search.go
    new file mode 100644
    index 000000000..3929b3454
    --- /dev/null
    +++ b/examples/assistant_search/assistant_search.go
    @@ -0,0 +1,113 @@
    +package main
    +
    +import (
    +	"fmt"
    +	"os"
    +	"strings"
    +
    +	"github.com/slack-go/slack"
    +)
    +
    +// This example demonstrates how to use the assistant.search.context API
    +// to search across a Slack workspace. This API is designed for AI/LLM
    +// consumption and returns messages, files, and channels matching a query.
    +//
    +// Usage: go run assistant_search.go 
    +//
    +// This example uses a user token (xoxp-...), which can call the API directly.
    +// Bot tokens (xoxb-...) require an action_token received from message events;
    +// see the ai_apps example for that pattern.
    +//
    +// Required scopes (user token): search:read.public, search:read.private,
    +//
    +//	search:read.im, search:read.mpim, search:read.files, search:read.users
    +//
    +// See https://docs.slack.dev/reference/methods/assistant.search.context
    +func main() {
    +	token := os.Getenv("SLACK_USER_TOKEN")
    +	if token == "" {
    +		fmt.Println("SLACK_USER_TOKEN environment variable is required (xoxp-... token)")
    +		os.Exit(1)
    +	}
    +
    +	if len(os.Args) < 2 {
    +		fmt.Println("Usage: go run assistant_search.go ")
    +		os.Exit(1)
    +	}
    +
    +	query := strings.Join(os.Args[1:], " ")
    +	api := slack.New(token)
    +
    +	// Search across public channels for messages, files, and channels
    +	resp, err := api.SearchAssistantContext(slack.AssistantSearchContextParameters{
    +		Query:        query,
    +		ChannelTypes: []string{"public_channel"},
    +		ContentTypes: []string{"messages", "files", "channels"},
    +		Limit:        10,
    +	})
    +	if err != nil {
    +		fmt.Printf("Error: %s\n", err)
    +		os.Exit(1)
    +	}
    +
    +	printResults(resp)
    +
    +	// Paginate using cursor
    +	if resp.ResponseMetadata.NextCursor != "" {
    +		fmt.Printf("\nNext page cursor: %s\n", resp.ResponseMetadata.NextCursor)
    +	}
    +
    +	// Advanced search: keyword-only, sorted by time, with context messages
    +	advanced, err := api.SearchAssistantContext(slack.AssistantSearchContextParameters{
    +		Query:                  query,
    +		ChannelTypes:           []string{"public_channel", "private_channel"},
    +		ContentTypes:           []string{"messages", "files"},
    +		Sort:                   "timestamp",
    +		SortDir:                "desc",
    +		IncludeContextMessages: true,
    +		Highlight:              true,
    +		DisableSemanticSearch:  true,
    +		Limit:                  5,
    +	})
    +	if err != nil {
    +		fmt.Printf("Advanced search error: %s\n", err)
    +		os.Exit(1)
    +	}
    +
    +	fmt.Println("\n--- Advanced Search (keyword-only, with context) ---")
    +	printResults(advanced)
    +}
    +
    +func printResults(resp *slack.AssistantSearchContextResponse) {
    +	fmt.Printf("=== Messages (%d) ===\n", len(resp.Results.Messages))
    +	for _, msg := range resp.Results.Messages {
    +		bot := ""
    +		if msg.IsAuthorBot {
    +			bot = " [bot]"
    +		}
    +		fmt.Printf("  %s (%s)%s in #%s:\n    %s\n    %s\n",
    +			msg.AuthorName, msg.AuthorUserID, bot,
    +			msg.ChannelName, msg.Content, msg.Permalink)
    +
    +		if msg.ContextMessages != nil {
    +			for _, before := range msg.ContextMessages.Before {
    +				fmt.Printf("    [before] %s: %s\n", before.AuthorUserID, before.Content)
    +			}
    +			for _, after := range msg.ContextMessages.After {
    +				fmt.Printf("    [after]  %s: %s\n", after.AuthorUserID, after.Content)
    +			}
    +		}
    +	}
    +
    +	fmt.Printf("\n=== Files (%d) ===\n", len(resp.Results.Files))
    +	for _, f := range resp.Results.Files {
    +		fmt.Printf("  %s (%s) by %s\n    %s\n",
    +			f.Title, f.FileType, f.AuthorName, f.Permalink)
    +	}
    +
    +	fmt.Printf("\n=== Channels (%d) ===\n", len(resp.Results.Channels))
    +	for _, ch := range resp.Results.Channels {
    +		fmt.Printf("  #%s — %s\n    %s\n",
    +			ch.Name, ch.Purpose, ch.Permalink)
    +	}
    +}
    
    From 5cfc21aec83746d05aaa3deb3b9b66d91bcead2d Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sat, 11 Apr 2026 21:25:38 +0200
    Subject: [PATCH 553/606] docs(assistant): mention deprecated assistant:write
     in favour of chat:write scope
    
    ---
     assistant.go | 8 ++++++--
     1 file changed, 6 insertions(+), 2 deletions(-)
    
    diff --git a/assistant.go b/assistant.go
    index 0f44d31af..abf2ff73c 100644
    --- a/assistant.go
    +++ b/assistant.go
    @@ -178,13 +178,17 @@ func (api *Client) SetAssistantThreadsSuggestedPromptsContext(ctx context.Contex
     	return response.Err()
     }
     
    -// SetAssistantThreadStatus sets the status of a thread
    +// SetAssistantThreadsStatus sets the status of a thread.
    +// This method accepts either the chat:write or assistant:write scope.
    +// Note: the assistant:write scope is being deprecated in favor of chat:write.
     // @see https://api.slack.com/methods/assistant.threads.setStatus
     func (api *Client) SetAssistantThreadsStatus(params AssistantThreadsSetStatusParameters) (err error) {
     	return api.SetAssistantThreadsStatusContext(context.Background(), params)
     }
     
    -// SetAssistantThreadStatusContext sets the status of a thread with a custom context
    +// SetAssistantThreadsStatusContext sets the status of a thread with a custom context.
    +// This method accepts either the chat:write or assistant:write scope.
    +// Note: the assistant:write scope is being deprecated in favor of chat:write.
     // @see https://api.slack.com/methods/assistant.threads.setStatus
     func (api *Client) SetAssistantThreadsStatusContext(ctx context.Context, params AssistantThreadsSetStatusParameters) (err error) {
     
    
    From 92379cc4066ac5994f234d1d5714545ecd7f9eff Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sat, 11 Apr 2026 21:54:55 +0200
    Subject: [PATCH 554/606] feat(blocks): add underline, highlight,
     client_highlight, unlink
    
    Added missing style fields documented in the rich text block API reference.
    
    Also added the `Style` field to `RichTextSectionUserGroupElement` for parity.
    ---
     CHANGELOG.md            |  2 ++
     block_rich_text.go      | 13 ++++++++----
     block_rich_text_test.go | 47 +++++++++++++++++++++++++++++++++++++++++
     3 files changed, 58 insertions(+), 4 deletions(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 23ad3fa0f..e046e3726 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
       `TermClauses`) and new response types (`AssistantSearchContextFile`,
       `AssistantSearchContextChannel`, `AssistantSearchContextMessageContext`) to match the
       full Real-Time Search API surface.
    +- Added `Underline`, `Highlight`, `ClientHighlight`, and `Unlink` fields to
    +  `RichTextSectionTextStyle`. Added `Style` field to `RichTextSectionUserGroupElement`.
     
     ### Fixed
     
    diff --git a/block_rich_text.go b/block_rich_text.go
    index b0bc87b9b..3b0ead2ee 100644
    --- a/block_rich_text.go
    +++ b/block_rich_text.go
    @@ -298,10 +298,14 @@ type RichTextSectionElement interface {
     }
     
     type RichTextSectionTextStyle struct {
    -	Bold   bool `json:"bold,omitempty"`
    -	Italic bool `json:"italic,omitempty"`
    -	Strike bool `json:"strike,omitempty"`
    -	Code   bool `json:"code,omitempty"`
    +	Bold            bool `json:"bold,omitempty"`
    +	Italic          bool `json:"italic,omitempty"`
    +	Strike          bool `json:"strike,omitempty"`
    +	Code            bool `json:"code,omitempty"`
    +	Underline       bool `json:"underline,omitempty"`
    +	Highlight       bool `json:"highlight,omitempty"`
    +	ClientHighlight bool `json:"client_highlight,omitempty"`
    +	Unlink          bool `json:"unlink,omitempty"`
     }
     
     type RichTextSectionTextElement struct {
    @@ -420,6 +424,7 @@ func NewRichTextSectionTeamElement(teamID string, style *RichTextSectionTextStyl
     type RichTextSectionUserGroupElement struct {
     	Type        RichTextSectionElementType `json:"type"`
     	UsergroupID string                     `json:"usergroup_id"`
    +	Style       *RichTextSectionTextStyle  `json:"style,omitempty"`
     }
     
     func (r RichTextSectionUserGroupElement) RichTextSectionElementType() RichTextSectionElementType {
    diff --git a/block_rich_text_test.go b/block_rich_text_test.go
    index fd302d735..9eeb25f5f 100644
    --- a/block_rich_text_test.go
    +++ b/block_rich_text_test.go
    @@ -408,4 +408,51 @@ func TestNewRichTextSectionChannelElement(t *testing.T) {
     	assert.Contains(t, string(data), `"channel_id":"C012345678"`)
     }
     
    +func TestRichTextSectionUsergroupStyleExtendedFields(t *testing.T) {
    +	raw := `{
    +		"type": "rich_text_section",
    +		"elements": [
    +			{
    +				"type": "usergroup",
    +				"usergroup_id": "ausergroupid",
    +				"style": {
    +					"bold": true,
    +					"underline": true,
    +					"highlight": true,
    +					"client_highlight": true,
    +					"unlink": true
    +				}
    +			}
    +		]
    +	}`
    +
    +	var section RichTextSection
    +	err := json.Unmarshal([]byte(raw), §ion)
    +	assert.NoError(t, err)
    +	assert.Len(t, section.Elements, 1)
    +
    +	elem, ok := section.Elements[0].(*RichTextSectionUserGroupElement)
    +	assert.True(t, ok)
    +	assert.Equal(t, "ausergroupid", elem.UsergroupID)
    +	assert.NotNil(t, elem.Style)
    +	assert.True(t, elem.Style.Bold)
    +	assert.True(t, elem.Style.Underline)
    +	assert.True(t, elem.Style.Highlight)
    +	assert.True(t, elem.Style.ClientHighlight)
    +	assert.True(t, elem.Style.Unlink)
    +	assert.False(t, elem.Style.Italic)
    +	assert.False(t, elem.Style.Strike)
    +	assert.False(t, elem.Style.Code)
    +
    +	// Round-trip: marshal and verify fields survive
    +	data, err := json.Marshal(section)
    +	assert.NoError(t, err)
    +	assert.Contains(t, string(data), `"underline":true`)
    +	assert.Contains(t, string(data), `"highlight":true`)
    +	assert.Contains(t, string(data), `"client_highlight":true`)
    +	assert.Contains(t, string(data), `"unlink":true`)
    +	assert.NotContains(t, string(data), `"italic"`)
    +	assert.NotContains(t, string(data), `"strike"`)
    +}
    +
     func strp(in string) *string { return &in }
    
    From 86249b5752114bad1cd551bf412253b3ed551080 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sat, 11 Apr 2026 22:20:22 +0200
    Subject: [PATCH 555/606] feat(manifests): add optional scope fields to
     `OAuthScopes`
    
    Add `BotOptional` and `UserOptional` fields so app manifests can declare scopes that users
    may selectively grant during installation.
    ---
     CHANGELOG.md      |  1 +
     manifests.go      |  6 ++++--
     manifests_test.go | 38 ++++++++++++++++++++++++++++++++++++++
     3 files changed, 43 insertions(+), 2 deletions(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index e046e3726..72ef3b68a 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
       full Real-Time Search API surface.
     - Added `Underline`, `Highlight`, `ClientHighlight`, and `Unlink` fields to
       `RichTextSectionTextStyle`. Added `Style` field to `RichTextSectionUserGroupElement`.
    +- Added `BotOptional` and `UserOptional` fields to `OAuthScopes` for app manifests.
     
     ### Fixed
     
    diff --git a/manifests.go b/manifests.go
    index ab6b1f2f6..59f6a70bc 100644
    --- a/manifests.go
    +++ b/manifests.go
    @@ -269,8 +269,10 @@ type OAuthConfig struct {
     
     // OAuthScopes is a group of settings that describe permission scopes configuration
     type OAuthScopes struct {
    -	Bot  []string `json:"bot,omitempty" yaml:"bot,omitempty"`
    -	User []string `json:"user,omitempty" yaml:"user,omitempty"`
    +	Bot          []string `json:"bot,omitempty" yaml:"bot,omitempty"`
    +	User         []string `json:"user,omitempty" yaml:"user,omitempty"`
    +	BotOptional  []string `json:"bot_optional,omitempty" yaml:"bot_optional,omitempty"`
    +	UserOptional []string `json:"user_optional,omitempty" yaml:"user_optional,omitempty"`
     }
     
     // ManifestResponse is the response returned by the API for apps.manifest.x endpoints
    diff --git a/manifests_test.go b/manifests_test.go
    index 9133e68c8..0e263ce7d 100644
    --- a/manifests_test.go
    +++ b/manifests_test.go
    @@ -4,6 +4,7 @@ import (
     	"encoding/json"
     	"net/http"
     	"reflect"
    +	"strings"
     	"testing"
     )
     
    @@ -140,6 +141,43 @@ func getTestManifest() Manifest {
     	}
     }
     
    +func TestOAuthScopesOptionalFields(t *testing.T) {
    +	scopes := OAuthScopes{
    +		Bot:          []string{"chat:write", "commands"},
    +		User:         []string{"users:read"},
    +		BotOptional:  []string{"files:read", "reactions:read"},
    +		UserOptional: []string{"channels:read"},
    +	}
    +
    +	data, err := json.Marshal(scopes)
    +	if err != nil {
    +		t.Fatalf("Marshal error: %s", err)
    +	}
    +
    +	var roundtrip OAuthScopes
    +	if err := json.Unmarshal(data, &roundtrip); err != nil {
    +		t.Fatalf("Unmarshal error: %s", err)
    +	}
    +
    +	if !reflect.DeepEqual(scopes, roundtrip) {
    +		t.Errorf("Round-trip mismatch: got %+v, want %+v", roundtrip, scopes)
    +	}
    +
    +	// Verify omitempty: empty optional fields should not appear
    +	minimal := OAuthScopes{Bot: []string{"chat:write"}}
    +	data, err = json.Marshal(minimal)
    +	if err != nil {
    +		t.Fatalf("Marshal error: %s", err)
    +	}
    +	s := string(data)
    +	if strings.Contains(s, "bot_optional") {
    +		t.Errorf("Expected bot_optional to be omitted from JSON: %s", s)
    +	}
    +	if strings.Contains(s, "user_optional") {
    +		t.Errorf("Expected user_optional to be omitted from JSON: %s", s)
    +	}
    +}
    +
     func getTestManifestResponse() *ManifestResponse {
     	return &ManifestResponse{
     		SlackResponse: SlackResponse{
    
    From 5a498f60442530630bfe6474d7631431d7eaf253 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 12 Apr 2026 09:19:17 +0200
    Subject: [PATCH 556/606] feat(oauth): add PKCE support for OAuth v2 token
     exchange
    
    Added `OAuthOptionCodeVerifier` for passing the `code_verifier` during token exchange, and
    a couple helpers (`GenerateCodeVerifier` and `GenerateCodeChallenge`). `client_secret` is
    omitted when empty, supporting public client flows.
    ---
     CHANGELOG.md  |  4 ++++
     oauth.go      | 60 +++++++++++++++++++++++++++++++++++++++++++--------
     oauth_test.go | 58 +++++++++++++++++++++++++++++++++++++++++++++++++
     3 files changed, 113 insertions(+), 9 deletions(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 72ef3b68a..640820400 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     - Added `Underline`, `Highlight`, `ClientHighlight`, and `Unlink` fields to
       `RichTextSectionTextStyle`. Added `Style` field to `RichTextSectionUserGroupElement`.
     - Added `BotOptional` and `UserOptional` fields to `OAuthScopes` for app manifests.
    +- Added PKCE support for OAuth: `OAuthOptionCodeVerifier` option for
    +  `GetOAuthV2Response`, `GenerateCodeVerifier()` and `GenerateCodeChallenge()`
    +  helper functions (RFC 7636). `client_secret` is now conditionally omitted when
    +  empty in both `GetOAuthV2ResponseContext` and `RefreshOAuthV2TokenContext`.
     
     ### Fixed
     
    diff --git a/oauth.go b/oauth.go
    index 1df1369c6..f98284d23 100644
    --- a/oauth.go
    +++ b/oauth.go
    @@ -2,6 +2,9 @@ package slack
     
     import (
     	"context"
    +	"crypto/rand"
    +	"crypto/sha256"
    +	"encoding/base64"
     	"net/url"
     )
     
    @@ -80,7 +83,8 @@ type OpenIDConnectResponse struct {
     }
     
     type oauthConfig struct {
    -	apiURL string
    +	apiURL       string
    +	codeVerifier string
     }
     
     // OAuthOption configures package-level OAuth functions.
    @@ -91,12 +95,22 @@ func OAuthOptionAPIURL(url string) OAuthOption {
     	return func(c *oauthConfig) { c.apiURL = url }
     }
     
    -func resolveOAuthAPIURL(opts []OAuthOption) string {
    +// OAuthOptionCodeVerifier sets the PKCE code_verifier for the OAuth token exchange.
    +// Use this when your authorization request included a code_challenge.
    +func OAuthOptionCodeVerifier(verifier string) OAuthOption {
    +	return func(c *oauthConfig) { c.codeVerifier = verifier }
    +}
    +
    +func resolveOAuthConfig(opts []OAuthOption) oauthConfig {
     	c := oauthConfig{apiURL: APIURL}
     	for _, o := range opts {
     		o(&c)
     	}
    -	return c.apiURL
    +	return c
    +}
    +
    +func resolveOAuthAPIURL(opts []OAuthOption) string {
    +	return resolveOAuthConfig(opts).apiURL
     }
     
     // GetOAuthToken retrieves an AccessToken.
    @@ -160,16 +174,23 @@ func GetOAuthV2Response(client httpClient, clientID, clientSecret, code, redirec
     }
     
     // GetOAuthV2ResponseContext with a context, gets a V2 OAuth access token response.
    +// For PKCE flows, pass OAuthOptionCodeVerifier and an empty clientSecret.
     // Slack API docs: https://api.slack.com/methods/oauth.v2.access
     func GetOAuthV2ResponseContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string, opts ...OAuthOption) (resp *OAuthV2Response, err error) {
    +	cfg := resolveOAuthConfig(opts)
     	values := url.Values{
    -		"client_id":     {clientID},
    -		"client_secret": {clientSecret},
    -		"code":          {code},
    -		"redirect_uri":  {redirectURI},
    +		"client_id":    {clientID},
    +		"code":         {code},
    +		"redirect_uri": {redirectURI},
    +	}
    +	if clientSecret != "" {
    +		values.Set("client_secret", clientSecret)
    +	}
    +	if cfg.codeVerifier != "" {
    +		values.Set("code_verifier", cfg.codeVerifier)
     	}
     	response := &OAuthV2Response{}
    -	if _, err = postForm(ctx, client, resolveOAuthAPIURL(opts)+"oauth.v2.access", values, response, discard{}); err != nil {
    +	if _, err = postForm(ctx, client, cfg.apiURL+"oauth.v2.access", values, response, discard{}); err != nil {
     		return nil, err
     	}
     	return response, response.Err()
    @@ -182,14 +203,17 @@ func RefreshOAuthV2Token(client httpClient, clientID, clientSecret, refreshToken
     }
     
     // RefreshOAuthV2TokenContext with a context, gets a V2 OAuth access token response.
    +// For PKCE public clients, pass an empty clientSecret.
     // Slack API docs: https://api.slack.com/methods/oauth.v2.access
     func RefreshOAuthV2TokenContext(ctx context.Context, client httpClient, clientID, clientSecret, refreshToken string, opts ...OAuthOption) (resp *OAuthV2Response, err error) {
     	values := url.Values{
     		"client_id":     {clientID},
    -		"client_secret": {clientSecret},
     		"refresh_token": {refreshToken},
     		"grant_type":    {"refresh_token"},
     	}
    +	if clientSecret != "" {
    +		values.Set("client_secret", clientSecret)
    +	}
     	response := &OAuthV2Response{}
     	if _, err = postForm(ctx, client, resolveOAuthAPIURL(opts)+"oauth.v2.access", values, response, discard{}); err != nil {
     		return nil, err
    @@ -286,3 +310,21 @@ func GetOpenIDConnectTokenContext(ctx context.Context, client httpClient, client
     	}
     	return response, response.Err()
     }
    +
    +// GenerateCodeVerifier creates a cryptographically random PKCE code verifier
    +// string suitable for use with OAuth 2.0 PKCE flows. The returned string is
    +// 43 characters of URL-safe base64 (no padding).
    +func GenerateCodeVerifier() (string, error) {
    +	b := make([]byte, 32)
    +	if _, err := rand.Read(b); err != nil {
    +		return "", err
    +	}
    +	return base64.RawURLEncoding.EncodeToString(b), nil
    +}
    +
    +// GenerateCodeChallenge creates a PKCE code challenge from a code verifier
    +// using the S256 method (SHA-256 hash, base64url-encoded without padding).
    +func GenerateCodeChallenge(verifier string) string {
    +	h := sha256.Sum256([]byte(verifier))
    +	return base64.RawURLEncoding.EncodeToString(h[:])
    +}
    diff --git a/oauth_test.go b/oauth_test.go
    index 62212a911..2aa8b2a0f 100644
    --- a/oauth_test.go
    +++ b/oauth_test.go
    @@ -2,6 +2,7 @@ package slack
     
     import (
     	"net/http"
    +	"net/url"
     	"testing"
     
     	"github.com/stretchr/testify/assert"
    @@ -93,3 +94,60 @@ func TestGetOpenIDConnectUserInfo(t *testing.T) {
     	assert.Equal(t, "slackcorp", resp.TeamDomain)
     	assert.True(t, resp.TeamImageDefault)
     }
    +
    +func TestGenerateCodeVerifier(t *testing.T) {
    +	v1, err := GenerateCodeVerifier()
    +	require.NoError(t, err)
    +	assert.Len(t, v1, 43) // 32 bytes -> 43 chars base64url without padding
    +
    +	v2, err := GenerateCodeVerifier()
    +	require.NoError(t, err)
    +	assert.NotEqual(t, v1, v2, "two calls should produce different verifiers")
    +}
    +
    +func TestGenerateCodeChallenge(t *testing.T) {
    +	// RFC 7636 Appendix B test vector
    +	verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    +	expected := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
    +
    +	challenge := GenerateCodeChallenge(verifier)
    +	assert.Equal(t, expected, challenge)
    +}
    +
    +func TestOAuthOptionCodeVerifier(t *testing.T) {
    +	cfg := resolveOAuthConfig([]OAuthOption{
    +		OAuthOptionCodeVerifier("test-verifier"),
    +	})
    +	assert.Equal(t, "test-verifier", cfg.codeVerifier)
    +	assert.Equal(t, APIURL, cfg.apiURL) // default preserved
    +}
    +
    +func TestGetOAuthV2ResponsePKCE(t *testing.T) {
    +	once.Do(startServer)
    +
    +	// The existing /oauth.v2.access handler returns a valid response.
    +	// Verify that PKCE params are accepted without error.
    +	resp, err := GetOAuthV2Response(
    +		http.DefaultClient,
    +		"client-id", "", "code", "http://localhost/callback",
    +		OAuthOptionAPIURL("http://"+serverAddr+"/"),
    +		OAuthOptionCodeVerifier("test-verifier"),
    +	)
    +	require.NoError(t, err)
    +	assert.Equal(t, "xoxb-test-token", resp.AccessToken)
    +}
    +
    +func TestGetOAuthV2ResponseOmitsEmptySecret(t *testing.T) {
    +	// Verify that resolveOAuthConfig + empty secret produces no client_secret key
    +	values := url.Values{
    +		"client_id":    {"id"},
    +		"code":         {"code"},
    +		"redirect_uri": {"http://localhost"},
    +	}
    +	clientSecret := ""
    +	if clientSecret != "" {
    +		values.Set("client_secret", clientSecret)
    +	}
    +	_, hasSecret := values["client_secret"]
    +	assert.False(t, hasSecret, "empty client_secret should not be in form values")
    +}
    
    From 7c6fb1f113fc38faf803a7bded46955c60ae041d Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 12 Apr 2026 11:09:17 +0200
    Subject: [PATCH 557/606] fix(socketmode): apply `debug_reconnects` query param
     to URL
    
    The param was silently dropped before, so no one could have been relying on it I'm afraid.
    After this commit, anything with api.debug set will have debug_reconnects set to true
    correctly this time.
    ---
     socket_mode.go | 4 +++-
     1 file changed, 3 insertions(+), 1 deletion(-)
    
    diff --git a/socket_mode.go b/socket_mode.go
    index d9e7b5863..9498cdf73 100644
    --- a/socket_mode.go
    +++ b/socket_mode.go
    @@ -36,7 +36,9 @@ func (api *Client) StartSocketModeContext(ctx context.Context) (info *SocketMode
     	// time significantly shorter (360 seconds).
     	if api.debug {
     		u, _ := url.Parse(response.SocketModeConnection.URL)
    -		u.Query().Add("debug_reconnects", "true")
    +		q := u.Query()
    +		q.Set("debug_reconnects", "true")
    +		u.RawQuery = q.Encode()
     		response.SocketModeConnection.URL = u.String()
     	}
     	return &response.SocketModeConnection, response.SocketModeConnection.URL, response.Err()
    
    From 3d2c59d1ce987f9aa8c58ecd77e219eca820131e Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 12 Apr 2026 11:15:39 +0200
    Subject: [PATCH 558/606] chore: fix linting and formatting issues
    
    ---
     slacktest/data.go              | 136 ++++++++++++++++-----------------
     slacktest/funcs.go             |  14 ++--
     slacktest/handlers.go          |   4 +-
     slacktest/handlers_test.go     |   2 +-
     team_test.go                   |  34 ++++-----
     users_test.go                  |   2 +-
     websocket_managed_conn_test.go |   2 +-
     7 files changed, 97 insertions(+), 97 deletions(-)
    
    diff --git a/slacktest/data.go b/slacktest/data.go
    index 31d9a08f9..ac2b7f726 100644
    --- a/slacktest/data.go
    +++ b/slacktest/data.go
    @@ -3,7 +3,7 @@ package slacktest
     import (
     	"fmt"
     
    -	slack "github.com/slack-go/slack"
    +	"github.com/slack-go/slack"
     )
     
     const defaultBotName = "TestSlackBot"
    @@ -75,82 +75,82 @@ var defaultUsersInfoJSON = fmt.Sprintf(`
     
     var defaultGeneralChannelJSON = fmt.Sprintf(`
     	{
    -        "id": "C024BE91L",
    -        "name": "general",
    -        "is_channel": true,
    -        "created": %d,
    -        "creator": "%s",
    -        "is_archived": false,
    -        "is_general": true,
    +		"id": "C024BE91L",
    +		"name": "general",
    +		"is_channel": true,
    +		"created": %d,
    +		"creator": "%s",
    +		"is_archived": false,
    +		"is_general": true,
     
    -        "members": [
    -            "W012A3CDE"
    -        ],
    +		"members": [
    +			"W012A3CDE"
    +		],
     
    -        "topic": {
    -            "value": "Fun times",
    -            "creator": "%s",
    -            "last_set": %d
    -        },
    -        "purpose": {
    -            "value": "This channel is for fun",
    -            "creator": "%s",
    -            "last_set": %d
    -        },
    +		"topic": {
    +			"value": "Fun times",
    +			"creator": "%s",
    +			"last_set": %d
    +		},
    +		"purpose": {
    +			"value": "This channel is for fun",
    +			"creator": "%s",
    +			"last_set": %d
    +		},
     
    -        "is_member": true
    -    }
    +		"is_member": true
    +	}
     `, nowAsJSONTime(), defaultNonBotUserID, defaultNonBotUserID, nowAsJSONTime(), defaultNonBotUserID, nowAsJSONTime())
     
     var defaultExtraChannelJSON = fmt.Sprintf(`
     	{
    -        "id": "C024BE92L",
    -        "name": "bot-playground",
    -        "is_channel": true,
    -        "created": %d,
    -        "creator": "%s",
    -        "is_archived": false,
    -        "is_general": true,
    +		"id": "C024BE92L",
    +		"name": "bot-playground",
    +		"is_channel": true,
    +		"created": %d,
    +		"creator": "%s",
    +		"is_archived": false,
    +		"is_general": true,
     
    -        "members": [
    -            "W012A3CDE"
    -        ],
    +		"members": [
    +			"W012A3CDE"
    +		],
     
    -        "topic": {
    -            "value": "Fun times",
    -            "creator": "%s",
    -            "last_set": %d
    -        },
    -        "purpose": {
    -            "value": "This channel is for fun",
    -            "creator": "%s",
    -            "last_set": %d
    -        },
    +		"topic": {
    +			"value": "Fun times",
    +			"creator": "%s",
    +			"last_set": %d
    +		},
    +		"purpose": {
    +			"value": "This channel is for fun",
    +			"creator": "%s",
    +			"last_set": %d
    +		},
     
    -        "is_member": true
    -    }
    +		"is_member": true
    +	}
     `, nowAsJSONTime(), defaultNonBotUserID, defaultNonBotUserID, nowAsJSONTime(), defaultNonBotUserID, nowAsJSONTime())
     
     var defaultGroupJSON = fmt.Sprintf(`{
    -    "id": "G024BE91L",
    -    "name": "secretplans",
    -    "is_group": true,
    -    "created": %d,
    -    "creator": "%s",
    -    "is_archived": false,
    -    "members": [
    -        "W012A3CDE"
    -    ],
    -    "topic": {
    -        "value": "Secret plans on hold",
    -        "creator": "%s",
    -        "last_set": %d
    -    },
    -    "purpose": {
    -        "value": "Discuss secret plans that no-one else should know",
    -        "creator": "%s",
    -        "last_set": %d
    -    }
    +	"id": "G024BE91L",
    +	"name": "secretplans",
    +	"is_group": true,
    +	"created": %d,
    +	"creator": "%s",
    +	"is_archived": false,
    +	"members": [
    +		"W012A3CDE"
    +	],
    +	"topic": {
    +		"value": "Secret plans on hold",
    +		"creator": "%s",
    +		"last_set": %d
    +	},
    +	"purpose": {
    +		"value": "Discuss secret plans that no-one else should know",
    +		"creator": "%s",
    +		"last_set": %d
    +	}
     }`, nowAsJSONTime(), defaultNonBotUserID, defaultNonBotUserID, nowAsJSONTime(), defaultNonBotUserID, nowAsJSONTime())
     
     var defaultNonBotUser = fmt.Sprintf(`
    @@ -232,7 +232,7 @@ var templateConversationJSON = `
     			"creator": "%s",
     			"last_set": %d
     		},
    -        "num_members": %d,
    +		"num_members": %d,
     		"previous_names": [],
     		"priority": 0
     	}
    @@ -252,7 +252,7 @@ var inviteConversationJSON = fmt.Sprintf(templateConversationJSON, defaultConver
     	nowAsJSONTime(), defaultBotID, defaultConversationName, "", "", 0, "", "", 0, 1)
     
     const inviteSharedResponseJSON = `{
    -    "ok": true,
    -    "invite_id": "I02UKAJ6RJA",
    -    "is_legacy_shared_channel": false
    +	"ok": true,
    +	"invite_id": "I02UKAJ6RJA",
    +	"is_legacy_shared_channel": false
     }`
    diff --git a/slacktest/funcs.go b/slacktest/funcs.go
    index 2b47f8794..19c0bba67 100644
    --- a/slacktest/funcs.go
    +++ b/slacktest/funcs.go
    @@ -6,9 +6,9 @@ import (
     	"log"
     	"time"
     
    -	websocket "github.com/gorilla/websocket"
    +	"github.com/gorilla/websocket"
     
    -	slack "github.com/slack-go/slack"
    +	"github.com/slack-go/slack"
     )
     
     func (sts *Server) queueForWebsocket(s, hubname string) {
    @@ -146,9 +146,9 @@ func defaultBotInfoJSON(ctx context.Context) string {
     func defaultAppsConnectionsJSON(ctx context.Context) string {
     	url := ServerWSFromContext(ctx)
     	return fmt.Sprintf(`
    -               {
    -                       "ok":true,
    -                       "url": "%s"
    -               }
    -               `, url)
    +			   {
    +					   "ok":true,
    +					   "url": "%s"
    +			   }
    +			   `, url)
     }
    diff --git a/slacktest/handlers.go b/slacktest/handlers.go
    index 7d5777689..d537efbcf 100644
    --- a/slacktest/handlers.go
    +++ b/slacktest/handlers.go
    @@ -10,9 +10,9 @@ import (
     	"net/url"
     	"time"
     
    -	websocket "github.com/gorilla/websocket"
    +	"github.com/gorilla/websocket"
     
    -	slack "github.com/slack-go/slack"
    +	"github.com/slack-go/slack"
     )
     
     func contextHandler(server *Server, next http.HandlerFunc) http.Handler {
    diff --git a/slacktest/handlers_test.go b/slacktest/handlers_test.go
    index f155d0154..eefbe03a6 100644
    --- a/slacktest/handlers_test.go
    +++ b/slacktest/handlers_test.go
    @@ -5,7 +5,7 @@ import (
     
     	"github.com/stretchr/testify/assert"
     
    -	slack "github.com/slack-go/slack"
    +	"github.com/slack-go/slack"
     )
     
     func TestAuthTestHandler(t *testing.T) {
    diff --git a/team_test.go b/team_test.go
    index 22186d361..925e44464 100644
    --- a/team_test.go
    +++ b/team_test.go
    @@ -8,7 +8,7 @@ import (
     )
     
     var (
    -	ErrIncorrectResponse = errors.New("Response is incorrect")
    +	ErrIncorrectResponse = errors.New("response is incorrect")
     )
     
     func getTeamInfo(rw http.ResponseWriter, r *http.Request) {
    @@ -18,11 +18,11 @@ func getTeamInfo(rw http.ResponseWriter, r *http.Request) {
     			"name": "notalar",
     			"domain": "notalar",
     			"icon": {
    -              "image_34": "https://slack.global.ssl.fastly.net/66f9/img/avatars-teams/ava_0002-34.png",
    -              "image_44": "https://slack.global.ssl.fastly.net/66f9/img/avatars-teams/ava_0002-44.png",
    -              "image_55": "https://slack.global.ssl.fastly.net/66f9/img/avatars-teams/ava_0002-55.png",
    -              "image_default": true
    -          }
    +			  "image_34": "https://slack.global.ssl.fastly.net/66f9/img/avatars-teams/ava_0002-34.png",
    +			  "image_44": "https://slack.global.ssl.fastly.net/66f9/img/avatars-teams/ava_0002-44.png",
    +			  "image_55": "https://slack.global.ssl.fastly.net/66f9/img/avatars-teams/ava_0002-55.png",
    +			  "image_default": true
    +		  }
     		}}`)
     	rw.Write(response)
     }
    @@ -135,11 +135,11 @@ func getTeamAccessLogs(rw http.ResponseWriter, r *http.Request) {
     			"ip": "127.0.0.1",
     			"user_agent": "SlackWeb/3abb0ae2380d48a9ae20c58cc624ebcd Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Slack/1.2.6 Chrome/45.0.2454.85 AtomShell/0.34.3 Safari/537.36 Slack_SSB/1.2.6",
     			"isp": "AT&T U-verse",
    -                        "country": "US",
    -                        "region": "IN"
    -                        },
    -                        {
    -                        "user_id": "XUHWU0F",
    +						"country": "US",
    +						"region": "IN"
    +						},
    +						{
    +						"user_id": "XUHWU0F",
     			"username": "ralaton",
     			"date_first": 1447395893,
     			"date_last": 1447395965,
    @@ -147,12 +147,12 @@ func getTeamAccessLogs(rw http.ResponseWriter, r *http.Request) {
     			"ip": "192.168.0.1",
     			"user_agent": "com.tinyspeck.chatlyio/2.60 (iPhone; iOS 9.1; Scale/3.00)",
     			"isp": null,
    -                        "country": null,
    -                        "region": null
    -                        }],
    -                        "response_metadata": {
    -    			"next_cursor": "dGVhbV9pZDo5MDAwMTcw"
    -    			}
    +						"country": null,
    +						"region": null
    +						}],
    +						"response_metadata": {
    +				"next_cursor": "dGVhbV9pZDo5MDAwMTcw"
    +				}
       }`)
     	rw.Write(response)
     }
    diff --git a/users_test.go b/users_test.go
    index 9db10482d..965365cb4 100644
    --- a/users_test.go
    +++ b/users_test.go
    @@ -637,7 +637,7 @@ func setUserPhotoHandler(wantBytes []byte, wantParams UserSetPhotoParams) http.H
     // contents, and a function that can be called to remove the file.
     func createUserPhoto(t *testing.T) (*os.File, []byte, func()) {
     	photo := image.NewRGBA(image.Rect(0, 0, 64, 64))
    -	draw.Draw(photo, photo.Bounds(), image.Black, image.ZP, draw.Src)
    +	draw.Draw(photo, photo.Bounds(), image.Black, image.Point{}, draw.Src)
     
     	f, err := os.CreateTemp(os.TempDir(), "profile.png")
     	if err != nil {
    diff --git a/websocket_managed_conn_test.go b/websocket_managed_conn_test.go
    index efbbe97ec..983001605 100644
    --- a/websocket_managed_conn_test.go
    +++ b/websocket_managed_conn_test.go
    @@ -8,7 +8,7 @@ import (
     	"testing"
     	"time"
     
    -	websocket "github.com/gorilla/websocket"
    +	"github.com/gorilla/websocket"
     	"github.com/stretchr/testify/assert"
     	"github.com/stretchr/testify/require"
     
    
    From d86412e1f8d120e2485547c6204d880b86064c0f Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 12 Apr 2026 14:17:40 +0200
    Subject: [PATCH 559/606] fix(socketmode): skip reconnect on malformed JSON
     messages
    
    A single malformed message caused a full reconnect even though the connection was healthy.
    With this change JSON syntax and also type errors emit an IncomingError event but the
    socket continues just fine.
    ---
     CHANGELOG.md                                |   2 +
     socketmode/request.go                       |   3 +-
     socketmode/socket_mode_managed_conn.go      |  13 ++-
     socketmode/socket_mode_managed_conn_test.go | 106 ++++++++++++++++++++
     4 files changed, 120 insertions(+), 4 deletions(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 640820400..d1007e898 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     - `ChannelTypes` and `ContentTypes` now send comma-separated values instead of repeated
       form keys, matching the convention used by every other method in the library.
    +- In `socketmode` malformed JSON messages no longer force an unnecessary reconnect.
    +  Instead the error is emitted and the connection continues as normal.
     
     ## [0.21.1] - 2026-04-08
     
    diff --git a/socketmode/request.go b/socketmode/request.go
    index 254c8c49e..1baef5748 100644
    --- a/socketmode/request.go
    +++ b/socketmode/request.go
    @@ -29,7 +29,8 @@ type Request struct {
     
     	// `events_api` type only
     	EnvelopeID string `json:"envelope_id"`
    -	// TODO Can it really be a non-object type?
    +	// Payload is typed as json.RawMessage because the Slack API sends different
    +	// shapes depending on the envelope type (object, array, or string).
     	// See https://github.com/slackapi/python-slack-sdk/blob/3f1c4c6e27bf7ee8af57699b2543e6eb7848bcf9/slack_sdk/socket_mode/request.py#L26-L31
     	Payload                json.RawMessage `json:"payload"`
     	AcceptsResponsePayload bool            `json:"accepts_response_payload"`
    diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go
    index 2f9e15d67..125c0dbfe 100644
    --- a/socketmode/socket_mode_managed_conn.go
    +++ b/socketmode/socket_mode_managed_conn.go
    @@ -566,13 +566,20 @@ func (smc *Client) receiveMessagesInto(ctx context.Context, conn *websocket.Conn
     			return nil
     		}
     
    -		// All other errors from ReadJSON come from NextReader, and should
    -		// kill the read loop and force a reconnect.
    -		// TODO: Unless it's a JSON unmarshal-type error in which case maybe reconnecting isn't needed...
     		smc.sendEvent(ctx, newEvent(EventTypeIncomingError, &slack.IncomingEventError{
     			ErrorObj: err,
     		}))
     
    +		// JSON unmarshal errors indicate a malformed message, not a broken
    +		// connection — keep the connection alive.
    +		var syntaxErr *json.SyntaxError
    +		var typeErr *json.UnmarshalTypeError
    +		if errors.As(err, &syntaxErr) || errors.As(err, &typeErr) {
    +			return nil
    +		}
    +
    +		// All other errors from ReadJSON come from NextReader, and should
    +		// kill the read loop and force a reconnect.
     		return err
     	}
     
    diff --git a/socketmode/socket_mode_managed_conn_test.go b/socketmode/socket_mode_managed_conn_test.go
    index 178c6028d..d1ff28b7a 100644
    --- a/socketmode/socket_mode_managed_conn_test.go
    +++ b/socketmode/socket_mode_managed_conn_test.go
    @@ -11,6 +11,7 @@ import (
     	"testing"
     	"time"
     
    +	"github.com/gorilla/websocket"
     	"github.com/slack-go/slack"
     	"github.com/slack-go/slack/slacktest"
     
    @@ -152,3 +153,108 @@ func TestAck_ReturnsErrorWhenPayloadTooLarge(t *testing.T) {
     	assert.Error(t, err)
     	assert.Contains(t, err.Error(), "silently dropped")
     }
    +
    +// dialTestWebSocket starts a slacktest.Server with a custom /ws handler,
    +// then returns a client-side *websocket.Conn connected to it.
    +func dialTestWebSocket(t *testing.T, serverFunc func(conn *websocket.Conn)) *websocket.Conn {
    +	t.Helper()
    +	srv := slacktest.NewTestServer(func(c slacktest.Customize) {
    +		c.Handle("/ws", slacktest.Websocket(serverFunc))
    +	})
    +	srv.Start()
    +	t.Cleanup(srv.Stop)
    +
    +	conn, _, err := websocket.DefaultDialer.Dial(srv.GetWSURL(), nil)
    +	if err != nil {
    +		t.Fatalf("dial: %v", err)
    +	}
    +	t.Cleanup(func() { conn.Close() })
    +	return conn
    +}
    +
    +func TestReceiveMessagesInto_WebSocketCloseError(t *testing.T) {
    +	conn := dialTestWebSocket(t, func(srvConn *websocket.Conn) {
    +		// Cleanly close the server side to trigger a CloseError on the client.
    +		srvConn.WriteMessage(
    +			websocket.CloseMessage,
    +			websocket.FormatCloseMessage(websocket.CloseGoingAway, "bye"),
    +		)
    +	})
    +
    +	api := slack.New("ABCDEFG")
    +	cli := New(api)
    +	sink := make(chan json.RawMessage, 1)
    +
    +	err := cli.receiveMessagesInto(context.Background(), conn, sink)
    +
    +	// WebSocket close errors SHOULD force a reconnect (err != nil).
    +	assert.Error(t, err)
    +}
    +
    +// TestRunMessageReceiver_SurvivesMalformedJSON demonstrates the full message
    +// receiver loop: valid messages are forwarded, malformed JSON produces an error
    +// event without dropping the connection, and a WebSocket close terminates the
    +// loop so the caller can reconnect.
    +func TestRunMessageReceiver_SurvivesMalformedJSON(t *testing.T) {
    +	ready := make(chan struct{})
    +	conn := dialTestWebSocket(t, func(srvConn *websocket.Conn) {
    +		// Wait for the receiver loop to start before sending.
    +		<-ready
    +
    +		// 1. Valid JSON message
    +		srvConn.WriteMessage(websocket.TextMessage, []byte(`{"type":"hello"}`))
    +		// 2. Malformed JSON — should NOT kill the connection
    +		srvConn.WriteMessage(websocket.TextMessage, []byte(`{not json`))
    +		// 3. Another valid message — proves the connection survived
    +		srvConn.WriteMessage(websocket.TextMessage, []byte(`{"type":"disconnect"}`))
    +		// 4. Close the WebSocket — should terminate the loop
    +		srvConn.WriteMessage(
    +			websocket.CloseMessage,
    +			websocket.FormatCloseMessage(websocket.CloseNormalClosure, "done"),
    +		)
    +	})
    +
    +	api := slack.New("ABCDEFG")
    +	cli := New(api)
    +	sink := make(chan json.RawMessage, 10)
    +
    +	// Run the receiver loop in a goroutine.
    +	loopErr := make(chan error, 1)
    +	go func() {
    +		close(ready)
    +		loopErr <- cli.runMessageReceiver(context.Background(), conn, sink)
    +	}()
    +
    +	// Wait for the loop to finish.
    +	var err error
    +	select {
    +	case err = <-loopErr:
    +	case <-time.After(5 * time.Second):
    +		t.Fatal("timed out waiting for receiver loop to finish")
    +	}
    +
    +	// Drain all messages that made it through to the sink.
    +	var messages []string
    +	for len(sink) > 0 {
    +		messages = append(messages, string(<-sink))
    +	}
    +
    +	// The loop should exit with an error (WebSocket close triggers reconnect).
    +	assert.Error(t, err)
    +
    +	// Both valid messages should have been forwarded.
    +	assert.Equal(t, []string{`{"type":"hello"}`, `{"type":"disconnect"}`}, messages)
    +
    +	// The malformed JSON should have produced an IncomingError event.
    +	select {
    +	case evt := <-cli.Events:
    +		assert.Equal(t, EventTypeIncomingError, evt.Type)
    +		incomingErr, ok := evt.Data.(*slack.IncomingEventError)
    +		if assert.True(t, ok) {
    +			var syntaxErr *json.SyntaxError
    +			assert.ErrorAs(t, incomingErr.ErrorObj, &syntaxErr)
    +		}
    +	default:
    +		t.Fatal("expected an IncomingError event for the malformed JSON")
    +	}
    +}
    
    From 38dc2b9e5793850c7c199a2ce2cd9f0a2315708b Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 12 Apr 2026 14:33:14 +0200
    Subject: [PATCH 560/606] chore: simplify with fmt.Fprintf
    
    ---
     security.go | 4 ++--
     1 file changed, 2 insertions(+), 2 deletions(-)
    
    diff --git a/security.go b/security.go
    index 451035293..fbc1c22b4 100644
    --- a/security.go
    +++ b/security.go
    @@ -42,7 +42,7 @@ func unsafeSignatureVerifier(header http.Header, secret string) (_ SecretsVerifi
     	}
     
     	hash := hmac.New(sha256.New, []byte(secret))
    -	if _, err = hash.Write([]byte(fmt.Sprintf("v0:%s:", stimestamp))); err != nil {
    +	if _, err = fmt.Fprintf(hash, "v0:%s:", stimestamp); err != nil {
     		return SecretsVerifier{}, err
     	}
     
    @@ -95,7 +95,7 @@ func (v SecretsVerifier) Ensure() error {
     	if v.d != nil && v.d.Debug() {
     		v.d.Debugln(fmt.Sprintf("Expected signing signature: %s, but computed: %s", hex.EncodeToString(v.signature), hex.EncodeToString(computed)))
     	}
    -	return fmt.Errorf("Computed unexpected signature of: %s", hex.EncodeToString(computed))
    +	return fmt.Errorf("computed unexpected signature of: %s", hex.EncodeToString(computed))
     }
     
     func abs64(n int64) int64 {
    
    From f7a96166bb3edb5d948ddf82637d95e64b46d4a3 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 12 Apr 2026 14:47:40 +0200
    Subject: [PATCH 561/606] chore: add staticcheck to mise
    
    ---
     mise.toml | 1 +
     1 file changed, 1 insertion(+)
    
    diff --git a/mise.toml b/mise.toml
    index ce5d07a9e..912c02c98 100644
    --- a/mise.toml
    +++ b/mise.toml
    @@ -1,3 +1,4 @@
     [tools]
     go = "1.25"
     golangci-lint = "2.10.1"
    +"go:honnef.co/go/tools/cmd/staticcheck" = "2026.1"
    
    From 19e0416c15851aa3f28d41e2b92dbb2fb541ad96 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 12 Apr 2026 14:52:46 +0200
    Subject: [PATCH 562/606] ci: add staticcheck
    
    ---
     .github/workflows/test.yml | 4 ++++
     1 file changed, 4 insertions(+)
    
    diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
    index bd3c6e8b4..14bd70851 100644
    --- a/.github/workflows/test.yml
    +++ b/.github/workflows/test.yml
    @@ -46,6 +46,10 @@ jobs:
             uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
             with:
               version: v2.10.1
    +      - name: staticcheck
    +        uses: dominikh/staticcheck-action@9716614d4101e79b4340dd97b10e54d68234e431 # 1.4.1
    +        with:
    +          version: "2026.1"
           - name: Check 'go mod tidy' makes no changes
             run: |
               go mod tidy
    
    From 3a5db9ddb81e7c9e5379efa510ba826b1e5d935c Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 12 Apr 2026 14:50:18 +0100
    Subject: [PATCH 563/606] chore: fix staticcheck errors (#1548)
    
    ---
     chat.go                                       |  7 ++
     examples/manifests/manifest.go                |  2 +-
     .../socketmode_handler/socketmode_handler.go  |  5 +-
     examples/tokens/tokens.go                     |  2 +-
     files.go                                      |  1 +
     slacktest/data.go                             | 94 -------------------
     slacktest/handlers.go                         |  5 -
     7 files changed, 13 insertions(+), 103 deletions(-)
    
    diff --git a/chat.go b/chat.go
    index 8d4066cf0..1761c506d 100644
    --- a/chat.go
    +++ b/chat.go
    @@ -644,6 +644,7 @@ func MsgOptionDeleteOriginal(responseURL string) MsgOption {
     // MsgOptionAsUser whether or not to send the message as the user.
     func MsgOptionAsUser(b bool) MsgOption {
     	return func(config *sendConfig) error {
    +		//lint:ignore S1002 - we want to explicitly check against the constant
     		if b != DEFAULT_MESSAGE_ASUSER {
     			config.values.Set("as_user", "true")
     		}
    @@ -945,15 +946,19 @@ func MsgOptionPostMessageParameters(params PostMessageParameters) MsgOption {
     			config.values.Set("link_names", "1")
     		}
     
    +		//lint:ignore S1002 - we want to explicitly check against the constant
     		if params.UnfurlLinks != DEFAULT_MESSAGE_UNFURL_LINKS {
     			config.values.Set("unfurl_links", "true")
     		}
     
     		// I want to send a message with explicit `as_user` `true` and `unfurl_links` `false` in request.
     		// Because setting `as_user` to `true` will change the default value for `unfurl_links` to `true` on Slack API side.
    +		//lint:ignore S1002 - we want to explicitly check against the constants
     		if params.AsUser != DEFAULT_MESSAGE_ASUSER && params.UnfurlLinks == DEFAULT_MESSAGE_UNFURL_LINKS {
     			config.values.Set("unfurl_links", "false")
     		}
    +
    +		//lint:ignore S1002 - we want to explicitly check against the constant
     		if params.UnfurlMedia != DEFAULT_MESSAGE_UNFURL_MEDIA {
     			config.values.Set("unfurl_media", "false")
     		}
    @@ -963,6 +968,7 @@ func MsgOptionPostMessageParameters(params PostMessageParameters) MsgOption {
     		if params.IconEmoji != DEFAULT_MESSAGE_ICON_EMOJI {
     			config.values.Set("icon_emoji", params.IconEmoji)
     		}
    +		//lint:ignore S1002 - we want to explicitly check against the constant
     		if params.Markdown != DEFAULT_MESSAGE_MARKDOWN {
     			config.values.Set("mrkdwn", "false")
     		}
    @@ -970,6 +976,7 @@ func MsgOptionPostMessageParameters(params PostMessageParameters) MsgOption {
     		if params.ThreadTimestamp != DEFAULT_MESSAGE_THREAD_TIMESTAMP {
     			config.values.Set("thread_ts", params.ThreadTimestamp)
     		}
    +		//lint:ignore S1002 - we want to explicitly check against the constant
     		if params.ReplyBroadcast != DEFAULT_MESSAGE_REPLY_BROADCAST {
     			config.values.Set("reply_broadcast", "true")
     		}
    diff --git a/examples/manifests/manifest.go b/examples/manifests/manifest.go
    index d1e0cd6d6..739937b07 100644
    --- a/examples/manifests/manifest.go
    +++ b/examples/manifests/manifest.go
    @@ -1,4 +1,4 @@
    -package manifests
    +package main
     
     import (
     	"fmt"
    diff --git a/examples/socketmode_handler/socketmode_handler.go b/examples/socketmode_handler/socketmode_handler.go
    index c23c059e9..cb2b70606 100644
    --- a/examples/socketmode_handler/socketmode_handler.go
    +++ b/examples/socketmode_handler/socketmode_handler.go
    @@ -90,7 +90,8 @@ func main() {
     	socketmodeHandler.Handle(socketmode.EventTypeSlashCommand, middlewareSlashCommand)
     	socketmodeHandler.HandleSlashCommand("/rocket", middlewareSlashCommand)
     
    -	// socketmodeHandler.HandleDefault(middlewareDefault)
    +	// Handle all other events
    +	socketmodeHandler.HandleDefault(middlewareDefault)
     
     	socketmodeHandler.RunEventLoop()
     }
    @@ -227,5 +228,5 @@ func middlewareSlashCommand(evt *socketmode.Event, client *socketmode.Client) {
     }
     
     func middlewareDefault(evt *socketmode.Event, client *socketmode.Client) {
    -	// fmt.Fprintf(os.Stderr, "Unexpected event type received: %s\n", evt.Type)
    +	fmt.Fprintf(os.Stderr, "Unexpected event type received: %s\n", evt.Type)
     }
    diff --git a/examples/tokens/tokens.go b/examples/tokens/tokens.go
    index 6ac97e67a..c5607e97a 100644
    --- a/examples/tokens/tokens.go
    +++ b/examples/tokens/tokens.go
    @@ -1,4 +1,4 @@
    -package tokens
    +package main
     
     import (
     	"fmt"
    diff --git a/files.go b/files.go
    index 729ab4ffe..974ba0a43 100644
    --- a/files.go
    +++ b/files.go
    @@ -310,6 +310,7 @@ func (api *Client) GetFilesContext(ctx context.Context, params GetFilesParameter
     	if params.Page != DEFAULT_FILES_PAGE {
     		values.Add("page", strconv.Itoa(params.Page))
     	}
    +	//lint:ignore S1002 - we want to explicitly check against the constant
     	if params.ShowHidden != DEFAULT_FILES_SHOW_HIDDEN {
     		values.Add("show_files_hidden_by_limit", strconv.FormatBool(params.ShowHidden))
     	}
    diff --git a/slacktest/data.go b/slacktest/data.go
    index ac2b7f726..98697cf36 100644
    --- a/slacktest/data.go
    +++ b/slacktest/data.go
    @@ -41,20 +41,6 @@ var defaultOkJSON = `
     	}
     	`
     
    -var defaultChannelsListJSON = fmt.Sprintf(`
    -	{
    -		"ok": true,
    -		"channels": [%s, %s]
    -	}
    -	`, defaultGeneralChannelJSON, defaultExtraChannelJSON)
    -
    -var defaultGroupsListJSON = fmt.Sprintf(`
    -		{
    -			"ok": true,
    -			"groups": [%s]
    -		}
    -		`, defaultGroupJSON)
    -
     var defaultAuthTestJSON = fmt.Sprintf(`
     	{
     		"ok": true,
    @@ -73,86 +59,6 @@ var defaultUsersInfoJSON = fmt.Sprintf(`
     	}
     	`, defaultNonBotUser)
     
    -var defaultGeneralChannelJSON = fmt.Sprintf(`
    -	{
    -		"id": "C024BE91L",
    -		"name": "general",
    -		"is_channel": true,
    -		"created": %d,
    -		"creator": "%s",
    -		"is_archived": false,
    -		"is_general": true,
    -
    -		"members": [
    -			"W012A3CDE"
    -		],
    -
    -		"topic": {
    -			"value": "Fun times",
    -			"creator": "%s",
    -			"last_set": %d
    -		},
    -		"purpose": {
    -			"value": "This channel is for fun",
    -			"creator": "%s",
    -			"last_set": %d
    -		},
    -
    -		"is_member": true
    -	}
    -`, nowAsJSONTime(), defaultNonBotUserID, defaultNonBotUserID, nowAsJSONTime(), defaultNonBotUserID, nowAsJSONTime())
    -
    -var defaultExtraChannelJSON = fmt.Sprintf(`
    -	{
    -		"id": "C024BE92L",
    -		"name": "bot-playground",
    -		"is_channel": true,
    -		"created": %d,
    -		"creator": "%s",
    -		"is_archived": false,
    -		"is_general": true,
    -
    -		"members": [
    -			"W012A3CDE"
    -		],
    -
    -		"topic": {
    -			"value": "Fun times",
    -			"creator": "%s",
    -			"last_set": %d
    -		},
    -		"purpose": {
    -			"value": "This channel is for fun",
    -			"creator": "%s",
    -			"last_set": %d
    -		},
    -
    -		"is_member": true
    -	}
    -`, nowAsJSONTime(), defaultNonBotUserID, defaultNonBotUserID, nowAsJSONTime(), defaultNonBotUserID, nowAsJSONTime())
    -
    -var defaultGroupJSON = fmt.Sprintf(`{
    -	"id": "G024BE91L",
    -	"name": "secretplans",
    -	"is_group": true,
    -	"created": %d,
    -	"creator": "%s",
    -	"is_archived": false,
    -	"members": [
    -		"W012A3CDE"
    -	],
    -	"topic": {
    -		"value": "Secret plans on hold",
    -		"creator": "%s",
    -		"last_set": %d
    -	},
    -	"purpose": {
    -		"value": "Discuss secret plans that no-one else should know",
    -		"creator": "%s",
    -		"last_set": %d
    -	}
    -}`, nowAsJSONTime(), defaultNonBotUserID, defaultNonBotUserID, nowAsJSONTime(), defaultNonBotUserID, nowAsJSONTime())
    -
     var defaultNonBotUser = fmt.Sprintf(`
     		"user": {
     			"id": "%s",
    diff --git a/slacktest/handlers.go b/slacktest/handlers.go
    index d537efbcf..a3a604aa1 100644
    --- a/slacktest/handlers.go
    +++ b/slacktest/handlers.go
    @@ -113,11 +113,6 @@ func inviteSharedConversationHandler(w http.ResponseWriter, r *http.Request) {
     	_, _ = w.Write([]byte(inviteSharedResponseJSON))
     }
     
    -// handle groups.list
    -func listGroupsHandler(w http.ResponseWriter, _ *http.Request) {
    -	_, _ = w.Write([]byte(defaultGroupsListJSON))
    -}
    -
     // handle reaction.Add
     func reactionAddHandler(w http.ResponseWriter, _ *http.Request) {
     	_, _ = w.Write([]byte(defaultOkJSON))
    
    From f482b199d4e33975c13e65e075bcf87173ad902f Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 12 Apr 2026 15:59:45 +0200
    Subject: [PATCH 564/606] chore: v0.22.0
    
    ---
     CHANGELOG.md | 5 ++++-
     1 file changed, 4 insertions(+), 1 deletion(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index d1007e898..2047ff5e1 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ## [Unreleased]
     
    +## [0.22.0] - 2026-04-12
    +
     ### Added
     
     - Added missing parameters to `assistant.search.context` (`Sort`, `SortDir`, `Before`,
    @@ -512,7 +514,8 @@ for details.
     [#1196]: https://github.com/slack-go/slack/issues/1196
     [#1547]: https://github.com/slack-go/slack/pull/1547
     
    -[Unreleased]: https://github.com/slack-go/slack/compare/v0.21.1...HEAD
    +[Unreleased]: https://github.com/slack-go/slack/compare/v0.22.0...HEAD
    +[0.22.0]: https://github.com/slack-go/slack/compare/v0.21.1...0.22.0
     [0.21.1]: https://github.com/slack-go/slack/compare/v0.21.0...v0.21.1
     [0.21.0]: https://github.com/slack-go/slack/compare/v0.20.0...v0.21.0
     [0.20.0]: https://github.com/slack-go/slack/compare/v0.19.0...v0.20.0
    
    From 4c472cd10a45bd81ef26db9510a317a674293c78 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Thu, 16 Apr 2026 21:01:39 +0100
    Subject: [PATCH 565/606] feat(socketmode): expose socketmode handler
     `dispatcher` method (#1550)
    
    Closes #1549.
    ---
     socketmode/socketmode_handler.go | 23 ++++++++++++++++++-----
     1 file changed, 18 insertions(+), 5 deletions(-)
    
    diff --git a/socketmode/socketmode_handler.go b/socketmode/socketmode_handler.go
    index 97f000e8d..baf4b166e 100644
    --- a/socketmode/socketmode_handler.go
    +++ b/socketmode/socketmode_handler.go
    @@ -10,12 +10,14 @@ import (
     type SocketmodeHandler struct {
     	Client *Client
     
    -	//lvl 1 - the most generic type of event
    +	// level 1 - the most generic type of event
     	EventMap map[EventType][]SocketmodeHandlerFunc
    -	//lvl 2 - Manage event by inner type
    +
    +	// level 2 - Manage event by inner type
     	InteractionEventMap map[slack.InteractionType][]SocketmodeHandlerFunc
     	EventApiMap         map[slackevents.EventsAPIType][]SocketmodeHandlerFunc
    -	//lvl 3 - the most userfriendly way of managing event
    +
    +	// level 3 - the most user friendly way of managing event
     	InteractionBlockActionEventMap    map[string]SocketmodeHandlerFunc
     	InteractionShortcutEventMap       map[string]SocketmodeHandlerFunc
     	InteractionViewSubmissionEventMap map[string]SocketmodeHandlerFunc
    @@ -187,8 +189,15 @@ func (r *SocketmodeHandler) runEventLoop(ctx context.Context) {
     	}
     }
     
    -// Dispatch events to the specialized dispatcher
    -func (r *SocketmodeHandler) dispatcher(evt Event) {
    +// DispatchEvent routes an event to the appropriate registered handlers. Handlers are
    +// invoked asynchronously in goroutines, matching the behavior of RunEventLoop. This
    +// method is useful for integration testing handler registrations without a WebSocket
    +// connection.
    +//
    +// NOTE: This method does not implement the same dispatching logic as RunEventLoop. It
    +// should only be used for testing purposes, and not as a general-purpose event
    +// dispatcher.
    +func (r *SocketmodeHandler) DispatchEvent(evt Event) {
     	var ishandled bool
     
     	// Some eventType can be further decomposed
    @@ -208,6 +217,10 @@ func (r *SocketmodeHandler) dispatcher(evt Event) {
     	}
     }
     
    +func (r *SocketmodeHandler) dispatcher(evt Event) {
    +	r.DispatchEvent(evt)
    +}
    +
     // Dispatch socketmode events to the registered middleware
     func (r *SocketmodeHandler) socketmodeDispatcher(evt *Event) bool {
     	if handlers, ok := r.EventMap[evt.Type]; ok {
    
    From e3c0e8b15630749da93cd18168a26e78a74fecd0 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Fri, 17 Apr 2026 22:12:55 +0100
    Subject: [PATCH 566/606] feat(block): add card and carousel blocks (#1551)
    
    Added the new blocks as announced in
    https://docs.slack.dev/changelog/2026/04/16/block-kit-new-blocks except
    for `Alert` which doesn't yet seem to work (not in any of my
    environments at least).
    
    `Alert` will come later in a different PR once I can test in a sandbox
    environment and confirm it working.
    ---
     CHANGELOG.md           |  25 +++++++++
     block.go               |   2 +
     block_card.go          |  90 ++++++++++++++++++++++++++++++++
     block_card_test.go     | 116 +++++++++++++++++++++++++++++++++++++++++
     block_carousel.go      |  42 +++++++++++++++
     block_carousel_test.go |  87 +++++++++++++++++++++++++++++++
     block_conv.go          |   4 ++
     chat.go                |  18 +++++++
     chat_stream_chunks.go  |  95 +++++++++++++++++++++++++++++++++
     9 files changed, 479 insertions(+)
     create mode 100644 block_card.go
     create mode 100644 block_card_test.go
     create mode 100644 block_carousel.go
     create mode 100644 block_carousel_test.go
     create mode 100644 chat_stream_chunks.go
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 2047ff5e1..6aecf98e0 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ## [Unreleased]
     
    +### Added
    +
    +- **Block Kit: `CardBlock` and `CarouselBlock`** — Support for two of the new
    +  agent-UI blocks announced in the
    +  [April 16 Slack changelog](https://docs.slack.dev/changelog/2026/04/16/block-kit-new-blocks).
    +  `CardBlock` is constructed via `NewCardBlock` with a functional-options
    +  pattern and fluent `With*` builders (`WithTitle`, `WithSubtitle`, `WithBody`,
    +  `WithIcon`, `WithHeroImage`, `WithActions`). `CarouselBlock` is constructed
    +  via `NewCarouselBlock` with a variadic `*CardBlock` list plus `WithBlockID`
    +  and `AddCard` helpers. Both blocks wire into `Blocks.UnmarshalJSON` for
    +  round-trip fidelity, and reuse existing `ImageBlockElement` /
    +  `ButtonBlockElement` / `BlockElements` types rather than introducing new
    +  composition objects. The Alert block is deliberately not included in this
    +  release pending sandbox-verified rendering.
    +- **Streaming-message chunks API** — `chat.startStream` / `chat.appendStream` /
    +  `chat.stopStream` now accept a `chunks` parameter. Added `MsgOptionChunks`
    +  along with a `StreamChunk` interface and four chunk types:
    +  `MarkdownTextChunk`, `TaskUpdateChunk`, `PlanUpdateChunk`, and `BlocksChunk`
    +  (each with a `New*Chunk` constructor). This is the supported transport for
    +  streaming Block Kit content and the new agent-UI blocks in particular
    +  (which `chat.postMessage` rejects as `Unsupported block type`).
    +- **`MsgOptionTaskDisplayMode`** — New option for `chat.startStream` controlling
    +  whether task chunks render as a sequential timeline or a grouped plan.
    +  Accepts `TaskDisplayModeTimeline` or `TaskDisplayModePlan`.
    +
     ## [0.22.0] - 2026-04-12
     
     ### Added
    diff --git a/block.go b/block.go
    index 8963c7afd..0965437a3 100644
    --- a/block.go
    +++ b/block.go
    @@ -21,6 +21,8 @@ const (
     	MBTTable          MessageBlockType = "table"
     	MBTTaskCard       MessageBlockType = "task_card"
     	MBTPlan           MessageBlockType = "plan"
    +	MBTCard           MessageBlockType = "card"
    +	MBTCarousel       MessageBlockType = "carousel"
     )
     
     // Block defines an interface all block types should implement
    diff --git a/block_card.go b/block_card.go
    new file mode 100644
    index 000000000..ccee17420
    --- /dev/null
    +++ b/block_card.go
    @@ -0,0 +1,90 @@
    +package slack
    +
    +// CardBlock defines a block of type card used to display a rich, self-contained
    +// piece of content with an optional hero image, icon, title, subtitle, body,
    +// and action buttons. Cards can stand alone or be grouped inside a
    +// CarouselBlock.
    +//
    +// More Information: https://docs.slack.dev/reference/block-kit/blocks/card-block/
    +type CardBlock struct {
    +	Type      MessageBlockType   `json:"type"`
    +	BlockID   string             `json:"block_id,omitempty"`
    +	HeroImage *ImageBlockElement `json:"hero_image,omitempty"`
    +	Icon      *ImageBlockElement `json:"icon,omitempty"`
    +	Title     *TextBlockObject   `json:"title,omitempty"`
    +	Subtitle  *TextBlockObject   `json:"subtitle,omitempty"`
    +	Body      *TextBlockObject   `json:"body,omitempty"`
    +	Actions   *BlockElements     `json:"actions,omitempty"`
    +}
    +
    +// BlockType returns the type of the block
    +func (s CardBlock) BlockType() MessageBlockType {
    +	return s.Type
    +}
    +
    +// ID returns the ID of the block
    +func (s CardBlock) ID() string {
    +	return s.BlockID
    +}
    +
    +// CardBlockOption allows configuration of options for a new card block
    +type CardBlockOption func(*CardBlock)
    +
    +// CardBlockOptionBlockID sets the block ID for the card block
    +func CardBlockOptionBlockID(blockID string) CardBlockOption {
    +	return func(block *CardBlock) {
    +		block.BlockID = blockID
    +	}
    +}
    +
    +// NewCardBlock returns a new instance of a card block. Use the chainable
    +// With* methods or provide options to populate its fields.
    +func NewCardBlock(options ...CardBlockOption) *CardBlock {
    +	block := CardBlock{
    +		Type: MBTCard,
    +	}
    +
    +	for _, option := range options {
    +		if option != nil {
    +			option(&block)
    +		}
    +	}
    +
    +	return &block
    +}
    +
    +// WithTitle sets the title text for the CardBlock
    +func (s *CardBlock) WithTitle(title *TextBlockObject) *CardBlock {
    +	s.Title = title
    +	return s
    +}
    +
    +// WithSubtitle sets the subtitle text for the CardBlock
    +func (s *CardBlock) WithSubtitle(subtitle *TextBlockObject) *CardBlock {
    +	s.Subtitle = subtitle
    +	return s
    +}
    +
    +// WithBody sets the body text for the CardBlock
    +func (s *CardBlock) WithBody(body *TextBlockObject) *CardBlock {
    +	s.Body = body
    +	return s
    +}
    +
    +// WithIcon sets the icon image for the CardBlock
    +func (s *CardBlock) WithIcon(icon *ImageBlockElement) *CardBlock {
    +	s.Icon = icon
    +	return s
    +}
    +
    +// WithHeroImage sets the hero image for the CardBlock
    +func (s *CardBlock) WithHeroImage(heroImage *ImageBlockElement) *CardBlock {
    +	s.HeroImage = heroImage
    +	return s
    +}
    +
    +// WithActions sets the action buttons displayed at the bottom of the card
    +func (s *CardBlock) WithActions(elements ...BlockElement) *CardBlock {
    +	s.Actions = &BlockElements{ElementSet: elements}
    +	return s
    +}
    diff --git a/block_card_test.go b/block_card_test.go
    new file mode 100644
    index 000000000..4b2c0fa89
    --- /dev/null
    +++ b/block_card_test.go
    @@ -0,0 +1,116 @@
    +package slack
    +
    +import (
    +	"encoding/json"
    +	"testing"
    +
    +	"github.com/stretchr/testify/assert"
    +	"github.com/stretchr/testify/require"
    +)
    +
    +func TestNewCardBlock(t *testing.T) {
    +	title := NewTextBlockObject("mrkdwn", "Card title", false, false)
    +	subtitle := NewTextBlockObject("mrkdwn", "Card subtitle", false, false)
    +	body := NewTextBlockObject("mrkdwn", "Card body text.", false, false)
    +
    +	iconURL := "https://example.com/icon.png"
    +	icon := &ImageBlockElement{Type: METImage, ImageURL: &iconURL, AltText: "icon"}
    +	heroURL := "https://example.com/hero.png"
    +	hero := &ImageBlockElement{Type: METImage, ImageURL: &heroURL, AltText: "hero"}
    +
    +	btnText := NewTextBlockObject("plain_text", "Open", false, false)
    +	btn := NewButtonBlockElement("open_action", "go", btnText)
    +
    +	block := NewCardBlock(CardBlockOptionBlockID("card-1")).
    +		WithTitle(title).
    +		WithSubtitle(subtitle).
    +		WithBody(body).
    +		WithIcon(icon).
    +		WithHeroImage(hero).
    +		WithActions(btn)
    +
    +	assert.Equal(t, MBTCard, block.BlockType())
    +	assert.Equal(t, "card", string(block.Type))
    +	assert.Equal(t, "card-1", block.ID())
    +	assert.Equal(t, title, block.Title)
    +	assert.Equal(t, subtitle, block.Subtitle)
    +	assert.Equal(t, body, block.Body)
    +	assert.Equal(t, icon, block.Icon)
    +	assert.Equal(t, hero, block.HeroImage)
    +	require.NotNil(t, block.Actions)
    +	require.Len(t, block.Actions.ElementSet, 1)
    +}
    +
    +func TestCardBlockJSONRoundTrip(t *testing.T) {
    +	payload := `{
    +		"type": "card",
    +		"block_id": "card-1",
    +		"hero_image": {
    +			"type": "image",
    +			"image_url": "https://example.com/hero.png",
    +			"alt_text": "hero"
    +		},
    +		"icon": {
    +			"type": "image",
    +			"image_url": "https://example.com/icon.png",
    +			"alt_text": "icon"
    +		},
    +		"title": {"type": "mrkdwn", "text": "Lumon Industries"},
    +		"subtitle": {"type": "mrkdwn", "text": "Macrodata Refinement"},
    +		"body": {"type": "mrkdwn", "text": "The work is mysterious and important."},
    +		"actions": [
    +			{
    +				"type": "button",
    +				"text": {"type": "plain_text", "text": "Enter"},
    +				"action_id": "enter",
    +				"value": "mdr"
    +			}
    +		]
    +	}`
    +
    +	var block CardBlock
    +	err := json.Unmarshal([]byte(payload), &block)
    +	require.NoError(t, err)
    +
    +	assert.Equal(t, MBTCard, block.BlockType())
    +	assert.Equal(t, "card-1", block.ID())
    +	require.NotNil(t, block.HeroImage)
    +	require.NotNil(t, block.Icon)
    +	require.NotNil(t, block.Title)
    +	require.NotNil(t, block.Subtitle)
    +	require.NotNil(t, block.Body)
    +	require.NotNil(t, block.Actions)
    +	require.Len(t, block.Actions.ElementSet, 1)
    +
    +	btn, ok := block.Actions.ElementSet[0].(*ButtonBlockElement)
    +	require.True(t, ok, "expected *ButtonBlockElement, got %T", block.Actions.ElementSet[0])
    +	assert.Equal(t, "enter", btn.ActionID)
    +
    +	marshalled, err := json.Marshal(block)
    +	require.NoError(t, err)
    +
    +	var expected, actual map[string]interface{}
    +	require.NoError(t, json.Unmarshal([]byte(payload), &expected))
    +	require.NoError(t, json.Unmarshal(marshalled, &actual))
    +
    +	assert.Equal(t, expected, actual)
    +}
    +
    +func TestCardBlockUnmarshalViaBlocks(t *testing.T) {
    +	payload := `[
    +		{
    +			"type": "card",
    +			"title": {"type": "mrkdwn", "text": "Hello"}
    +		}
    +	]`
    +
    +	var blocks Blocks
    +	require.NoError(t, json.Unmarshal([]byte(payload), &blocks))
    +	require.Len(t, blocks.BlockSet, 1)
    +
    +	card, ok := blocks.BlockSet[0].(*CardBlock)
    +	require.True(t, ok, "expected *CardBlock, got %T", blocks.BlockSet[0])
    +	assert.Equal(t, MBTCard, card.BlockType())
    +	require.NotNil(t, card.Title)
    +	assert.Equal(t, "Hello", card.Title.Text)
    +}
    diff --git a/block_carousel.go b/block_carousel.go
    new file mode 100644
    index 000000000..5d407db9c
    --- /dev/null
    +++ b/block_carousel.go
    @@ -0,0 +1,42 @@
    +package slack
    +
    +// CarouselBlock defines a block of type carousel that displays a scrollable
    +// list of cards. A carousel must contain between 1 and 10 cards.
    +//
    +// More Information: https://docs.slack.dev/reference/block-kit/blocks/carousel-block/
    +type CarouselBlock struct {
    +	Type     MessageBlockType `json:"type"`
    +	BlockID  string           `json:"block_id,omitempty"`
    +	Elements []*CardBlock     `json:"elements"`
    +}
    +
    +// BlockType returns the type of the block
    +func (s CarouselBlock) BlockType() MessageBlockType {
    +	return s.Type
    +}
    +
    +// ID returns the ID of the block
    +func (s CarouselBlock) ID() string {
    +	return s.BlockID
    +}
    +
    +// NewCarouselBlock returns a new instance of a carousel block containing the
    +// given cards.
    +func NewCarouselBlock(cards ...*CardBlock) *CarouselBlock {
    +	return &CarouselBlock{
    +		Type:     MBTCarousel,
    +		Elements: cards,
    +	}
    +}
    +
    +// WithBlockID sets the block ID for the CarouselBlock
    +func (s *CarouselBlock) WithBlockID(blockID string) *CarouselBlock {
    +	s.BlockID = blockID
    +	return s
    +}
    +
    +// AddCard appends a card to the carousel
    +func (s *CarouselBlock) AddCard(card *CardBlock) *CarouselBlock {
    +	s.Elements = append(s.Elements, card)
    +	return s
    +}
    diff --git a/block_carousel_test.go b/block_carousel_test.go
    new file mode 100644
    index 000000000..2813b3e00
    --- /dev/null
    +++ b/block_carousel_test.go
    @@ -0,0 +1,87 @@
    +package slack
    +
    +import (
    +	"encoding/json"
    +	"testing"
    +
    +	"github.com/stretchr/testify/assert"
    +	"github.com/stretchr/testify/require"
    +)
    +
    +func TestNewCarouselBlock(t *testing.T) {
    +	cardA := NewCardBlock().WithTitle(NewTextBlockObject("mrkdwn", "A", false, false))
    +	cardB := NewCardBlock().WithTitle(NewTextBlockObject("mrkdwn", "B", false, false))
    +
    +	block := NewCarouselBlock(cardA, cardB).WithBlockID("carousel-1")
    +
    +	assert.Equal(t, MBTCarousel, block.BlockType())
    +	assert.Equal(t, "carousel", string(block.Type))
    +	assert.Equal(t, "carousel-1", block.ID())
    +	require.Len(t, block.Elements, 2)
    +	assert.Equal(t, cardA, block.Elements[0])
    +	assert.Equal(t, cardB, block.Elements[1])
    +
    +	cardC := NewCardBlock().WithTitle(NewTextBlockObject("mrkdwn", "C", false, false))
    +	block.AddCard(cardC)
    +	require.Len(t, block.Elements, 3)
    +	assert.Equal(t, cardC, block.Elements[2])
    +}
    +
    +func TestCarouselBlockJSONRoundTrip(t *testing.T) {
    +	payload := `{
    +		"type": "carousel",
    +		"block_id": "carousel-1",
    +		"elements": [
    +			{
    +				"type": "card",
    +				"title": {"type": "mrkdwn", "text": "MDR"},
    +				"body": {"type": "mrkdwn", "text": "Macrodata Refinement"}
    +			},
    +			{
    +				"type": "card",
    +				"title": {"type": "mrkdwn", "text": "O&D"},
    +				"body": {"type": "mrkdwn", "text": "Optics and Design"}
    +			}
    +		]
    +	}`
    +
    +	var block CarouselBlock
    +	err := json.Unmarshal([]byte(payload), &block)
    +	require.NoError(t, err)
    +
    +	assert.Equal(t, MBTCarousel, block.BlockType())
    +	assert.Equal(t, "carousel-1", block.ID())
    +	require.Len(t, block.Elements, 2)
    +	assert.Equal(t, "MDR", block.Elements[0].Title.Text)
    +	assert.Equal(t, "O&D", block.Elements[1].Title.Text)
    +
    +	marshalled, err := json.Marshal(block)
    +	require.NoError(t, err)
    +
    +	var expected, actual map[string]interface{}
    +	require.NoError(t, json.Unmarshal([]byte(payload), &expected))
    +	require.NoError(t, json.Unmarshal(marshalled, &actual))
    +
    +	assert.Equal(t, expected, actual)
    +}
    +
    +func TestCarouselBlockUnmarshalViaBlocks(t *testing.T) {
    +	payload := `[
    +		{
    +			"type": "carousel",
    +			"elements": [
    +				{"type": "card", "title": {"type": "mrkdwn", "text": "Only"}}
    +			]
    +		}
    +	]`
    +
    +	var blocks Blocks
    +	require.NoError(t, json.Unmarshal([]byte(payload), &blocks))
    +	require.Len(t, blocks.BlockSet, 1)
    +
    +	carousel, ok := blocks.BlockSet[0].(*CarouselBlock)
    +	require.True(t, ok, "expected *CarouselBlock, got %T", blocks.BlockSet[0])
    +	assert.Equal(t, MBTCarousel, carousel.BlockType())
    +	require.Len(t, carousel.Elements, 1)
    +	assert.Equal(t, "Only", carousel.Elements[0].Title.Text)
    +}
    diff --git a/block_conv.go b/block_conv.go
    index a792f26e3..89b4c0b5c 100644
    --- a/block_conv.go
    +++ b/block_conv.go
    @@ -83,6 +83,10 @@ func (b *Blocks) UnmarshalJSON(data []byte) error {
     			block = &TaskCardBlock{}
     		case "plan":
     			block = &PlanBlock{}
    +		case "card":
    +			block = &CardBlock{}
    +		case "carousel":
    +			block = &CarouselBlock{}
     		default:
     			b := &UnknownBlock{raw: r}
     			if err = json.Unmarshal(r, b); err != nil {
    diff --git a/chat.go b/chat.go
    index 1761c506d..a58b4e805 100644
    --- a/chat.go
    +++ b/chat.go
    @@ -912,6 +912,24 @@ func MsgOptionMarkdownText(text string) MsgOption {
     	}
     }
     
    +// TaskDisplayMode controls how task_card / task_update chunks render in a
    +// streamed message. Used with chat.startStream.
    +type TaskDisplayMode string
    +
    +const (
    +	TaskDisplayModeTimeline TaskDisplayMode = "timeline"
    +	TaskDisplayModePlan     TaskDisplayMode = "plan"
    +)
    +
    +// MsgOptionTaskDisplayMode sets task_display_mode on chat.startStream,
    +// controlling whether tasks render as a sequential timeline or a grouped plan.
    +func MsgOptionTaskDisplayMode(mode TaskDisplayMode) MsgOption {
    +	return func(config *sendConfig) error {
    +		config.values.Set("task_display_mode", string(mode))
    +		return nil
    +	}
    +}
    +
     // UnsafeMsgOptionEndpoint deliver the message to the specified endpoint.
     // NOTE: USE AT YOUR OWN RISK: No issues relating to the use of this Option
     // will be supported by the library, it is subject to change without notice that
    diff --git a/chat_stream_chunks.go b/chat_stream_chunks.go
    new file mode 100644
    index 000000000..fbdab3be9
    --- /dev/null
    +++ b/chat_stream_chunks.go
    @@ -0,0 +1,95 @@
    +package slack
    +
    +import (
    +	"encoding/json"
    +)
    +
    +// StreamChunkType identifies a chunk in the chat.startStream / chat.appendStream
    +// / chat.stopStream streaming-message protocol.
    +//
    +// More information: https://docs.slack.dev/reference/methods/chat.appendStream/
    +type StreamChunkType string
    +
    +const (
    +	StreamChunkMarkdownText StreamChunkType = "markdown_text"
    +	StreamChunkTaskUpdate   StreamChunkType = "task_update"
    +	StreamChunkPlanUpdate   StreamChunkType = "plan_update"
    +	StreamChunkBlocks       StreamChunkType = "blocks"
    +)
    +
    +// StreamChunk represents a single chunk in the streaming-message chunks array.
    +type StreamChunk interface {
    +	ChunkType() StreamChunkType
    +}
    +
    +// MarkdownTextChunk streams markdown-formatted text.
    +type MarkdownTextChunk struct {
    +	Type StreamChunkType `json:"type"`
    +	Text string          `json:"text"`
    +}
    +
    +func (c MarkdownTextChunk) ChunkType() StreamChunkType { return c.Type }
    +
    +// NewMarkdownTextChunk returns a markdown_text chunk.
    +func NewMarkdownTextChunk(text string) MarkdownTextChunk {
    +	return MarkdownTextChunk{Type: StreamChunkMarkdownText, Text: text}
    +}
    +
    +// TaskUpdateChunk streams a task status update that renders as a task card.
    +type TaskUpdateChunk struct {
    +	Type    StreamChunkType  `json:"type"`
    +	ID      string           `json:"id"`
    +	Title   string           `json:"title"`
    +	Status  TaskCardStatus   `json:"status,omitempty"`
    +	Details string           `json:"details,omitempty"`
    +	Output  string           `json:"output,omitempty"`
    +	Sources []TaskCardSource `json:"sources,omitempty"`
    +}
    +
    +func (c TaskUpdateChunk) ChunkType() StreamChunkType { return c.Type }
    +
    +// NewTaskUpdateChunk returns a task_update chunk with the given id and title.
    +func NewTaskUpdateChunk(id, title string) TaskUpdateChunk {
    +	return TaskUpdateChunk{Type: StreamChunkTaskUpdate, ID: id, Title: title}
    +}
    +
    +// PlanUpdateChunk streams an update to the current plan's title.
    +type PlanUpdateChunk struct {
    +	Type  StreamChunkType `json:"type"`
    +	Title string          `json:"title"`
    +}
    +
    +func (c PlanUpdateChunk) ChunkType() StreamChunkType { return c.Type }
    +
    +// NewPlanUpdateChunk returns a plan_update chunk.
    +func NewPlanUpdateChunk(title string) PlanUpdateChunk {
    +	return PlanUpdateChunk{Type: StreamChunkPlanUpdate, Title: title}
    +}
    +
    +// BlocksChunk streams a group of Block Kit blocks. Up to 50 blocks per chunk.
    +type BlocksChunk struct {
    +	Type   StreamChunkType `json:"type"`
    +	Blocks []Block         `json:"blocks"`
    +}
    +
    +func (c BlocksChunk) ChunkType() StreamChunkType { return c.Type }
    +
    +// NewBlocksChunk returns a blocks chunk containing the given blocks.
    +func NewBlocksChunk(blocks ...Block) BlocksChunk {
    +	return BlocksChunk{Type: StreamChunkBlocks, Blocks: blocks}
    +}
    +
    +// MsgOptionChunks sets the `chunks` parameter for the streaming chat methods
    +// (chat.startStream / chat.appendStream / chat.stopStream). It is the
    +// transport for Block Kit agent-UI blocks (Alert, Card, Carousel, etc.) which
    +// chat.postMessage rejects as "Unsupported block type".
    +func MsgOptionChunks(chunks ...StreamChunk) MsgOption {
    +	return func(config *sendConfig) error {
    +		encoded, err := json.Marshal(chunks)
    +		if err != nil {
    +			return err
    +		}
    +		config.values.Set("chunks", string(encoded))
    +		return nil
    +	}
    +}
    
    From 2df5cfa0b974d57fc8077ecd030be22e42a2e4a1 Mon Sep 17 00:00:00 2001
    From: Charleen 
    Date: Mon, 20 Apr 2026 15:11:45 -0700
    Subject: [PATCH 567/606] feat(assistant): add username and icon to status
     update (#1553)
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    Slack's
    [assistant.threads.setStatus](https://docs.slack.dev/reference/methods/assistant.threads.setStatus)
    added a couple of additional optional parameters: `username`,
    `icon_url`, and `icon_emoji` for customization. This PR adds these new
    parameters to `SetAssistantThreadsStatusContext`.
    
    Changes:
    - updates `AssistantThreadsSetStatusParameters` to include these new
    parameters
    - updates `SetAssistantThreadsStatusContext` to forward these new
    parameters when set
    - updates existing test to include these fields
    
    | icon_url | icon_emoji |
    | --- | --- |
    |Screenshot 2026-04-20 at 12 36
    03 PM|Screenshot 2026-04-20 at 1 00
    10 PM|
    ---
     assistant.go      | 15 +++++++++++++++
     assistant_test.go |  3 +++
     2 files changed, 18 insertions(+)
    
    diff --git a/assistant.go b/assistant.go
    index abf2ff73c..fb82cb64a 100644
    --- a/assistant.go
    +++ b/assistant.go
    @@ -14,6 +14,9 @@ type AssistantThreadsSetStatusParameters struct {
     	Status          string   `json:"status"`
     	ThreadTS        string   `json:"thread_ts"`
     	LoadingMessages []string `json:"loading_messages,omitempty"`
    +	Username        string   `json:"username,omitempty"`
    +	IconURL         string   `json:"icon_url,omitempty"`
    +	IconEmoji       string   `json:"icon_emoji,omitempty"`
     }
     
     // AssistantThreadSetTitleParameters are the parameters for AssistantThreadSetTitle
    @@ -209,6 +212,18 @@ func (api *Client) SetAssistantThreadsStatusContext(ctx context.Context, params
     		values.Add("loading_messages", strings.Join(params.LoadingMessages, ","))
     	}
     
    +	if params.Username != "" {
    +		values.Add("username", params.Username)
    +	}
    +
    +	if params.IconURL != "" {
    +		values.Add("icon_url", params.IconURL)
    +	}
    +
    +	if params.IconEmoji != "" {
    +		values.Add("icon_emoji", params.IconEmoji)
    +	}
    +
     	response := struct {
     		SlackResponse
     	}{}
    diff --git a/assistant_test.go b/assistant_test.go
    index 162516ee3..39a9aabad 100644
    --- a/assistant_test.go
    +++ b/assistant_test.go
    @@ -38,6 +38,9 @@ func TestSetAssistantThreadsStatus(t *testing.T) {
     		ThreadTS:        "1234567890.123456",
     		Status:          "updated status",
     		LoadingMessages: []string{"updating status..."},
    +		Username:        "custom name",
    +		IconURL:         "https://example.com/icon.png",
    +		IconEmoji:       ":thinking_face:",
     	}
     
     	err := api.SetAssistantThreadsStatus(params)
    
    From ae59061d9e69253ce76fa676a2a91db238d363cf Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Wed, 22 Apr 2026 20:48:12 +0100
    Subject: [PATCH 568/606] feat(block): add alert block (#1552)
    
    Adds `AlertBlock`, the third agent-UI block from the April 16 Slack
    changelog. Uses the functional-options pattern (`NewAlertBlock`) with
    severity via `AlertBlockOptionLevel`
    (default/info/warning/error/success) and block ID via
    `AlertBlockOptionBlockID`. Wires into `Blocks.UnmarshalJSON` for
    round-trip fidelity and adds coverage in `block_alert_test.go`.
    
    Co-authored-by: Claude Opus 4.7 (1M context) 
    ---
     CHANGELOG.md        | 13 ++++++--
     block.go            |  1 +
     block_alert.go      | 70 +++++++++++++++++++++++++++++++++++++++
     block_alert_test.go | 80 +++++++++++++++++++++++++++++++++++++++++++++
     block_conv.go       |  2 ++
     5 files changed, 164 insertions(+), 2 deletions(-)
     create mode 100644 block_alert.go
     create mode 100644 block_alert_test.go
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 6aecf98e0..90313a216 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -19,8 +19,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
       and `AddCard` helpers. Both blocks wire into `Blocks.UnmarshalJSON` for
       round-trip fidelity, and reuse existing `ImageBlockElement` /
       `ButtonBlockElement` / `BlockElements` types rather than introducing new
    -  composition objects. The Alert block is deliberately not included in this
    -  release pending sandbox-verified rendering.
    +  composition objects.
    +- **Block Kit: `AlertBlock`** — Support for the third of the new agent-UI
    +  blocks from the
    +  [April 16 Slack changelog](https://docs.slack.dev/changelog/2026/04/16/block-kit-new-blocks).
    +  `AlertBlock` is constructed via `NewAlertBlock` with a `*TextBlockObject`
    +  body and a functional-options pattern. Severity is set via
    +  `AlertBlockOptionLevel` (`AlertLevelDefault`, `AlertLevelInfo`,
    +  `AlertLevelWarning`, `AlertLevelError`, `AlertLevelSuccess`) and the block
    +  ID via `AlertBlockOptionBlockID`. Wires into `Blocks.UnmarshalJSON` for
    +  round-trip fidelity. Must be delivered via the streaming chunks API —
    +  `chat.postMessage` rejects it as an unsupported block type.
     - **Streaming-message chunks API** — `chat.startStream` / `chat.appendStream` /
       `chat.stopStream` now accept a `chunks` parameter. Added `MsgOptionChunks`
       along with a `StreamChunk` interface and four chunk types:
    diff --git a/block.go b/block.go
    index 0965437a3..cc5d80f24 100644
    --- a/block.go
    +++ b/block.go
    @@ -21,6 +21,7 @@ const (
     	MBTTable          MessageBlockType = "table"
     	MBTTaskCard       MessageBlockType = "task_card"
     	MBTPlan           MessageBlockType = "plan"
    +	MBTAlert          MessageBlockType = "alert"
     	MBTCard           MessageBlockType = "card"
     	MBTCarousel       MessageBlockType = "carousel"
     )
    diff --git a/block_alert.go b/block_alert.go
    new file mode 100644
    index 000000000..ddb151b44
    --- /dev/null
    +++ b/block_alert.go
    @@ -0,0 +1,70 @@
    +package slack
    +
    +// AlertLevel defines the severity for an AlertBlock.
    +type AlertLevel string
    +
    +const (
    +	AlertLevelDefault AlertLevel = "default"
    +	AlertLevelInfo    AlertLevel = "info"
    +	AlertLevelWarning AlertLevel = "warning"
    +	AlertLevelError   AlertLevel = "error"
    +	AlertLevelSuccess AlertLevel = "success"
    +)
    +
    +// AlertBlock defines a block of type alert used to surface a notification
    +// message with an optional severity level.
    +//
    +// Surface: modal only. Slack rejects alert blocks sent via chat.postMessage
    +// or the streaming APIs — use OpenView / UpdateView / PushView with a
    +// ModalViewRequest whose Blocks include the alert.
    +//
    +// More Information: https://docs.slack.dev/reference/block-kit/blocks/alert-block/
    +type AlertBlock struct {
    +	Type    MessageBlockType `json:"type"`
    +	Text    *TextBlockObject `json:"text"`
    +	Level   AlertLevel       `json:"level,omitempty"`
    +	BlockID string           `json:"block_id,omitempty"`
    +}
    +
    +// BlockType returns the type of the block
    +func (s AlertBlock) BlockType() MessageBlockType {
    +	return s.Type
    +}
    +
    +// ID returns the ID of the block
    +func (s AlertBlock) ID() string {
    +	return s.BlockID
    +}
    +
    +// AlertBlockOption allows configuration of options for a new alert block
    +type AlertBlockOption func(*AlertBlock)
    +
    +// AlertBlockOptionLevel sets the severity level for the alert block
    +func AlertBlockOptionLevel(level AlertLevel) AlertBlockOption {
    +	return func(block *AlertBlock) {
    +		block.Level = level
    +	}
    +}
    +
    +// AlertBlockOptionBlockID sets the block ID for the alert block
    +func AlertBlockOptionBlockID(blockID string) AlertBlockOption {
    +	return func(block *AlertBlock) {
    +		block.BlockID = blockID
    +	}
    +}
    +
    +// NewAlertBlock returns a new instance of an alert block
    +func NewAlertBlock(text *TextBlockObject, options ...AlertBlockOption) *AlertBlock {
    +	block := AlertBlock{
    +		Type: MBTAlert,
    +		Text: text,
    +	}
    +
    +	for _, option := range options {
    +		if option != nil {
    +			option(&block)
    +		}
    +	}
    +
    +	return &block
    +}
    diff --git a/block_alert_test.go b/block_alert_test.go
    new file mode 100644
    index 000000000..7d86b4b84
    --- /dev/null
    +++ b/block_alert_test.go
    @@ -0,0 +1,80 @@
    +package slack
    +
    +import (
    +	"encoding/json"
    +	"testing"
    +
    +	"github.com/stretchr/testify/assert"
    +	"github.com/stretchr/testify/require"
    +)
    +
    +func TestNewAlertBlock(t *testing.T) {
    +	text := NewTextBlockObject("mrkdwn", "The work is mysterious and important.", false, false)
    +	block := NewAlertBlock(text,
    +		AlertBlockOptionLevel(AlertLevelInfo),
    +		AlertBlockOptionBlockID("alert-1"),
    +	)
    +
    +	assert.Equal(t, MBTAlert, block.BlockType())
    +	assert.Equal(t, "alert", string(block.Type))
    +	assert.Equal(t, "alert-1", block.ID())
    +	assert.Equal(t, AlertLevelInfo, block.Level)
    +	assert.Equal(t, text, block.Text)
    +}
    +
    +func TestNewAlertBlockWithNilOption(t *testing.T) {
    +	text := NewTextBlockObject("plain_text", "hi", false, false)
    +	assert.NotPanics(t, func() {
    +		NewAlertBlock(text, nil)
    +	}, "should not panic when nil option passed")
    +}
    +
    +func TestAlertBlockJSONRoundTrip(t *testing.T) {
    +	payload := `{
    +		"type": "alert",
    +		"text": {
    +			"type": "mrkdwn",
    +			"text": "The work is mysterious and important."
    +		},
    +		"level": "info",
    +		"block_id": "alert-1"
    +	}`
    +
    +	var block AlertBlock
    +	err := json.Unmarshal([]byte(payload), &block)
    +	require.NoError(t, err)
    +
    +	assert.Equal(t, MBTAlert, block.BlockType())
    +	assert.Equal(t, "alert-1", block.ID())
    +	assert.Equal(t, AlertLevelInfo, block.Level)
    +	require.NotNil(t, block.Text)
    +	assert.Equal(t, "mrkdwn", block.Text.Type)
    +
    +	marshalled, err := json.Marshal(block)
    +	require.NoError(t, err)
    +
    +	var expected, actual map[string]interface{}
    +	require.NoError(t, json.Unmarshal([]byte(payload), &expected))
    +	require.NoError(t, json.Unmarshal(marshalled, &actual))
    +
    +	assert.Equal(t, expected, actual)
    +}
    +
    +func TestAlertBlockUnmarshalViaBlocks(t *testing.T) {
    +	payload := `[
    +		{
    +			"type": "alert",
    +			"text": {"type": "plain_text", "text": "Heads up"},
    +			"level": "warning"
    +		}
    +	]`
    +
    +	var blocks Blocks
    +	require.NoError(t, json.Unmarshal([]byte(payload), &blocks))
    +	require.Len(t, blocks.BlockSet, 1)
    +
    +	alert, ok := blocks.BlockSet[0].(*AlertBlock)
    +	require.True(t, ok, "expected *AlertBlock, got %T", blocks.BlockSet[0])
    +	assert.Equal(t, MBTAlert, alert.BlockType())
    +	assert.Equal(t, AlertLevelWarning, alert.Level)
    +}
    diff --git a/block_conv.go b/block_conv.go
    index 89b4c0b5c..b83ad8312 100644
    --- a/block_conv.go
    +++ b/block_conv.go
    @@ -81,6 +81,8 @@ func (b *Blocks) UnmarshalJSON(data []byte) error {
     			block = &TableBlock{}
     		case "task_card":
     			block = &TaskCardBlock{}
    +		case "alert":
    +			block = &AlertBlock{}
     		case "plan":
     			block = &PlanBlock{}
     		case "card":
    
    From 35d8f31a076f73db88bf08304a8418846ed7b865 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Wed, 22 Apr 2026 20:53:56 +0100
    Subject: [PATCH 569/606] chore: bump to v0.23.0
    
    ---
     CHANGELOG.md | 14 +++++++++++++-
     1 file changed, 13 insertions(+), 1 deletion(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 90313a216..3a21848f7 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ## [Unreleased]
     
    +## [0.23.0] - 2026-04-22
    +
     ### Added
     
     - **Block Kit: `CardBlock` and `CarouselBlock`** — Support for two of the new
    @@ -40,6 +42,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     - **`MsgOptionTaskDisplayMode`** — New option for `chat.startStream` controlling
       whether task chunks render as a sequential timeline or a grouped plan.
       Accepts `TaskDisplayModeTimeline` or `TaskDisplayModePlan`.
    +- Added `Username`, `IconURL`, and `IconEmoji` fields to
    +  `AssistantThreadsSetStatusParameters`, forwarded by
    +  `SetAssistantThreadsStatusContext`, matching the new optional parameters on
    +  [`assistant.threads.setStatus`](https://docs.slack.dev/reference/methods/assistant.threads.setStatus)
    +  for customising the status-update presentation.
    +- Exposed `SocketmodeHandler.DispatchEvent` (previously the unexported
    +  `dispatcher`), enabling integration tests to exercise registered handlers
    +  without a live WebSocket connection. The unexported `dispatcher` is kept as
    +  a thin wrapper for backwards compatibility. Closes #1549.
     
     ## [0.22.0] - 2026-04-12
     
    @@ -548,7 +559,8 @@ for details.
     [#1196]: https://github.com/slack-go/slack/issues/1196
     [#1547]: https://github.com/slack-go/slack/pull/1547
     
    -[Unreleased]: https://github.com/slack-go/slack/compare/v0.22.0...HEAD
    +[Unreleased]: https://github.com/slack-go/slack/compare/v0.23.0...HEAD
    +[0.23.0]: https://github.com/slack-go/slack/compare/v0.22.0...v0.23.0
     [0.22.0]: https://github.com/slack-go/slack/compare/v0.21.1...0.22.0
     [0.21.1]: https://github.com/slack-go/slack/compare/v0.21.0...v0.21.1
     [0.21.0]: https://github.com/slack-go/slack/compare/v0.20.0...v0.21.0
    
    From c6edc2762f59b0fcd2af7f2d8eab36e2f29bad7d Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Mon, 27 Apr 2026 16:38:34 +0100
    Subject: [PATCH 570/606] chore: bump go to 1.25.9
    
    ---
     go.mod | 2 +-
     1 file changed, 1 insertion(+), 1 deletion(-)
    
    diff --git a/go.mod b/go.mod
    index 2ab17a83c..fb8aaaf11 100644
    --- a/go.mod
    +++ b/go.mod
    @@ -2,7 +2,7 @@ module github.com/slack-go/slack
     
     go 1.25
     
    -toolchain go1.25.7
    +toolchain go1.25.9
     
     require (
     	github.com/go-test/deep v1.1.1
    
    From 34ad5c052e446f58505ae8d81a2a72821de107cc Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 10 May 2026 10:27:28 +0100
    Subject: [PATCH 571/606] security: reject empty signing secret for
     NewSecretsVerifier
    
    ---
     CHANGELOG.md     | 10 +++++++++-
     security.go      |  4 ++++
     security_test.go | 15 +++++++++++++++
     3 files changed, 28 insertions(+), 1 deletion(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 3a21848f7..2f9f821e2 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ## [Unreleased]
     
    +## [0.23.1] - 2026-05-10
    +
    +### Fixed
    +
    +- `NewSecretsVerifier` now rejects empty signing secrets to avoid accepting forged request
    +  signatures when applications are misconfigured.
    +
     ## [0.23.0] - 2026-04-22
     
     ### Added
    @@ -559,7 +566,8 @@ for details.
     [#1196]: https://github.com/slack-go/slack/issues/1196
     [#1547]: https://github.com/slack-go/slack/pull/1547
     
    -[Unreleased]: https://github.com/slack-go/slack/compare/v0.23.0...HEAD
    +[Unreleased]: https://github.com/slack-go/slack/compare/v0.23.1...HEAD
    +[0.23.1]: https://github.com/slack-go/slack/compare/v0.23.0...v0.23.1
     [0.23.0]: https://github.com/slack-go/slack/compare/v0.22.0...v0.23.0
     [0.22.0]: https://github.com/slack-go/slack/compare/v0.21.1...0.22.0
     [0.21.1]: https://github.com/slack-go/slack/compare/v0.21.0...v0.21.1
    diff --git a/security.go b/security.go
    index fbc1c22b4..8124c2c09 100644
    --- a/security.go
    +++ b/security.go
    @@ -30,6 +30,10 @@ func unsafeSignatureVerifier(header http.Header, secret string) (_ SecretsVerifi
     		bsignature []byte
     	)
     
    +	if secret == "" {
    +		return SecretsVerifier{}, ErrInvalidConfiguration
    +	}
    +
     	signature := header.Get(hSignature)
     	stimestamp := header.Get(hTimestamp)
     
    diff --git a/security_test.go b/security_test.go
    index cbd61f107..959331eb9 100644
    --- a/security_test.go
    +++ b/security_test.go
    @@ -1,6 +1,7 @@
     package slack
     
     import (
    +	"errors"
     	"io"
     	"log"
     	"net/http"
    @@ -32,6 +33,20 @@ func TestExpiredTimestamp(t *testing.T) {
     	}
     }
     
    +func TestNewSecretsVerifierRejectsEmptySigningSecret(t *testing.T) {
    +	_, err := NewSecretsVerifier(newHeader(true), "")
    +	if !errors.Is(err, ErrInvalidConfiguration) {
    +		t.Fatalf("expected ErrInvalidConfiguration, got %v", err)
    +	}
    +}
    +
    +func TestUnsafeSignatureVerifierRejectsEmptySigningSecret(t *testing.T) {
    +	_, err := unsafeSignatureVerifier(newHeader(true), "")
    +	if !errors.Is(err, ErrInvalidConfiguration) {
    +		t.Fatalf("expected ErrInvalidConfiguration, got %v", err)
    +	}
    +}
    +
     func TestUnsafeSignatureVerifier(t *testing.T) {
     	tests := []struct {
     		title         string
    
    From ff3ada69277b00264224624c8a6e3192f2348c63 Mon Sep 17 00:00:00 2001
    From: Akhil George <74812392+akhil-ge0rge@users.noreply.github.com>
    Date: Sun, 24 May 2026 16:30:39 +0530
    Subject: [PATCH 572/606] docs: format go get command in code block (#1554)
    
    ---
     README.md | 4 +++-
     1 file changed, 3 insertions(+), 1 deletion(-)
    
    diff --git a/README.md b/README.md
    index 53dd912d3..a10f2a269 100644
    --- a/README.md
    +++ b/README.md
    @@ -24,7 +24,9 @@ Release Policy](https://go.dev/doc/devel/release#policy)).
     
     ### *go get*
     
    -    $ go get -u github.com/slack-go/slack
    +```bash
    +go get -u github.com/slack-go/slack
    +```
     
     ## Example
     
    
    From 8c5ef3c18b2999a9a7b4913560c8722249c531c5 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 24 May 2026 16:37:15 +0100
    Subject: [PATCH 573/606] feat: add new block kit block Data Table (#1555)
    
    More details at
    https://docs.slack.dev/reference/block-kit/blocks/data-table-block/.
    ---
     CHANGELOG.md             |  11 ++
     block.go                 |   1 +
     block_conv.go            |   2 +
     block_data_table.go      | 212 +++++++++++++++++++++++++++++++++++++++
     block_data_table_test.go | 181 +++++++++++++++++++++++++++++++++
     block_plan.go            |   4 +-
     block_task_card.go       |   4 +-
     7 files changed, 413 insertions(+), 2 deletions(-)
     create mode 100644 block_data_table.go
     create mode 100644 block_data_table_test.go
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 2f9f821e2..231868514 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ## [Unreleased]
     
    +### Added
    +
    +- Block Kit: `DataTableBlock` for the [`data_table`](https://docs.slack.dev/reference/block-kit/blocks/data-table-block/)
    +  block, with `NewDataTableBlock`, `AddRow`, raw-text/raw-number/rich-text cell
    +  constructors, and `WithPageSize` / `WithRowHeaderColumnIndex` builders.
    +
    +### Changed
    +
    +- `NewTaskCardBlock` and `NewPlanBlock` nil-guard their variadic options,
    +  matching the other block constructors (#1236).
    +
     ## [0.23.1] - 2026-05-10
     
     ### Fixed
    diff --git a/block.go b/block.go
    index cc5d80f24..34be9655f 100644
    --- a/block.go
    +++ b/block.go
    @@ -19,6 +19,7 @@ const (
     	MBTVideo          MessageBlockType = "video"
     	MBTMarkdown       MessageBlockType = "markdown"
     	MBTTable          MessageBlockType = "table"
    +	MBTDataTable      MessageBlockType = "data_table"
     	MBTTaskCard       MessageBlockType = "task_card"
     	MBTPlan           MessageBlockType = "plan"
     	MBTAlert          MessageBlockType = "alert"
    diff --git a/block_conv.go b/block_conv.go
    index b83ad8312..6c74ea282 100644
    --- a/block_conv.go
    +++ b/block_conv.go
    @@ -79,6 +79,8 @@ func (b *Blocks) UnmarshalJSON(data []byte) error {
     			block = &VideoBlock{}
     		case "table":
     			block = &TableBlock{}
    +		case "data_table":
    +			block = &DataTableBlock{}
     		case "task_card":
     			block = &TaskCardBlock{}
     		case "alert":
    diff --git a/block_data_table.go b/block_data_table.go
    new file mode 100644
    index 000000000..074b29c0e
    --- /dev/null
    +++ b/block_data_table.go
    @@ -0,0 +1,212 @@
    +package slack
    +
    +import (
    +	"encoding/json"
    +	"fmt"
    +)
    +
    +// DataTableCellType identifies the variant of a cell inside a DataTableBlock row.
    +type DataTableCellType string
    +
    +const (
    +	DataTableCellRawText   DataTableCellType = "raw_text"
    +	DataTableCellRawNumber DataTableCellType = "raw_number"
    +	DataTableCellRichText  DataTableCellType = "rich_text"
    +)
    +
    +// DataTableCell is implemented by every cell type valid inside a DataTableBlock row:
    +// DataTableRawTextCell, DataTableRawNumberCell, and DataTableRichTextCell.
    +type DataTableCell interface {
    +	DataTableCellType() DataTableCellType
    +}
    +
    +// DataTableRawTextCell is a plain-text cell in a DataTableBlock.
    +type DataTableRawTextCell struct {
    +	Type DataTableCellType `json:"type"`
    +	Text string            `json:"text"`
    +}
    +
    +// DataTableCellType returns the cell variant.
    +func (c DataTableRawTextCell) DataTableCellType() DataTableCellType {
    +	return c.Type
    +}
    +
    +// NewDataTableRawTextCell returns a raw_text cell with the given text.
    +func NewDataTableRawTextCell(text string) *DataTableRawTextCell {
    +	return &DataTableRawTextCell{Type: DataTableCellRawText, Text: text}
    +}
    +
    +// DataTableRawNumberCell is a numeric cell in a DataTableBlock. When every cell in a
    +// column is a raw_number cell, Slack performs a numeric sort on that column instead of
    +// the default alphabetic sort. Text, when set, overrides the displayed value.
    +type DataTableRawNumberCell struct {
    +	Type  DataTableCellType `json:"type"`
    +	Value float64           `json:"value"`
    +	Text  string            `json:"text,omitempty"`
    +}
    +
    +// DataTableCellType returns the cell variant.
    +func (c DataTableRawNumberCell) DataTableCellType() DataTableCellType {
    +	return c.Type
    +}
    +
    +// NewDataTableRawNumberCell returns a raw_number cell with the given value.
    +func NewDataTableRawNumberCell(value float64) *DataTableRawNumberCell {
    +	return &DataTableRawNumberCell{Type: DataTableCellRawNumber, Value: value}
    +}
    +
    +// WithText sets the display text shown in place of the numeric value.
    +func (c *DataTableRawNumberCell) WithText(text string) *DataTableRawNumberCell {
    +	c.Text = text
    +	return c
    +}
    +
    +// DataTableRichTextCell is a cell holding rich text formatting. Rich text cells cannot
    +// appear in the header row.
    +type DataTableRichTextCell struct {
    +	Type     DataTableCellType `json:"type"`
    +	Elements []RichTextElement `json:"elements"`
    +}
    +
    +// DataTableCellType returns the cell variant.
    +func (c DataTableRichTextCell) DataTableCellType() DataTableCellType {
    +	return c.Type
    +}
    +
    +// NewDataTableRichTextCell returns a rich_text cell with the given rich text elements.
    +func NewDataTableRichTextCell(elements ...RichTextElement) *DataTableRichTextCell {
    +	return &DataTableRichTextCell{Type: DataTableCellRichText, Elements: elements}
    +}
    +
    +// UnmarshalJSON delegates rich text element parsing to RichTextBlock so the cell handles
    +// the same set of inner elements (sections, lists, quotes, preformatted, unknown).
    +func (c *DataTableRichTextCell) UnmarshalJSON(data []byte) error {
    +	var rt RichTextBlock
    +	if err := json.Unmarshal(data, &rt); err != nil {
    +		return err
    +	}
    +	c.Type = DataTableCellRichText
    +	c.Elements = rt.Elements
    +	return nil
    +}
    +
    +// DataTableBlock displays paginated tabular data with optional numeric sorting.
    +//
    +// Caption is required. Rows is an array of cell arrays; the first row is the header and
    +// must contain only raw_text cells. PageSize defaults to 5 (min 1, max 100) and
    +// RowHeaderColumnIndex defaults to 0 when omitted.
    +//
    +// More Information: https://docs.slack.dev/reference/block-kit/blocks/data-table-block/
    +type DataTableBlock struct {
    +	Type                 MessageBlockType  `json:"type"`
    +	BlockID              string            `json:"block_id,omitempty"`
    +	Caption              string            `json:"caption"`
    +	Rows                 [][]DataTableCell `json:"rows"`
    +	PageSize             int               `json:"page_size,omitempty"`
    +	RowHeaderColumnIndex int               `json:"row_header_column_index,omitempty"`
    +}
    +
    +// BlockType returns the type of the block.
    +func (s DataTableBlock) BlockType() MessageBlockType {
    +	return s.Type
    +}
    +
    +// ID returns the ID of the block.
    +func (s DataTableBlock) ID() string {
    +	return s.BlockID
    +}
    +
    +// UnmarshalJSON parses the heterogeneous cell types in each row.
    +func (s *DataTableBlock) UnmarshalJSON(data []byte) error {
    +	var raw struct {
    +		Type                 MessageBlockType    `json:"type"`
    +		BlockID              string              `json:"block_id"`
    +		Caption              string              `json:"caption"`
    +		PageSize             int                 `json:"page_size"`
    +		RowHeaderColumnIndex int                 `json:"row_header_column_index"`
    +		Rows                 [][]json.RawMessage `json:"rows"`
    +	}
    +	if err := json.Unmarshal(data, &raw); err != nil {
    +		return err
    +	}
    +
    +	rows := make([][]DataTableCell, 0, len(raw.Rows))
    +	for _, rawRow := range raw.Rows {
    +		row := make([]DataTableCell, 0, len(rawRow))
    +		for _, rawCell := range rawRow {
    +			var probe struct {
    +				Type DataTableCellType `json:"type"`
    +			}
    +			if err := json.Unmarshal(rawCell, &probe); err != nil {
    +				return err
    +			}
    +			var cell DataTableCell
    +			switch probe.Type {
    +			case DataTableCellRawText:
    +				cell = &DataTableRawTextCell{}
    +			case DataTableCellRawNumber:
    +				cell = &DataTableRawNumberCell{}
    +			case DataTableCellRichText:
    +				cell = &DataTableRichTextCell{}
    +			default:
    +				return fmt.Errorf("unsupported data_table cell type %q", probe.Type)
    +			}
    +			if err := json.Unmarshal(rawCell, cell); err != nil {
    +				return err
    +			}
    +			row = append(row, cell)
    +		}
    +		rows = append(rows, row)
    +	}
    +
    +	s.Type = raw.Type
    +	s.BlockID = raw.BlockID
    +	s.Caption = raw.Caption
    +	s.PageSize = raw.PageSize
    +	s.RowHeaderColumnIndex = raw.RowHeaderColumnIndex
    +	s.Rows = rows
    +	return nil
    +}
    +
    +// DataTableBlockOption configures optional fields on a new DataTableBlock.
    +type DataTableBlockOption func(*DataTableBlock)
    +
    +// DataTableBlockOptionBlockID sets the block ID.
    +func DataTableBlockOptionBlockID(blockID string) DataTableBlockOption {
    +	return func(b *DataTableBlock) { b.BlockID = blockID }
    +}
    +
    +// NewDataTableBlock returns a new DataTableBlock with the given caption. Add header and
    +// data rows with AddRow.
    +func NewDataTableBlock(caption string, options ...DataTableBlockOption) *DataTableBlock {
    +	block := &DataTableBlock{
    +		Type:    MBTDataTable,
    +		Caption: caption,
    +		Rows:    make([][]DataTableCell, 0),
    +	}
    +	for _, opt := range options {
    +		if opt != nil {
    +			opt(block)
    +		}
    +	}
    +	return block
    +}
    +
    +// WithPageSize sets the number of rows per page (min 1, max 100).
    +func (s *DataTableBlock) WithPageSize(pageSize int) *DataTableBlock {
    +	s.PageSize = pageSize
    +	return s
    +}
    +
    +// WithRowHeaderColumnIndex sets the 0-based index of the column that uniquely identifies
    +// each row.
    +func (s *DataTableBlock) WithRowHeaderColumnIndex(idx int) *DataTableBlock {
    +	s.RowHeaderColumnIndex = idx
    +	return s
    +}
    +
    +// AddRow appends a row of cells to the DataTableBlock.
    +func (s *DataTableBlock) AddRow(cells ...DataTableCell) *DataTableBlock {
    +	s.Rows = append(s.Rows, append([]DataTableCell{}, cells...))
    +	return s
    +}
    diff --git a/block_data_table_test.go b/block_data_table_test.go
    new file mode 100644
    index 000000000..0df288143
    --- /dev/null
    +++ b/block_data_table_test.go
    @@ -0,0 +1,181 @@
    +package slack
    +
    +import (
    +	"encoding/json"
    +	"testing"
    +
    +	"github.com/stretchr/testify/assert"
    +	"github.com/stretchr/testify/require"
    +)
    +
    +func TestNewDataTableBlock(t *testing.T) {
    +	block := NewDataTableBlock("A Fabulous Table",
    +		DataTableBlockOptionBlockID("dt-1"),
    +	).
    +		WithPageSize(10).
    +		WithRowHeaderColumnIndex(1)
    +
    +	assert.Equal(t, MBTDataTable, block.BlockType())
    +	assert.Equal(t, "data_table", string(block.Type))
    +	assert.Equal(t, "dt-1", block.ID())
    +	assert.Equal(t, "A Fabulous Table", block.Caption)
    +	assert.Equal(t, 10, block.PageSize)
    +	assert.Equal(t, 1, block.RowHeaderColumnIndex)
    +	assert.Empty(t, block.Rows)
    +}
    +
    +func TestNewDataTableBlockWithNilOption(t *testing.T) {
    +	assert.NotPanics(t, func() {
    +		NewDataTableBlock("caption", nil)
    +	}, "should not panic when nil option passed")
    +}
    +
    +func TestDataTableBlockAddRow(t *testing.T) {
    +	block := NewDataTableBlock("caption")
    +	block.AddRow(NewDataTableRawTextCell("Name"), NewDataTableRawTextCell("Score"))
    +	block.AddRow(
    +		NewDataTableRawTextCell("Helly"),
    +		NewDataTableRawNumberCell(42).WithText("forty-two"),
    +	)
    +
    +	require.Len(t, block.Rows, 2)
    +	require.Len(t, block.Rows[0], 2)
    +	require.Len(t, block.Rows[1], 2)
    +
    +	assert.Equal(t, DataTableCellRawText, block.Rows[0][0].DataTableCellType())
    +	assert.Equal(t, DataTableCellRawNumber, block.Rows[1][1].DataTableCellType())
    +
    +	num, ok := block.Rows[1][1].(*DataTableRawNumberCell)
    +	require.True(t, ok)
    +	assert.Equal(t, float64(42), num.Value)
    +	assert.Equal(t, "forty-two", num.Text)
    +}
    +
    +func TestDataTableBlockJSONRoundTrip(t *testing.T) {
    +	payload := `{
    +		"type": "data_table",
    +		"block_id": "dt-1",
    +		"caption": "A Fabulous Table",
    +		"page_size": 5,
    +		"rows": [
    +			[
    +				{"type": "raw_text", "text": "Name"},
    +				{"type": "raw_text", "text": "Department"},
    +				{"type": "raw_text", "text": "Badge"}
    +			],
    +			[
    +				{"type": "raw_text", "text": "Data Refinement Department"},
    +				{"type": "raw_text", "text": "MDR"},
    +				{
    +					"type": "rich_text",
    +					"elements": [
    +						{
    +							"type": "rich_text_section",
    +							"elements": [
    +								{"type": "text", "text": "Blue", "style": {"bold": true}}
    +							]
    +						}
    +					]
    +				}
    +			],
    +			[
    +				{"type": "raw_text", "text": "Wellness Department"},
    +				{"type": "raw_number", "value": 7, "text": "seven"},
    +				{
    +					"type": "rich_text",
    +					"elements": [
    +						{
    +							"type": "rich_text_section",
    +							"elements": [
    +								{"type": "text", "text": "Limited", "style": {"bold": true}}
    +							]
    +						}
    +					]
    +				}
    +			]
    +		]
    +	}`
    +
    +	var block DataTableBlock
    +	require.NoError(t, json.Unmarshal([]byte(payload), &block))
    +
    +	assert.Equal(t, MBTDataTable, block.BlockType())
    +	assert.Equal(t, "dt-1", block.ID())
    +	assert.Equal(t, "A Fabulous Table", block.Caption)
    +	assert.Equal(t, 5, block.PageSize)
    +	require.Len(t, block.Rows, 3)
    +	require.Len(t, block.Rows[0], 3)
    +
    +	header, ok := block.Rows[0][0].(*DataTableRawTextCell)
    +	require.True(t, ok)
    +	assert.Equal(t, "Name", header.Text)
    +
    +	num, ok := block.Rows[2][1].(*DataTableRawNumberCell)
    +	require.True(t, ok)
    +	assert.Equal(t, float64(7), num.Value)
    +	assert.Equal(t, "seven", num.Text)
    +
    +	rich, ok := block.Rows[1][2].(*DataTableRichTextCell)
    +	require.True(t, ok)
    +	require.Len(t, rich.Elements, 1)
    +	section, ok := rich.Elements[0].(*RichTextSection)
    +	require.True(t, ok)
    +	require.Len(t, section.Elements, 1)
    +	text, ok := section.Elements[0].(*RichTextSectionTextElement)
    +	require.True(t, ok)
    +	assert.Equal(t, "Blue", text.Text)
    +	require.NotNil(t, text.Style)
    +	assert.True(t, text.Style.Bold)
    +
    +	marshalled, err := json.Marshal(block)
    +	require.NoError(t, err)
    +
    +	var expected, actual map[string]interface{}
    +	require.NoError(t, json.Unmarshal([]byte(payload), &expected))
    +	require.NoError(t, json.Unmarshal(marshalled, &actual))
    +	assert.Equal(t, expected, actual)
    +}
    +
    +func TestDataTableBlockUnmarshalViaBlocks(t *testing.T) {
    +	payload := `[
    +		{
    +			"type": "data_table",
    +			"caption": "Tiny Table",
    +			"rows": [
    +				[{"type": "raw_text", "text": "Col"}],
    +				[{"type": "raw_number", "value": 1}]
    +			]
    +		}
    +	]`
    +
    +	var blocks Blocks
    +	require.NoError(t, json.Unmarshal([]byte(payload), &blocks))
    +	require.Len(t, blocks.BlockSet, 1)
    +
    +	dt, ok := blocks.BlockSet[0].(*DataTableBlock)
    +	require.True(t, ok, "expected *DataTableBlock, got %T", blocks.BlockSet[0])
    +	assert.Equal(t, MBTDataTable, dt.BlockType())
    +	assert.Equal(t, "Tiny Table", dt.Caption)
    +	require.Len(t, dt.Rows, 2)
    +
    +	num, ok := dt.Rows[1][0].(*DataTableRawNumberCell)
    +	require.True(t, ok)
    +	assert.Equal(t, float64(1), num.Value)
    +	assert.Empty(t, num.Text)
    +}
    +
    +func TestDataTableBlockUnknownCellType(t *testing.T) {
    +	payload := `{
    +		"type": "data_table",
    +		"caption": "Bad Cell",
    +		"rows": [
    +			[{"type": "raw_text", "text": "ok"}],
    +			[{"type": "mystery_cell", "text": "huh"}]
    +		]
    +	}`
    +
    +	var block DataTableBlock
    +	err := json.Unmarshal([]byte(payload), &block)
    +	require.Error(t, err)
    +	assert.Contains(t, err.Error(), "mystery_cell")
    +}
    diff --git a/block_plan.go b/block_plan.go
    index c6e417490..8b83d2994 100644
    --- a/block_plan.go
    +++ b/block_plan.go
    @@ -39,7 +39,9 @@ func NewPlanBlock(title string, options ...PlanBlockOption) *PlanBlock {
     	}
     
     	for _, option := range options {
    -		option(&block)
    +		if option != nil {
    +			option(&block)
    +		}
     	}
     
     	return &block
    diff --git a/block_task_card.go b/block_task_card.go
    index 2053a82d6..d7647cc14 100644
    --- a/block_task_card.go
    +++ b/block_task_card.go
    @@ -70,7 +70,9 @@ func NewTaskCardBlock(taskID, title string, options ...TaskCardBlockOption) *Tas
     	}
     
     	for _, option := range options {
    -		option(&block)
    +		if option != nil {
    +			option(&block)
    +		}
     	}
     
     	return &block
    
    From 0b30f31349140ef0cf77f60448d3cb449fec1813 Mon Sep 17 00:00:00 2001
    From: Norberto Lopes 
    Date: Sun, 24 May 2026 16:40:05 +0100
    Subject: [PATCH 574/606] chore: bump to v0.24.0
    
    ---
     CHANGELOG.md | 5 ++++-
     1 file changed, 4 insertions(+), 1 deletion(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 231868514..699ec39b3 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ## [Unreleased]
     
    +## [0.24.0]
    +
     ### Added
     
     - Block Kit: `DataTableBlock` for the [`data_table`](https://docs.slack.dev/reference/block-kit/blocks/data-table-block/)
    @@ -577,7 +579,8 @@ for details.
     [#1196]: https://github.com/slack-go/slack/issues/1196
     [#1547]: https://github.com/slack-go/slack/pull/1547
     
    -[Unreleased]: https://github.com/slack-go/slack/compare/v0.23.1...HEAD
    +[Unreleased]: https://github.com/slack-go/slack/compare/v0.24.0...HEAD
    +[0.24.0]: https://github.com/slack-go/slack/compare/v0.23.1...v0.24.0
     [0.23.1]: https://github.com/slack-go/slack/compare/v0.23.0...v0.23.1
     [0.23.0]: https://github.com/slack-go/slack/compare/v0.22.0...v0.23.0
     [0.22.0]: https://github.com/slack-go/slack/compare/v0.21.1...0.22.0
    
    From 43c28d8386ba2bdcfb03c8a46c1e10ad30b3f53e Mon Sep 17 00:00:00 2001
    From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
    Date: Mon, 25 May 2026 12:49:55 +0100
    Subject: [PATCH 575/606] chore(deps): bump actions/stale from 10.2.0 to 10.3.0
     (#1556)
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    Bumps [actions/stale](https://github.com/actions/stale) from 10.2.0 to
    10.3.0.
    
    Release notes

    Sourced from actions/stale's releases.

    v10.3.0

    What's Changed

    Bug Fix

    Dependency Updates

    New Contributors

    Full Changelog: https://github.com/actions/stale/compare/v10...v10.3.0

    Commits

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/stale&package-manager=github_actions&previous-version=10.2.0&new-version=10.3.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index d4e24cbf7..e2a84f8ce 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -13,7 +13,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 with: any-of-labels: 'feedback given' days-before-stale: 45 From 98066ee5026dce3fa1cc893d20895e3f5c3af21c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 12:52:24 +0100 Subject: [PATCH 576/606] chore(deps): bump golangci/golangci-lint-action from 9.2.0 to 9.2.1 (#1557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 9.2.0 to 9.2.1.
    Release notes

    Sourced from golangci/golangci-lint-action's releases.

    v9.2.1

    What's Changed

    IMPORTANT: this is the first immutable release.

    Changes

    Dependencies

    Full Changelog: https://github.com/golangci/golangci-lint-action/compare/v9.2.0...v9.2.1

    Commits
    • 82606bf chore: prepare release v9.2.1
    • 97c8387 chore: improve workflows (#1394)
    • 28d0a19 build(deps): bump the dependencies group across 1 directory with 2 updates
    • 633fbc7 build(deps): bump github/codeql-action from 4.35.3 to 4.35.4 (#1391)
    • 59f43e2 build(deps): bump github/codeql-action from 4.35.2 to 4.35.3 (#1389)
    • 9eb174e build(deps): bump fast-xml-builder from 1.1.5 to 1.2.0 (#1386)
    • 4f52504 build(deps): bump github/codeql-action from 4 to 4.35.2 (#1384)
    • 6f87dfd docs: update examples
    • c9500d7 chore: improve workflows
    • 03b1faa chore: improve issue templates
    • Additional commits viewable in compare view

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=golangci/golangci-lint-action&package-manager=github_actions&previous-version=9.2.0&new-version=9.2.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 14bd70851..258295049 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,7 +43,7 @@ jobs: with: fetch-depth: 0 - name: golangci-lint - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 with: version: v2.10.1 - name: staticcheck From af28090b40b608521911a84c0d563d13c93f193d Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Thu, 4 Jun 2026 21:18:16 +0200 Subject: [PATCH 577/606] fix!: preserve all cell types in `TableBlock` (#1560) TableBlock.Rows was typed [][]*RichTextBlock, so it can't represent anything but `rich_text` cells. Slack's table block does support `raw_text` and `raw_number` and `null` for empty cells which meant that trying to decode those into *RichTextBlock would silently drop their content. I've changed the `Rows` to instead become a union of concrete cell types according to the documentation: - `TableRichTextCell` (rich_text, "elements") - `TableRawTextCell` (raw_text, "text") - `TableRawNumberCell` (raw_number, "value") - `nil` (empty cell; I believe slack just sends null) Funnily enough (not funny at all!) it doesn't seem possible to use `raw_number` when posting a message to slack, only `raw_text` or `rich_text`, therefore I've documented that. BREAKING CHANGE: TableBlock.Rows is no [][]TableCell instead of [][]*RichTextBlock. --- CHANGELOG.md | 8 ++ block_roundtrip_test.go | 113 ++++++++++++++++++++++++++ block_table.go | 172 ++++++++++++++++++++++++++++++++++++++-- block_table_test.go | 123 ++++++++++++++++++++-------- 4 files changed, 374 insertions(+), 42 deletions(-) create mode 100644 block_roundtrip_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 699ec39b3..c32318018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +> [!WARNING] +> **Breaking change.** + +- `TableBlock.Rows` is now `[][]TableCell` (was `[][]*RichTextBlock`), so `table` + blocks no longer drop `raw_text`, `raw_number`, and `null` cells (#1558). + ## [0.24.0] ### Added diff --git a/block_roundtrip_test.go b/block_roundtrip_test.go new file mode 100644 index 000000000..cb5e969fc --- /dev/null +++ b/block_roundtrip_test.go @@ -0,0 +1,113 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestBlockRealPayloadRoundTrip is a guardrail against silent data loss when decoding the +// JSON that Slack actually sends. +// +// Each case is a single block payload written as raw JSON in the shape Slack delivers +// (i.e. authored from the JSON in, not built via the SDK constructors — that distinction +// matters, see below). Every case is decoded through the same path inbound events take — +// Blocks.UnmarshalJSON, which dispatches to the concrete block type — and the test asserts: +// +// 1. it decodes into a recognised concrete block (never UnknownBlock), and +// 2. re-marshalling the decoded block reproduces the input exactly (semantic JSON equality). +// +// (2) is the important part: a field the concrete type fails to model — like the per-cell +// text that went missing in issue #1558 — disappears on the way back out and fails the +// comparison. Constructor-based tests can't catch this, because they only ever serialise +// what the SDK already knows how to represent. +// +// Note this deliberately does NOT use BlockFromJSON: that helper preserves the raw bytes and +// echoes them back unchanged, so it would round-trip any payload perfectly while exercising +// none of the concrete UnmarshalJSON logic this guardrail exists to protect. +// +// To cover a new block, add a case below with a payload in the shape Slack sends. If it +// doesn't round-trip, the SDK is dropping something Slack sent — fix the type, don't trim +// the payload. +func TestBlockRealPayloadRoundTrip(t *testing.T) { + tests := []struct { + name string + payload string + }{ + { + // A table pasted from a spreadsheet (issue #1558): a bold rich_text header + // row, raw_text/raw_number data cells, and a null cell for an empty space. + name: "table with mixed cell types and an empty cell", + payload: `{ + "type": "table", + "block_id": "tbl_pasted", + "rows": [ + [ + {"type": "rich_text", "elements": [{"type": "rich_text_section", "elements": [{"type": "text", "text": "Name", "style": {"bold": true}}]}]}, + {"type": "rich_text", "elements": [{"type": "rich_text_section", "elements": [{"type": "text", "text": "Score", "style": {"bold": true}}]}]} + ], + [ + {"type": "raw_text", "text": "Alice"}, + {"type": "raw_number", "value": 42} + ], + [ + {"type": "raw_text", "text": "Bob"}, + null + ] + ] + }`, + }, + { + name: "data_table with raw_text, raw_number and rich_text cells", + payload: `{ + "type": "data_table", + "block_id": "dt-1", + "caption": "A Fabulous Table", + "page_size": 5, + "rows": [ + [ + {"type": "raw_text", "text": "Name"}, + {"type": "raw_text", "text": "Department"}, + {"type": "raw_text", "text": "Badge"} + ], + [ + {"type": "raw_text", "text": "Helly"}, + {"type": "raw_text", "text": "MDR"}, + {"type": "rich_text", "elements": [{"type": "rich_text_section", "elements": [{"type": "text", "text": "Blue", "style": {"bold": true}}]}]} + ], + [ + {"type": "raw_text", "text": "Score"}, + {"type": "raw_text", "text": "Wellness"}, + {"type": "raw_number", "value": 97} + ] + ] + }`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Slack delivers blocks as a JSON array; Blocks.UnmarshalJSON is what + // dispatches each element to its concrete type. + var blocks Blocks + require.NoError(t, json.Unmarshal([]byte("["+tt.payload+"]"), &blocks), "payload failed to decode") + require.Len(t, blocks.BlockSet, 1) + + block := blocks.BlockSet[0] + _, isUnknown := block.(*UnknownBlock) + assert.False(t, isUnknown, + "payload decoded to an UnknownBlock; the block type is not modelled") + + marshalled, err := json.Marshal(block) + require.NoError(t, err) + + var want, got interface{} + require.NoError(t, json.Unmarshal([]byte(tt.payload), &want)) + require.NoError(t, json.Unmarshal(marshalled, &got)) + assert.Equal(t, want, got, + "round-trip lost or altered data: the concrete block type is not preserving everything Slack sent") + }) + } +} diff --git a/block_table.go b/block_table.go index 5c7b0f13a..1eaf092e5 100644 --- a/block_table.go +++ b/block_table.go @@ -1,13 +1,118 @@ package slack +import ( + "encoding/json" + "fmt" +) + +// TableCellType identifies the variant of a cell inside a TableBlock row. +type TableCellType string + +const ( + TableCellRawText TableCellType = "raw_text" + TableCellRawNumber TableCellType = "raw_number" + TableCellRichText TableCellType = "rich_text" +) + +// TableCell is implemented by every cell type valid inside a TableBlock row: +// TableRichTextCell, TableRawTextCell, and TableRawNumberCell. A nil TableCell +// represents an empty cell, which Slack sends as null (common in user-pasted tables). +type TableCell interface { + TableCellType() TableCellType +} + +// TableRichTextCell is a cell holding rich text formatting. Slack uses rich_text cells +// for styled content such as the bold header row of a pasted table. +type TableRichTextCell struct { + Type TableCellType `json:"type"` + Elements []RichTextElement `json:"elements"` +} + +// TableCellType returns the cell variant. +func (c TableRichTextCell) TableCellType() TableCellType { + return c.Type +} + +// NewTableRichTextCell returns a rich_text cell with the given rich text elements. +func NewTableRichTextCell(elements ...RichTextElement) *TableRichTextCell { + return &TableRichTextCell{Type: TableCellRichText, Elements: elements} +} + +// UnmarshalJSON delegates rich text element parsing to RichTextBlock so the cell handles +// the same set of inner elements (sections, lists, quotes, preformatted, unknown). +func (c *TableRichTextCell) UnmarshalJSON(data []byte) error { + var rt RichTextBlock + if err := json.Unmarshal(data, &rt); err != nil { + return err + } + c.Type = TableCellRichText + c.Elements = rt.Elements + return nil +} + +// TableRawTextCell is a plain-text cell in a TableBlock. Slack sends raw_text cells for +// non-numeric data, including text pasted from a spreadsheet. +type TableRawTextCell struct { + Type TableCellType `json:"type"` + Text string `json:"text"` +} + +// TableCellType returns the cell variant. +func (c TableRawTextCell) TableCellType() TableCellType { + return c.Type +} + +// NewTableRawTextCell returns a raw_text cell with the given text. +func NewTableRawTextCell(text string) *TableRawTextCell { + return &TableRawTextCell{Type: TableCellRawText, Text: text} +} + +// TableRawNumberCell is a numeric cell in a TableBlock. Text, when set, overrides the +// displayed value. +// +// raw_number cells are receive-only: Slack emits them for numeric columns in user-pasted +// tables, but chat.postMessage rejects them. To post a number, use a TableRawTextCell. See +// TableBlock for the full list of postable vs receive-only cell types. +type TableRawNumberCell struct { + Type TableCellType `json:"type"` + Value float64 `json:"value"` + Text string `json:"text,omitempty"` +} + +// TableCellType returns the cell variant. +func (c TableRawNumberCell) TableCellType() TableCellType { + return c.Type +} + +// NewTableRawNumberCell returns a raw_number cell with the given value. +func NewTableRawNumberCell(value float64) *TableRawNumberCell { + return &TableRawNumberCell{Type: TableCellRawNumber, Value: value} +} + +// WithText sets the display text shown in place of the numeric value. +func (c *TableRawNumberCell) WithText(text string) *TableRawNumberCell { + c.Text = text + return c +} + // TableBlock defines a block that lets you use a table to display your data. // +// Rows is an array of cell arrays. Each cell is a TableRichTextCell, TableRawTextCell, +// or TableRawNumberCell; a nil cell represents an empty cell (Slack sends null). +// +// Not every cell type can be posted. chat.postMessage validates a table cell against two +// schemas only: raw_text (TableRawTextCell, requires "text") and rich_text +// (TableRichTextCell, requires "elements"). TableRawNumberCell and nil cells are produced +// by Slack on output — numeric columns and empty cells in user-pasted tables — but the API +// rejects them on input with invalid_blocks. So you may receive all cell types, but post +// only raw_text and rich_text; render a number you want to send as a raw_text cell. +// // More Information: https://docs.slack.dev/reference/block-kit/blocks/table-block/ type TableBlock struct { - Type MessageBlockType `json:"type"` - BlockID string `json:"block_id,omitempty"` - Rows [][]*RichTextBlock `json:"rows"` - ColumnSettings []ColumnSetting `json:"column_settings,omitempty"` + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + Rows [][]TableCell `json:"rows"` + ColumnSettings []ColumnSetting `json:"column_settings,omitempty"` } type ColumnAlignment string @@ -33,6 +138,59 @@ func (s TableBlock) ID() string { return s.BlockID } +// UnmarshalJSON parses the heterogeneous cell types in each row. A null cell is decoded +// as a nil TableCell. +func (s *TableBlock) UnmarshalJSON(data []byte) error { + var raw struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id"` + ColumnSettings []ColumnSetting `json:"column_settings"` + Rows [][]json.RawMessage `json:"rows"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + rows := make([][]TableCell, 0, len(raw.Rows)) + for _, rawRow := range raw.Rows { + row := make([]TableCell, 0, len(rawRow)) + for _, rawCell := range rawRow { + if len(rawCell) == 0 || string(rawCell) == "null" { + row = append(row, nil) + continue + } + var probe struct { + Type TableCellType `json:"type"` + } + if err := json.Unmarshal(rawCell, &probe); err != nil { + return err + } + var cell TableCell + switch probe.Type { + case TableCellRawText: + cell = &TableRawTextCell{} + case TableCellRawNumber: + cell = &TableRawNumberCell{} + case TableCellRichText: + cell = &TableRichTextCell{} + default: + return fmt.Errorf("unsupported table cell type %q", probe.Type) + } + if err := json.Unmarshal(rawCell, cell); err != nil { + return err + } + row = append(row, cell) + } + rows = append(rows, row) + } + + s.Type = raw.Type + s.BlockID = raw.BlockID + s.ColumnSettings = raw.ColumnSettings + s.Rows = rows + return nil +} + // WithColumnSettings sets the column settings for the Table Block func (s *TableBlock) WithColumnSettings(columnSettings ...ColumnSetting) *TableBlock { s.ColumnSettings = columnSettings @@ -40,8 +198,8 @@ func (s *TableBlock) WithColumnSettings(columnSettings ...ColumnSetting) *TableB } // AddRow adds a new row of cells to the Table Block -func (s *TableBlock) AddRow(cells ...*RichTextBlock) *TableBlock { - s.Rows = append(s.Rows, append([]*RichTextBlock{}, cells...)) +func (s *TableBlock) AddRow(cells ...TableCell) *TableBlock { + s.Rows = append(s.Rows, append([]TableCell{}, cells...)) return s } @@ -50,6 +208,6 @@ func NewTableBlock(blockID string) *TableBlock { return &TableBlock{ Type: MBTTable, BlockID: blockID, - Rows: make([][]*RichTextBlock, 0), + Rows: make([][]TableCell, 0), } } diff --git a/block_table_test.go b/block_table_test.go index f74360ca1..935ec7e5c 100644 --- a/block_table_test.go +++ b/block_table_test.go @@ -73,49 +73,37 @@ func TestNewTableBlock(t *testing.T) { tableBlock := NewTableBlock("test1") - tableBlock.AddRow(&RichTextBlock{ - Type: MBTRichText, - Elements: []RichTextElement{ - &RichTextSection{ - Type: RTESection, - Elements: []RichTextSectionElement{ - &RichTextSectionTextElement{Type: RTSEText, Text: "Col1"}, - }, + tableBlock.AddRow(NewTableRichTextCell( + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "Col1"}, }, }, - }, &RichTextBlock{ - Type: MBTRichText, - Elements: []RichTextElement{ - &RichTextSection{ - Type: RTESection, - Elements: []RichTextSectionElement{ - &RichTextSectionTextElement{Type: RTSEText, Text: "Col2"}, - }, + ), NewTableRichTextCell( + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "Col2"}, }, }, - }) - - tableBlock.AddRow(&RichTextBlock{ - Type: MBTRichText, - Elements: []RichTextElement{ - &RichTextSection{ - Type: RTESection, - Elements: []RichTextSectionElement{ - &RichTextSectionTextElement{Type: RTSEText, Text: "Val1"}, - }, + )) + + tableBlock.AddRow(NewTableRichTextCell( + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "Val1"}, }, }, - }, &RichTextBlock{ - Type: MBTRichText, - Elements: []RichTextElement{ - &RichTextSection{ - Type: RTESection, - Elements: []RichTextSectionElement{ - &RichTextSectionTextElement{Type: RTSEText, Text: "Val2"}, - }, + ), NewTableRichTextCell( + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "Val2"}, }, }, - }) + )) assert.Equal(t, tableBlock.BlockType(), MBTTable) assert.Equal(t, string(tableBlock.Type), "table") @@ -136,3 +124,68 @@ func TestNewTableBlock(t *testing.T) { assert.Equal(t, expected, actual) } + +// TestTableBlockHeterogeneousCells covers the case reported in issue #1558: tables +// pasted from a spreadsheet deliver header cells as rich_text and data cells as +// raw_text/raw_number, with null for empty cells. All cell text must be preserved +// across an unmarshal/marshal round trip. +func TestTableBlockHeterogeneousCells(t *testing.T) { + payload := `{ + "type":"table", + "block_id":"t1", + "rows":[ + [ + {"type":"rich_text","elements":[{"type":"rich_text_section","elements":[{"type":"text","text":"Name","style":{"bold":true}}]}]}, + {"type":"rich_text","elements":[{"type":"rich_text_section","elements":[{"type":"text","text":"Score","style":{"bold":true}}]}]} + ], + [ + {"type":"raw_text","text":"Alice"}, + {"type":"raw_number","value":42} + ], + [ + {"type":"raw_text","text":"Bob"}, + null + ] + ] + }` + + var tb TableBlock + err := json.Unmarshal([]byte(payload), &tb) + assert.NoError(t, err) + assert.Equal(t, MBTTable, tb.BlockType()) + assert.Len(t, tb.Rows, 3) + + // Header row: rich_text cells. + header, ok := tb.Rows[0][0].(*TableRichTextCell) + assert.True(t, ok) + assert.Equal(t, TableCellRichText, header.TableCellType()) + assert.Len(t, header.Elements, 1) + + // Data row: raw_text + raw_number must keep their values. + rawText, ok := tb.Rows[1][0].(*TableRawTextCell) + assert.True(t, ok) + assert.Equal(t, "Alice", rawText.Text) + + rawNumber, ok := tb.Rows[1][1].(*TableRawNumberCell) + assert.True(t, ok) + assert.Equal(t, float64(42), rawNumber.Value) + + // Empty cell decodes as nil. + assert.Nil(t, tb.Rows[2][1]) + + // Round trip preserves the payload. + marshalled, err := json.Marshal(&tb) + assert.NoError(t, err) + + var expected, actual map[string]interface{} + assert.NoError(t, json.Unmarshal([]byte(payload), &expected)) + assert.NoError(t, json.Unmarshal(marshalled, &actual)) + assert.Equal(t, expected, actual) +} + +func TestTableBlockUnsupportedCellType(t *testing.T) { + payload := `{"type":"table","rows":[[{"type":"bogus","text":"x"}]]}` + var tb TableBlock + err := json.Unmarshal([]byte(payload), &tb) + assert.Error(t, err) +} From f4bffa826f6651c3c240f905ce0e7cbc1df26100 Mon Sep 17 00:00:00 2001 From: Md Mushfiqur Rahim <20mahin2020@gmail.com> Date: Thu, 4 Jun 2026 13:32:34 -0700 Subject: [PATCH 578/606] refactor: replace interface{} with any (Go 1.18+) (#1559) Replace all occurrences of with (available since Go 1.18) for modern Go idiom. Fixes 34 files found via code search. --------- Co-authored-by: Norberto Lopes --- .golangci.yml | 1 + attachments_test.go | 4 +- audit.go | 16 ++--- block_alert_test.go | 2 +- block_card_test.go | 2 +- block_carousel_test.go | 2 +- block_data_table_test.go | 2 +- block_json_test.go | 12 ++-- block_object.go | 4 +- block_plan_test.go | 2 +- block_roundtrip_test.go | 2 +- block_table_test.go | 4 +- block_task_card_test.go | 2 +- chat_test.go | 12 ++-- dialog.go | 2 +- entity.go | 8 +-- entity_test.go | 6 +- examples/socketmode/socketmode.go | 4 +- .../socketmode_handler/socketmode_handler.go | 4 +- files_test.go | 6 +- interactions.go | 4 +- logger.go | 20 +++--- metadata.go | 10 +-- slackevents/inner_events.go | 62 +++++++++---------- slackevents/outer_events.go | 4 +- slackevents/parsers.go | 2 +- slash.go | 2 +- socket_mode.go | 4 +- socketmode/event.go | 2 +- socketmode/log.go | 16 ++--- socketmode/response.go | 4 +- socketmode/socket_mode_managed_conn.go | 8 +-- socketmode/socketmode_test.go | 4 +- team.go | 10 +-- websocket_managed_conn.go | 4 +- websocket_misc.go | 2 +- 36 files changed, 128 insertions(+), 127 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 0ef0f87ec..7a760ad4b 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -8,6 +8,7 @@ linters: - gocritic - govet - misspell + - modernize - unconvert - unused exclusions: diff --git a/attachments_test.go b/attachments_test.go index 4b951f110..68e0cdbf7 100644 --- a/attachments_test.go +++ b/attachments_test.go @@ -58,8 +58,8 @@ func TestAttachment_UnmarshalMarshalJSON_WithBlocks(t *testing.T) { } var ( - actual interface{} - expected interface{} + actual any + expected any ) if err = json.Unmarshal([]byte(originalAttachmentJson), &expected); err != nil { t.Fatal(err) diff --git a/audit.go b/audit.go index 135a68d78..f258a412d 100644 --- a/audit.go +++ b/audit.go @@ -40,14 +40,14 @@ type AuditEntry struct { IPAddress string `json:"ip_address"` } `json:"context"` Details struct { - NewValue interface{} `json:"new_value"` - PreviousValue interface{} `json:"previous_value"` - MobileOnly bool `json:"mobile_only"` - WebOnly bool `json:"web_only"` - NonSSOOnly bool `json:"non_sso_only"` - ExportType string `json:"export_type"` - ExportStart string `json:"export_start_ts"` - ExportEnd string `json:"export_end_ts"` + NewValue any `json:"new_value"` + PreviousValue any `json:"previous_value"` + MobileOnly bool `json:"mobile_only"` + WebOnly bool `json:"web_only"` + NonSSOOnly bool `json:"non_sso_only"` + ExportType string `json:"export_type"` + ExportStart string `json:"export_start_ts"` + ExportEnd string `json:"export_end_ts"` } `json:"details"` } diff --git a/block_alert_test.go b/block_alert_test.go index 7d86b4b84..c5673c2a1 100644 --- a/block_alert_test.go +++ b/block_alert_test.go @@ -53,7 +53,7 @@ func TestAlertBlockJSONRoundTrip(t *testing.T) { marshalled, err := json.Marshal(block) require.NoError(t, err) - var expected, actual map[string]interface{} + var expected, actual map[string]any require.NoError(t, json.Unmarshal([]byte(payload), &expected)) require.NoError(t, json.Unmarshal(marshalled, &actual)) diff --git a/block_card_test.go b/block_card_test.go index 4b2c0fa89..de5798670 100644 --- a/block_card_test.go +++ b/block_card_test.go @@ -89,7 +89,7 @@ func TestCardBlockJSONRoundTrip(t *testing.T) { marshalled, err := json.Marshal(block) require.NoError(t, err) - var expected, actual map[string]interface{} + var expected, actual map[string]any require.NoError(t, json.Unmarshal([]byte(payload), &expected)) require.NoError(t, json.Unmarshal(marshalled, &actual)) diff --git a/block_carousel_test.go b/block_carousel_test.go index 2813b3e00..ab624e000 100644 --- a/block_carousel_test.go +++ b/block_carousel_test.go @@ -58,7 +58,7 @@ func TestCarouselBlockJSONRoundTrip(t *testing.T) { marshalled, err := json.Marshal(block) require.NoError(t, err) - var expected, actual map[string]interface{} + var expected, actual map[string]any require.NoError(t, json.Unmarshal([]byte(payload), &expected)) require.NoError(t, json.Unmarshal(marshalled, &actual)) diff --git a/block_data_table_test.go b/block_data_table_test.go index 0df288143..f6dea87f6 100644 --- a/block_data_table_test.go +++ b/block_data_table_test.go @@ -130,7 +130,7 @@ func TestDataTableBlockJSONRoundTrip(t *testing.T) { marshalled, err := json.Marshal(block) require.NoError(t, err) - var expected, actual map[string]interface{} + var expected, actual map[string]any require.NoError(t, json.Unmarshal([]byte(payload), &expected)) require.NoError(t, json.Unmarshal(marshalled, &actual)) assert.Equal(t, expected, actual) diff --git a/block_json_test.go b/block_json_test.go index a99aaf57a..1d6efdeaf 100644 --- a/block_json_test.go +++ b/block_json_test.go @@ -108,7 +108,7 @@ func TestRawJSONBlockRoundTrip(t *testing.T) { assert.NoError(t, err) // Unmarshal both to compare (ignoring whitespace differences) - var original, result map[string]interface{} + var original, result map[string]any assert.NoError(t, json.Unmarshal([]byte(originalJSON), &original)) assert.NoError(t, json.Unmarshal(marshalled, &result)) @@ -153,14 +153,14 @@ func TestRawJSONBlockRoundTrip(t *testing.T) { assert.NoError(t, err) // Unmarshal both to compare - var original, result map[string]interface{} + var original, result map[string]any assert.NoError(t, json.Unmarshal([]byte(originalJSON), &original)) assert.NoError(t, json.Unmarshal(marshalled, &result)) assert.Equal(t, original, result, "Complex block should preserve all nested fields") // Specifically verify elements array is preserved - resultElements, ok := result["elements"].([]interface{}) + resultElements, ok := result["elements"].([]any) assert.True(t, ok, "elements should be an array") assert.Equal(t, 2, len(resultElements), "should have 2 elements") }) @@ -211,19 +211,19 @@ func TestRawJSONBlockInDeepStructure(t *testing.T) { assert.Equal(t, 3, len(result.Blocks), "should have 3 blocks") // Verify the middle block (our RawJSONBlock) has all fields preserved - var contextActionsBlock map[string]interface{} + var contextActionsBlock map[string]any assert.NoError(t, json.Unmarshal(result.Blocks[1], &contextActionsBlock)) assert.Equal(t, "context_actions", contextActionsBlock["type"]) assert.Equal(t, "feedback_block", contextActionsBlock["block_id"]) // Verify elements array is present and intact - elements, ok := contextActionsBlock["elements"].([]interface{}) + elements, ok := contextActionsBlock["elements"].([]any) assert.True(t, ok, "elements should be an array") assert.Equal(t, 1, len(elements), "should have 1 element") // Verify the nested feedback_buttons element - firstElement, ok := elements[0].(map[string]interface{}) + firstElement, ok := elements[0].(map[string]any) assert.True(t, ok, "first element should be an object") assert.Equal(t, "feedback_buttons", firstElement["type"]) assert.Equal(t, "ai_feedback", firstElement["action_id"]) diff --git a/block_object.go b/block_object.go index c1b64cc4c..29fd7f9ea 100644 --- a/block_object.go +++ b/block_object.go @@ -46,7 +46,7 @@ func (b *BlockObjects) UnmarshalJSON(data []byte) error { } for _, r := range raw { - var obj map[string]interface{} + var obj map[string]any err := json.Unmarshal(r, &obj) if err != nil { return err @@ -89,7 +89,7 @@ func (b *BlockObjects) UnmarshalJSON(data []byte) error { // Ideally would have a better way to identify the block objects for // type casting at time of unmarshalling, should be adapted if possible // to accomplish in a more reliable manner. -func getBlockObjectType(obj map[string]interface{}) string { +func getBlockObjectType(obj map[string]any) string { if t, ok := obj["type"].(string); ok { return t } diff --git a/block_plan_test.go b/block_plan_test.go index 1871daf1d..74776839a 100644 --- a/block_plan_test.go +++ b/block_plan_test.go @@ -90,7 +90,7 @@ func TestPlanBlockJSONRoundTrip(t *testing.T) { marshalled, err := json.Marshal(block) require.NoError(t, err) - var expected, actual map[string]interface{} + var expected, actual map[string]any err = json.Unmarshal([]byte(payload), &expected) require.NoError(t, err) err = json.Unmarshal(marshalled, &actual) diff --git a/block_roundtrip_test.go b/block_roundtrip_test.go index cb5e969fc..e070c5d22 100644 --- a/block_roundtrip_test.go +++ b/block_roundtrip_test.go @@ -103,7 +103,7 @@ func TestBlockRealPayloadRoundTrip(t *testing.T) { marshalled, err := json.Marshal(block) require.NoError(t, err) - var want, got interface{} + var want, got any require.NoError(t, json.Unmarshal([]byte(tt.payload), &want)) require.NoError(t, json.Unmarshal(marshalled, &got)) assert.Equal(t, want, got, diff --git a/block_table_test.go b/block_table_test.go index 935ec7e5c..fbd36098a 100644 --- a/block_table_test.go +++ b/block_table_test.go @@ -116,7 +116,7 @@ func TestNewTableBlock(t *testing.T) { marshalled, err := json.Marshal(tableBlock) assert.NoError(t, err) - var expected, actual map[string]interface{} + var expected, actual map[string]any err = json.Unmarshal([]byte(testPayload), &expected) assert.NoError(t, err) err = json.Unmarshal(marshalled, &actual) @@ -177,7 +177,7 @@ func TestTableBlockHeterogeneousCells(t *testing.T) { marshalled, err := json.Marshal(&tb) assert.NoError(t, err) - var expected, actual map[string]interface{} + var expected, actual map[string]any assert.NoError(t, json.Unmarshal([]byte(payload), &expected)) assert.NoError(t, json.Unmarshal(marshalled, &actual)) assert.Equal(t, expected, actual) diff --git a/block_task_card_test.go b/block_task_card_test.go index 656da826c..0e337c626 100644 --- a/block_task_card_test.go +++ b/block_task_card_test.go @@ -122,7 +122,7 @@ func TestTaskCardBlockJSONRoundTrip(t *testing.T) { marshalled, err := json.Marshal(block) require.NoError(t, err) - var expected, actual map[string]interface{} + var expected, actual map[string]any err = json.Unmarshal([]byte(payload), &expected) require.NoError(t, err) err = json.Unmarshal(marshalled, &actual) diff --git a/chat_test.go b/chat_test.go index defdd97af..c1218bdea 100644 --- a/chat_test.go +++ b/chat_test.go @@ -151,7 +151,7 @@ func TestPostMessage(t *testing.T) { MsgOptionMetadata( SlackMetadata{ EventType: "testing-event", - EventPayload: map[string]interface{}{ + EventPayload: map[string]any{ "id": 13, "name": "testing-name", }, @@ -255,7 +255,7 @@ func TestPostMessage(t *testing.T) { URL: "https://example.com/doc/1", ExternalRef: WorkObjectExternalRef{ID: "1"}, EntityType: EntityTypeFile, - EntityPayload: map[string]interface{}{"title": "Doc"}, + EntityPayload: map[string]any{"title": "Doc"}, }}, }), }, @@ -294,7 +294,7 @@ func TestPostMessage(t *testing.T) { MsgOptionPostMessageParameters(PostMessageParameters{ MetaData: SlackMetadata{ EventType: "testing-event", - EventPayload: map[string]interface{}{ + EventPayload: map[string]any{ "id": 13, "name": "testing-name", }, @@ -612,7 +612,7 @@ func TestWorkObjectMetadata(t *testing.T) { Type: "document", }, EntityType: "slack#/entities/file", - EntityPayload: map[string]interface{}{ + EntityPayload: map[string]any{ "title": "Test Document", "description": "A test document for Work Objects", }, @@ -661,7 +661,7 @@ func TestMsgOptionWorkObjectMetadata(t *testing.T) { ID: "456", }, EntityType: "slack#/entities/task", - EntityPayload: map[string]interface{}{ + EntityPayload: map[string]any{ "title": "Test Task", "status": "in_progress", }, @@ -724,7 +724,7 @@ func TestMsgOptionWorkObjectEntity(t *testing.T) { Type: "incident", }, EntityType: "slack#/entities/incident", - EntityPayload: map[string]interface{}{ + EntityPayload: map[string]any{ "title": "Production Outage", "severity": "high", }, diff --git a/dialog.go b/dialog.go index 4c507fcdf..e97fc2a02 100644 --- a/dialog.go +++ b/dialog.go @@ -47,7 +47,7 @@ type Dialog struct { } // DialogElement abstract type for dialogs. -type DialogElement interface{} +type DialogElement any // DialogCallback DEPRECATED use InteractionCallback type DialogCallback InteractionCallback diff --git a/entity.go b/entity.go index 815de4d75..592ee158a 100644 --- a/entity.go +++ b/entity.go @@ -18,10 +18,10 @@ type EntityPresentDetailsParameters struct { // EntityDetailsMetadata represents the metadata for entity details type EntityDetailsMetadata struct { - EntityType string `json:"entity_type"` - URL string `json:"url,omitempty"` - ExternalRef WorkObjectExternalRef `json:"external_ref,omitempty"` - EntityPayload map[string]interface{} `json:"entity_payload"` + EntityType string `json:"entity_type"` + URL string `json:"url,omitempty"` + ExternalRef WorkObjectExternalRef `json:"external_ref,omitzero"` + EntityPayload map[string]any `json:"entity_payload"` } // EntityDetailsError represents an error response for entity details diff --git a/entity_test.go b/entity_test.go index dbc772fe4..abeaba57e 100644 --- a/entity_test.go +++ b/entity_test.go @@ -17,7 +17,7 @@ func TestEntityPresentDetailsParameters(t *testing.T) { ID: "123", Type: "document", }, - EntityPayload: map[string]interface{}{ + EntityPayload: map[string]any{ "title": "Test Document", "description": "A test document for Work Objects", "status": "active", @@ -166,7 +166,7 @@ func TestEntityPresentDetailsWithMetadata(t *testing.T) { ID: "123", Type: "document", }, - EntityPayload: map[string]interface{}{ + EntityPayload: map[string]any{ "title": "Test Document", "description": "A test document for Work Objects", }, @@ -308,7 +308,7 @@ func TestEntityPresentDetailsContext(t *testing.T) { ExternalRef: WorkObjectExternalRef{ ID: "456", }, - EntityPayload: map[string]interface{}{ + EntityPayload: map[string]any{ "title": "Test Task", "status": "in_progress", }, diff --git a/examples/socketmode/socketmode.go b/examples/socketmode/socketmode.go index ee79bbdcc..c2f7ae6ba 100644 --- a/examples/socketmode/socketmode.go +++ b/examples/socketmode/socketmode.go @@ -122,7 +122,7 @@ func main() { fmt.Printf("Interaction received: %+v\n", callback) - var payload interface{} + var payload any switch callback.Type { case slack.InteractionTypeBlockActions: @@ -148,7 +148,7 @@ func main() { client.Debugf("Slash command received: %+v", cmd) - payload := map[string]interface{}{ + payload := map[string]any{ "blocks": []slack.Block{ slack.NewSectionBlock( &slack.TextBlockObject{ diff --git a/examples/socketmode_handler/socketmode_handler.go b/examples/socketmode_handler/socketmode_handler.go index cb2b70606..152eda7e4 100644 --- a/examples/socketmode_handler/socketmode_handler.go +++ b/examples/socketmode_handler/socketmode_handler.go @@ -174,7 +174,7 @@ func middlewareInteractive(evt *socketmode.Event, client *socketmode.Client) { fmt.Printf("Interaction received: %+v\n", callback) - var payload interface{} + var payload any switch callback.Type { case slack.InteractionTypeBlockActions: @@ -204,7 +204,7 @@ func middlewareSlashCommand(evt *socketmode.Event, client *socketmode.Client) { client.Debugf("Slash command received: %+v", cmd) - payload := map[string]interface{}{ + payload := map[string]any{ "blocks": []slack.Block{ slack.NewSectionBlock( &slack.TextBlockObject{ diff --git a/files_test.go b/files_test.go index 2c4ccbbe1..798aea2a5 100644 --- a/files_test.go +++ b/files_test.go @@ -461,9 +461,9 @@ func TestCompleteUploadExternalContext(t *testing.T) { for _, tc := range testCases { t.Run(tc.title, func(t *testing.T) { - var resBody map[string]interface{} + var resBody map[string]any if !tc.wantErr { - resBody = map[string]interface{}{ + resBody = map[string]any{ "ok": true, } files := make([]map[string]string, 0) @@ -478,7 +478,7 @@ func TestCompleteUploadExternalContext(t *testing.T) { } resBody["files"] = files } else { - resBody = map[string]interface{}{ + resBody = map[string]any{ "ok": false, "error": "errored", } diff --git a/interactions.go b/interactions.go index f658a72c2..6170e1fdd 100644 --- a/interactions.go +++ b/interactions.go @@ -171,7 +171,7 @@ func (a ActionCallbacks) MarshalJSON() ([]byte, error) { length := len(a.AttachmentActions) + len(a.BlockActions) buffer := bytes.NewBufferString("[") - f := func(obj interface{}) error { + f := func(obj any) error { js, err := json.Marshal(obj) if err != nil { return err @@ -215,7 +215,7 @@ func (a *ActionCallbacks) UnmarshalJSON(data []byte) error { } for _, r := range raw { - var obj map[string]interface{} + var obj map[string]any err := json.Unmarshal(r, &obj) if err != nil { return err diff --git a/logger.go b/logger.go index 90cb3caab..ba2f78cae 100644 --- a/logger.go +++ b/logger.go @@ -13,18 +13,18 @@ type logger interface { // ilogger represents the internal logging api we use. type ilogger interface { logger - Print(...interface{}) - Printf(string, ...interface{}) - Println(...interface{}) + Print(...any) + Printf(string, ...any) + Println(...any) } type Debug interface { Debug() bool // Debugf print a formatted debug line. - Debugf(format string, v ...interface{}) + Debugf(format string, v ...any) // Debugln print a debug line. - Debugln(v ...interface{}) + Debugln(v ...any) } // internalLog implements the additional methods used by our internal logging. @@ -33,17 +33,17 @@ type internalLog struct { } // Println replicates the behaviour of the standard logger. -func (t internalLog) Println(v ...interface{}) { +func (t internalLog) Println(v ...any) { t.Output(2, fmt.Sprintln(v...)) } // Printf replicates the behaviour of the standard logger. -func (t internalLog) Printf(format string, v ...interface{}) { +func (t internalLog) Printf(format string, v ...any) { t.Output(2, fmt.Sprintf(format, v...)) } // Print replicates the behaviour of the standard logger. -func (t internalLog) Print(v ...interface{}) { +func (t internalLog) Print(v ...any) { t.Output(2, fmt.Sprint(v...)) } @@ -54,7 +54,7 @@ func (t discard) Debug() bool { } // Debugf print a formatted debug line. -func (t discard) Debugf(format string, v ...interface{}) {} +func (t discard) Debugf(format string, v ...any) {} // Debugln print a debug line. -func (t discard) Debugln(v ...interface{}) {} +func (t discard) Debugln(v ...any) {} diff --git a/metadata.go b/metadata.go index a1e34e990..2ec435335 100644 --- a/metadata.go +++ b/metadata.go @@ -24,11 +24,11 @@ type WorkObjectExternalRef struct { // WorkObjectEntity represents a single Work Object entity type WorkObjectEntity struct { - AppUnfurlURL string `json:"app_unfurl_url,omitempty"` - URL string `json:"url"` - ExternalRef WorkObjectExternalRef `json:"external_ref"` - EntityType string `json:"entity_type"` - EntityPayload map[string]interface{} `json:"entity_payload"` + AppUnfurlURL string `json:"app_unfurl_url,omitempty"` + URL string `json:"url"` + ExternalRef WorkObjectExternalRef `json:"external_ref"` + EntityType string `json:"entity_type"` + EntityPayload map[string]any `json:"entity_payload"` } // WorkObjectMetadata represents the metadata for Work Objects diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 676a005e1..4e2fa7435 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -11,7 +11,7 @@ import ( // EventsAPIInnerEvent the inner event of a EventsAPI event_callback Event. type EventsAPIInnerEvent struct { Type string `json:"type"` - Data interface{} + Data any } // AssistantThreadMessageEvent is an (inner) EventsAPI subscribable event. @@ -954,7 +954,7 @@ type AppRequestedEvent struct { Name string `json:"name"` Domain string `json:"domain"` } `json:"team"` - Enterprise interface{} `json:"enterprise"` + Enterprise any `json:"enterprise"` Scopes []struct { Name string `json:"name"` Description string `json:"description"` @@ -1042,11 +1042,11 @@ type FunctionExecutedEvent struct { DateUpdated int64 `json:"date_updated"` DateDeleted int64 `json:"date_deleted"` } `json:"function"` - Inputs map[string]interface{} `json:"inputs"` - FunctionExecutionID string `json:"function_execution_id"` - WorkflowExecutionID string `json:"workflow_execution_id"` - EventTs string `json:"event_ts"` - BotAccessToken string `json:"bot_access_token"` + Inputs map[string]any `json:"inputs"` + FunctionExecutionID string `json:"function_execution_id"` + WorkflowExecutionID string `json:"workflow_execution_id"` + EventTs string `json:"event_ts"` + BotAccessToken string `json:"bot_access_token"` } type InviteRequestedEvent struct { @@ -1117,29 +1117,29 @@ type User struct { } type Profile struct { - Title string `json:"title"` - Phone string `json:"phone"` - Skype string `json:"skype"` - RealName string `json:"real_name"` - RealNameNormalized string `json:"real_name_normalized"` - DisplayName string `json:"display_name"` - DisplayNameNormalized string `json:"display_name_normalized"` - Fields map[string]interface{} `json:"fields"` - StatusText string `json:"status_text"` - StatusEmoji string `json:"status_emoji"` - StatusEmojiDisplayInfo []interface{} `json:"status_emoji_display_info"` - StatusExpiration int `json:"status_expiration"` - AvatarHash string `json:"avatar_hash"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Image24 string `json:"image_24"` - Image32 string `json:"image_32"` - Image48 string `json:"image_48"` - Image72 string `json:"image_72"` - Image192 string `json:"image_192"` - Image512 string `json:"image_512"` - StatusTextCanonical string `json:"status_text_canonical"` - Team string `json:"team"` + Title string `json:"title"` + Phone string `json:"phone"` + Skype string `json:"skype"` + RealName string `json:"real_name"` + RealNameNormalized string `json:"real_name_normalized"` + DisplayName string `json:"display_name"` + DisplayNameNormalized string `json:"display_name_normalized"` + Fields map[string]any `json:"fields"` + StatusText string `json:"status_text"` + StatusEmoji string `json:"status_emoji"` + StatusEmojiDisplayInfo []any `json:"status_emoji_display_info"` + StatusExpiration int `json:"status_expiration"` + AvatarHash string `json:"avatar_hash"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Image24 string `json:"image_24"` + Image32 string `json:"image_32"` + Image48 string `json:"image_48"` + Image72 string `json:"image_72"` + Image192 string `json:"image_192"` + Image512 string `json:"image_512"` + StatusTextCanonical string `json:"status_text_canonical"` + Team string `json:"team"` } type UserStatusChangedEvent struct { @@ -1395,7 +1395,7 @@ const ( // EventsAPIInnerEventMapping maps INNER Event API events to their corresponding struct // implementations. The structs should be instances of the unmarshalling // target for the matching event type. -var EventsAPIInnerEventMapping = map[EventsAPIType]interface{}{ +var EventsAPIInnerEventMapping = map[EventsAPIType]any{ AppDeleted: AppDeletedEvent{}, AppHomeOpened: AppHomeOpenedEvent{}, AppInstalled: AppInstalledEvent{}, diff --git a/slackevents/outer_events.go b/slackevents/outer_events.go index bcc85f331..5d8238c88 100644 --- a/slackevents/outer_events.go +++ b/slackevents/outer_events.go @@ -13,7 +13,7 @@ type EventsAPIEvent struct { Type string `json:"type"` APIAppID string `json:"api_app_id"` EnterpriseID string `json:"enterprise_id"` - Data interface{} + Data any InnerEvent EventsAPIInnerEvent } @@ -65,7 +65,7 @@ const ( // EventsAPIEventMap maps OUTER Event API events to their corresponding struct // implementations. The structs should be instances of the unmarshalling // target for the matching event type. -var EventsAPIEventMap = map[string]interface{}{ +var EventsAPIEventMap = map[string]any{ CallbackEvent: EventsAPICallbackEvent{}, URLVerification: EventsAPIURLVerificationEvent{}, AppRateLimited: EventsAPIAppRateLimited{}, diff --git a/slackevents/parsers.go b/slackevents/parsers.go index 80308eae7..114eba3e8 100644 --- a/slackevents/parsers.go +++ b/slackevents/parsers.go @@ -13,7 +13,7 @@ import ( // eventsMap checks both slackevents.EventsAPIInnerEventMapping and slack.EventMapping // (RTM). EventsAPI mapping is checked first because both define a "message" type, and // EventsAPI's MessageEvent is the correct choice for Events API payloads. -func eventsMap(t string) (interface{}, bool) { +func eventsMap(t string) (any, bool) { // EventsAPI mapping takes precedence over RTM mapping. v, exists := EventsAPIInnerEventMapping[EventsAPIType(t)] if exists { diff --git a/slash.go b/slash.go index fd46abfc4..6ee9ec10e 100644 --- a/slash.go +++ b/slash.go @@ -65,7 +65,7 @@ func (s *SlashCommand) UnmarshalJSON(data []byte) error { type SlashCommandCopy SlashCommand scopy := &struct { *SlashCommandCopy - IsEnterpriseInstall interface{} `json:"is_enterprise_install"` + IsEnterpriseInstall any `json:"is_enterprise_install"` }{ SlashCommandCopy: (*SlashCommandCopy)(s), } diff --git a/socket_mode.go b/socket_mode.go index 9498cdf73..26276ac16 100644 --- a/socket_mode.go +++ b/socket_mode.go @@ -8,8 +8,8 @@ import ( // SocketModeConnection contains various details about the SocketMode connection. // It is returned by an "apps.connections.open" API call. type SocketModeConnection struct { - URL string `json:"url,omitempty"` - Data map[string]interface{} `json:"-"` + URL string `json:"url,omitempty"` + Data map[string]any `json:"-"` } type openResponseFull struct { diff --git a/socketmode/event.go b/socketmode/event.go index 5ae434a70..e9d75ed4a 100644 --- a/socketmode/event.go +++ b/socketmode/event.go @@ -5,7 +5,7 @@ import "encoding/json" // Event is the event sent to the consumer of Client type Event struct { Type EventType - Data interface{} + Data any // Request is the json-decoded raw WebSocket message that is received via the Slack Socket Mode // WebSocket connection. diff --git a/socketmode/log.go b/socketmode/log.go index 9f3b7f690..6ac134e9b 100644 --- a/socketmode/log.go +++ b/socketmode/log.go @@ -13,9 +13,9 @@ type logger interface { // ilogger represents the internal logging api we use. type ilogger interface { logger - Print(...interface{}) - Printf(string, ...interface{}) - Println(...interface{}) + Print(...any) + Printf(string, ...any) + Println(...any) } // internalLog implements the additional methods used by our internal logging. @@ -24,27 +24,27 @@ type internalLog struct { } // Println replicates the behaviour of the standard logger. -func (t internalLog) Println(v ...interface{}) { +func (t internalLog) Println(v ...any) { t.Output(2, fmt.Sprintln(v...)) } // Printf replicates the behaviour of the standard logger. -func (t internalLog) Printf(format string, v ...interface{}) { +func (t internalLog) Printf(format string, v ...any) { t.Output(2, fmt.Sprintf(format, v...)) } // Print replicates the behaviour of the standard logger. -func (t internalLog) Print(v ...interface{}) { +func (t internalLog) Print(v ...any) { t.Output(2, fmt.Sprint(v...)) } -func (smc *Client) Debugf(format string, v ...interface{}) { +func (smc *Client) Debugf(format string, v ...any) { if smc.debug { smc.log.Output(2, fmt.Sprintf(format, v...)) } } -func (smc *Client) Debugln(v ...interface{}) { +func (smc *Client) Debugln(v ...any) { if smc.debug { smc.log.Output(2, fmt.Sprintln(v...)) } diff --git a/socketmode/response.go b/socketmode/response.go index c1ca625ed..fa7c7924b 100644 --- a/socketmode/response.go +++ b/socketmode/response.go @@ -1,8 +1,8 @@ package socketmode type Response struct { - EnvelopeID string `json:"envelope_id"` - Payload interface{} `json:"payload,omitempty"` + EnvelopeID string `json:"envelope_id"` + Payload any `json:"payload,omitempty"` // rawJSON holds the pre-marshaled JSON bytes when set by SendCtx. // This avoids double-marshaling: once for the size check and once for diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index 125c0dbfe..456dd8e4d 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -448,7 +448,7 @@ func unsafeWriteSocketModeResponse(conn *websocket.Conn, res *Response) error { return conn.WriteJSON(res) } -func newEvent(tpe EventType, data interface{}, req ...*Request) Event { +func newEvent(tpe EventType, data any, req ...*Request) Event { evt := Event{Type: tpe, Data: data} if len(req) > 0 { @@ -466,8 +466,8 @@ func newEvent(tpe EventType, data interface{}, req ...*Request) Event { // Returns an error if the serialized response is 20KB or larger, as Slack // silently drops oversized Socket Mode responses. Use Web API methods (e.g. // chat.PostMessage, views.Push) for large payloads. -func (smc *Client) Ack(req Request, payload ...interface{}) error { - var pld interface{} +func (smc *Client) Ack(req Request, payload ...any) error { + var pld any if len(payload) > 0 { pld = payload[0] } @@ -482,7 +482,7 @@ func (smc *Client) Ack(req Request, payload ...interface{}) error { // // Returns an error if the serialized response is 20KB or larger, as Slack // silently drops oversized Socket Mode responses. -func (smc *Client) AckCtx(ctx context.Context, reqID string, payload interface{}) error { +func (smc *Client) AckCtx(ctx context.Context, reqID string, payload any) error { return smc.SendCtx(ctx, Response{ EnvelopeID: reqID, Payload: payload, diff --git a/socketmode/socketmode_test.go b/socketmode/socketmode_test.go index 3ff5e623c..80c984c53 100644 --- a/socketmode/socketmode_test.go +++ b/socketmode/socketmode_test.go @@ -275,7 +275,7 @@ func TestEventParsing(t *testing.T) { }) } -func testParsing(t *testing.T, raw string, want interface{}) { +func testParsing(t *testing.T, raw string, want any) { t.Helper() got, err := parse(raw) @@ -288,7 +288,7 @@ func testParsing(t *testing.T, raw string, want interface{}) { } } -func dump(t *testing.T, data interface{}) string { +func dump(t *testing.T, data any) string { t.Helper() var buf bytes.Buffer diff --git a/team.go b/team.go index 55364a9b4..610de311b 100644 --- a/team.go +++ b/team.go @@ -12,11 +12,11 @@ type TeamResponse struct { } type TeamInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Domain string `json:"domain"` - EmailDomain string `json:"email_domain"` - Icon map[string]interface{} `json:"icon"` + ID string `json:"id"` + Name string `json:"name"` + Domain string `json:"domain"` + EmailDomain string `json:"email_domain"` + Icon map[string]any `json:"icon"` } type TeamProfileResponse struct { diff --git a/websocket_managed_conn.go b/websocket_managed_conn.go index da861fa87..68bdfeb78 100644 --- a/websocket_managed_conn.go +++ b/websocket_managed_conn.go @@ -321,7 +321,7 @@ func (rtm *RTM) handleIncomingEvents(events chan json.RawMessage) { } } -func (rtm *RTM) sendWithDeadline(msg interface{}) error { +func (rtm *RTM) sendWithDeadline(msg any) error { // set a write deadline on the connection if err := rtm.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)); err != nil { return err @@ -518,7 +518,7 @@ func (rtm *RTM) handleEvent(typeStr string, event json.RawMessage) { // EventMapping holds a mapping of event names to their corresponding struct // implementations. The structs should be instances of the unmarshalling // target for the matching event type. -var EventMapping = map[string]interface{}{ +var EventMapping = map[string]any{ "message": MessageEvent{}, "presence_change": PresenceChangeEvent{}, "user_typing": UserTypingEvent{}, diff --git a/websocket_misc.go b/websocket_misc.go index fb301f5eb..a12980f6b 100644 --- a/websocket_misc.go +++ b/websocket_misc.go @@ -35,7 +35,7 @@ type MessageEvent Message // RTMEvent is the main wrapper. You will find all the other messages attached type RTMEvent struct { Type string - Data interface{} + Data any } // HelloEvent represents the hello event From a7d12104df8dc74a7f2cd43b10929454b1ee89dd Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 5 Jun 2026 12:00:01 +0200 Subject: [PATCH 579/606] chore: fix a bunch of issues detected by golangci-lint (#1561) --- .golangci.yml | 8 +++- apps_test.go | 7 +-- attachments.go | 3 +- chat.go | 7 +-- chat_test.go | 43 +++++++++++++++++++ dialog.go | 8 ++-- dialog_select.go | 14 +++--- entity.go | 2 +- examples/eventsapi/events.go | 2 +- examples/function/function.go | 3 +- examples/parse_action_event/main.go | 3 +- .../socketmode_handler/socketmode_handler.go | 6 +-- files.go | 7 +-- info.go | 4 +- internal/backoff/backoff.go | 19 ++++---- remotefiles.go | 14 +++--- slackevents/inner_events_test.go | 8 ++-- slacktest/rtm_test.go | 12 ++---- slacktest/server_test.go | 6 +-- slash.go | 8 +--- socketmode/socket_mode_managed_conn.go | 17 +++----- socketmode/socket_mode_managed_conn_test.go | 9 ++-- socketmode/socketmode_handler_test.go | 28 ++++++------ websocket_managed_conn.go | 7 +-- 24 files changed, 138 insertions(+), 107 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 7a760ad4b..b26581e2e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -11,6 +11,12 @@ linters: - modernize - unconvert - unused + settings: + modernize: + # omitempty on nested struct fields has no effect, but switching to + # omitzero would change JSON output for a public API. Keep the tags as-is. + disable: + - omitzero exclusions: generated: lax presets: @@ -22,8 +28,6 @@ linters: - third_party$ - builtin$ - examples$ -issues: - new: true formatters: enable: - goimports diff --git a/apps_test.go b/apps_test.go index 6d5f6216c..1a2e23521 100644 --- a/apps_test.go +++ b/apps_test.go @@ -14,11 +14,12 @@ func TestListEventAuthorizations(t *testing.T) { authorizations, err := api.ListEventAuthorizations("1-message-T012345678-DR12345678") - if err != nil { + switch { + case err != nil: t.Errorf("Failed, but should have succeeded") - } else if len(authorizations) != 1 { + case len(authorizations) != 1: t.Errorf("Didn't get 1 authorization") - } else if authorizations[0].UserID != "U123456789" { + case authorizations[0].UserID != "U123456789": t.Errorf("User ID is wrong") } } diff --git a/attachments.go b/attachments.go index be04e9049..9720b99e3 100644 --- a/attachments.go +++ b/attachments.go @@ -47,7 +47,8 @@ type AttachmentActionOptionGroup struct { } // AttachmentActionCallback is sent from Slack when a user clicks a button in an interactive message (aka AttachmentAction) -// DEPRECATED: use InteractionCallback +// +// Deprecated: use InteractionCallback type AttachmentActionCallback InteractionCallback // ConfirmationField are used to ask users to confirm actions diff --git a/chat.go b/chat.go index a58b4e805..ba789c92a 100644 --- a/chat.go +++ b/chat.go @@ -278,12 +278,7 @@ func (api *Client) SendMessageContext(ctx context.Context, channelID string, opt func redactToken(b []byte) []byte { // See https://api.slack.com/authentication/token-types // and https://api.slack.com/authentication/rotation - re, err := regexp.Compile(`(token=x[a-z.]+)-[0-9A-Za-z-]+`) - if err != nil { - // The regular expression above should never result in errors, - // but just in case, do no harm. - return b - } + re := regexp.MustCompile(`(token=x[a-z.]+)-[0-9A-Za-z-]+`) // Keep "token=" and the first element of the token, which identifies its type // (this could be useful for debugging, e.g. when using a wrong token). return re.ReplaceAll(b, []byte("$1-REDACTED")) diff --git a/chat_test.go b/chat_test.go index c1218bdea..b1e449390 100644 --- a/chat_test.go +++ b/chat_test.go @@ -899,3 +899,46 @@ func TestStopStream(t *testing.T) { }) } } + +func TestRedactToken(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "bot token", + input: "token=xoxb-1234567890-0987654321-AbCdEfGhIjKlMnOpQrStUvWx&channel=CXXX", + expected: "token=xoxb-REDACTED&channel=CXXX", + }, + { + name: "user token", + input: "channel=CXXX&token=xoxp-1234567890-1234567890123-1234567890123-abcdef", + expected: "channel=CXXX&token=xoxp-REDACTED", + }, + { + name: "app-level token", + input: "token=xapp-1-A012BCDEF-1234567890123-abcdef0123456789", + expected: "token=xapp-REDACTED", + }, + { + name: "rotated token type with dot is preserved", + input: "token=xoxe.xoxp-1-abc123def456&channel=CXXX", + expected: "token=xoxe.xoxp-REDACTED&channel=CXXX", + }, + { + name: "no token is left untouched", + input: "channel=CXXX&text=hello+world", + expected: "channel=CXXX&text=hello+world", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := string(redactToken([]byte(test.input))) + if actual != test.expected { + t.Errorf("\nexpected: %s\n actual: %s", test.expected, actual) + } + }) + } +} diff --git a/dialog.go b/dialog.go index e97fc2a02..35d4fdfe9 100644 --- a/dialog.go +++ b/dialog.go @@ -30,8 +30,8 @@ type DialogInput struct { // DialogTrigger ... type DialogTrigger struct { - TriggerID string `json:"trigger_id"` //Required. Must respond within 3 seconds. - Dialog Dialog `json:"dialog"` //Required. + TriggerID string `json:"trigger_id"` // Required. Must respond within 3 seconds. + Dialog Dialog `json:"dialog"` // Required. } // Dialog as in Slack dialogs @@ -49,7 +49,9 @@ type Dialog struct { // DialogElement abstract type for dialogs. type DialogElement any -// DialogCallback DEPRECATED use InteractionCallback +// DialogCallback +// +// Deprecated: use InteractionCallback type DialogCallback InteractionCallback // DialogSubmissionCallback is sent from Slack when a user submits a form from within a dialog diff --git a/dialog_select.go b/dialog_select.go index 3d6be989e..95bf9ebcc 100644 --- a/dialog_select.go +++ b/dialog_select.go @@ -19,13 +19,13 @@ const ( // DialogInputSelect dialog support for select boxes. type DialogInputSelect struct { DialogInput - Value string `json:"value,omitempty"` //Optional. - DataSource SelectDataSource `json:"data_source,omitempty"` //Optional. Allowed values: "users", "channels", "conversations", "external". - SelectedOptions []DialogSelectOption `json:"selected_options,omitempty"` //Optional. May hold at most one element, for use with "external" only. - Options []DialogSelectOption `json:"options,omitempty"` //One of options or option_groups is required. - OptionGroups []DialogOptionGroup `json:"option_groups,omitempty"` //Provide up to 100 options. - MinQueryLength int `json:"min_query_length,omitempty"` //Optional. minimum characters before query is sent. - Hint string `json:"hint,omitempty"` //Optional. Additional hint text. + Value string `json:"value,omitempty"` // Optional. + DataSource SelectDataSource `json:"data_source,omitempty"` // Optional. Allowed values: "users", "channels", "conversations", "external". + SelectedOptions []DialogSelectOption `json:"selected_options,omitempty"` // Optional. May hold at most one element, for use with "external" only. + Options []DialogSelectOption `json:"options,omitempty"` // One of options or option_groups is required. + OptionGroups []DialogOptionGroup `json:"option_groups,omitempty"` // Provide up to 100 options. + MinQueryLength int `json:"min_query_length,omitempty"` // Optional. minimum characters before query is sent. + Hint string `json:"hint,omitempty"` // Optional. Additional hint text. } // DialogSelectOption is an option for the user to select from the menu diff --git a/entity.go b/entity.go index 592ee158a..10bfcba33 100644 --- a/entity.go +++ b/entity.go @@ -20,7 +20,7 @@ type EntityPresentDetailsParameters struct { type EntityDetailsMetadata struct { EntityType string `json:"entity_type"` URL string `json:"url,omitempty"` - ExternalRef WorkObjectExternalRef `json:"external_ref,omitzero"` + ExternalRef WorkObjectExternalRef `json:"external_ref,omitempty"` EntityPayload map[string]any `json:"entity_payload"` } diff --git a/examples/eventsapi/events.go b/examples/eventsapi/events.go index 2656caa94..826f8c95f 100644 --- a/examples/eventsapi/events.go +++ b/examples/eventsapi/events.go @@ -46,7 +46,7 @@ func main() { switch eventsAPIEvent.Type { case slackevents.URLVerification: var r *slackevents.ChallengeResponse - err := json.Unmarshal([]byte(body), &r) + err := json.Unmarshal(body, &r) if err != nil { w.WriteHeader(http.StatusInternalServerError) return diff --git a/examples/function/function.go b/examples/function/function.go index f9d45e100..de9643fe7 100644 --- a/examples/function/function.go +++ b/examples/function/function.go @@ -46,8 +46,7 @@ func main() { switch eventsAPIEvent.Type { case slackevents.CallbackEvent: innerEvent := eventsAPIEvent.InnerEvent - switch ev := innerEvent.Data.(type) { - case *slackevents.FunctionExecutedEvent: + if ev, ok := innerEvent.Data.(*slackevents.FunctionExecutedEvent); ok { callbackID := ev.Function.CallbackID if callbackID == "sample_function" { userId := ev.Inputs["user_id"] diff --git a/examples/parse_action_event/main.go b/examples/parse_action_event/main.go index ac20d6f9a..ff298d5d9 100644 --- a/examples/parse_action_event/main.go +++ b/examples/parse_action_event/main.go @@ -101,8 +101,7 @@ func main() { innerEvent := eventsAPIEvent.InnerEvent fmt.Printf("[EVENTS] inner type=%q\n", innerEvent.Type) - switch ev := innerEvent.Data.(type) { - case *slackevents.MessageEvent: + if ev, ok := innerEvent.Data.(*slackevents.MessageEvent); ok { // Ignore bot messages to avoid loops. if ev.BotID != "" { return diff --git a/examples/socketmode_handler/socketmode_handler.go b/examples/socketmode_handler/socketmode_handler.go index 152eda7e4..04105ed5b 100644 --- a/examples/socketmode_handler/socketmode_handler.go +++ b/examples/socketmode_handler/socketmode_handler.go @@ -72,15 +72,13 @@ func main() { socketmodeHandler.Handle(socketmode.EventTypeConnected, middlewareConnected) socketmodeHandler.Handle(socketmode.EventTypeHello, middlewareHello) - //\\ EventTypeEventsAPI //\\ - // Handle all EventsAPI + // EventTypeEventsAPI: handle all EventsAPI socketmodeHandler.Handle(socketmode.EventTypeEventsAPI, middlewareEventsAPI) // Handle a specific event from EventsAPI socketmodeHandler.HandleEvents(slackevents.AppMention, middlewareAppMentionEvent) - //\\ EventTypeInteractive //\\ - // Handle all Interactive Events + // EventTypeInteractive: handle all Interactive Events socketmodeHandler.Handle(socketmode.EventTypeInteractive, middlewareInteractive) // Handle a specific Interaction diff --git a/files.go b/files.go index 974ba0a43..a88c9c7d2 100644 --- a/files.go +++ b/files.go @@ -477,12 +477,13 @@ func (api *Client) GetUploadURLExternalContext(ctx context.Context, params GetUp // This is not a Slack API method, but a helper function to upload files to the URL func (api *Client) UploadToURL(ctx context.Context, params UploadToURLParameters) (err error) { values := url.Values{} - if params.Content != "" { + switch { + case params.Content != "": contentReader := strings.NewReader(params.Content) err = postWithMultipartResponse(ctx, api.httpclient, params.UploadURL, params.Filename, "file", api.token, values, contentReader, nil, api) - } else if params.File != "" { + case params.File != "": err = postLocalWithMultipartResponse(ctx, api.httpclient, params.UploadURL, params.File, "file", api.token, values, nil, api) - } else if params.Reader != nil { + case params.Reader != nil: err = postWithMultipartResponse(ctx, api.httpclient, params.UploadURL, params.Filename, "file", api.token, values, params.Reader, nil, api) } return err diff --git a/info.go b/info.go index d5276f757..c2fa99f0e 100644 --- a/info.go +++ b/info.go @@ -340,8 +340,8 @@ func (api *Client) MuteChat(channelID string) (*UserPrefsCarrier, error) { if err != nil { return nil, err } - chnls := strings.Split(prefs.UserPrefs.MutedChannels, ",") - for _, chn := range chnls { + chnls := strings.SplitSeq(prefs.UserPrefs.MutedChannels, ",") + for chn := range chnls { if chn == channelID { return nil, nil // noop } diff --git a/internal/backoff/backoff.go b/internal/backoff/backoff.go index 833e9f2b6..87f22c92d 100644 --- a/internal/backoff/backoff.go +++ b/internal/backoff/backoff.go @@ -7,10 +7,10 @@ import ( // This one was ripped from https://github.com/jpillora/backoff/blob/master/backoff.go -// Backoff is a time.Duration counter. It starts at Min. After every -// call to Duration() it is multiplied by Factor. It is capped at -// Max. It returns to Min on every call to Reset(). Used in -// conjunction with the time package. +// Backoff is a time.Duration counter. It starts at Initial. After every +// call to Duration() it is doubled. It is capped at Max. It returns to +// Initial on every call to Reset(). Used in conjunction with the time +// package. type Backoff struct { attempts int // Initial value to scale out @@ -21,8 +21,9 @@ type Backoff struct { Max time.Duration } -// Returns the current value of the counter and then multiplies it -// Factor +// Duration returns the current value of the counter, then doubles it for the +// next call. Optional jitter is added to the returned value, and the result is +// capped at Max. func (b *Backoff) Duration() (dur time.Duration) { // Zero-values are nonsensical, so we use // them to apply defaults @@ -36,13 +37,13 @@ func (b *Backoff) Duration() (dur time.Duration) { // calculate this duration if dur = time.Duration(1 << uint(b.attempts)); dur > 0 { - dur = dur * b.Initial + dur *= b.Initial } else { dur = b.Max } if b.Jitter > 0 { - dur = dur + time.Duration(rand.Intn(int(b.Jitter))) + dur += time.Duration(rand.Intn(int(b.Jitter))) } // bump attempts count @@ -51,7 +52,7 @@ func (b *Backoff) Duration() (dur time.Duration) { return dur } -// Resets the current value of the counter back to Min +// Reset sets the current value of the counter back to Initial func (b *Backoff) Reset() { b.attempts = 0 } diff --git a/remotefiles.go b/remotefiles.go index 51d3e8565..46e9de5b8 100644 --- a/remotefiles.go +++ b/remotefiles.go @@ -126,15 +126,16 @@ func (api *Client) AddRemoteFileContext(ctx context.Context, params RemoteFilePa if params.IndexableFileContents != "" { values.Add("indexable_file_contents", params.IndexableFileContents) } - if params.PreviewImage != "" { + switch { + case params.PreviewImage != "": err = postLocalWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.add", params.PreviewImage, "preview_image", api.token, values, response, api) - } else if params.PreviewImageReader != nil { + case params.PreviewImageReader != nil: name := params.PreviewImageName if name == "" { name = "preview.png" } err = postWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.add", name, "preview_image", api.token, values, params.PreviewImageReader, response, api) - } else { + default: response, err = api.remoteFileRequest(ctx, "files.remote.add", values) } @@ -275,15 +276,16 @@ func (api *Client) UpdateRemoteFileContext(ctx context.Context, fileID string, p if params.IndexableFileContents != "" { values.Add("indexable_file_contents", params.IndexableFileContents) } - if params.PreviewImage != "" { + switch { + case params.PreviewImage != "": err = postLocalWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.update", params.PreviewImage, "preview_image", api.token, values, response, api) - } else if params.PreviewImageReader != nil { + case params.PreviewImageReader != nil: name := params.PreviewImageName if name == "" { name = "preview.png" } err = postWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.update", name, "preview_image", api.token, values, params.PreviewImageReader, response, api) - } else { + default: values.Add("token", api.token) response, err = api.remoteFileRequest(ctx, "files.remote.update", values) } diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index 28eab34a3..48e100a39 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -957,7 +957,7 @@ func TestMessageMetadataPosted(t *testing.T) { t.Fail() } payload := mmp.Metadata.EventPayload - if len(payload) <= 0 { + if len(payload) == 0 { t.Fail() } if mmp.EventTimestamp != "1660398079.756349" { @@ -1009,7 +1009,7 @@ func TestMessageMetadataUpdated(t *testing.T) { t.Fail() } payload := mmp.PreviousMetadata.EventPayload - if len(payload) <= 0 { + if len(payload) == 0 { t.Fail() } if mmp.AppId != "APPXXX" { @@ -1031,7 +1031,7 @@ func TestMessageMetadataUpdated(t *testing.T) { t.Fail() } payload = mmp.Metadata.EventPayload - if len(payload) <= 0 { + if len(payload) == 0 { t.Fail() } } @@ -1074,7 +1074,7 @@ func TestMessageMetadataDeleted(t *testing.T) { t.Fail() } payload := mmp.PreviousMetadata.EventPayload - if len(payload) <= 0 { + if len(payload) == 0 { t.Fail() } if mmp.AppId != "APPXXX" { diff --git a/slacktest/rtm_test.go b/slacktest/rtm_test.go index 4beab76c3..2174c64d4 100644 --- a/slacktest/rtm_test.go +++ b/slacktest/rtm_test.go @@ -20,8 +20,7 @@ func TestRTMInfo(t *testing.T) { messageChan := make(chan (*slack.ConnectedEvent), 1) go func() { for msg := range rtm.IncomingEvents { - switch ev := msg.Data.(type) { - case *slack.ConnectedEvent: + if ev, ok := msg.Data.(*slack.ConnectedEvent); ok { messageChan <- ev } } @@ -51,8 +50,7 @@ func TestRTMPing(t *testing.T) { messageChan := make(chan (*slack.LatencyReport), 1) go func() { for msg := range rtm.IncomingEvents { - switch ev := msg.Data.(type) { - case *slack.LatencyReport: + if ev, ok := msg.Data.(*slack.LatencyReport); ok { messageChan <- ev } } @@ -79,8 +77,7 @@ func TestRTMDirectMessage(t *testing.T) { messageChan := make(chan (*slack.MessageEvent), 1) go func() { for msg := range rtm.IncomingEvents { - switch ev := msg.Data.(type) { - case *slack.MessageEvent: + if ev, ok := msg.Data.(*slack.MessageEvent); ok { messageChan <- ev } } @@ -108,8 +105,7 @@ func TestRTMChannelMessage(t *testing.T) { messageChan := make(chan (*slack.MessageEvent), 1) go func() { for msg := range rtm.IncomingEvents { - switch ev := msg.Data.(type) { - case *slack.MessageEvent: + if ev, ok := msg.Data.(*slack.MessageEvent); ok { messageChan <- ev } } diff --git a/slacktest/server_test.go b/slacktest/server_test.go index 01a603a46..24582a6fb 100644 --- a/slacktest/server_test.go +++ b/slacktest/server_test.go @@ -114,8 +114,7 @@ func TestSendChannelInvite(t *testing.T) { evChan := make(chan (slack.Channel), 1) go func() { for msg := range rtm.IncomingEvents { - switch ev := msg.Data.(type) { - case *slack.ChannelJoinedEvent: + if ev, ok := msg.Data.(*slack.ChannelJoinedEvent); ok { evChan <- ev.Channel } } @@ -143,8 +142,7 @@ func TestSendGroupInvite(t *testing.T) { evChan := make(chan (slack.Channel), 1) go func() { for msg := range rtm.IncomingEvents { - switch ev := msg.Data.(type) { - case *slack.GroupJoinedEvent: + if ev, ok := msg.Data.(*slack.GroupJoinedEvent); ok { evChan <- ev.Channel } } diff --git a/slash.go b/slash.go index 6ee9ec10e..d13b1a670 100644 --- a/slash.go +++ b/slash.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "slices" "strconv" ) @@ -51,12 +52,7 @@ func SlashCommandParse(r *http.Request) (s SlashCommand, err error) { // ValidateToken validates verificationTokens func (s SlashCommand) ValidateToken(verificationTokens ...string) bool { - for _, token := range verificationTokens { - if s.Token == token { - return true - } - } - return false + return slices.Contains(verificationTokens, s.Token) } // UnmarshalJSON handles is_enterprise_install being either a boolean or a diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index 456dd8e4d..4b77393f5 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -102,27 +102,23 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { } } - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { defer cancel() // The response sender sends Socket Mode responses over the WebSocket conn if err := smc.runResponseSender(ctx, conn); err != nil { sendErr(err) } - }() + }) - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { defer cancel() // The handler reads Socket Mode requests, and enqueues responses for sending by the response sender if err := smc.runRequestHandler(ctx, messages); err != nil { sendErr(err) } - }() + }) go func() { defer cancel() @@ -339,7 +335,8 @@ func (smc *Client) openAndDial(ctx context.Context, additionalPingHandler func(s // We don't need to conn.SetCloseHandler because the default handler is effective enough that // it sends back the CLOSE message to the server and let conn.ReadJSON() fail with CloseError. // The CloseError must be handled normally in our receiveMessagesInto function. - //conn.SetCloseHandler(func(code int, text string) error { + // + // conn.SetCloseHandler(func(code int, text string) error { // ... // }) @@ -354,7 +351,7 @@ func (smc *Client) runResponseSender(ctx context.Context, conn *websocket.Conn) select { case <-ctx.Done(): return ctx.Err() - // 3. listen for messages that need to be sent + // listen for messages that need to be sent case res := <-smc.socketModeResponses: smc.Debugf("Sending Socket Mode response with envelope ID %q: %v", res.EnvelopeID, res) diff --git a/socketmode/socket_mode_managed_conn_test.go b/socketmode/socket_mode_managed_conn_test.go index d1ff28b7a..a6a0cd3d6 100644 --- a/socketmode/socket_mode_managed_conn_test.go +++ b/socketmode/socket_mode_managed_conn_test.go @@ -1,6 +1,3 @@ -//go:build go1.13 -// +build go1.13 - package socketmode import ( @@ -29,21 +26,21 @@ func Test_passContext(t *testing.T) { defer cancel() t.Run("RunWithContext", func(t *testing.T) { - // should fail imidiatly. + // should fail immediately. assert.EqualError(t, cli.RunContext(ctx), context.DeadlineExceeded.Error()) }) t.Run("openAndDial", func(t *testing.T) { _, _, err := cli.openAndDial(ctx, func(_ string) error { return nil }) - // should fail imidiatly. + // should fail immediately. assert.EqualError(t, errors.Unwrap(err), context.DeadlineExceeded.Error()) }) t.Run("OpenWithContext", func(t *testing.T) { _, _, err := cli.OpenContext(ctx) - // should fail imidiatly. + // should fail immediately. assert.EqualError(t, errors.Unwrap(err), context.DeadlineExceeded.Error()) }) } diff --git a/socketmode/socketmode_handler_test.go b/socketmode/socketmode_handler_test.go index 194572ced..9aaf4c08c 100644 --- a/socketmode/socketmode_handler_test.go +++ b/socketmode/socketmode_handler_test.go @@ -59,39 +59,39 @@ func testing_wrapper(ch chan<- string, f SocketmodeHandlerFunc) SocketmodeHandle } func middleware_interaction(evt *Event, client *Client) { - //do nothing + // do nothing } func middleware_interaction_block_action(evt *Event, client *Client) { - //do nothing + // do nothing } func middleware_eventapi(evt *Event, client *Client) { - //do nothing + // do nothing } func middleware(evt *Event, client *Client) { - //do nothing + // do nothing } func defaultmiddleware(evt *Event, client *Client) { - //do nothing + // do nothing } func middleware_slach_command(evt *Event, client *Client) { - //do nothing + // do nothing } func middleware_shortcut(evt *Event, client *Client) { - //do nothing + // do nothing } func middleware_view_submission(evt *Event, client *Client) { - //do nothing + // do nothing } func middleware_view_closed(evt *Event, client *Client) { - //do nothing + // do nothing } func TestSocketmodeHandler_Handle(t *testing.T) { @@ -102,7 +102,7 @@ func TestSocketmodeHandler_Handle(t *testing.T) { tests := []struct { name string args args - want string //what is the name of the function we want to be called + want string // the name of the function we want to be called }{ { name: "Event Match registered function", @@ -152,7 +152,7 @@ func TestSocketmodeHandler_HandleInteraction(t *testing.T) { tests := []struct { name string args args - want string //what is the name of the function we want to be called + want string // the name of the function we want to be called }{ { name: "Event Match registered function", @@ -240,7 +240,7 @@ func TestSocketmodeHandler_HandleEvents(t *testing.T) { tests := []struct { name string args args - want string //what is the name of the function we want to be called + want string // the name of the function we want to be called }{ { name: "Event Match registered function", @@ -337,7 +337,7 @@ func TestSocketmodeHandler_HandleInteractionBlockAction(t *testing.T) { tests := []struct { name string args args - want string //what is the name of the function we want to be called + want string // the name of the function we want to be called }{ { name: "Event Match registered function", @@ -408,7 +408,7 @@ func TestSocketmodeHandler_HandleSlashCommand(t *testing.T) { tests := []struct { name string args args - want string //what is the name of the function we want to be called + want string // the name of the function we want to be called }{ { name: "Event Match registered function", diff --git a/websocket_managed_conn.go b/websocket_managed_conn.go index 68bdfeb78..d2539aacc 100644 --- a/websocket_managed_conn.go +++ b/websocket_managed_conn.go @@ -455,9 +455,10 @@ func (rtm *RTM) handleAck(event json.RawMessage) { return } - if ack.Ok { + switch { + case ack.Ok: rtm.IncomingEvents <- RTMEvent{"ack", ack} - } else if ack.RTMResponse.Error != nil { + case ack.RTMResponse.Error != nil: // As there is no documentation for RTM error-codes, this // identification of a rate-limit warning is very brittle. if ack.RTMResponse.Error.Code == -1 && ack.RTMResponse.Error.Msg == "slow down, too many messages..." { @@ -465,7 +466,7 @@ func (rtm *RTM) handleAck(event json.RawMessage) { } else { rtm.IncomingEvents <- RTMEvent{"ack_error", &AckErrorEvent{ack.Error, ack.ReplyTo}} } - } else { + default: rtm.IncomingEvents <- RTMEvent{"ack_error", &AckErrorEvent{ErrorObj: fmt.Errorf("ack decode failure")}} } } From 77b2103e2bef61ca5019de381b2bcee2e0e220c8 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 5 Jun 2026 12:25:13 +0200 Subject: [PATCH 580/606] chore: fix flaky test I really hate doing this but 5s as an upper bound is fine. --- slacktest/rtm_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slacktest/rtm_test.go b/slacktest/rtm_test.go index 2174c64d4..2183f3f8b 100644 --- a/slacktest/rtm_test.go +++ b/slacktest/rtm_test.go @@ -10,7 +10,7 @@ import ( ) func TestRTMInfo(t *testing.T) { - maxWait := 10 * time.Millisecond + maxWait := 5 * time.Second s := NewTestServer() go s.Start() From f2af58e54bc91d95c990b2f6803db5f655e3a155 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Fri, 5 Jun 2026 12:37:50 +0200 Subject: [PATCH 581/606] chore: bump to v0.25.0 --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c32318018..bad8ec5fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.25.0] + ### Changed > [!WARNING] @@ -587,7 +589,8 @@ for details. [#1196]: https://github.com/slack-go/slack/issues/1196 [#1547]: https://github.com/slack-go/slack/pull/1547 -[Unreleased]: https://github.com/slack-go/slack/compare/v0.24.0...HEAD +[Unreleased]: https://github.com/slack-go/slack/compare/v0.25.0...HEAD +[0.25.0]: https://github.com/slack-go/slack/compare/v0.24.0...v0.25.0 [0.24.0]: https://github.com/slack-go/slack/compare/v0.23.1...v0.24.0 [0.23.1]: https://github.com/slack-go/slack/compare/v0.23.0...v0.23.1 [0.23.0]: https://github.com/slack-go/slack/compare/v0.22.0...v0.23.0 From 2e46275e6b0db8286243b94c0fc697dcfe7f35ee Mon Sep 17 00:00:00 2001 From: jjiang-stripe <55402658+jjiang-stripe@users.noreply.github.com> Date: Sun, 14 Jun 2026 06:26:14 -0400 Subject: [PATCH 582/606] feat: unmarshal is_ext_shared_channel field in EventsAPIEvent (#1565) Adds `IsExtSharedChannel` to the `EventsAPIEvent` struct so it gets unmarshalled from JSON. This is a field on the outer event as described by https://docs.slack.dev/apis/events-api/#callback-field and I would like to be able to write some logic around this field after calling `slackevents.ParseEvent`. --- slackevents/outer_events.go | 15 ++++++++------- slackevents/outer_events_test.go | 3 ++- slackevents/parsers.go | 14 ++++++++++++++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/slackevents/outer_events.go b/slackevents/outer_events.go index 5d8238c88..7e71598b8 100644 --- a/slackevents/outer_events.go +++ b/slackevents/outer_events.go @@ -8,13 +8,14 @@ import ( // EventsAPIEvent is the base EventsAPIEvent type EventsAPIEvent struct { - Token string `json:"token"` - TeamID string `json:"team_id"` - Type string `json:"type"` - APIAppID string `json:"api_app_id"` - EnterpriseID string `json:"enterprise_id"` - Data any - InnerEvent EventsAPIInnerEvent + Token string `json:"token"` + TeamID string `json:"team_id"` + Type string `json:"type"` + APIAppID string `json:"api_app_id"` + EnterpriseID string `json:"enterprise_id"` + IsExtSharedChannel bool `json:"is_ext_shared_channel"` + Data any + InnerEvent EventsAPIInnerEvent } // EventsAPIURLVerificationEvent received when configuring a EventsAPI driven app diff --git a/slackevents/outer_events_test.go b/slackevents/outer_events_test.go index 942d6240d..b0b36ff4a 100644 --- a/slackevents/outer_events_test.go +++ b/slackevents/outer_events_test.go @@ -33,7 +33,8 @@ func TestCallBackEvent(t *testing.T) { "type": "event_callback", "authed_users": [ "UXXXXXXX1" ], "event_id": "Ev08MFMKH6", - "event_time": 1234567890 + "event_time": 1234567890, + "is_ext_shared_channel": true } `) err := json.Unmarshal(rawE, &EventsAPICallbackEvent{}) diff --git a/slackevents/parsers.go b/slackevents/parsers.go index 114eba3e8..e9d74b831 100644 --- a/slackevents/parsers.go +++ b/slackevents/parsers.go @@ -33,6 +33,7 @@ func parseOuterEvent(rawE json.RawMessage) (EventsAPIEvent, error) { "unmarshalling_error", "", "", + false, &slack.UnmarshallingErrorEvent{ErrorObj: err}, EventsAPIInnerEvent{}, }, err @@ -47,6 +48,7 @@ func parseOuterEvent(rawE json.RawMessage) (EventsAPIEvent, error) { "unmarshalling_error", "", "", + false, &slack.UnmarshallingErrorEvent{ErrorObj: err}, EventsAPIInnerEvent{}, }, err @@ -57,6 +59,7 @@ func parseOuterEvent(rawE json.RawMessage) (EventsAPIEvent, error) { e.Type, e.APIAppID, e.EnterpriseID, + e.IsExtSharedChannel, cbEvent, EventsAPIInnerEvent{}, }, nil @@ -70,6 +73,7 @@ func parseOuterEvent(rawE json.RawMessage) (EventsAPIEvent, error) { "unmarshalling_error", "", "", + false, &slack.UnmarshallingErrorEvent{ErrorObj: err}, EventsAPIInnerEvent{}, }, err @@ -80,6 +84,7 @@ func parseOuterEvent(rawE json.RawMessage) (EventsAPIEvent, error) { e.Type, e.APIAppID, e.EnterpriseID, + e.IsExtSharedChannel, urlVE, EventsAPIInnerEvent{}, }, nil @@ -96,6 +101,7 @@ func parseInnerEvent(e *EventsAPICallbackEvent) (EventsAPIEvent, error) { "unmarshalling_error", e.APIAppID, e.EnterpriseID, + false, &slack.UnmarshallingErrorEvent{ErrorObj: err}, EventsAPIInnerEvent{}, }, err @@ -108,6 +114,7 @@ func parseInnerEvent(e *EventsAPICallbackEvent) (EventsAPIEvent, error) { iE.Type, e.APIAppID, e.EnterpriseID, + false, nil, EventsAPIInnerEvent{}, }, fmt.Errorf("inner Event does not exist! %s", iE.Type) @@ -122,6 +129,7 @@ func parseInnerEvent(e *EventsAPICallbackEvent) (EventsAPIEvent, error) { "unmarshalling_error", e.APIAppID, e.EnterpriseID, + false, &slack.UnmarshallingErrorEvent{ErrorObj: err}, EventsAPIInnerEvent{}, }, err @@ -132,6 +140,7 @@ func parseInnerEvent(e *EventsAPICallbackEvent) (EventsAPIEvent, error) { e.Type, e.APIAppID, e.EnterpriseID, + false, e, EventsAPIInnerEvent{iE.Type, recvEvent}, }, nil @@ -199,6 +208,7 @@ func ParseEvent(rawEvent json.RawMessage, opts ...Option) (EventsAPIEvent, error "unmarshalling_error", "", "", + false, &slack.UnmarshallingErrorEvent{ErrorObj: err}, EventsAPIInnerEvent{}, }, err @@ -216,6 +226,7 @@ func ParseEvent(rawEvent json.RawMessage, opts ...Option) (EventsAPIEvent, error "unmarshalling_error", "", "", + false, &slack.UnmarshallingErrorEvent{ErrorObj: err}, EventsAPIInnerEvent{}, }, err @@ -226,6 +237,7 @@ func ParseEvent(rawEvent json.RawMessage, opts ...Option) (EventsAPIEvent, error e.Type, e.APIAppID, e.EnterpriseID, + e.IsExtSharedChannel, appRateLimitedEvent, EventsAPIInnerEvent{}, }, nil @@ -240,6 +252,7 @@ func ParseEvent(rawEvent json.RawMessage, opts ...Option) (EventsAPIEvent, error "unmarshalling_error", "", "", + false, &slack.UnmarshallingErrorEvent{ErrorObj: err}, EventsAPIInnerEvent{}, }, err @@ -250,6 +263,7 @@ func ParseEvent(rawEvent json.RawMessage, opts ...Option) (EventsAPIEvent, error e.Type, e.APIAppID, e.EnterpriseID, + e.IsExtSharedChannel, urlVerificationEvent, EventsAPIInnerEvent{}, }, nil From 9843657bd1294a17550d1324e1288a17f375ec9b Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 14 Jun 2026 11:05:57 +0200 Subject: [PATCH 583/606] Propagate `IsExtSharedChannel` in `ParseEvent` for callback events Fixes #1564. --- slackevents/outer_events.go | 23 +++++++------ slackevents/outer_events_test.go | 6 +++- slackevents/parsers.go | 2 +- slackevents/parsers_test.go | 57 ++++++++++++++++++++++++-------- 4 files changed, 61 insertions(+), 27 deletions(-) diff --git a/slackevents/outer_events.go b/slackevents/outer_events.go index 7e71598b8..5199da088 100644 --- a/slackevents/outer_events.go +++ b/slackevents/outer_events.go @@ -32,17 +32,18 @@ type ChallengeResponse struct { // EventsAPICallbackEvent is the main (outer) EventsAPI event. type EventsAPICallbackEvent struct { - Type string `json:"type"` - Token string `json:"token"` - TeamID string `json:"team_id"` - APIAppID string `json:"api_app_id"` - EnterpriseID string `json:"enterprise_id"` - InnerEvent *json.RawMessage `json:"event"` - AuthedUsers []string `json:"authed_users"` - AuthedTeams []string `json:"authed_teams"` - EventID string `json:"event_id"` - EventTime int `json:"event_time"` - EventContext string `json:"event_context"` + Type string `json:"type"` + Token string `json:"token"` + TeamID string `json:"team_id"` + APIAppID string `json:"api_app_id"` + EnterpriseID string `json:"enterprise_id"` + InnerEvent *json.RawMessage `json:"event"` + AuthedUsers []string `json:"authed_users"` + AuthedTeams []string `json:"authed_teams"` + EventID string `json:"event_id"` + EventTime int `json:"event_time"` + EventContext string `json:"event_context"` + IsExtSharedChannel bool `json:"is_ext_shared_channel"` } // EventsAPIAppRateLimited indicates your app's event subscriptions are being rate limited diff --git a/slackevents/outer_events_test.go b/slackevents/outer_events_test.go index b0b36ff4a..62f861171 100644 --- a/slackevents/outer_events_test.go +++ b/slackevents/outer_events_test.go @@ -37,10 +37,14 @@ func TestCallBackEvent(t *testing.T) { "is_ext_shared_channel": true } `) - err := json.Unmarshal(rawE, &EventsAPICallbackEvent{}) + var cb EventsAPICallbackEvent + err := json.Unmarshal(rawE, &cb) if err != nil { t.Error(err) } + if !cb.IsExtSharedChannel { + t.Errorf("expected IsExtSharedChannel to be true, got false") + } } func TestAppRateLimitedEvent(t *testing.T) { diff --git a/slackevents/parsers.go b/slackevents/parsers.go index e9d74b831..af8ff39dd 100644 --- a/slackevents/parsers.go +++ b/slackevents/parsers.go @@ -140,7 +140,7 @@ func parseInnerEvent(e *EventsAPICallbackEvent) (EventsAPIEvent, error) { e.Type, e.APIAppID, e.EnterpriseID, - false, + e.IsExtSharedChannel, e, EventsAPIInnerEvent{iE.Type, recvEvent}, }, nil diff --git a/slackevents/parsers_test.go b/slackevents/parsers_test.go index 4046b0137..c93118ebf 100644 --- a/slackevents/parsers_test.go +++ b/slackevents/parsers_test.go @@ -10,20 +10,20 @@ import ( func TestParserOuterCallBackEvent(t *testing.T) { eventsAPIRawCallbackEvent := ` - { - "token": "XXYYZZ", - "team_id": "TXXXXXXXX", - "api_app_id": "AXXXXXXXXX", - "event": { - "type": "app_mention", - "event_ts": "1234567890.123456", - "user": "UXXXXXXX1" - }, - "type": "event_callback", - "authed_users": [ "UXXXXXXX1" ], - "event_id": "Ev08MFMKH6", - "event_time": 1234567890 - } + { + "token": "XXYYZZ", + "team_id": "TXXXXXXXX", + "api_app_id": "AXXXXXXXXX", + "event": { + "type": "app_mention", + "event_ts": "1234567890.123456", + "user": "UXXXXXXX1" + }, + "type": "event_callback", + "authed_users": [ "UXXXXXXX1" ], + "event_id": "Ev08MFMKH6", + "event_time": 1234567890 + } ` msg, e := ParseEvent(json.RawMessage(eventsAPIRawCallbackEvent), OptionVerifyToken(&TokenComparator{"XXYYZZ"})) if e != nil { @@ -142,6 +142,35 @@ func TestThatOuterCallbackEventHasInnerEvent(t *testing.T) { } } +func TestParseEventExposesIsExtSharedChannel(t *testing.T) { + // is_ext_shared_channel lives on the outer event_callback wrapper. Verify + // that it survives all the way through ParseEvent for a callback event that + // carries an inner event (the common case), not just on the outer parse. + eventsAPIRawCallbackEvent := ` + { + "token": "XXYYZZ", + "team_id": "TXXXXXXXX", + "api_app_id": "AXXXXXXXXX", + "event": { + "type": "app_mention", + "event_ts": "1234567890.123456", + "user": "UXXXXXXX1" + }, + "type": "event_callback", + "event_id": "Ev08MFMKH6", + "event_time": 1234567890, + "is_ext_shared_channel": true + } + ` + msg, err := ParseEvent(json.RawMessage(eventsAPIRawCallbackEvent), OptionVerifyToken(&TokenComparator{"XXYYZZ"})) + if err != nil { + t.Fatal(err) + } + if !msg.IsExtSharedChannel { + t.Fatalf("expected IsExtSharedChannel to be true, got false") + } +} + func TestBadTokenVerification(t *testing.T) { urlVerificationEvent := ` { From 0af06c5cf6931a92a3daf09429345a6889bdcc45 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 14 Jun 2026 12:34:02 +0200 Subject: [PATCH 584/606] feat: add level field support to `HeaderBlock` Closes #1563. --- CHANGELOG.md | 5 +++++ block_header.go | 11 +++++++++++ block_header_test.go | 8 ++++++++ 3 files changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bad8ec5fa..872c7c488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Block Kit: `HeaderBlock` now supports the optional `level` field (1-4, mapping + to H1-H4 heading levels), with a `HeaderBlockOptionLevel` builder (#1563). + ## [0.25.0] ### Changed diff --git a/block_header.go b/block_header.go index 4277057f7..157079030 100644 --- a/block_header.go +++ b/block_header.go @@ -7,6 +7,9 @@ type HeaderBlock struct { Type MessageBlockType `json:"type"` Text *TextBlockObject `json:"text,omitempty"` BlockID string `json:"block_id,omitempty"` + // Level sets the heading level. Values 1-4 correspond to H1-H4 heading + // levels, respectively. + Level int `json:"level,omitempty"` } // BlockType returns the type of the block @@ -28,6 +31,14 @@ func HeaderBlockOptionBlockID(blockID string) HeaderBlockOption { } } +// HeaderBlockOptionLevel sets the heading level of the header block. Values 1-4 +// correspond to H1-H4 heading levels, respectively. +func HeaderBlockOptionLevel(level int) HeaderBlockOption { + return func(block *HeaderBlock) { + block.Level = level + } +} + // NewHeaderBlock returns a new instance of a header block to be rendered func NewHeaderBlock(textObj *TextBlockObject, options ...HeaderBlockOption) *HeaderBlock { block := HeaderBlock{ diff --git a/block_header_test.go b/block_header_test.go index 0dcbf8780..a7480d448 100644 --- a/block_header_test.go +++ b/block_header_test.go @@ -18,6 +18,14 @@ func TestNewHeaderBlock(t *testing.T) { assert.Contains(t, headerBlock.Text.Text, "quite the header") } +func TestNewHeaderBlockWithLevel(t *testing.T) { + textInfo := NewTextBlockObject("plain_text", "This is quite the header", false, false) + headerBlock := NewHeaderBlock(textInfo, HeaderBlockOptionLevel(2)) + + assert.Equal(t, headerBlock.BlockType(), MBTHeader) + assert.Equal(t, 2, headerBlock.Level) +} + // TestNewHeaderBlockWithNilOption reproduces issue #1236: passing nil as an // option to NewHeaderBlock causes a nil pointer dereference panic. func TestNewHeaderBlockWithNilOption(t *testing.T) { From 56c434ca39c16724acf22e4b907500d6bd132d69 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 14 Jun 2026 13:23:14 +0200 Subject: [PATCH 585/606] feat: add `slack_icon` and `subtext` support to `CardBlock` Closes #1562. --- CHANGELOG.md | 4 ++++ block_card.go | 27 +++++++++++++++++++++++---- block_card_test.go | 43 +++++++++++++++++++++++++++++++++++++++++++ block_object.go | 24 ++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 872c7c488..ffed84e65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Block Kit: `HeaderBlock` now supports the optional `level` field (1-4, mapping to H1-H4 heading levels), with a `HeaderBlockOptionLevel` builder (#1563). +- Block Kit: `CardBlock` now supports the `slack_icon` field via the new + `SlackIconObject` composition object (with a `NewSlackIconObject` constructor + and `WithSlackIcon` builder) and the `subtext` field (with a `WithSubtext` + builder). `slack_icon` is mutually exclusive with `icon` (#1562). ## [0.25.0] diff --git a/block_card.go b/block_card.go index ccee17420..06b03ccf0 100644 --- a/block_card.go +++ b/block_card.go @@ -1,9 +1,12 @@ package slack // CardBlock defines a block of type card used to display a rich, self-contained -// piece of content with an optional hero image, icon, title, subtitle, body, -// and action buttons. Cards can stand alone or be grouped inside a -// CarouselBlock. +// piece of content with an optional hero image, icon (or Slack icon), title, +// subtitle, body, subtext, and action buttons. Cards can stand alone or be +// grouped inside a CarouselBlock. +// +// Only one of Icon and SlackIcon can be set, as they render in the same +// location. // // More Information: https://docs.slack.dev/reference/block-kit/blocks/card-block/ type CardBlock struct { @@ -11,9 +14,11 @@ type CardBlock struct { BlockID string `json:"block_id,omitempty"` HeroImage *ImageBlockElement `json:"hero_image,omitempty"` Icon *ImageBlockElement `json:"icon,omitempty"` + SlackIcon *SlackIconObject `json:"slack_icon,omitempty"` Title *TextBlockObject `json:"title,omitempty"` Subtitle *TextBlockObject `json:"subtitle,omitempty"` Body *TextBlockObject `json:"body,omitempty"` + Subtext *TextBlockObject `json:"subtext,omitempty"` Actions *BlockElements `json:"actions,omitempty"` } @@ -71,12 +76,26 @@ func (s *CardBlock) WithBody(body *TextBlockObject) *CardBlock { return s } -// WithIcon sets the icon image for the CardBlock +// WithSubtext sets the subtext displayed below the body of the CardBlock +func (s *CardBlock) WithSubtext(subtext *TextBlockObject) *CardBlock { + s.Subtext = subtext + return s +} + +// WithIcon sets the icon image for the CardBlock. It is mutually exclusive with +// SlackIcon, as both render in the same location. func (s *CardBlock) WithIcon(icon *ImageBlockElement) *CardBlock { s.Icon = icon return s } +// WithSlackIcon sets the built-in Slack icon for the CardBlock. It is mutually +// exclusive with Icon, as both render in the same location. +func (s *CardBlock) WithSlackIcon(slackIcon *SlackIconObject) *CardBlock { + s.SlackIcon = slackIcon + return s +} + // WithHeroImage sets the hero image for the CardBlock func (s *CardBlock) WithHeroImage(heroImage *ImageBlockElement) *CardBlock { s.HeroImage = heroImage diff --git a/block_card_test.go b/block_card_test.go index de5798670..a98dff6e4 100644 --- a/block_card_test.go +++ b/block_card_test.go @@ -12,6 +12,7 @@ func TestNewCardBlock(t *testing.T) { title := NewTextBlockObject("mrkdwn", "Card title", false, false) subtitle := NewTextBlockObject("mrkdwn", "Card subtitle", false, false) body := NewTextBlockObject("mrkdwn", "Card body text.", false, false) + subtext := NewTextBlockObject("mrkdwn", "Card subtext.", false, false) iconURL := "https://example.com/icon.png" icon := &ImageBlockElement{Type: METImage, ImageURL: &iconURL, AltText: "icon"} @@ -25,6 +26,7 @@ func TestNewCardBlock(t *testing.T) { WithTitle(title). WithSubtitle(subtitle). WithBody(body). + WithSubtext(subtext). WithIcon(icon). WithHeroImage(hero). WithActions(btn) @@ -35,12 +37,26 @@ func TestNewCardBlock(t *testing.T) { assert.Equal(t, title, block.Title) assert.Equal(t, subtitle, block.Subtitle) assert.Equal(t, body, block.Body) + assert.Equal(t, subtext, block.Subtext) assert.Equal(t, icon, block.Icon) assert.Equal(t, hero, block.HeroImage) require.NotNil(t, block.Actions) require.Len(t, block.Actions.ElementSet, 1) } +func TestNewCardBlockWithSlackIcon(t *testing.T) { + slackIcon := NewSlackIconObject("rocket") + + block := NewCardBlock(). + WithTitle(NewTextBlockObject("mrkdwn", "Card title", false, false)). + WithSlackIcon(slackIcon) + + require.NotNil(t, block.SlackIcon) + assert.Equal(t, "icon", block.SlackIcon.Type) + assert.Equal(t, "rocket", block.SlackIcon.Name) + assert.Equal(t, slackIcon, block.SlackIcon) +} + func TestCardBlockJSONRoundTrip(t *testing.T) { payload := `{ "type": "card", @@ -58,6 +74,7 @@ func TestCardBlockJSONRoundTrip(t *testing.T) { "title": {"type": "mrkdwn", "text": "Lumon Industries"}, "subtitle": {"type": "mrkdwn", "text": "Macrodata Refinement"}, "body": {"type": "mrkdwn", "text": "The work is mysterious and important."}, + "subtext": {"type": "mrkdwn", "text": "Praise Kier."}, "actions": [ { "type": "button", @@ -79,6 +96,7 @@ func TestCardBlockJSONRoundTrip(t *testing.T) { require.NotNil(t, block.Title) require.NotNil(t, block.Subtitle) require.NotNil(t, block.Body) + require.NotNil(t, block.Subtext) require.NotNil(t, block.Actions) require.Len(t, block.Actions.ElementSet, 1) @@ -96,6 +114,31 @@ func TestCardBlockJSONRoundTrip(t *testing.T) { assert.Equal(t, expected, actual) } +func TestCardBlockSlackIconJSONRoundTrip(t *testing.T) { + payload := `{ + "type": "card", + "slack_icon": {"type": "icon", "name": "rocket"}, + "title": {"type": "mrkdwn", "text": "Sample Card Title"} + }` + + var block CardBlock + require.NoError(t, json.Unmarshal([]byte(payload), &block)) + + require.NotNil(t, block.SlackIcon) + assert.Equal(t, "icon", block.SlackIcon.Type) + assert.Equal(t, "rocket", block.SlackIcon.Name) + assert.Nil(t, block.Icon) + + marshalled, err := json.Marshal(block) + require.NoError(t, err) + + var expected, actual map[string]any + require.NoError(t, json.Unmarshal([]byte(payload), &expected)) + require.NoError(t, json.Unmarshal(marshalled, &actual)) + + assert.Equal(t, expected, actual) +} + func TestCardBlockUnmarshalViaBlocks(t *testing.T) { payload := `[ { diff --git a/block_object.go b/block_object.go index 29fd7f9ea..48eac47d2 100644 --- a/block_object.go +++ b/block_object.go @@ -271,3 +271,27 @@ func NewOptionGroupBlockElement(label *TextBlockObject, options ...*OptionBlockO Options: options, } } + +// SlackIconObject defines a built-in Slack icon for use in a card block's +// slack_icon field. It is mutually exclusive with the card block's icon field, +// as both render in the same location. +// +// More Information: https://docs.slack.dev/reference/block-kit/composition-objects/slack-icon-object/ +type SlackIconObject struct { + Type string `json:"type"` + Name string `json:"name"` +} + +// validateType enforces block objects for element and block parameters +func (s SlackIconObject) validateType() MessageObjectType { + return MessageObjectType(s.Type) +} + +// NewSlackIconObject returns an instance of a new Slack icon object. The name +// must be one of the icon names supported by Slack. +func NewSlackIconObject(name string) *SlackIconObject { + return &SlackIconObject{ + Type: "icon", + Name: name, + } +} From 967c4e7a907d47f3d62c8ef0bddab49ddbf486ff Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 14 Jun 2026 13:38:14 +0200 Subject: [PATCH 586/606] chore: bump go to 1.25.11 --- CHANGELOG.md | 19 ++++++++++++------- go.mod | 2 +- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffed84e65..e9ce4bf71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.26.0] + ### Added -- Block Kit: `HeaderBlock` now supports the optional `level` field (1-4, mapping - to H1-H4 heading levels), with a `HeaderBlockOptionLevel` builder (#1563). -- Block Kit: `CardBlock` now supports the `slack_icon` field via the new - `SlackIconObject` composition object (with a `NewSlackIconObject` constructor - and `WithSlackIcon` builder) and the `subtext` field (with a `WithSubtext` - builder). `slack_icon` is mutually exclusive with `icon` (#1562). +- `slackevents`: `EventsAPIEvent` now unmarshals the `is_ext_shared_channel` field, and + `ParseEvent` propagates it for callback events (#1564 and #1565). +- Block Kit: `HeaderBlock` now supports the optional `level` field (1-4, mapping to H1-H4 + heading levels), with a `HeaderBlockOptionLevel` builder (#1563). +- Block Kit: `CardBlock` now supports the `slack_icon` field via the new `SlackIconObject` + composition object (with a `NewSlackIconObject` constructor and `WithSlackIcon` builder) + and the `subtext` field (with a `WithSubtext` builder). `slack_icon` is mutually + exclusive with `icon` (#1562). ## [0.25.0] @@ -598,7 +602,8 @@ for details. [#1196]: https://github.com/slack-go/slack/issues/1196 [#1547]: https://github.com/slack-go/slack/pull/1547 -[Unreleased]: https://github.com/slack-go/slack/compare/v0.25.0...HEAD +[Unreleased]: https://github.com/slack-go/slack/compare/v0.26.0...HEAD +[0.25.0]: https://github.com/slack-go/slack/compare/v0.25.0...v0.26.0 [0.25.0]: https://github.com/slack-go/slack/compare/v0.24.0...v0.25.0 [0.24.0]: https://github.com/slack-go/slack/compare/v0.23.1...v0.24.0 [0.23.1]: https://github.com/slack-go/slack/compare/v0.23.0...v0.23.1 diff --git a/go.mod b/go.mod index fb8aaaf11..f794e1b80 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/slack-go/slack go 1.25 -toolchain go1.25.9 +toolchain go1.25.11 require ( github.com/go-test/deep v1.1.1 From 6fd5178071da1be1bcb5b1ed02b4871ff551bca8 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 14 Jun 2026 14:01:05 +0200 Subject: [PATCH 587/606] chore: bump golangci-lint to 2.12.2 --- mise.toml | 4 +- retry_test.go | 100 +++++++++++++++++++++++++------------------------- 2 files changed, 52 insertions(+), 52 deletions(-) diff --git a/mise.toml b/mise.toml index 912c02c98..679b53465 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,4 @@ [tools] -go = "1.25" -golangci-lint = "2.10.1" +go = "1.25.11" +golangci-lint = "2.12.2" "go:honnef.co/go/tools/cmd/staticcheck" = "2026.1" diff --git a/retry_test.go b/retry_test.go index b74c4b0be..3d3a17cfc 100644 --- a/retry_test.go +++ b/retry_test.go @@ -19,9 +19,9 @@ import ( func TestRetryOn429ThenSuccess(t *testing.T) { t.Parallel() - var callCount int32 + var callCount atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - n := atomic.AddInt32(&callCount, 1) + n := callCount.Add(1) if n <= 2 { w.Header().Set("Retry-After", "0") w.WriteHeader(http.StatusTooManyRequests) @@ -48,7 +48,7 @@ func TestRetryOn429ThenSuccess(t *testing.T) { if !out.Ok { t.Errorf("want ok=true, got ok=%v", out.Ok) } - if got := atomic.LoadInt32(&callCount); got != 3 { + if got := callCount.Load(); got != 3 { t.Errorf("want 3 calls (2x 429 + 1x 200), got %d", got) } } @@ -57,9 +57,9 @@ func TestRetryOn429ThenSuccess(t *testing.T) { func TestRetryOn500ThenSuccess(t *testing.T) { t.Parallel() - var callCount int32 + var callCount atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - n := atomic.AddInt32(&callCount, 1) + n := callCount.Add(1) if n <= 2 { w.WriteHeader(http.StatusInternalServerError) return @@ -84,7 +84,7 @@ func TestRetryOn500ThenSuccess(t *testing.T) { if !out.Ok { t.Errorf("want ok=true, got ok=%v", out.Ok) } - if got := atomic.LoadInt32(&callCount); got != 3 { + if got := callCount.Load(); got != 3 { t.Errorf("want 3 calls (2x 500 + 1x 200), got %d", got) } } @@ -94,9 +94,9 @@ func TestRetryOn500ThenSuccess(t *testing.T) { func TestRetryExhaustedReturnsLastError(t *testing.T) { t.Parallel() - var callCount int32 + var callCount atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&callCount, 1) + callCount.Add(1) w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() @@ -116,7 +116,7 @@ func TestRetryExhaustedReturnsLastError(t *testing.T) { if _, ok := err.(StatusCodeError); !ok { t.Errorf("expected StatusCodeError, got %T: %v", err, err) } - if got := atomic.LoadInt32(&callCount); got != 3 { + if got := callCount.Load(); got != 3 { t.Errorf("want 3 calls (all 500), got %d", got) } } @@ -205,9 +205,9 @@ func TestRetryExhausted429ResponseBodyReadable(t *testing.T) { func TestRetryExhausted429ReturnsError(t *testing.T) { t.Parallel() - var callCount int32 + var callCount atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&callCount, 1) + callCount.Add(1) w.Header().Set("Retry-After", "1") w.WriteHeader(http.StatusTooManyRequests) })) @@ -228,7 +228,7 @@ func TestRetryExhausted429ReturnsError(t *testing.T) { if _, ok := err.(*RateLimitedError); !ok { t.Errorf("expected *RateLimitedError, got %T: %v", err, err) } - if got := atomic.LoadInt32(&callCount); got != 3 { + if got := callCount.Load(); got != 3 { t.Errorf("want 3 calls (all 429), got %d", got) } } @@ -261,9 +261,9 @@ func TestOptionRetryNonPositiveDisablesRetry(t *testing.T) { func TestRetryOn503ThenSuccess(t *testing.T) { t.Parallel() - var callCount int32 + var callCount atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - n := atomic.AddInt32(&callCount, 1) + n := callCount.Add(1) if n <= 2 { w.WriteHeader(http.StatusServiceUnavailable) return @@ -288,7 +288,7 @@ func TestRetryOn503ThenSuccess(t *testing.T) { if !out.Ok { t.Errorf("want ok=true, got ok=%v", out.Ok) } - if got := atomic.LoadInt32(&callCount); got != 3 { + if got := callCount.Load(); got != 3 { t.Errorf("want 3 calls (2x 503 + 1x 200), got %d", got) } } @@ -322,7 +322,7 @@ func TestRetryOnConnectionErrorThenSuccess(t *testing.T) { if !out.Ok { t.Errorf("want ok=true, got ok=%v", out.Ok) } - if got := atomic.LoadInt32(&wrapped.attempts); got != 3 { + if got := wrapped.attempts.Load(); got != 3 { t.Errorf("want 3 calls (2x connection error + 1x 200), got %d", got) } } @@ -348,7 +348,7 @@ func TestRetryOnConnectionErrorExhausted(t *testing.T) { if resp != nil { t.Errorf("expected nil response when error returned, got %v", resp) } - if got := atomic.LoadInt32(&failClient.attempts); got != 3 { + if got := failClient.attempts.Load(); got != 3 { t.Errorf("want 3 attempts (MaxRetries+1), got %d", got) } } @@ -376,7 +376,7 @@ func TestNoRetryWhenGetBodyNil(t *testing.T) { t.Fatalf("Do: %v", err) } resp.Body.Close() - if got := atomic.LoadInt32(&mock.count); got != 1 { + if got := mock.count.Load(); got != 1 { t.Errorf("when body present and GetBody is nil should not retry, got %d calls", got) } } @@ -405,7 +405,7 @@ func TestRetryGetRequestWithNilBody(t *testing.T) { t.Fatalf("Do: %v", err) } resp.Body.Close() - if got := atomic.LoadInt32(&wrapped.calls); got != 3 { + if got := wrapped.calls.Load(); got != 3 { t.Errorf("GET with nil body should retry on 429 (2x 429 + 1x 200), got %d calls", got) } } @@ -413,11 +413,11 @@ func TestRetryGetRequestWithNilBody(t *testing.T) { // contextCancelClient returns context.Canceled when the request context is already cancelled. type contextCancelClient struct { httpClient - calls int32 + calls atomic.Int32 } func (c *contextCancelClient) Do(req *http.Request) (*http.Response, error) { - atomic.AddInt32(&c.calls, 1) + c.calls.Add(1) if err := req.Context().Err(); err != nil { return nil, err } @@ -460,7 +460,7 @@ func TestRetryRespectsContextCancelation(t *testing.T) { if err != context.Canceled { t.Errorf("expected context.Canceled, got %v", err) } - if got := atomic.LoadInt32(&wrapped.calls); got != 1 { + if got := wrapped.calls.Load(); got != 1 { t.Errorf("expected 1 call, got %d", got) } } @@ -508,7 +508,7 @@ func TestRetryContextCanceledDuringSleep(t *testing.T) { if err != context.Canceled { t.Errorf("expected context.Canceled, got %v", err) } - if got := atomic.LoadInt32(&mock.count); got != 1 { + if got := mock.count.Load(); got != 1 { t.Errorf("expected 1 call (cancel during sleep before retry), got %d", got) } } @@ -545,9 +545,9 @@ func TestConnectionOnlyRetryHandlersOnlyRetriesConnection(t *testing.T) { t.Run("429_not_retried", func(t *testing.T) { t.Parallel() - var callCount int32 + var callCount atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&callCount, 1) + callCount.Add(1) w.WriteHeader(http.StatusTooManyRequests) })) defer srv.Close() @@ -560,16 +560,16 @@ func TestConnectionOnlyRetryHandlersOnlyRetriesConnection(t *testing.T) { if err == nil { t.Fatal("expected error on 429 with connection-only handlers") } - if got := atomic.LoadInt32(&callCount); got != 1 { + if got := callCount.Load(); got != 1 { t.Errorf("429 should not be retried with ConnectionOnlyRetryHandlers, got %d calls", got) } }) t.Run("5xx_not_retried", func(t *testing.T) { t.Parallel() - var callCount int32 + var callCount atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&callCount, 1) + callCount.Add(1) w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() @@ -582,7 +582,7 @@ func TestConnectionOnlyRetryHandlersOnlyRetriesConnection(t *testing.T) { if err == nil { t.Fatal("expected error on 500 with connection-only handlers") } - if got := atomic.LoadInt32(&callCount); got != 1 { + if got := callCount.Load(); got != 1 { t.Errorf("5xx should not be retried with ConnectionOnlyRetryHandlers, got %d calls", got) } }) @@ -594,9 +594,9 @@ func TestOptionRetryRetries429Not5xx(t *testing.T) { t.Run("429_retried", func(t *testing.T) { t.Parallel() - var callCount int32 + var callCount atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - n := atomic.AddInt32(&callCount, 1) + n := callCount.Add(1) if n <= 2 { w.Header().Set("Retry-After", "0") w.WriteHeader(http.StatusTooManyRequests) @@ -612,16 +612,16 @@ func TestOptionRetryRetries429Not5xx(t *testing.T) { if err != nil { t.Fatalf("postMethod: %v", err) } - if got := atomic.LoadInt32(&callCount); got != 3 { + if got := callCount.Load(); got != 3 { t.Errorf("OptionRetry should retry 429, want 3 calls, got %d", got) } }) t.Run("5xx_not_retried", func(t *testing.T) { t.Parallel() - var callCount int32 + var callCount atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&callCount, 1) + callCount.Add(1) w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() @@ -631,7 +631,7 @@ func TestOptionRetryRetries429Not5xx(t *testing.T) { if err == nil { t.Fatal("expected error on 500 with OptionRetry (no 5xx handler)") } - if got := atomic.LoadInt32(&callCount); got != 1 { + if got := callCount.Load(); got != 1 { t.Errorf("OptionRetry should not retry 5xx by default, got %d calls", got) } }) @@ -642,9 +642,9 @@ func TestOptionRetryRetries429Not5xx(t *testing.T) { func TestOptionRetryConfigWithNilHandlersDefaultsTo429Only(t *testing.T) { t.Parallel() - var callCount int32 + var callCount atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - n := atomic.AddInt32(&callCount, 1) + n := callCount.Add(1) if n <= 2 { w.Header().Set("Retry-After", "0") w.WriteHeader(http.StatusTooManyRequests) @@ -666,7 +666,7 @@ func TestOptionRetryConfigWithNilHandlersDefaultsTo429Only(t *testing.T) { if !out.Ok { t.Errorf("want ok=true, got ok=%v", out.Ok) } - if got := atomic.LoadInt32(&callCount); got != 3 { + if got := callCount.Load(); got != 3 { t.Errorf("with nil Handlers default is DefaultRetryHandlers (429 only), 429 should retry; want 3 calls, got %d", got) } } @@ -693,7 +693,7 @@ func TestOptionRetryConfigWithNilHandlersDoesNotRetryConnection(t *testing.T) { if err == nil { t.Fatal("expected error when connection fails with default (429-only) handlers") } - if got := atomic.LoadInt32(&wrapped.attempts); got != 1 { + if got := wrapped.attempts.Load(); got != 1 { t.Errorf("with default (429 only) handlers connection should not be retried, got %d calls", got) } } @@ -702,9 +702,9 @@ func TestOptionRetryConfigWithNilHandlersDoesNotRetryConnection(t *testing.T) { func TestServerErrorRetryHandlerOptIn(t *testing.T) { t.Parallel() - var callCount int32 + var callCount atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - n := atomic.AddInt32(&callCount, 1) + n := callCount.Add(1) if n <= 2 { w.WriteHeader(http.StatusInternalServerError) return @@ -727,7 +727,7 @@ func TestServerErrorRetryHandlerOptIn(t *testing.T) { if !out.Ok { t.Errorf("want ok=true, got ok=%v", out.Ok) } - if got := atomic.LoadInt32(&callCount); got != 3 { + if got := callCount.Load(); got != 3 { t.Errorf("with ServerErrorRetryHandler want 3 calls (2x 500 + 1x 200), got %d", got) } } @@ -767,11 +767,11 @@ func TestIsRetryableConnError(t *testing.T) { type failingThenOKClient struct { httpClient failAttempts int32 - attempts int32 + attempts atomic.Int32 } func (c *failingThenOKClient) Do(req *http.Request) (*http.Response, error) { - n := atomic.AddInt32(&c.attempts, 1) + n := c.attempts.Add(1) if n <= c.failAttempts { return nil, &mockErr{msg: "connection reset by peer"} } @@ -780,12 +780,12 @@ func (c *failingThenOKClient) Do(req *http.Request) (*http.Response, error) { // countAndRespondClient counts Do calls and returns a fixed HTTP status code. type countAndRespondClient struct { - count int32 + count atomic.Int32 code int } func (c *countAndRespondClient) Do(req *http.Request) (*http.Response, error) { - atomic.AddInt32(&c.count, 1) + c.count.Add(1) return &http.Response{ StatusCode: c.code, Body: io.NopCloser(strings.NewReader("")), @@ -796,11 +796,11 @@ func (c *countAndRespondClient) Do(req *http.Request) (*http.Response, error) { // retryCountThenSuccessClient returns 429 for the first needFail Do calls, then 200. type retryCountThenSuccessClient struct { needFail int32 - calls int32 + calls atomic.Int32 } func (c *retryCountThenSuccessClient) Do(req *http.Request) (*http.Response, error) { - n := atomic.AddInt32(&c.calls, 1) + n := c.calls.Add(1) code := http.StatusTooManyRequests if n > c.needFail { code = http.StatusOK @@ -814,11 +814,11 @@ func (c *retryCountThenSuccessClient) Do(req *http.Request) (*http.Response, err // connectionFailingClient always returns a retryable connection error; attempts is incremented each Do. type connectionFailingClient struct { - attempts int32 + attempts atomic.Int32 } func (c *connectionFailingClient) Do(*http.Request) (*http.Response, error) { - atomic.AddInt32(&c.attempts, 1) + c.attempts.Add(1) return nil, &mockErr{msg: "connection reset by peer"} } From b08c432fd32620747b8803c33ad9112c08bc76d0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:02:51 +0100 Subject: [PATCH 588/606] chore(deps): bump actions/checkout from 6 to 7 (#1567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
    Release notes

    Sourced from actions/checkout's releases.

    v7.0.0

    What's Changed

    New Contributors

    Full Changelog: https://github.com/actions/checkout/compare/v6.0.3...v7.0.0

    v6.0.3

    What's Changed

    New Contributors

    Full Changelog: https://github.com/actions/checkout/compare/v6...v6.0.3

    v6.0.2

    What's Changed

    Full Changelog: https://github.com/actions/checkout/compare/v6.0.1...v6.0.2

    v6.0.1

    What's Changed

    Full Changelog: https://github.com/actions/checkout/compare/v6...v6.0.1

    Changelog

    Sourced from actions/checkout's changelog.

    Changelog

    v7.0.0

    v6.0.3

    v6.0.2

    v6.0.1

    v6.0.0

    v5.0.1

    v5.0.0

    v4.3.1

    v4.3.0

    v4.2.2

    v4.2.1

    ... (truncated)

    Commits

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=6&new-version=7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 258295049..af155b250 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: - "1.26" name: test go-${{ matrix.go }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: go-version: ${{ matrix.go }} @@ -39,7 +39,7 @@ jobs: with: go-version: ${{ matrix.go }} cache: false - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: golangci-lint From c1c5406de814a3b8e2e3d626497de5c233470238 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 27 Jun 2026 12:55:55 +0200 Subject: [PATCH 589/606] feat: add `data_visualization` block kit support (#1568) Add support for https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block/ --- CHANGELOG.md | 5 + block.go | 41 +-- block_conv.go | 2 + block_data_visualization.go | 455 +++++++++++++++++++++++++++++ block_data_visualization_test.go | 471 +++++++++++++++++++++++++++++++ 5 files changed, 954 insertions(+), 20 deletions(-) create mode 100644 block_data_visualization.go create mode 100644 block_data_visualization_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e9ce4bf71..52adc1527 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Block Kit: Add support for + [`data_visualization`](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block/) block through `DataVisualizationBlock`. + ## [0.26.0] ### Added diff --git a/block.go b/block.go index 34be9655f..fd281e0ec 100644 --- a/block.go +++ b/block.go @@ -5,26 +5,27 @@ package slack type MessageBlockType string const ( - MBTSection MessageBlockType = "section" - MBTDivider MessageBlockType = "divider" - MBTImage MessageBlockType = "image" - MBTAction MessageBlockType = "actions" - MBTContext MessageBlockType = "context" - MBTContextActions MessageBlockType = "context_actions" - MBTFile MessageBlockType = "file" - MBTInput MessageBlockType = "input" - MBTHeader MessageBlockType = "header" - MBTRichText MessageBlockType = "rich_text" - MBTCall MessageBlockType = "call" - MBTVideo MessageBlockType = "video" - MBTMarkdown MessageBlockType = "markdown" - MBTTable MessageBlockType = "table" - MBTDataTable MessageBlockType = "data_table" - MBTTaskCard MessageBlockType = "task_card" - MBTPlan MessageBlockType = "plan" - MBTAlert MessageBlockType = "alert" - MBTCard MessageBlockType = "card" - MBTCarousel MessageBlockType = "carousel" + MBTSection MessageBlockType = "section" + MBTDivider MessageBlockType = "divider" + MBTImage MessageBlockType = "image" + MBTAction MessageBlockType = "actions" + MBTContext MessageBlockType = "context" + MBTContextActions MessageBlockType = "context_actions" + MBTFile MessageBlockType = "file" + MBTInput MessageBlockType = "input" + MBTHeader MessageBlockType = "header" + MBTRichText MessageBlockType = "rich_text" + MBTCall MessageBlockType = "call" + MBTVideo MessageBlockType = "video" + MBTMarkdown MessageBlockType = "markdown" + MBTTable MessageBlockType = "table" + MBTDataTable MessageBlockType = "data_table" + MBTDataVisualization MessageBlockType = "data_visualization" + MBTTaskCard MessageBlockType = "task_card" + MBTPlan MessageBlockType = "plan" + MBTAlert MessageBlockType = "alert" + MBTCard MessageBlockType = "card" + MBTCarousel MessageBlockType = "carousel" ) // Block defines an interface all block types should implement diff --git a/block_conv.go b/block_conv.go index 6c74ea282..1cce78e93 100644 --- a/block_conv.go +++ b/block_conv.go @@ -81,6 +81,8 @@ func (b *Blocks) UnmarshalJSON(data []byte) error { block = &TableBlock{} case "data_table": block = &DataTableBlock{} + case "data_visualization": + block = &DataVisualizationBlock{} case "task_card": block = &TaskCardBlock{} case "alert": diff --git a/block_data_visualization.go b/block_data_visualization.go new file mode 100644 index 000000000..d6c96a640 --- /dev/null +++ b/block_data_visualization.go @@ -0,0 +1,455 @@ +package slack + +import ( + "encoding/json" + "fmt" + "unicode/utf8" +) + +// DataVisualizationChartType identifies the chart payload inside a +// DataVisualizationBlock. +type DataVisualizationChartType string + +const ( + DataVisualizationChartPie DataVisualizationChartType = "pie" + DataVisualizationChartBar DataVisualizationChartType = "bar" + DataVisualizationChartArea DataVisualizationChartType = "area" + DataVisualizationChartLine DataVisualizationChartType = "line" +) + +// DataVisualizationChart is implemented by every chart payload valid inside a +// DataVisualizationBlock: pie, bar, area, and line. +type DataVisualizationChart interface { + DataVisualizationChartType() DataVisualizationChartType +} + +// DataVisualizationSegment is a labeled slice in a pie chart. +type DataVisualizationSegment struct { + // Label is the display name for this slice, shown in the legend and on hover. + // Slack requires a maximum of 20 characters. + Label string `json:"label"` + // Value is the numeric weight of this slice. Slack requires it to be greater + // than 0. + Value float64 `json:"value"` +} + +// NewDataVisualizationSegment returns a pie-chart segment. +func NewDataVisualizationSegment(label string, value float64) DataVisualizationSegment { + return DataVisualizationSegment{Label: label, Value: value} +} + +// DataVisualizationDataPoint is a labeled point in a bar, area, or line chart. +type DataVisualizationDataPoint struct { + // Label is the x-axis category this point belongs to. Slack requires it to + // match one of AxisConfig.Categories and be at most 20 characters. + Label string `json:"label"` + // Value is the numeric y-axis value. Slack permits negative values. + Value float64 `json:"value"` +} + +// NewDataVisualizationDataPoint returns a chart data point. +func NewDataVisualizationDataPoint(label string, value float64) DataVisualizationDataPoint { + return DataVisualizationDataPoint{Label: label, Value: value} +} + +// DataVisualizationDataSeries is a named sequence of data points. +type DataVisualizationDataSeries struct { + // Name is the human-readable identifier displayed in the chart legend. Slack + // requires it to be unique across all series in the same chart and at most 20 + // characters. + Name string `json:"name"` + // Data is the ordered set of data points. Slack requires 1 to 20 points and + // exactly one point for every AxisConfig.Categories entry. + Data []DataVisualizationDataPoint `json:"data"` +} + +// NewDataVisualizationDataSeries returns a named chart series. +func NewDataVisualizationDataSeries(name string, data ...DataVisualizationDataPoint) DataVisualizationDataSeries { + return DataVisualizationDataSeries{Name: name, Data: append([]DataVisualizationDataPoint{}, data...)} +} + +// DataVisualizationAxisConfig configures axis categories and optional labels. +type DataVisualizationAxisConfig struct { + // Categories defines valid data point labels and their left-to-right display + // order. Slack requires each category label to be at most 20 characters. + Categories []string `json:"categories"` + // XLabel is an optional descriptive title displayed below the x-axis. Slack + // requires a maximum of 50 characters. + XLabel string `json:"x_label,omitempty"` + // YLabel is an optional descriptive title displayed beside the y-axis. Slack + // requires a maximum of 50 characters. + YLabel string `json:"y_label,omitempty"` +} + +// NewDataVisualizationAxisConfig returns an axis configuration with the given +// categories. +func NewDataVisualizationAxisConfig(categories ...string) DataVisualizationAxisConfig { + return DataVisualizationAxisConfig{Categories: append([]string{}, categories...)} +} + +// WithXLabel sets the x-axis label. +func (c DataVisualizationAxisConfig) WithXLabel(label string) DataVisualizationAxisConfig { + c.XLabel = label + return c +} + +// WithYLabel sets the y-axis label. +func (c DataVisualizationAxisConfig) WithYLabel(label string) DataVisualizationAxisConfig { + c.YLabel = label + return c +} + +// DataVisualizationPieChart is a pie chart payload. +type DataVisualizationPieChart struct { + Type DataVisualizationChartType `json:"type"` + // Segments are the labeled slices that make up the pie. Slack requires 1 to + // 6 segments. + Segments []DataVisualizationSegment `json:"segments"` +} + +// DataVisualizationChartType returns the chart variant. +func (c DataVisualizationPieChart) DataVisualizationChartType() DataVisualizationChartType { + return c.Type +} + +// NewDataVisualizationPieChart returns a pie chart with the given segments. +func NewDataVisualizationPieChart(segments ...DataVisualizationSegment) *DataVisualizationPieChart { + return &DataVisualizationPieChart{ + Type: DataVisualizationChartPie, + Segments: append([]DataVisualizationSegment{}, segments...), + } +} + +// DataVisualizationBarChart is a bar chart payload. +type DataVisualizationBarChart struct { + Type DataVisualizationChartType `json:"type"` + // Series are plotted as bar groups. Slack requires 1 to 6 series; for + // multiple series, bars are grouped by data point label. + Series []DataVisualizationDataSeries `json:"series"` + // AxisConfig defines x-axis categories and axis titles. Slack requires this + // field for bar charts. + AxisConfig DataVisualizationAxisConfig `json:"axis_config"` +} + +// DataVisualizationChartType returns the chart variant. +func (c DataVisualizationBarChart) DataVisualizationChartType() DataVisualizationChartType { + return c.Type +} + +// NewDataVisualizationBarChart returns a bar chart. +func NewDataVisualizationBarChart(axisConfig DataVisualizationAxisConfig, series ...DataVisualizationDataSeries) *DataVisualizationBarChart { + return &DataVisualizationBarChart{ + Type: DataVisualizationChartBar, + Series: append([]DataVisualizationDataSeries{}, series...), + AxisConfig: axisConfig, + } +} + +// DataVisualizationAreaChart is an area chart payload. +type DataVisualizationAreaChart struct { + Type DataVisualizationChartType `json:"type"` + // Series are plotted as filled areas. Slack requires 1 to 6 series and + // layers them in array order, with the first series at the back. + Series []DataVisualizationDataSeries `json:"series"` + // AxisConfig defines x-axis categories and axis titles. Slack requires this + // field for area charts. + AxisConfig DataVisualizationAxisConfig `json:"axis_config"` +} + +// DataVisualizationChartType returns the chart variant. +func (c DataVisualizationAreaChart) DataVisualizationChartType() DataVisualizationChartType { + return c.Type +} + +// NewDataVisualizationAreaChart returns an area chart. +func NewDataVisualizationAreaChart(axisConfig DataVisualizationAxisConfig, series ...DataVisualizationDataSeries) *DataVisualizationAreaChart { + return &DataVisualizationAreaChart{ + Type: DataVisualizationChartArea, + Series: append([]DataVisualizationDataSeries{}, series...), + AxisConfig: axisConfig, + } +} + +// DataVisualizationLineChart is a line chart payload. +type DataVisualizationLineChart struct { + Type DataVisualizationChartType `json:"type"` + // Series are plotted as lines. Slack requires 1 to 6 series. + Series []DataVisualizationDataSeries `json:"series"` + // AxisConfig defines x-axis categories and axis titles. Slack requires this + // field for line charts. + AxisConfig DataVisualizationAxisConfig `json:"axis_config"` +} + +// DataVisualizationChartType returns the chart variant. +func (c DataVisualizationLineChart) DataVisualizationChartType() DataVisualizationChartType { + return c.Type +} + +// NewDataVisualizationLineChart returns a line chart. +func NewDataVisualizationLineChart(axisConfig DataVisualizationAxisConfig, series ...DataVisualizationDataSeries) *DataVisualizationLineChart { + return &DataVisualizationLineChart{ + Type: DataVisualizationChartLine, + Series: append([]DataVisualizationDataSeries{}, series...), + AxisConfig: axisConfig, + } +} + +// DataVisualizationBlock defines a block that displays data as a pie, bar, +// area, or line chart. +// +// More Information: https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block/ +type DataVisualizationBlock struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + // Title is the short label displayed above the chart. Slack requires a + // maximum of 50 characters. + Title string `json:"title"` + // Chart is the chart-specific payload. Slack requires one of pie, bar, area, + // or line. + Chart DataVisualizationChart `json:"chart"` +} + +// BlockType returns the type of the block. +func (s DataVisualizationBlock) BlockType() MessageBlockType { + return s.Type +} + +// ID returns the ID of the block. +func (s DataVisualizationBlock) ID() string { + return s.BlockID +} + +// Validate checks whether the block satisfies Slack's documented data +// visualization constraints. +func (s DataVisualizationBlock) Validate() error { + if s.Type != MBTDataVisualization { + return fmt.Errorf("type must be %q", MBTDataVisualization) + } + if s.Title == "" { + return fmt.Errorf("title must have a minimum length of 1") + } + if runeLen(s.Title) > 50 { + return fmt.Errorf("title cannot be longer than 50 characters") + } + if isNilDataVisualizationChart(s.Chart) { + return fmt.Errorf("chart is required") + } + + switch chart := s.Chart.(type) { + case *DataVisualizationPieChart: + return validateDataVisualizationPieChart(chart) + case DataVisualizationPieChart: + return validateDataVisualizationPieChart(&chart) + case *DataVisualizationBarChart: + return validateDataVisualizationSeriesChart(DataVisualizationChartBar, chart.Type, chart.Series, chart.AxisConfig) + case DataVisualizationBarChart: + return validateDataVisualizationSeriesChart(DataVisualizationChartBar, chart.Type, chart.Series, chart.AxisConfig) + case *DataVisualizationAreaChart: + return validateDataVisualizationSeriesChart(DataVisualizationChartArea, chart.Type, chart.Series, chart.AxisConfig) + case DataVisualizationAreaChart: + return validateDataVisualizationSeriesChart(DataVisualizationChartArea, chart.Type, chart.Series, chart.AxisConfig) + case *DataVisualizationLineChart: + return validateDataVisualizationSeriesChart(DataVisualizationChartLine, chart.Type, chart.Series, chart.AxisConfig) + case DataVisualizationLineChart: + return validateDataVisualizationSeriesChart(DataVisualizationChartLine, chart.Type, chart.Series, chart.AxisConfig) + default: + return fmt.Errorf("unsupported data_visualization chart type %q", s.Chart.DataVisualizationChartType()) + } +} + +func isNilDataVisualizationChart(chart DataVisualizationChart) bool { + switch chart := chart.(type) { + case nil: + return true + case *DataVisualizationPieChart: + return chart == nil + case *DataVisualizationBarChart: + return chart == nil + case *DataVisualizationAreaChart: + return chart == nil + case *DataVisualizationLineChart: + return chart == nil + default: + return false + } +} + +func validateDataVisualizationPieChart(chart *DataVisualizationPieChart) error { + if chart.Type != DataVisualizationChartPie { + return fmt.Errorf("chart type must be %q", DataVisualizationChartPie) + } + if len(chart.Segments) < 1 { + return fmt.Errorf("pie chart must have at least 1 segment") + } + if len(chart.Segments) > 6 { + return fmt.Errorf("pie chart cannot have more than 6 segments") + } + for i, segment := range chart.Segments { + if segment.Label == "" { + return fmt.Errorf("segment %d label must have a minimum length of 1", i) + } + if runeLen(segment.Label) > 20 { + return fmt.Errorf("segment %d label cannot be longer than 20 characters", i) + } + if segment.Value <= 0 { + return fmt.Errorf("segment %d value must be greater than 0", i) + } + } + return nil +} + +func validateDataVisualizationSeriesChart( + expectedType DataVisualizationChartType, + actualType DataVisualizationChartType, + series []DataVisualizationDataSeries, + axisConfig DataVisualizationAxisConfig, +) error { + if actualType != expectedType { + return fmt.Errorf("chart type must be %q", expectedType) + } + if len(series) < 1 { + return fmt.Errorf("%s chart must have at least 1 series", expectedType) + } + if len(series) > 6 { + return fmt.Errorf("%s chart cannot have more than 6 series", expectedType) + } + if len(axisConfig.Categories) == 0 { + return fmt.Errorf("axis_config.categories must have at least 1 category") + } + if runeLen(axisConfig.XLabel) > 50 { + return fmt.Errorf("axis_config.x_label cannot be longer than 50 characters") + } + if runeLen(axisConfig.YLabel) > 50 { + return fmt.Errorf("axis_config.y_label cannot be longer than 50 characters") + } + + categories := make(map[string]struct{}, len(axisConfig.Categories)) + for i, category := range axisConfig.Categories { + if category == "" { + return fmt.Errorf("axis_config.categories[%d] must have a minimum length of 1", i) + } + if runeLen(category) > 20 { + return fmt.Errorf("axis_config.categories[%d] cannot be longer than 20 characters", i) + } + if _, exists := categories[category]; exists { + return fmt.Errorf("axis_config.categories must not contain duplicate category %q", category) + } + categories[category] = struct{}{} + } + + seriesNames := make(map[string]struct{}, len(series)) + for i, s := range series { + if s.Name == "" { + return fmt.Errorf("series %d name must have a minimum length of 1", i) + } + if runeLen(s.Name) > 20 { + return fmt.Errorf("series %d name cannot be longer than 20 characters", i) + } + if _, exists := seriesNames[s.Name]; exists { + return fmt.Errorf("series names must be unique: %q", s.Name) + } + seriesNames[s.Name] = struct{}{} + + if len(s.Data) < 1 { + return fmt.Errorf("series %d data must have at least 1 point", i) + } + if len(s.Data) > 20 { + return fmt.Errorf("series %d data cannot have more than 20 points", i) + } + if len(s.Data) != len(axisConfig.Categories) { + return fmt.Errorf("series %d data must contain exactly one point for every category", i) + } + + seenLabels := make(map[string]struct{}, len(s.Data)) + for j, point := range s.Data { + if point.Label == "" { + return fmt.Errorf("series %d data point %d label must have a minimum length of 1", i, j) + } + if runeLen(point.Label) > 20 { + return fmt.Errorf("series %d data point %d label cannot be longer than 20 characters", i, j) + } + if _, exists := categories[point.Label]; !exists { + return fmt.Errorf("series %d data point %d label %q must match axis_config.categories", i, j, point.Label) + } + if _, exists := seenLabels[point.Label]; exists { + return fmt.Errorf("series %d data must not contain duplicate label %q", i, point.Label) + } + seenLabels[point.Label] = struct{}{} + } + } + + return nil +} + +func runeLen(s string) int { + return utf8.RuneCountInString(s) +} + +// UnmarshalJSON parses the chart-specific payload. +func (s *DataVisualizationBlock) UnmarshalJSON(data []byte) error { + var raw struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id"` + Title string `json:"title"` + Chart json.RawMessage `json:"chart"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + var probe struct { + Type DataVisualizationChartType `json:"type"` + } + if err := json.Unmarshal(raw.Chart, &probe); err != nil { + return err + } + + var chart DataVisualizationChart + switch probe.Type { + case DataVisualizationChartPie: + chart = &DataVisualizationPieChart{} + case DataVisualizationChartBar: + chart = &DataVisualizationBarChart{} + case DataVisualizationChartArea: + chart = &DataVisualizationAreaChart{} + case DataVisualizationChartLine: + chart = &DataVisualizationLineChart{} + default: + return fmt.Errorf("unsupported data_visualization chart type %q", probe.Type) + } + + if err := json.Unmarshal(raw.Chart, chart); err != nil { + return err + } + + s.Type = raw.Type + s.BlockID = raw.BlockID + s.Title = raw.Title + s.Chart = chart + return nil +} + +// DataVisualizationBlockOption configures optional fields on a new +// DataVisualizationBlock. +type DataVisualizationBlockOption func(*DataVisualizationBlock) + +// DataVisualizationBlockOptionBlockID sets the block ID. +func DataVisualizationBlockOptionBlockID(blockID string) DataVisualizationBlockOption { + return func(b *DataVisualizationBlock) { b.BlockID = blockID } +} + +// NewDataVisualizationBlock returns a new DataVisualizationBlock with the given +// title and chart payload. +func NewDataVisualizationBlock(title string, chart DataVisualizationChart, options ...DataVisualizationBlockOption) *DataVisualizationBlock { + block := &DataVisualizationBlock{ + Type: MBTDataVisualization, + Title: title, + Chart: chart, + } + for _, opt := range options { + if opt != nil { + opt(block) + } + } + return block +} diff --git a/block_data_visualization_test.go b/block_data_visualization_test.go new file mode 100644 index 000000000..f112b5bb9 --- /dev/null +++ b/block_data_visualization_test.go @@ -0,0 +1,471 @@ +package slack + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewDataVisualizationBlock(t *testing.T) { + chart := NewDataVisualizationPieChart( + NewDataVisualizationSegment("Kit Kat", 45), + NewDataVisualizationSegment("Twix", 28), + ) + block := NewDataVisualizationBlock("Candy Bars", chart, + DataVisualizationBlockOptionBlockID("dv-1"), + ) + + assert.Equal(t, MBTDataVisualization, block.BlockType()) + assert.Equal(t, "data_visualization", string(block.Type)) + assert.Equal(t, "dv-1", block.ID()) + assert.Equal(t, "Candy Bars", block.Title) + assert.Equal(t, DataVisualizationChartPie, block.Chart.DataVisualizationChartType()) +} + +func TestNewDataVisualizationBlockWithNilOption(t *testing.T) { + assert.NotPanics(t, func() { + NewDataVisualizationBlock("title", NewDataVisualizationPieChart(), nil) + }, "should not panic when nil option passed") +} + +func TestDataVisualizationBlockPieJSONRoundTrip(t *testing.T) { + payload := `{ + "type": "data_visualization", + "block_id": "dv-pie", + "title": "My Favorite Candy Bars", + "chart": { + "type": "pie", + "segments": [ + {"label": "Kit Kat", "value": 45}, + {"label": "Twix", "value": 28}, + {"label": "Crunch", "value": 18}, + {"label": "Milky Way", "value": 9} + ] + } + }` + + var block DataVisualizationBlock + require.NoError(t, json.Unmarshal([]byte(payload), &block)) + + assert.Equal(t, MBTDataVisualization, block.BlockType()) + assert.Equal(t, "dv-pie", block.ID()) + assert.Equal(t, "My Favorite Candy Bars", block.Title) + pie, ok := block.Chart.(*DataVisualizationPieChart) + require.True(t, ok) + require.Len(t, pie.Segments, 4) + assert.Equal(t, "Kit Kat", pie.Segments[0].Label) + assert.Equal(t, float64(45), pie.Segments[0].Value) + + marshalled, err := json.Marshal(block) + require.NoError(t, err) + + var expected, actual map[string]any + require.NoError(t, json.Unmarshal([]byte(payload), &expected)) + require.NoError(t, json.Unmarshal(marshalled, &actual)) + assert.Equal(t, expected, actual) +} + +func TestDataVisualizationBlockSeriesChartsJSONRoundTrip(t *testing.T) { + payload := `[ + { + "type": "data_visualization", + "block_id": "dv-bar", + "title": "Pie Tastiness", + "chart": { + "type": "bar", + "series": [ + { + "name": "Pies", + "data": [ + {"label": "Pumpkin", "value": 70}, + {"label": "Blueberry", "value": 90} + ] + } + ], + "axis_config": { + "categories": ["Pumpkin", "Blueberry"], + "x_label": "Pies", + "y_label": "Percentage of Tastiness" + } + } + }, + { + "type": "data_visualization", + "block_id": "dv-area", + "title": "Daily Active Users", + "chart": { + "type": "area", + "series": [ + { + "name": "Free Tier", + "data": [ + {"label": "Mon", "value": 12000}, + {"label": "Tue", "value": 13500} + ] + }, + { + "name": "Paid Tier", + "data": [ + {"label": "Mon", "value": 4500}, + {"label": "Tue", "value": 4800} + ] + } + ], + "axis_config": { + "categories": ["Mon", "Tue"], + "x_label": "Day", + "y_label": "Users" + } + } + }, + { + "type": "data_visualization", + "block_id": "dv-line", + "title": "Weekly Paper Sales", + "chart": { + "type": "line", + "series": [ + { + "name": "Website", + "data": [ + {"label": "Week 1", "value": 32000}, + {"label": "Week 2", "value": 35000} + ] + } + ], + "axis_config": { + "categories": ["Week 1", "Week 2"], + "x_label": "Week", + "y_label": "Paper Sales (USD)" + } + } + } + ]` + + var blocks Blocks + require.NoError(t, json.Unmarshal([]byte(payload), &blocks)) + require.Len(t, blocks.BlockSet, 3) + + bar, ok := blocks.BlockSet[0].(*DataVisualizationBlock) + require.True(t, ok, "expected *DataVisualizationBlock, got %T", blocks.BlockSet[0]) + assert.Equal(t, MBTDataVisualization, bar.BlockType()) + assert.Equal(t, "dv-bar", bar.ID()) + barChart, ok := bar.Chart.(*DataVisualizationBarChart) + require.True(t, ok) + require.Len(t, barChart.Series, 1) + assert.Equal(t, "Pies", barChart.Series[0].Name) + assert.Equal(t, []string{"Pumpkin", "Blueberry"}, barChart.AxisConfig.Categories) + + area := blocks.BlockSet[1].(*DataVisualizationBlock) + areaChart, ok := area.Chart.(*DataVisualizationAreaChart) + require.True(t, ok) + require.Len(t, areaChart.Series, 2) + assert.Equal(t, DataVisualizationChartArea, areaChart.DataVisualizationChartType()) + + line := blocks.BlockSet[2].(*DataVisualizationBlock) + lineChart, ok := line.Chart.(*DataVisualizationLineChart) + require.True(t, ok) + assert.Equal(t, "Week", lineChart.AxisConfig.XLabel) + assert.Equal(t, "Paper Sales (USD)", lineChart.AxisConfig.YLabel) + + marshalled, err := json.Marshal(blocks) + require.NoError(t, err) + + var expected, actual []map[string]any + require.NoError(t, json.Unmarshal([]byte(payload), &expected)) + require.NoError(t, json.Unmarshal(marshalled, &actual)) + assert.Equal(t, expected, actual) +} + +func TestDataVisualizationBlockConstructorsMarshal(t *testing.T) { + axis := NewDataVisualizationAxisConfig("Week 1", "Week 2"). + WithXLabel("Week"). + WithYLabel("Paper Sales") + series := NewDataVisualizationDataSeries("Website", + NewDataVisualizationDataPoint("Week 1", 32000), + NewDataVisualizationDataPoint("Week 2", 35000), + ) + block := NewDataVisualizationBlock( + "Weekly Paper Sales", + NewDataVisualizationLineChart(axis, series), + ) + + marshalled, err := json.Marshal(block) + require.NoError(t, err) + assert.JSONEq(t, `{ + "type": "data_visualization", + "title": "Weekly Paper Sales", + "chart": { + "type": "line", + "series": [ + { + "name": "Website", + "data": [ + {"label": "Week 1", "value": 32000}, + {"label": "Week 2", "value": 35000} + ] + } + ], + "axis_config": { + "categories": ["Week 1", "Week 2"], + "x_label": "Week", + "y_label": "Paper Sales" + } + } + }`, string(marshalled)) +} + +func TestDataVisualizationBlockUnknownChartType(t *testing.T) { + payload := `{ + "type": "data_visualization", + "title": "Mystery Chart", + "chart": { + "type": "scatter", + "series": [] + } + }` + + var block DataVisualizationBlock + err := json.Unmarshal([]byte(payload), &block) + require.Error(t, err) + assert.Contains(t, err.Error(), "scatter") +} + +func TestDataVisualizationBlockValidate(t *testing.T) { + validPie := NewDataVisualizationBlock("Candy Bars", + NewDataVisualizationPieChart(NewDataVisualizationSegment("Kit Kat", 45)), + ) + require.NoError(t, validPie.Validate()) + + validLine := NewDataVisualizationBlock("Weekly Paper Sales", + NewDataVisualizationLineChart( + NewDataVisualizationAxisConfig("Week 1", "Week 2"). + WithXLabel("Week"). + WithYLabel("Paper Sales"), + NewDataVisualizationDataSeries("Website", + NewDataVisualizationDataPoint("Week 1", 32000), + NewDataVisualizationDataPoint("Week 2", -10), + ), + ), + ) + require.NoError(t, validLine.Validate(), "negative data point values are allowed") +} + +func TestDataVisualizationBlockValidateFieldLimits(t *testing.T) { + tests := []struct { + name string + block *DataVisualizationBlock + errorText string + }{ + { + name: "missing title", + block: NewDataVisualizationBlock("", + NewDataVisualizationPieChart(NewDataVisualizationSegment("Kit Kat", 45)), + ), + errorText: "title must have a minimum length of 1", + }, + { + name: "title too long", + block: NewDataVisualizationBlock(strings.Repeat("a", 51), + NewDataVisualizationPieChart(NewDataVisualizationSegment("Kit Kat", 45)), + ), + errorText: "title cannot be longer than 50 characters", + }, + { + name: "missing chart", + block: &DataVisualizationBlock{ + Type: MBTDataVisualization, + Title: "No Chart", + }, + errorText: "chart is required", + }, + { + name: "typed nil chart", + block: &DataVisualizationBlock{ + Type: MBTDataVisualization, + Title: "Typed Nil Chart", + Chart: (*DataVisualizationLineChart)(nil), + }, + errorText: "chart is required", + }, + { + name: "segment label too long", + block: NewDataVisualizationBlock("Candy Bars", + NewDataVisualizationPieChart(NewDataVisualizationSegment(strings.Repeat("a", 21), 45)), + ), + errorText: "segment 0 label cannot be longer than 20 characters", + }, + { + name: "segment value is zero", + block: NewDataVisualizationBlock("Candy Bars", + NewDataVisualizationPieChart(NewDataVisualizationSegment("Kit Kat", 0)), + ), + errorText: "segment 0 value must be greater than 0", + }, + { + name: "series name too long", + block: NewDataVisualizationBlock("Weekly Paper Sales", + NewDataVisualizationLineChart( + NewDataVisualizationAxisConfig("Week 1"), + NewDataVisualizationDataSeries(strings.Repeat("a", 21), + NewDataVisualizationDataPoint("Week 1", 32000), + ), + ), + ), + errorText: "series 0 name cannot be longer than 20 characters", + }, + { + name: "category too long", + block: NewDataVisualizationBlock("Weekly Paper Sales", + NewDataVisualizationLineChart( + NewDataVisualizationAxisConfig(strings.Repeat("a", 21)), + NewDataVisualizationDataSeries("Website", + NewDataVisualizationDataPoint(strings.Repeat("a", 21), 32000), + ), + ), + ), + errorText: "axis_config.categories[0] cannot be longer than 20 characters", + }, + { + name: "axis label too long", + block: NewDataVisualizationBlock("Weekly Paper Sales", + NewDataVisualizationLineChart( + NewDataVisualizationAxisConfig("Week 1").WithXLabel(strings.Repeat("a", 51)), + NewDataVisualizationDataSeries("Website", + NewDataVisualizationDataPoint("Week 1", 32000), + ), + ), + ), + errorText: "axis_config.x_label cannot be longer than 50 characters", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.block.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorText) + }) + } +} + +func TestDataVisualizationBlockValidatePieConstraints(t *testing.T) { + noSegments := NewDataVisualizationBlock("Candy Bars", NewDataVisualizationPieChart()) + err := noSegments.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "pie chart must have at least 1 segment") + + tooManySegments := NewDataVisualizationBlock("Candy Bars", + NewDataVisualizationPieChart( + NewDataVisualizationSegment("A", 1), + NewDataVisualizationSegment("B", 1), + NewDataVisualizationSegment("C", 1), + NewDataVisualizationSegment("D", 1), + NewDataVisualizationSegment("E", 1), + NewDataVisualizationSegment("F", 1), + NewDataVisualizationSegment("G", 1), + ), + ) + err = tooManySegments.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "pie chart cannot have more than 6 segments") +} + +func TestDataVisualizationBlockValidateSeriesRuntimeRules(t *testing.T) { + tests := []struct { + name string + block *DataVisualizationBlock + errorText string + }{ + { + name: "missing axis config categories", + block: NewDataVisualizationBlock("Weekly Paper Sales", + NewDataVisualizationLineChart(DataVisualizationAxisConfig{}, + NewDataVisualizationDataSeries("Website", + NewDataVisualizationDataPoint("Week 1", 32000), + ), + ), + ), + errorText: "axis_config.categories must have at least 1 category", + }, + { + name: "data point label outside categories", + block: NewDataVisualizationBlock("Weekly Paper Sales", + NewDataVisualizationLineChart( + NewDataVisualizationAxisConfig("Week 1"), + NewDataVisualizationDataSeries("Website", + NewDataVisualizationDataPoint("Week 2", 32000), + ), + ), + ), + errorText: `label "Week 2" must match axis_config.categories`, + }, + { + name: "omitted category point", + block: NewDataVisualizationBlock("Weekly Paper Sales", + NewDataVisualizationLineChart( + NewDataVisualizationAxisConfig("Week 1", "Week 2"), + NewDataVisualizationDataSeries("Website", + NewDataVisualizationDataPoint("Week 1", 32000), + ), + ), + ), + errorText: "series 0 data must contain exactly one point for every category", + }, + { + name: "duplicate data label", + block: NewDataVisualizationBlock("Weekly Paper Sales", + NewDataVisualizationLineChart( + NewDataVisualizationAxisConfig("Week 1", "Week 2"), + NewDataVisualizationDataSeries("Website", + NewDataVisualizationDataPoint("Week 1", 32000), + NewDataVisualizationDataPoint("Week 1", 35000), + ), + ), + ), + errorText: `series 0 data must not contain duplicate label "Week 1"`, + }, + { + name: "duplicate series name", + block: NewDataVisualizationBlock("Weekly Paper Sales", + NewDataVisualizationLineChart( + NewDataVisualizationAxisConfig("Week 1"), + NewDataVisualizationDataSeries("Website", + NewDataVisualizationDataPoint("Week 1", 32000), + ), + NewDataVisualizationDataSeries("Website", + NewDataVisualizationDataPoint("Week 1", 35000), + ), + ), + ), + errorText: `series names must be unique: "Website"`, + }, + { + name: "too many series", + block: NewDataVisualizationBlock("Weekly Paper Sales", + NewDataVisualizationLineChart( + NewDataVisualizationAxisConfig("Week 1"), + NewDataVisualizationDataSeries("A", NewDataVisualizationDataPoint("Week 1", 1)), + NewDataVisualizationDataSeries("B", NewDataVisualizationDataPoint("Week 1", 1)), + NewDataVisualizationDataSeries("C", NewDataVisualizationDataPoint("Week 1", 1)), + NewDataVisualizationDataSeries("D", NewDataVisualizationDataPoint("Week 1", 1)), + NewDataVisualizationDataSeries("E", NewDataVisualizationDataPoint("Week 1", 1)), + NewDataVisualizationDataSeries("F", NewDataVisualizationDataPoint("Week 1", 1)), + NewDataVisualizationDataSeries("G", NewDataVisualizationDataPoint("Week 1", 1)), + ), + ), + errorText: "line chart cannot have more than 6 series", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.block.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorText) + }) + } +} From 46d27c4ed1021d20c466a64f9af82bdcaddcceac Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 27 Jun 2026 16:26:56 +0200 Subject: [PATCH 590/606] feat: add enterprise fields to interaction team payloads --- CHANGELOG.md | 2 ++ info.go | 10 ++++++---- interactions_test.go | 31 +++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52adc1527..de4421633 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Block Kit: Add support for [`data_visualization`](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block/) block through `DataVisualizationBlock`. +- Interaction payloads: `Team` now preserves `enterprise_id` and `enterprise_name` when + Slack includes Enterprise org details in interaction payload `team` objects. ## [0.26.0] diff --git a/info.go b/info.go index c2fa99f0e..8fb1f1367 100644 --- a/info.go +++ b/info.go @@ -425,10 +425,12 @@ func (t *JSONTime) UnmarshalJSON(buf []byte) error { // Team contains details about a team type Team struct { - ID string `json:"id"` - Name string `json:"name"` - Domain string `json:"domain"` - Icons *Icons `json:"icon,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Domain string `json:"domain"` + EnterpriseID string `json:"enterprise_id,omitempty"` + EnterpriseName string `json:"enterprise_name,omitempty"` + Icons *Icons `json:"icon,omitempty"` } // Icons contains the image URLs for the team icons in various sizes diff --git a/interactions_test.go b/interactions_test.go index 9e2743449..4f5b6b061 100644 --- a/interactions_test.go +++ b/interactions_test.go @@ -514,6 +514,37 @@ func TestInteractionCallback_UserUsername(t *testing.T) { assert.Equal(t, "T9TK3CUKW", cb.User.TeamID) } +func TestInteractionCallback_BlockSuggestionTeamEnterpriseFields(t *testing.T) { + raw := []byte(`{ + "type": "block_suggestion", + "user": { + "id": "U123456", + "name": "example", + "team_id": "T123456" + }, + "team": { + "id": "T123456", + "name": "example-team", + "domain": "example", + "enterprise_id": "E123456", + "enterprise_name": "Example Enterprise" + }, + "api_app_id": "A123456", + "block_id": "enterprise_team", + "action_id": "team_lookup", + "value": "test" + }`) + + var cb InteractionCallback + assert.NoError(t, json.Unmarshal(raw, &cb)) + assert.Equal(t, InteractionTypeBlockSuggestion, cb.Type) + assert.Equal(t, "T123456", cb.Team.ID) + assert.Equal(t, "example-team", cb.Team.Name) + assert.Equal(t, "example", cb.Team.Domain) + assert.Equal(t, "E123456", cb.Team.EnterpriseID) + assert.Equal(t, "Example Enterprise", cb.Team.EnterpriseName) +} + func TestInteractionCallback_Container_Marshal_And_Unmarshal(t *testing.T) { // Contrived - you generally won't see all of the fields set in a single message raw := []byte( From 7609cd3542fc2a2459986c31b7f4e61c7c83a190 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 27 Jun 2026 16:43:20 +0200 Subject: [PATCH 591/606] Bump slack-go/slack to 0.27.0 --- CHANGELOG.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de4421633..62c521473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.27.0] + ### Added - Block Kit: Add support for @@ -609,8 +611,9 @@ for details. [#1196]: https://github.com/slack-go/slack/issues/1196 [#1547]: https://github.com/slack-go/slack/pull/1547 -[Unreleased]: https://github.com/slack-go/slack/compare/v0.26.0...HEAD -[0.25.0]: https://github.com/slack-go/slack/compare/v0.25.0...v0.26.0 +[Unreleased]: https://github.com/slack-go/slack/compare/v0.27.0...HEAD +[0.27.0]: https://github.com/slack-go/slack/compare/v0.26.0...v0.27.0 +[0.26.0]: https://github.com/slack-go/slack/compare/v0.25.0...v0.26.0 [0.25.0]: https://github.com/slack-go/slack/compare/v0.24.0...v0.25.0 [0.24.0]: https://github.com/slack-go/slack/compare/v0.23.1...v0.24.0 [0.23.1]: https://github.com/slack-go/slack/compare/v0.23.0...v0.23.1 From 98300b2b5b39fdbb9b0e29cecf5773927dcea27e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:29:41 +0100 Subject: [PATCH 592/606] chore(deps): bump golangci/golangci-lint-action from 9.2.1 to 9.3.0 (#1569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 9.2.1 to 9.3.0.
    Release notes

    Sourced from golangci/golangci-lint-action's releases.

    v9.3.0

    What's Changed

    Changes

    Dependencies

    Full Changelog: https://github.com/golangci/golangci-lint-action/compare/v9.2.1...v9.3.0

    Commits
    • ba0d7d2 chore: prepare release v9.3.0
    • efd0857 feat: add no-run-logs-group as experimental option (#1403)
    • ed485de build(deps): bump undici from 6.24.0 to 6.27.0
    • 8872e8d build(deps-dev): bump js-yaml from 4.1.1 to 4.2.0 (#1400)
    • b163415 build(deps): bump tmp from 0.2.6 to 0.2.7 (#1399)
    • e52a9f8 build(deps): bump github/codeql-action from 4.35.5 to 4.36.0 in the github-ac...
    • 8182aa3 build(deps): bump tmp from 0.2.5 to 0.2.6 (#1397)
    • 5403a41 build(deps): bump github/codeql-action from 4.35.4 to 4.35.5 in the github-ac...
    • See full diff in compare view

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=golangci/golangci-lint-action&package-manager=github_actions&previous-version=9.2.1&new-version=9.3.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index af155b250..c49531b18 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,7 +43,7 @@ jobs: with: fetch-depth: 0 - name: golangci-lint - uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: version: v2.10.1 - name: staticcheck From 4738e22644a176d5d7bd9608511e3183e504cfa7 Mon Sep 17 00:00:00 2001 From: max <87073104+maxtaran2010@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:13:40 -0700 Subject: [PATCH 593/606] fix: correct typos in comments and debug strings (#1570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixed three typos in code comments and a debug log string: - `audit.go`: `entires` → `entries` in the `GetAuditLogs` godoc comment - `websocket_managed_conn.go`: `disonnected` → `disconnected` in an RTM debug log message - `socketmode/socketmode_handler.go`: `incomming` → `incoming` in a function comment No logic changes — comments and strings only. Co-authored-by: maxtaran2010 --- audit.go | 2 +- socketmode/socketmode_handler.go | 2 +- websocket_managed_conn.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/audit.go b/audit.go index f258a412d..9ec39f73e 100644 --- a/audit.go +++ b/audit.go @@ -115,7 +115,7 @@ func (api *Client) auditLogsRequest(ctx context.Context, path string, values url return response, response.Err() } -// GetAuditLogs retrieves a page of audit entires according to the parameters given +// GetAuditLogs retrieves a page of audit entries according to the parameters given func (api *Client) GetAuditLogs(params AuditLogParameters) (entries []AuditEntry, nextCursor string, err error) { return api.GetAuditLogsContext(context.Background(), params) } diff --git a/socketmode/socketmode_handler.go b/socketmode/socketmode_handler.go index baf4b166e..330a644ce 100644 --- a/socketmode/socketmode_handler.go +++ b/socketmode/socketmode_handler.go @@ -172,7 +172,7 @@ func (r *SocketmodeHandler) RunEventLoopContext(ctx context.Context) error { return r.Client.RunContext(ctx) } -// Call the dispatcher for each incomming event +// Call the dispatcher for each incoming event func (r *SocketmodeHandler) runEventLoop(ctx context.Context) { for { select { diff --git a/websocket_managed_conn.go b/websocket_managed_conn.go index d2539aacc..f58742b06 100644 --- a/websocket_managed_conn.go +++ b/websocket_managed_conn.go @@ -412,7 +412,7 @@ func (rtm *RTM) receiveIncomingEvent(events chan json.RawMessage) error { select { case events <- event: case <-rtm.disconnected: - rtm.Debugln("disonnected while attempting to send raw event") + rtm.Debugln("disconnected while attempting to send raw event") } } From 8e93d85bf1cac39919c14f0c8b32e8570dd79573 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:08:42 +0100 Subject: [PATCH 594/606] chore(deps): bump actions/stale from 10.3.0 to 10.4.0 (#1573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/stale](https://github.com/actions/stale) from 10.3.0 to 10.4.0.
    Release notes

    Sourced from actions/stale's releases.

    v10.4.0

    What's Changed

    Bug Fix

    Dependency Updates

    New Contributors

    Full Changelog: https://github.com/actions/stale/compare/v10.3.0...v10.4.0

    Commits
    • 1e223db Bump undici to 6.27.0 via override, clean up stale license files, and version...
    • 9461cb1 fix: only-issue-types does not affect PRs (#1338)
    • See full diff in compare view

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/stale&package-manager=github_actions&previous-version=10.3.0&new-version=10.4.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index e2a84f8ce..e9644966a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -13,7 +13,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 with: any-of-labels: 'feedback given' days-before-stale: 45 From 03b520326dc8528cf63e3c9272ef4afc83c419d9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:27:48 +0100 Subject: [PATCH 595/606] chore(deps): bump actions/setup-go from 6 to 7 (#1576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6 to 7.
    Release notes

    Sourced from actions/setup-go's releases.

    v7.0.0

    What's Changed

    New Contributors

    Full Changelog: https://github.com/actions/setup-go/compare/v6...v7.0.0

    v6.5.0

    What's Changed

    Dependency update

    New Contributors

    Full Changelog: https://github.com/actions/setup-go/compare/v6...v6.5.0

    v6.4.0

    What's Changed

    Enhancement

    Dependency update

    Documentation update

    New Contributors

    Full Changelog: https://github.com/actions/setup-go/compare/v6...v6.4.0

    v6.3.0

    What's Changed

    Full Changelog: https://github.com/actions/setup-go/compare/v6...v6.3.0

    v6.2.0

    What's Changed

    ... (truncated)

    Commits

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/setup-go&package-manager=github_actions&previous-version=6&new-version=7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c49531b18..f42e5f209 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: name: test go-${{ matrix.go }} steps: - uses: actions/checkout@v7 - - uses: actions/setup-go@v6 + - uses: actions/setup-go@v7 with: go-version: ${{ matrix.go }} - name: run test @@ -35,7 +35,7 @@ jobs: - "1.26" name: lint go-${{ matrix.go }} steps: - - uses: actions/setup-go@v6 + - uses: actions/setup-go@v7 with: go-version: ${{ matrix.go }} cache: false From ab81038843d74ae5523ce2121344f312bd937052 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:57:10 +0100 Subject: [PATCH 596/606] chore(deps): bump actions/stale from 10.4.0 to 11.0.0 (#1578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/stale](https://github.com/actions/stale) from 10.4.0 to 11.0.0.
    Release notes

    Sourced from actions/stale's releases.

    v11.0.0

    What's Changed

    Enhancement

    Dependency Update

    Full Changelog: https://github.com/actions/stale/compare/v10...v11.0.0

    Commits
    • 4391f3d Fix 24 high severity vulnerabilities by overriding brace-expansion to 5.0.8 (...
    • eaf9131 refactor: update imports to use ES module syntax and improve test structure (...
    • See full diff in compare view

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/stale&package-manager=github_actions&previous-version=10.4.0&new-version=11.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index e9644966a..e0812bd1a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -13,7 +13,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 with: any-of-labels: 'feedback given' days-before-stale: 45 From 46b261db635904f61121613418912e1bc9812026 Mon Sep 17 00:00:00 2001 From: kenta-ja8 <50163637+kenta-ja8@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:18:13 +0900 Subject: [PATCH 597/606] feat: add `Channels` to share an uploaded file with multiple conversations (#1579) ## Summary `files.completeUploadExternal` accepts a `channels` parameter that shares a single uploaded file with up to 100 conversations in one request. slack-go exposes only the singular `channel_id` through `CompleteUploadExternalParameters.Channel`, so sharing one file with N conversations requires N separate uploads. Slack added `channels` in early 2025 and the official SDKs followed ([java](https://github.com/slackapi/java-slack-sdk/pull/1427), [python](https://github.com/slackapi/python-slack-sdk/pull/1641)). slack-go still has the shape it needed when the v2 upload flow was single-channel only (#1171). ## Changes - `CompleteUploadExternalParameters` gains `Channels []string`, sent as a comma-separated `channels` value. `Channel` is untouched, so existing callers are unaffected. - `UploadFileParameters` gains `Channels []string`, forwarded to `CompleteUploadExternalContext`. - `TestCompleteUploadExternalContext` gains a `Channels` case. Setting both `Channel` and `Channels` sends both parameters, matching the official SDKs, which do not treat them as mutually exclusive. Verified against a real workspace with `Channels` set and `Channel` empty. --- files.go | 10 ++++++++-- files_test.go | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/files.go b/files.go index a88c9c7d2..155beba2e 100644 --- a/files.go +++ b/files.go @@ -162,6 +162,7 @@ type UploadFileParameters struct { InitialComment string Blocks Blocks Channel string + Channels []string ThreadTimestamp string AltTxt string SnippetType string @@ -197,6 +198,7 @@ type CompleteUploadExternalParameters struct { Files []FileSummary Blocks Blocks Channel string + Channels []string InitialComment string ThreadTimestamp string } @@ -489,7 +491,7 @@ func (api *Client) UploadToURL(ctx context.Context, params UploadToURLParameters return err } -// CompleteUploadExternalContext once files are uploaded, this completes the upload and shares it to the specified channel +// CompleteUploadExternalContext once files are uploaded, this completes the upload and shares it to the specified channels // Slack API docs: https://api.slack.com/methods/files.completeUploadExternal func (api *Client) CompleteUploadExternalContext(ctx context.Context, params CompleteUploadExternalParameters) (file *CompleteUploadExternalResponse, err error) { filesBytes, err := json.Marshal(params.Files) @@ -505,6 +507,9 @@ func (api *Client) CompleteUploadExternalContext(ctx context.Context, params Com if params.Channel != "" { values.Add("channel_id", params.Channel) } + if len(params.Channels) > 0 { + values.Add("channels", strings.Join(params.Channels, ",")) + } if params.InitialComment != "" { values.Add("initial_comment", params.InitialComment) } @@ -538,7 +543,7 @@ func (api *Client) UploadFile(params UploadFileParameters) (*FileSummary, error) // UploadFileContext uploads file to a given slack channel using 3 steps - // 1. Get an upload URL using files.getUploadURLExternal API // 2. Send the file as a post to the URL provided by slack -// 3. Complete the upload and share it to the specified channel using files.completeUploadExternal +// 3. Complete the upload and share it to the specified channels using files.completeUploadExternal // // Slack Docs: https://api.slack.com/messaging/files#uploading_files func (api *Client) UploadFileContext(ctx context.Context, params UploadFileParameters) (file *FileSummary, err error) { @@ -576,6 +581,7 @@ func (api *Client) UploadFileContext(ctx context.Context, params UploadFileParam Title: params.Title, }}, Channel: params.Channel, + Channels: params.Channels, InitialComment: params.InitialComment, ThreadTimestamp: params.ThreadTimestamp, Blocks: params.Blocks, diff --git a/files_test.go b/files_test.go index 798aea2a5..3e543f2b8 100644 --- a/files_test.go +++ b/files_test.go @@ -413,6 +413,26 @@ func TestCompleteUploadExternalContext(t *testing.T) { SlackResponse: SlackResponse{Ok: true}, }, }, + { + title: "Testing with multiple channels", + params: CompleteUploadExternalParameters{ + Files: []FileSummary{ + { + ID: "ID1", + }, + }, + Channels: []string{"test-channel-1", "test-channel-2"}, + InitialComment: "test-comment", + }, + wantResponse: CompleteUploadExternalResponse{ + Files: []FileSummary{ + { + ID: "ID1", + }, + }, + SlackResponse: SlackResponse{Ok: true}, + }, + }, { title: "Testing with blocks", params: CompleteUploadExternalParameters{ From 1bb5bb60637d2be3164af024f11b343ca427d89a Mon Sep 17 00:00:00 2001 From: Josh Hawn Date: Sat, 15 Aug 2026 08:26:42 -0700 Subject: [PATCH 598/606] feat: add support for the container block (#1574) Add ContainerBlock, modelling Slack's container block type introduced on 2026-06-29, which groups child blocks into a single, optionally collapsible unit with a title, subtitle, icon, and width. - Register MBTContainer and dispatch "container" in Blocks.UnmarshalJSON so inbound payloads decode to the concrete type. - Reuse the existing Blocks type for child_blocks, so nested blocks marshal and round-trip through the same polymorphic path as top-level blocks. - Add a NewContainerBlock constructor with fluent With* builders and a Validate method covering Slack's documented constraints. - Add constructor, validation, and JSON round-trip tests, plus a container case in the real-payload round-trip guardrail. Slack API docs: https://docs.slack.dev/reference/block-kit/blocks/container-block/ ##### Pull Request Guidelines These are recommendations for pull requests. They are strictly guidelines to help manage expectations. ##### PR preparation Run `make pr-prep` from the root of the repository to run formatting, linting and tests. ##### Should this be an issue instead - [ ] is it a convenience method? (no new functionality, streamlines some use case) - [ ] exposes a previously private type, const, method, etc. - [ ] is it application specific (caching, retry logic, rate limiting, etc) - [ ] is it performance related. ##### API changes Since API changes have to be maintained they undergo a more detailed review and are more likely to require changes. - no tests, if you're adding to the API include at least a single test of the happy case. - If you can accomplish your goal without changing the API, then do so. - dependency changes. updates are okay. adding/removing need justification. ###### Examples of API changes that do not meet guidelines: - in library cache for users. caches are use case specific. - Convenience methods for Sending Messages, update, post, ephemeral, etc. consider opening an issue instead. --- block.go | 1 + block_container.go | 182 ++++++++++++++++++++++++++++++++++++++++ block_container_test.go | 143 +++++++++++++++++++++++++++++++ block_conv.go | 2 + block_roundtrip_test.go | 19 +++++ 5 files changed, 347 insertions(+) create mode 100644 block_container.go create mode 100644 block_container_test.go diff --git a/block.go b/block.go index fd281e0ec..41c1cef4f 100644 --- a/block.go +++ b/block.go @@ -26,6 +26,7 @@ const ( MBTAlert MessageBlockType = "alert" MBTCard MessageBlockType = "card" MBTCarousel MessageBlockType = "carousel" + MBTContainer MessageBlockType = "container" ) // Block defines an interface all block types should implement diff --git a/block_container.go b/block_container.go new file mode 100644 index 000000000..b96c85667 --- /dev/null +++ b/block_container.go @@ -0,0 +1,182 @@ +package slack + +import "fmt" + +// ContainerWidth controls the rendered width of a ContainerBlock. When unset, +// Slack defaults to ContainerWidthStandard. +type ContainerWidth string + +const ( + ContainerWidthNarrow ContainerWidth = "narrow" + ContainerWidthStandard ContainerWidth = "standard" + ContainerWidthWide ContainerWidth = "wide" + ContainerWidthFull ContainerWidth = "full" +) + +// containerMaxChildBlocks is the maximum number of child blocks Slack permits +// inside a container block. +const containerMaxChildBlocks = 10 + +// ContainerBlock groups a set of child blocks so they render together as a +// single, optionally collapsible, unit with a title, subtitle, and icon. +// +// More Information: https://docs.slack.dev/reference/block-kit/blocks/container-block/ +type ContainerBlock struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + // Title is the container heading rendered as a plain_text object. One of + // Title or RichTextTitle is required; RichTextTitle takes precedence when + // both are set. Slack requires a maximum of 150 characters. + Title *TextBlockObject `json:"title,omitempty"` + // RichTextTitle is the container heading rendered as a rich_text block. It + // takes precedence over Title when both are set. + RichTextTitle *RichTextBlock `json:"rich_text_title,omitempty"` + // Subtitle is descriptive text below the title, rendered as a plain_text or + // mrkdwn object. Slack requires a maximum of 150 characters. + Subtitle *TextBlockObject `json:"subtitle,omitempty"` + // Icon is a small image displayed beside the title and subtitle. + Icon *ImageBlockElement `json:"icon,omitempty"` + // Width controls the container width. Slack defaults to standard when unset. + Width ContainerWidth `json:"width,omitempty"` + // IsCollapsible enables the container's collapse control. + IsCollapsible bool `json:"is_collapsible,omitempty"` + // DefaultCollapsed starts the container collapsed. It only applies when + // IsCollapsible is true. + DefaultCollapsed bool `json:"default_collapsed,omitempty"` + // HasHeaderDivider draws a border below the header. Slack only supports it on + // non-collapsible containers. + HasHeaderDivider bool `json:"has_header_divider,omitempty"` + // ChildBlocks are the blocks rendered inside the container. Slack requires 1 + // to 10 blocks: actions, context, divider, file, header, image, input, + // rich_text, section, table, and video blocks are supported. + ChildBlocks Blocks `json:"child_blocks"` +} + +// BlockType returns the type of the block. +func (s ContainerBlock) BlockType() MessageBlockType { + return s.Type +} + +// ID returns the ID of the block. +func (s ContainerBlock) ID() string { + return s.BlockID +} + +// Validate checks whether the block satisfies Slack's documented container +// constraints. +func (s ContainerBlock) Validate() error { + if s.Type != MBTContainer { + return fmt.Errorf("type must be %q", MBTContainer) + } + if s.Title == nil && s.RichTextTitle == nil { + return fmt.Errorf("one of title or rich_text_title is required") + } + if s.Title != nil { + if s.Title.Type != PlainTextType { + return fmt.Errorf("title must be a plain_text object") + } + if runeLen(s.Title.Text) > 150 { + return fmt.Errorf("title cannot be longer than 150 characters") + } + } + if s.Subtitle != nil { + if s.Subtitle.Type != PlainTextType && s.Subtitle.Type != MarkdownType { + return fmt.Errorf("subtitle must be a plain_text or mrkdwn object") + } + if runeLen(s.Subtitle.Text) > 150 { + return fmt.Errorf("subtitle cannot be longer than 150 characters") + } + } + switch s.Width { + case "", ContainerWidthNarrow, ContainerWidthStandard, ContainerWidthWide, ContainerWidthFull: + default: + return fmt.Errorf("width must be one of narrow, standard, wide, or full") + } + if s.Icon != nil { + if runeLen(s.Icon.AltText) > 2000 { + return fmt.Errorf("icon alt_text cannot be longer than 2000 characters") + } + if s.Icon.ImageURL != nil && runeLen(*s.Icon.ImageURL) > 3000 { + return fmt.Errorf("icon image_url cannot be longer than 3000 characters") + } + } + if s.HasHeaderDivider && s.IsCollapsible { + return fmt.Errorf("has_header_divider is only supported on non-collapsible containers") + } + if s.DefaultCollapsed && !s.IsCollapsible { + return fmt.Errorf("default_collapsed requires is_collapsible to be true") + } + if n := len(s.ChildBlocks.BlockSet); n < 1 { + return fmt.Errorf("child_blocks must have at least 1 block") + } else if n > containerMaxChildBlocks { + return fmt.Errorf("child_blocks cannot have more than %d blocks", containerMaxChildBlocks) + } + return nil +} + +// NewContainerBlock returns a new container block wrapping the given child +// blocks. Use the With* methods to set the title, subtitle, and other optional +// fields. +func NewContainerBlock(childBlocks ...Block) *ContainerBlock { + return &ContainerBlock{ + Type: MBTContainer, + ChildBlocks: Blocks{BlockSet: childBlocks}, + } +} + +// WithBlockID sets the block ID for the ContainerBlock. +func (s *ContainerBlock) WithBlockID(blockID string) *ContainerBlock { + s.BlockID = blockID + return s +} + +// WithTitle sets the plain_text title for the ContainerBlock. +func (s *ContainerBlock) WithTitle(title *TextBlockObject) *ContainerBlock { + s.Title = title + return s +} + +// WithRichTextTitle sets the rich_text title for the ContainerBlock. It takes +// precedence over a plain_text title set with WithTitle. +func (s *ContainerBlock) WithRichTextTitle(title *RichTextBlock) *ContainerBlock { + s.RichTextTitle = title + return s +} + +// WithSubtitle sets the subtitle for the ContainerBlock. +func (s *ContainerBlock) WithSubtitle(subtitle *TextBlockObject) *ContainerBlock { + s.Subtitle = subtitle + return s +} + +// WithIcon sets the icon displayed beside the title for the ContainerBlock. +func (s *ContainerBlock) WithIcon(icon *ImageBlockElement) *ContainerBlock { + s.Icon = icon + return s +} + +// WithWidth sets the rendered width of the ContainerBlock. +func (s *ContainerBlock) WithWidth(width ContainerWidth) *ContainerBlock { + s.Width = width + return s +} + +// WithCollapsible marks the ContainerBlock collapsible and controls whether it +// starts collapsed. +func (s *ContainerBlock) WithCollapsible(collapsible, defaultCollapsed bool) *ContainerBlock { + s.IsCollapsible = collapsible + s.DefaultCollapsed = defaultCollapsed + return s +} + +// WithHeaderDivider draws a border below the container header. +func (s *ContainerBlock) WithHeaderDivider(hasHeaderDivider bool) *ContainerBlock { + s.HasHeaderDivider = hasHeaderDivider + return s +} + +// AddChildBlock appends a block to the container's child blocks. +func (s *ContainerBlock) AddChildBlock(block Block) *ContainerBlock { + s.ChildBlocks.BlockSet = append(s.ChildBlocks.BlockSet, block) + return s +} diff --git a/block_container_test.go b/block_container_test.go new file mode 100644 index 000000000..9943b257c --- /dev/null +++ b/block_container_test.go @@ -0,0 +1,143 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewContainerBlock(t *testing.T) { + section := NewSectionBlock(NewTextBlockObject("mrkdwn", "Content", false, false), nil, nil) + divider := NewDividerBlock() + + block := NewContainerBlock(section). + WithBlockID("container-1"). + WithTitle(NewTextBlockObject(PlainTextType, "Title", false, false)). + WithSubtitle(NewTextBlockObject(MarkdownType, "Subtitle", false, false)). + WithIcon(NewImageBlockElement("https://example.com/icon.png", "icon")). + WithWidth(ContainerWidthWide). + WithCollapsible(true, true) + + assert.Equal(t, MBTContainer, block.BlockType()) + assert.Equal(t, "container", string(block.Type)) + assert.Equal(t, "container-1", block.ID()) + assert.Equal(t, ContainerWidthWide, block.Width) + assert.True(t, block.IsCollapsible) + assert.True(t, block.DefaultCollapsed) + require.Len(t, block.ChildBlocks.BlockSet, 1) + assert.Equal(t, section, block.ChildBlocks.BlockSet[0]) + + block.AddChildBlock(divider) + require.Len(t, block.ChildBlocks.BlockSet, 2) + assert.Equal(t, divider, block.ChildBlocks.BlockSet[1]) + + assert.NoError(t, block.Validate()) +} + +func TestContainerBlockValidate(t *testing.T) { + child := NewSectionBlock(NewTextBlockObject("mrkdwn", "Content", false, false), nil, nil) + plainTitle := NewTextBlockObject(PlainTextType, "Title", false, false) + + tests := []struct { + name string + block *ContainerBlock + wantErr bool + }{ + { + name: "valid", + block: NewContainerBlock(child).WithTitle(plainTitle), + }, + { + name: "valid with rich_text_title", + block: NewContainerBlock(child).WithRichTextTitle(NewRichTextBlock("rtt")), + }, + { + name: "missing title", + block: NewContainerBlock(child), + wantErr: true, + }, + { + name: "non plain_text title", + block: NewContainerBlock(child).WithTitle(NewTextBlockObject(MarkdownType, "Title", false, false)), + wantErr: true, + }, + { + name: "no child blocks", + block: NewContainerBlock().WithTitle(plainTitle), + wantErr: true, + }, + { + name: "too many child blocks", + block: NewContainerBlock( + child, child, child, child, child, child, child, child, child, child, child, + ).WithTitle(plainTitle), + wantErr: true, + }, + { + name: "invalid width", + block: NewContainerBlock(child).WithTitle(plainTitle).WithWidth("gigantic"), + wantErr: true, + }, + { + name: "header divider on collapsible", + block: NewContainerBlock(child).WithTitle(plainTitle).WithCollapsible(true, false).WithHeaderDivider(true), + wantErr: true, + }, + { + name: "default collapsed without collapsible", + block: &ContainerBlock{Type: MBTContainer, Title: plainTitle, DefaultCollapsed: true, ChildBlocks: Blocks{BlockSet: []Block{child}}}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.block.Validate() + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestContainerBlockJSONRoundTrip(t *testing.T) { + payload := `{ + "type": "container", + "block_id": "container-1", + "title": {"type": "plain_text", "text": "Deploy status"}, + "subtitle": {"type": "mrkdwn", "text": "*production*"}, + "icon": {"type": "image", "image_url": "https://example.com/icon.png", "alt_text": "icon"}, + "width": "wide", + "is_collapsible": true, + "default_collapsed": true, + "child_blocks": [ + {"type": "section", "text": {"type": "mrkdwn", "text": "All systems go"}}, + {"type": "divider"} + ] + }` + + var block ContainerBlock + require.NoError(t, json.Unmarshal([]byte(payload), &block)) + + assert.Equal(t, MBTContainer, block.BlockType()) + assert.Equal(t, "container-1", block.ID()) + assert.Equal(t, ContainerWidthWide, block.Width) + assert.True(t, block.IsCollapsible) + require.NotNil(t, block.Title) + assert.Equal(t, "Deploy status", block.Title.Text) + require.Len(t, block.ChildBlocks.BlockSet, 2) + assert.Equal(t, MBTSection, block.ChildBlocks.BlockSet[0].BlockType()) + assert.Equal(t, MBTDivider, block.ChildBlocks.BlockSet[1].BlockType()) + + marshalled, err := json.Marshal(block) + require.NoError(t, err) + + var want, got any + require.NoError(t, json.Unmarshal([]byte(payload), &want)) + require.NoError(t, json.Unmarshal(marshalled, &got)) + assert.Equal(t, want, got) +} diff --git a/block_conv.go b/block_conv.go index 1cce78e93..2a298522b 100644 --- a/block_conv.go +++ b/block_conv.go @@ -93,6 +93,8 @@ func (b *Blocks) UnmarshalJSON(data []byte) error { block = &CardBlock{} case "carousel": block = &CarouselBlock{} + case "container": + block = &ContainerBlock{} default: b := &UnknownBlock{raw: r} if err = json.Unmarshal(r, b); err != nil { diff --git a/block_roundtrip_test.go b/block_roundtrip_test.go index e070c5d22..d5f3077c9 100644 --- a/block_roundtrip_test.go +++ b/block_roundtrip_test.go @@ -59,6 +59,25 @@ func TestBlockRealPayloadRoundTrip(t *testing.T) { ] }`, }, + { + // A collapsible container wrapping a section and a divider, with a + // plain_text title, mrkdwn subtitle, and an icon. + name: "container with title, subtitle, icon and child blocks", + payload: `{ + "type": "container", + "block_id": "container-1", + "title": {"type": "plain_text", "text": "Deploy status"}, + "subtitle": {"type": "mrkdwn", "text": "*production*"}, + "icon": {"type": "image", "image_url": "https://example.com/icon.png", "alt_text": "icon"}, + "width": "wide", + "is_collapsible": true, + "default_collapsed": true, + "child_blocks": [ + {"type": "section", "text": {"type": "mrkdwn", "text": "All systems go"}}, + {"type": "divider"} + ] + }`, + }, { name: "data_table with raw_text, raw_number and rich_text cells", payload: `{ From 72865e17d813d849710d0d240f3b185857f932f9 Mon Sep 17 00:00:00 2001 From: Ravi <1299606+rustycl0ck@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:20:03 +0900 Subject: [PATCH 599/606] feat: Expose full message object from chat.postMessage responses (#1572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1571 ##### Summary `chat.postMessage` responses include a full `message` object, but the library dropped it during deserialization: `chatResponseFull` only captured `channel`, `ts`, `message_ts`, `scheduled_message_id`, and `text`. This made `message.thread_ts` inaccessible — which is the only way to detect that a threaded reply was silently posted as a top-level message because its parent was deleted between receipt and posting. The current workaround requires `UnsafeApplyMsgOptions` and a hand-rolled HTTP post. ##### Changes - Add a `Message Message `json:"message"`` field to `chatResponseFull` so the response's message object is no longer discarded. - Add `PostMessageWithResponse` / `PostMessageWithResponseContext`, which return the full `Message` alongside the existing channel/timestamp return values (API shape as proposed in #1571). - Refactor `SendMessageContext` into a thin wrapper over a new private `sendResponseFull` helper; existing behavior and signatures are unchanged. - Add `TestPostMessageWithResponse` covering the happy path, asserting `Message.Text`, `Message.Timestamp`, and `Message.ThreadTimestamp` are populated from the response. The change is purely additive — no existing exported signatures were modified. ##### PR preparation Ran `make pr-prep`: gofmt clean, `go vet` clean, full test suite (including `-race`) passes. --- chat.go | 67 ++++++++++++++++++++++++++++++++++++++++++---------- chat_test.go | 40 +++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 13 deletions(-) diff --git a/chat.go b/chat.go index ba789c92a..7fbcc191c 100644 --- a/chat.go +++ b/chat.go @@ -29,11 +29,12 @@ const ( ) type chatResponseFull struct { - Channel string `json:"channel"` - Timestamp string `json:"ts"` // Regular message timestamp - MessageTimeStamp string `json:"message_ts"` // Ephemeral message timestamp - ScheduledMessageID string `json:"scheduled_message_id,omitempty"` // Scheduled message id - Text string `json:"text"` + Channel string `json:"channel"` + Timestamp string `json:"ts"` // Regular message timestamp + MessageTimeStamp string `json:"message_ts"` // Ephemeral message timestamp + ScheduledMessageID string `json:"scheduled_message_id,omitempty"` // Scheduled message id + Text string `json:"text"` + Message Message `json:"message"` // Full message object, as returned by chat.postMessage and chat.update SlackResponse } @@ -148,6 +149,33 @@ func (api *Client) PostMessageContext(ctx context.Context, channelID string, opt return respChannel, respTimestamp, err } +// PostMessageWithResponse sends a message to a channel and returns the full +// message object from the Slack API response. +// For more details, see PostMessageWithResponseContext documentation. +func (api *Client) PostMessageWithResponse(channelID string, options ...MsgOption) (string, string, Message, error) { + return api.PostMessageWithResponseContext(context.Background(), channelID, options...) +} + +// PostMessageWithResponseContext sends a message to a channel with a custom +// context and returns the full message object from the Slack API response. +// Unlike PostMessageContext, it exposes fields that are only available in the +// response's message object, such as Message.ThreadTimestamp, which can be +// used to detect that a threaded reply was posted un-threaded because its +// parent message was deleted. +// Slack API docs: https://api.slack.com/methods/chat.postMessage +func (api *Client) PostMessageWithResponseContext(ctx context.Context, channelID string, options ...MsgOption) (string, string, Message, error) { + response, err := api.sendResponseFull( + ctx, + channelID, + MsgOptionPost(), + MsgOptionCompose(options...), + ) + if response == nil { + return "", "", Message{}, err + } + return response.Channel, response.getMessageTimestamp(), response.Message, err +} + // PostEphemeral sends an ephemeral message to a user in a channel. // Message is escaped by default according to https://api.slack.com/docs/formatting // Use http://davestevens.github.io/slack-message-builder/ to help crafting your message. @@ -245,34 +273,47 @@ func (api *Client) SendMessage(channel string, options ...MsgOption) (string, st // SendMessageContext more flexible method for configuring messages with a custom context. // Slack API docs: https://api.slack.com/methods/chat.postMessage func (api *Client) SendMessageContext(ctx context.Context, channelID string, options ...MsgOption) (_channel string, _timestampOrScheduledMessageID string, _text string, err error) { + response, err := api.sendResponseFull(ctx, channelID, options...) + if response == nil { + return "", "", "", err + } + + if response.ScheduledMessageID != "" { + return response.Channel, response.ScheduledMessageID, response.Text, err + } else { + return response.Channel, response.getMessageTimestamp(), response.Text, err + } +} + +// sendResponseFull sends a message and returns the full response. +// It returns a nil response if the request could not be built or sent; +// otherwise the returned error is the response's error, if any. +func (api *Client) sendResponseFull(ctx context.Context, channelID string, options ...MsgOption) (*chatResponseFull, error) { var ( req *http.Request parser func(*chatResponseFull) responseParser response chatResponseFull + err error ) if req, parser, err = buildSender(api.endpoint, options...).BuildRequestContext(ctx, api.token, channelID); err != nil { - return "", "", "", err + return nil, err } if api.Debug() { reqBody, err := io.ReadAll(req.Body) if err != nil { - return "", "", "", err + return nil, err } req.Body = io.NopCloser(bytes.NewBuffer(reqBody)) api.Debugf("Sending request: %s", redactToken(reqBody)) } if _, err = doPost(api.httpclient, req, parser(&response), api); err != nil { - return "", "", "", err + return nil, err } - if response.ScheduledMessageID != "" { - return response.Channel, response.ScheduledMessageID, response.Text, response.Err() - } else { - return response.Channel, response.getMessageTimestamp(), response.Text, response.Err() - } + return &response, response.Err() } func redactToken(b []byte) []byte { diff --git a/chat_test.go b/chat_test.go index b1e449390..d7f936cce 100644 --- a/chat_test.go +++ b/chat_test.go @@ -37,6 +37,46 @@ func TestPostMessageInvalidChannel(t *testing.T) { } } +func TestPostMessageWithResponse(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/chat.postMessage", func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + rw.Write([]byte(`{ + "ok": true, + "channel": "CXXX", + "ts": "1503435956.000247", + "message": { + "text": "hello", + "ts": "1503435956.000247", + "thread_ts": "1503435950.000000" + } + }`)) + }) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + channel, timestamp, message, err := api.PostMessageWithResponse("CXXX", MsgOptionText("hello", false)) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + if got, want := channel, "CXXX"; got != want { + t.Errorf("unexpected channel: got %s, want %s", got, want) + } + if got, want := timestamp, "1503435956.000247"; got != want { + t.Errorf("unexpected timestamp: got %s, want %s", got, want) + } + if got, want := message.Text, "hello"; got != want { + t.Errorf("unexpected message text: got %s, want %s", got, want) + } + if got, want := message.Timestamp, "1503435956.000247"; got != want { + t.Errorf("unexpected message timestamp: got %s, want %s", got, want) + } + if got, want := message.ThreadTimestamp, "1503435950.000000"; got != want { + t.Errorf("unexpected message thread timestamp: got %s, want %s", got, want) + } +} + func TestGetPermalink(t *testing.T) { channel := "C1H9RESGA" timeStamp := "p135854651500008" From fef3e74d6fbccca40aaaf563f54c2cecd9f7fe88 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 15 Aug 2026 18:01:15 +0100 Subject: [PATCH 600/606] chore: bump to 0.28.0 Ended up adding the dates to a few changelog entries I had forgotten. --- CHANGELOG.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62c521473..c42a352e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.27.0] +## [0.28.0] - 2026-08-15 + +### Added + +- Block Kit: Add support for [`container`](https://docs.slack.dev/reference/block-kit/blocks/container-block/) block through `ContainerBlock`, with a `NewContainerBlock` constructor, fluent `With*` builders (`WithTitle`, `WithRichTextTitle`, `WithSubtitle`, `WithIcon`, `WithWidth`, `WithCollapsible`, `WithHeaderDivider`, `WithBlockID`), an `AddChildBlock` helper and a `Validate` method (#1574). +- `PostMessageWithResponse` and `PostMessageWithResponseContext` return the full `Message` object from the `chat.postMessage` response alongside the channel and timestamp, giving access to response-only fields such as `Message.ThreadTimestamp` (#1572). +- `CompleteUploadExternalParameters` and `UploadFileParameters` now take `Channels`, which shares a single uploaded file with up to 100 conversations in one `files.completeUploadExternal` request (#1579). + +## [0.27.0] - 2026-06-27 ### Added @@ -16,7 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Interaction payloads: `Team` now preserves `enterprise_id` and `enterprise_name` when Slack includes Enterprise org details in interaction payload `team` objects. -## [0.26.0] +## [0.26.0] - 2026-06-14 ### Added @@ -29,7 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and the `subtext` field (with a `WithSubtext` builder). `slack_icon` is mutually exclusive with `icon` (#1562). -## [0.25.0] +## [0.25.0] - 2026-06-05 ### Changed @@ -39,7 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `TableBlock.Rows` is now `[][]TableCell` (was `[][]*RichTextBlock`), so `table` blocks no longer drop `raw_text`, `raw_number`, and `null` cells (#1558). -## [0.24.0] +## [0.24.0] - 2026-05-24 ### Added @@ -611,7 +619,8 @@ for details. [#1196]: https://github.com/slack-go/slack/issues/1196 [#1547]: https://github.com/slack-go/slack/pull/1547 -[Unreleased]: https://github.com/slack-go/slack/compare/v0.27.0...HEAD +[Unreleased]: https://github.com/slack-go/slack/compare/v0.28.0...HEAD +[0.28.0]: https://github.com/slack-go/slack/compare/v0.27.0...v0.28.0 [0.27.0]: https://github.com/slack-go/slack/compare/v0.26.0...v0.27.0 [0.26.0]: https://github.com/slack-go/slack/compare/v0.25.0...v0.26.0 [0.25.0]: https://github.com/slack-go/slack/compare/v0.24.0...v0.25.0 From 58389cced9f86aef362384e28dd1470e2cd27773 Mon Sep 17 00:00:00 2001 From: mjq2020 <74635395+mjq2020@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:08:33 +0800 Subject: [PATCH 601/606] fix: parse top-level action tokens (#1580) ## Summary - Parse top-level `action_token` values on `AppMentionEvent` and `MessageEvent`. - Preserve the existing nested `assistant_thread` representation for backwards compatibility. - Add direct JSON and full Events API parser regression coverage. ## Root cause Slack sends the Data Access API action token directly on real `app_mention` and `message` event objects. The typed events only modeled the token inside `assistant_thread`, so standard JSON unmarshalling silently discarded the top-level field and callers received no usable token. Fixes #1577. --- slackevents/inner_events.go | 4 ++ slackevents/inner_events_test.go | 45 ++++++++++++++++++++ slackevents/parsers_test.go | 71 ++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 4e2fa7435..cc0433606 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -76,6 +76,8 @@ type AppMentionEvent struct { // AssistantThread contains action token for Data Access API queries when app is mentioned AssistantThread *AssistantThreadActionToken `json:"assistant_thread,omitempty"` + // ActionToken contains the top-level action token for Data Access API queries. + ActionToken string `json:"action_token,omitempty"` } // AppHomeOpenedEvent Your Slack app home was opened. @@ -340,6 +342,8 @@ type MessageEvent struct { // AssistantThread contains action token for Data Access API queries in message events AssistantThread *AssistantThreadActionToken `json:"assistant_thread,omitempty"` + // ActionToken contains the top-level action token for Data Access API queries. + ActionToken string `json:"action_token,omitempty"` // Huddle-related fields (subtype "huddle_thread") Room *slack.HuddleRoom `json:"room,omitempty"` diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index 48e100a39..be9ac3405 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -115,6 +115,28 @@ func TestAppMentionWithAssistantThread(t *testing.T) { } } +func TestAppMentionWithActionToken(t *testing.T) { + rawE := []byte(` + { + "type": "app_mention", + "user": "U061F7AUR", + "text": "<@U0LAN0Z89> search slack", + "ts": "1515449522.000016", + "channel": "C0LAN2Q65", + "event_ts": "1515449522000016", + "action_token": "1234567.top-level" + } + `) + var event AppMentionEvent + if err := json.Unmarshal(rawE, &event); err != nil { + t.Fatal(err) + } + + if event.ActionToken != "1234567.top-level" { + t.Errorf("Expected ActionToken to be '1234567.top-level', got %s", event.ActionToken) + } +} + func TestAppMentionWithBlocksFilesAttachments(t *testing.T) { rawE := []byte(`{ "type": "app_mention", @@ -515,6 +537,29 @@ func TestMessageEventWithAssistantThread(t *testing.T) { } } +func TestMessageEventWithActionToken(t *testing.T) { + rawE := []byte(` + { + "type": "message", + "channel": "D024BE91L", + "user": "U2147483697", + "text": "Search slack", + "ts": "1355517523.000005", + "event_ts": "1355517523.000005", + "channel_type": "im", + "action_token": "9876543.top-level" + } + `) + var event MessageEvent + if err := json.Unmarshal(rawE, &event); err != nil { + t.Fatal(err) + } + + if event.ActionToken != "9876543.top-level" { + t.Errorf("Expected ActionToken to be '9876543.top-level', got %s", event.ActionToken) + } +} + func TestMessageChangedEvent(t *testing.T) { rawE := []byte(` { diff --git a/slackevents/parsers_test.go b/slackevents/parsers_test.go index c93118ebf..1cdcc8b93 100644 --- a/slackevents/parsers_test.go +++ b/slackevents/parsers_test.go @@ -255,6 +255,41 @@ func TestParseEventAPIAppMentionWithAssistantThread(t *testing.T) { } } +func TestParseEventAPIAppMentionWithActionToken(t *testing.T) { + eventsAPIRawCallbackEvent := ` + { + "token": "XXYYZZ", + "team_id": "TXXXXXXXX", + "api_app_id": "AXXXXXXXXX", + "event": { + "type": "app_mention", + "event_ts": "1234567890.123456", + "user": "UXXXXXXX1", + "text": "<@U0LAN0Z89> search slack", + "ts": "1515449522.000016", + "channel": "C0LAN2Q65", + "action_token": "1234567.top-level" + }, + "type": "event_callback", + "authed_users": [ "UXXXXXXX1" ], + "event_id": "Ev08MFMKH6", + "event_time": 1234567890 + } + ` + msg, err := ParseEvent(json.RawMessage(eventsAPIRawCallbackEvent), OptionVerifyToken(&TokenComparator{"XXYYZZ"})) + if err != nil { + t.Fatal(err) + } + + innerEvent, ok := msg.InnerEvent.Data.(*AppMentionEvent) + if !ok { + t.Fatalf("Expected *AppMentionEvent, got %T", msg.InnerEvent.Data) + } + if innerEvent.ActionToken != "1234567.top-level" { + t.Errorf("Expected ActionToken to be '1234567.top-level', got %s", innerEvent.ActionToken) + } +} + func TestParseEventAPIMessageIMWithAssistantThread(t *testing.T) { eventsAPIRawCallbackEvent := ` { @@ -314,6 +349,42 @@ func TestParseEventAPIMessageIMWithAssistantThread(t *testing.T) { } } +func TestParseEventAPIMessageIMWithActionToken(t *testing.T) { + eventsAPIRawCallbackEvent := ` + { + "token": "XXYYZZ", + "team_id": "TXXXXXXXX", + "api_app_id": "AXXXXXXXXX", + "event": { + "type": "message", + "channel": "D024BE91L", + "user": "U2147483697", + "text": "Search slack", + "ts": "1355517523.000005", + "event_ts": "1355517523.000005", + "channel_type": "im", + "action_token": "9876543.top-level" + }, + "type": "event_callback", + "authed_users": [ "U2147483697" ], + "event_id": "Ev08MFMKH7", + "event_time": 1234567890 + } + ` + msg, err := ParseEvent(json.RawMessage(eventsAPIRawCallbackEvent), OptionVerifyToken(&TokenComparator{"XXYYZZ"})) + if err != nil { + t.Fatal(err) + } + + innerEvent, ok := msg.InnerEvent.Data.(*MessageEvent) + if !ok { + t.Fatalf("Expected *MessageEvent, got %T", msg.InnerEvent.Data) + } + if innerEvent.ActionToken != "9876543.top-level" { + t.Errorf("Expected ActionToken to be '9876543.top-level', got %s", innerEvent.ActionToken) + } +} + func TestParseEventAPIMessageChannelWithAssistantThread(t *testing.T) { eventsAPIRawCallbackEvent := ` { From 49178c5802b159703edca319c803c994abd97960 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 15 Aug 2026 20:14:23 +0100 Subject: [PATCH 602/606] bump to 0.29.0 --- CHANGELOG.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c42a352e6..f904dd679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.29.0] - 2026-08-15 + +### Fixed + +- `slackevents`: `AppMentionEvent` and `MessageEvent` now expose the Data Access API action + token through a new `ActionToken` field, which reads `action_token` from the event object + itself. Slack sends the token there on `app_mention` and `message` events, but both types + only modelled it nested inside `assistant_thread`, so the token was silently dropped and + bot-token calls to `assistant.search.context` failed with `invalid_action_token`. The + existing `AssistantThread` field is unchanged (#1577, #1580). + ## [0.28.0] - 2026-08-15 ### Added @@ -619,7 +630,8 @@ for details. [#1196]: https://github.com/slack-go/slack/issues/1196 [#1547]: https://github.com/slack-go/slack/pull/1547 -[Unreleased]: https://github.com/slack-go/slack/compare/v0.28.0...HEAD +[Unreleased]: https://github.com/slack-go/slack/compare/v0.29.0...HEAD +[0.29.0]: https://github.com/slack-go/slack/compare/v0.28.0...v0.29.0 [0.28.0]: https://github.com/slack-go/slack/compare/v0.27.0...v0.28.0 [0.27.0]: https://github.com/slack-go/slack/compare/v0.26.0...v0.27.0 [0.26.0]: https://github.com/slack-go/slack/compare/v0.25.0...v0.26.0 From 89c86402ac81072d69c0ce5657844066936c97bb Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Thu, 20 Aug 2026 21:46:11 +0200 Subject: [PATCH 603/606] Introduce golang 1.27; remove 1.25 (#1581) --- .github/workflows/test.yml | 8 +++--- CHANGELOG.md | 5 ++++ block_rich_text_test.go | 4 +-- .../conversations_invite.go | 3 +-- examples/pagination/pagination.go | 3 +-- go.mod | 4 +-- misc_test.go | 15 +++-------- socketmode/socket_mode_managed_conn.go | 26 +++++++------------ users_test.go | 10 +++---- views_test.go | 6 +---- webhooks_test.go | 14 +++++----- 11 files changed, 38 insertions(+), 60 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f42e5f209..fa21dcb18 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,8 +15,8 @@ jobs: strategy: matrix: go: - - "1.25" - "1.26" + - "1.27" name: test go-${{ matrix.go }} steps: - uses: actions/checkout@v7 @@ -31,8 +31,8 @@ jobs: strategy: matrix: go: - - "1.25" - "1.26" + - "1.27" name: lint go-${{ matrix.go }} steps: - uses: actions/setup-go@v7 @@ -45,11 +45,11 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: - version: v2.10.1 + version: v2.13.1 - name: staticcheck uses: dominikh/staticcheck-action@9716614d4101e79b4340dd97b10e54d68234e431 # 1.4.1 with: - version: "2026.1" + version: "2026.2" - name: Check 'go mod tidy' makes no changes run: | go mod tidy diff --git a/CHANGELOG.md b/CHANGELOG.md index f904dd679..58b42c26b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- The minimum supported Go version is now 1.26. The library supports the two most recent Go + releases, so the test matrix covers Go 1.26 and Go 1.27. + ## [0.29.0] - 2026-08-15 ### Fixed diff --git a/block_rich_text_test.go b/block_rich_text_test.go index 9eeb25f5f..6abe7ccb3 100644 --- a/block_rich_text_test.go +++ b/block_rich_text_test.go @@ -184,7 +184,7 @@ func TestRichTextSection_UnmarshalJSON(t *testing.T) { &RichTextSectionUnknownElement{Type: RTSEUnknown, Raw: `{"type":"unknown","value":10}`}, &RichTextSectionTextElement{Type: RTSEText, Text: "hi"}, &RichTextSectionDateElement{Type: RTSEDate, Timestamp: JSONTime(1636961629), Format: "{date_short_pretty}"}, - &RichTextSectionDateElement{Type: RTSEDate, Timestamp: JSONTime(1636961629), Format: "{date_short_pretty}", URL: strp("https://example.com"), Fallback: strp("default")}, + &RichTextSectionDateElement{Type: RTSEDate, Timestamp: JSONTime(1636961629), Format: "{date_short_pretty}", URL: new("https://example.com"), Fallback: new("default")}, }, }, nil, @@ -454,5 +454,3 @@ func TestRichTextSectionUsergroupStyleExtendedFields(t *testing.T) { assert.NotContains(t, string(data), `"italic"`) assert.NotContains(t, string(data), `"strike"`) } - -func strp(in string) *string { return &in } diff --git a/examples/conversations_invite/conversations_invite.go b/examples/conversations_invite/conversations_invite.go index 7a4e04f5e..4a5a8d8be 100644 --- a/examples/conversations_invite/conversations_invite.go +++ b/examples/conversations_invite/conversations_invite.go @@ -37,8 +37,7 @@ func main() { api := slack.New(userToken) _, err := api.InviteUsersToConversation(*channelID, *userID) if err != nil { - var errorResponse slack.SlackErrorResponse - if errors.As(err, &errorResponse) { + if errorResponse, ok := errors.AsType[slack.SlackErrorResponse](err); ok { for _, e := range errorResponse.Errors { if e.ConversationsInviteResponseError != nil { fmt.Fprintf(os.Stderr, "error inviting user (%s) to conversation: %s\n", e.ConversationsInviteResponseError.User, e.ConversationsInviteResponseError.Error) diff --git a/examples/pagination/pagination.go b/examples/pagination/pagination.go index 1f9b232a0..f2523ff31 100644 --- a/examples/pagination/pagination.go +++ b/examples/pagination/pagination.go @@ -20,8 +20,7 @@ func getAllUserUIDs(ctx context.Context, client *slack.Client, pageSize int) ([] // Note reassignment of pager to the value returned by Next() pager, err = pager.Next(ctx) if failedErr := pager.Failure(err); failedErr != nil { - var rateLimited *slack.RateLimitedError - if errors.As(failedErr, &rateLimited) && rateLimited.Retryable() { + if rateLimited, ok := errors.AsType[*slack.RateLimitedError](failedErr); ok && rateLimited.Retryable() { fmt.Println("Rate limited by Slack API; sleeping", rateLimited.RetryAfter) select { case <-ctx.Done(): diff --git a/go.mod b/go.mod index f794e1b80..90346ae21 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/slack-go/slack -go 1.25 +go 1.26 -toolchain go1.25.11 +toolchain go1.26.7 require ( github.com/go-test/deep v1.1.1 diff --git a/misc_test.go b/misc_test.go index 07f32c0fb..9f9e9abe4 100644 --- a/misc_test.go +++ b/misc_test.go @@ -196,10 +196,7 @@ func TestSlackResponseErrorsUnmarshaling(t *testing.T) { name: "StringError", input: `"failed to match all allowed schemas [json-pointer:\\/blocks\\/3\\/text]"`, expected: SlackResponseErrors{ - Message: func() *string { - s := "failed to match all allowed schemas [json-pointer:\\/blocks\\/3\\/text]" - return &s - }(), + Message: new("failed to match all allowed schemas [json-pointer:\\/blocks\\/3\\/text]"), }, }, } @@ -289,16 +286,10 @@ func TestSlackResponseWithErrors(t *testing.T) { Error: "invalid_blocks", Errors: []SlackResponseErrors{ { - Message: func() *string { - s := "failed to match all allowed schemas [json-pointer:\\/blocks\\/3\\/text]" - return &s - }(), + Message: new("failed to match all allowed schemas [json-pointer:\\/blocks\\/3\\/text]"), }, { - Message: func() *string { - s := "invalid additional property: emoji [json-pointer:\\/blocks\\/3\\/text]" - return &s - }(), + Message: new("invalid additional property: emoji [json-pointer:\\/blocks\\/3\\/text]"), }, }, }, diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index 4b77393f5..985668a28 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -126,10 +126,8 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { defer close(messages) // The receiver reads WebSocket messages, and enqueues parsed Socket Mode requests to be handled by - // the request handler - if err := smc.runMessageReceiver(ctx, conn, messages); err != nil { - sendErr(err) - } + // the request handler. It only ever returns on error. + sendErr(smc.runMessageReceiver(ctx, conn, messages)) }() wg.Add(1) @@ -235,17 +233,12 @@ func (smc *Client) connect(ctx context.Context, connectionCount int, additionalP default: } - var ( - actual slack.StatusCodeError - rlError *slack.RateLimitedError - ) - - if errors.As(err, &actual) && actual.Code == http.StatusNotFound { + if codeErr, ok := errors.AsType[slack.StatusCodeError](err); ok && codeErr.Code == http.StatusNotFound { smc.Debugf("invalid auth when connecting with Socket Mode: %s", err) smc.sendEvent(ctx, newEvent(EventTypeInvalidAuth, &slack.InvalidAuthEvent{})) return nil, nil, err - } else if errors.As(err, &rlError) { + } else if rlError, ok := errors.AsType[*slack.RateLimitedError](err); ok { backoff = rlError.RetryAfter } @@ -408,7 +401,7 @@ func (smc *Client) runRequestHandler(ctx context.Context, websocket chan json.Ra // runMessageReceiver monitors the Socket Mode opened WebSocket connection for any incoming // messages. It pushes the raw events into the channel. -// The receiver runs until the context is closed. +// The receiver runs until a read fails, so it always returns a non-nil error. func (smc *Client) runMessageReceiver(ctx context.Context, conn *websocket.Conn, sink chan json.RawMessage) error { for { if err := smc.receiveMessagesInto(ctx, conn, sink); err != nil { @@ -546,8 +539,7 @@ func (smc *Client) receiveMessagesInto(ctx context.Context, conn *websocket.Conn // This version of the gorilla/websocket package also does a type assertion // on the error, rather than unwrapping it, so we'll do the unwrapping then pass // the unwrapped error - var wsErr *websocket.CloseError - if errors.As(err, &wsErr) && websocket.IsUnexpectedCloseError(wsErr) { + if wsErr, ok := errors.AsType[*websocket.CloseError](err); ok && websocket.IsUnexpectedCloseError(wsErr) { return err } @@ -569,9 +561,9 @@ func (smc *Client) receiveMessagesInto(ctx context.Context, conn *websocket.Conn // JSON unmarshal errors indicate a malformed message, not a broken // connection — keep the connection alive. - var syntaxErr *json.SyntaxError - var typeErr *json.UnmarshalTypeError - if errors.As(err, &syntaxErr) || errors.As(err, &typeErr) { + _, isSyntaxErr := errors.AsType[*json.SyntaxError](err) + _, isTypeErr := errors.AsType[*json.UnmarshalTypeError](err) + if isSyntaxErr || isTypeErr { return nil } diff --git a/users_test.go b/users_test.go index 965365cb4..25b1b1968 100644 --- a/users_test.go +++ b/users_test.go @@ -79,7 +79,7 @@ func getTestUserWithId(id string) User { IsRestricted: false, IsUltraRestricted: false, Updated: 1555425715, - Has2FA: boolPtr(false), + Has2FA: new(false), } } @@ -354,7 +354,7 @@ func testSetUserCustomStatus(api *Client, up *UserProfile, t *testing.T) { statusExpiration = 1551619082 ) if err := api.SetUserCustomStatus(statusText, statusEmoji, statusExpiration); err != nil { - t.Fatalf(`SetUserCustomStatus(%q, %q, %q) = %#v, want `, statusText, statusEmoji, statusExpiration, err) + t.Fatalf(`SetUserCustomStatus(%q, %q, %d) = %#v, want `, statusText, statusEmoji, statusExpiration, err) } if up.StatusText != statusText { @@ -365,7 +365,7 @@ func testSetUserCustomStatus(api *Client, up *UserProfile, t *testing.T) { t.Fatalf(`UserProfile.StatusEmoji = %q, want %q`, up.StatusEmoji, statusEmoji) } if up.StatusExpiration != statusExpiration { - t.Fatalf(`UserProfile.StatusExpiration = %q, want %q`, up.StatusExpiration, statusExpiration) + t.Fatalf(`UserProfile.StatusExpiration = %d, want %d`, up.StatusExpiration, statusExpiration) } } @@ -376,7 +376,7 @@ func testSetUserCustomStatusWithUser(api *Client, user string, up *UserProfile, statusExpiration = 1551619082 ) if err := api.SetUserCustomStatusWithUser(user, statusText, statusEmoji, statusExpiration); err != nil { - t.Fatalf(`SetUserCustomStatusWithUser(%q, %q, %q, %q) = %#v, want `, user, statusText, statusEmoji, statusExpiration, err) + t.Fatalf(`SetUserCustomStatusWithUser(%q, %q, %q, %d) = %#v, want `, user, statusText, statusEmoji, statusExpiration, err) } if up.StatusText != statusText { @@ -387,7 +387,7 @@ func testSetUserCustomStatusWithUser(api *Client, user string, up *UserProfile, t.Fatalf(`UserProfile.StatusEmoji = %q, want %q`, up.StatusEmoji, statusEmoji) } if up.StatusExpiration != statusExpiration { - t.Fatalf(`UserProfile.StatusExpiration = %q, want %q`, up.StatusExpiration, statusExpiration) + t.Fatalf(`UserProfile.StatusExpiration = %d, want %d`, up.StatusExpiration, statusExpiration) } } diff --git a/views_test.go b/views_test.go index 2bb49dd67..cfd715421 100644 --- a/views_test.go +++ b/views_test.go @@ -876,7 +876,7 @@ func TestPublishViewContextRequest_HashOmittedWhenNil(t *testing.T) { }, { name: "non-empty hash includes field", - hash: ptrString("156772938.1827394"), + hash: new("156772938.1827394"), wantHashKey: true, wantHashVal: "156772938.1827394", }, @@ -906,7 +906,3 @@ func TestPublishViewContextRequest_HashOmittedWhenNil(t *testing.T) { }) } } - -func ptrString(s string) *string { - return &s -} diff --git a/webhooks_test.go b/webhooks_test.go index b885f4d81..79d3ec21a 100644 --- a/webhooks_test.go +++ b/webhooks_test.go @@ -86,8 +86,6 @@ func TestPostWebhook_MessageLimitExceeded(t *testing.T) { assert.IsType(t, StatusCodeError{}, err) } -func boolPtr(b bool) *bool { return &b } - func TestWebhookMessage_UnfurlFields(t *testing.T) { t.Run("nil omits fields", func(t *testing.T) { msg := WebhookMessage{Text: "hello"} @@ -100,8 +98,8 @@ func TestWebhookMessage_UnfurlFields(t *testing.T) { t.Run("false is preserved", func(t *testing.T) { msg := WebhookMessage{ Text: "hello", - UnfurlLinks: boolPtr(false), - UnfurlMedia: boolPtr(false), + UnfurlLinks: new(false), + UnfurlMedia: new(false), } raw, err := json.Marshal(msg) assert.NoError(t, err) @@ -112,8 +110,8 @@ func TestWebhookMessage_UnfurlFields(t *testing.T) { t.Run("true is preserved", func(t *testing.T) { msg := WebhookMessage{ Text: "hello", - UnfurlLinks: boolPtr(true), - UnfurlMedia: boolPtr(true), + UnfurlLinks: new(true), + UnfurlMedia: new(true), } raw, err := json.Marshal(msg) assert.NoError(t, err) @@ -124,8 +122,8 @@ func TestWebhookMessage_UnfurlFields(t *testing.T) { t.Run("round-trip preserves values", func(t *testing.T) { original := WebhookMessage{ Text: "hello", - UnfurlLinks: boolPtr(false), - UnfurlMedia: boolPtr(true), + UnfurlLinks: new(false), + UnfurlMedia: new(true), } raw, err := json.Marshal(original) assert.NoError(t, err) From 73cf2078ff7368f4ee37590c1620cb38a4765491 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sat, 22 Aug 2026 16:18:44 +0200 Subject: [PATCH 604/606] refactor(socketmode): use wg.Go for the ping watchdog routine --- socketmode/socket_mode_managed_conn.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index 985668a28..1dc815875 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -130,9 +130,11 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { sendErr(smc.runMessageReceiver(ctx, conn, messages)) }() - wg.Add(1) - go func(pingInterval time.Duration) { - defer wg.Done() + // OptionPingInterval writes maxPingInterval, so snapshot it here instead of + // reading the field from the goroutine below. + pingInterval := smc.maxPingInterval + + wg.Go(func() { defer func() { // Detect when the connection is dead and try close connection. if err := conn.Close(); err != nil { @@ -168,7 +170,7 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { } } } - }(smc.maxPingInterval) + }) wg.Wait() From 00e716d909ba823c27ed85a1a282c73e83afb09a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:08:18 +0100 Subject: [PATCH 605/606] chore(deps): bump github.com/stretchr/testify from 1.11.1 to 1.12.1 (#1584) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.11.1 to 1.12.1.
    Release notes

    Sourced from github.com/stretchr/testify's releases.

    v1.12.1

    This is the first release which has the minimum dependencies practical in testify v1. The last remaining dependencies are github.com/stretchr/objx which itself has no dependencies, and go.yaml.in/yaml/v3. Removing objx would require v2, it cannot be vendored. Removing YAML would require vendoring the yaml library, which would do more harm than good. It's better to become aware of vulnerabilities in the official yaml package than to attempt to maintain our own.

    What's Changed

    New Contributors

    Full Changelog: https://github.com/stretchr/testify/compare/v1.12.0...v1.12.1

    What's Changed

    New Contributors

    Full Changelog: https://github.com/stretchr/testify/compare/v1.12.0...v1.12.1

    v1.12.0

    What's Changed

    Functional Changes

    Fixes

    Documentation, Build & CI

    ... (truncated)

    Commits
    • 959dbda Merge pull request #1935 from harryzcy/yaml-update
    • 9bb7176 Update go.yaml.in/yaml/v3 to v3.0.5
    • 001eb79 Merge pull request #1905 from Kentzo/patch-1
    • ad40f38 Merge pull request #1906 from stretchr/dependabot/github_actions/actions/chec...
    • 3bae017 build(deps): bump actions/checkout from 6.0.2 to 6.0.3
    • f8c01f3 mock: Mock.Return does not exist anymore
    • 12f8b56 Merge pull request #1563 from stretchr/make-AssertionFunc-types-aliases
    • a11649e assert: make *AssertionFunc type just aliases
    • dc20f41 Merge pull request #1890 from stretchr/dolmen/codegen-modernize
    • 098f8d7 _codegen: use strings.Builder
    • Additional commits viewable in compare view

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/stretchr/testify&package-manager=go_modules&previous-version=1.11.1&new-version=1.12.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 8 ++------ go.sum | 14 ++++---------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index 90346ae21..f0d35fbdb 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,7 @@ toolchain go1.26.7 require ( github.com/go-test/deep v1.1.1 github.com/gorilla/websocket v1.5.3 - github.com/stretchr/testify v1.11.1 + github.com/stretchr/testify v1.12.1 ) -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) +require go.yaml.in/yaml/v3 v3.0.5 // indirect diff --git a/go.sum b/go.sum index 2655928c7..85c647c12 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,8 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= From 4b9cec9bdb1eaca3b66968b02c9f08fecd860285 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Mon, 24 Aug 2026 13:24:37 +0200 Subject: [PATCH 606/606] chore(mise): bump go to 1.26.7 --- mise.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mise.toml b/mise.toml index 679b53465..43e556563 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,4 @@ [tools] -go = "1.25.11" +go = "1.26.7" golangci-lint = "2.12.2" "go:honnef.co/go/tools/cmd/staticcheck" = "2026.1"