cmd_server.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  1. package toc
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/base64"
  6. "errors"
  7. "fmt"
  8. "log/slog"
  9. "net"
  10. "strings"
  11. "time"
  12. "github.com/google/uuid"
  13. "github.com/mk6i/open-oscar-server/state"
  14. "github.com/mk6i/open-oscar-server/wire"
  15. )
  16. var (
  17. // cmdInternalSvcErr indicates a general failure. Use wire.TOCErrorAdminProcessingRequest
  18. // error code as this is the closest applicable code as per TiK, Tameclone, phptoclib.
  19. cmdInternalSvcErr = "ERROR:" + wire.TOCErrorAdminProcessingRequest + ":internal server error"
  20. rateLimitExceededErr = "ERROR:" + wire.TOCErrorGeneralRateLimitHit
  21. errDisconnect = errors.New("got booted by another session")
  22. )
  23. // RecvBOS routes incoming SNAC messages from the BOS server to their
  24. // corresponding TOC handlers. It ignores any SNAC messages for which there is
  25. // no TOC response.
  26. func (s OSCARProxy) RecvBOS(ctx context.Context, me *state.SessionInstance, chatRegistry *ChatRegistry, ch chan<- []string) error {
  27. for {
  28. select {
  29. case <-ctx.Done():
  30. func() {
  31. shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  32. defer cancel()
  33. s.Signout(shutdownCtx, me, chatRegistry)
  34. }()
  35. return nil
  36. case <-me.Closed():
  37. return errDisconnect
  38. case snac := <-me.ReceiveMessage():
  39. switch v := snac.Body.(type) {
  40. case wire.SNAC_0x03_0x0B_BuddyArrived:
  41. sendOrCancel(ctx, ch, s.UpdateBuddyArrival(v, me))
  42. case wire.SNAC_0x03_0x0C_BuddyDeparted:
  43. sendOrCancel(ctx, ch, s.UpdateBuddyDeparted(v, me))
  44. case wire.SNAC_0x04_0x07_ICBMChannelMsgToClient:
  45. sendOrCancel(ctx, ch, s.IMIn(ctx, chatRegistry, me, v))
  46. case wire.SNAC_0x01_0x10_OServiceEvilNotification:
  47. sendOrCancel(ctx, ch, s.Eviled(v))
  48. case wire.SNAC_0x04_0x14_ICBMClientEvent:
  49. if me.IsTOC2() {
  50. sendOrCancel(ctx, ch, s.ClientEvent(v))
  51. }
  52. case wire.SNAC_0x13_0x09_FeedbagUpdateItem:
  53. if me.IsTOC2() {
  54. sendOrCancel(ctx, ch, s.Inserted2(ctx, me, v))
  55. }
  56. case wire.SNAC_0x13_0x0A_FeedbagDeleteItem:
  57. if me.IsTOC2() {
  58. sendOrCancel(ctx, ch, s.Deleted2(ctx, me, v))
  59. }
  60. default:
  61. s.Logger.DebugContext(ctx, fmt.Sprintf("unsupported snac. foodgroup: %s subgroup: %s",
  62. wire.FoodGroupName(snac.Frame.FoodGroup),
  63. wire.SubGroupName(snac.Frame.FoodGroup, snac.Frame.SubGroup)))
  64. }
  65. }
  66. }
  67. }
  68. // RecvChat routes incoming SNAC messages from the chat server to their
  69. // corresponding TOC handlers. It ignores any SNAC messages for which there is
  70. // no TOC response.
  71. func (s OSCARProxy) RecvChat(ctx context.Context, me *state.SessionInstance, chatID int, ch chan<- []string) {
  72. for {
  73. select {
  74. case <-ctx.Done():
  75. return
  76. case <-me.Closed():
  77. return
  78. case snac := <-me.ReceiveMessage():
  79. switch v := snac.Body.(type) {
  80. case wire.SNAC_0x0E_0x04_ChatUsersLeft:
  81. sendOrCancel(ctx, ch, s.ChatUpdateBuddyLeft(v, chatID))
  82. case wire.SNAC_0x0E_0x03_ChatUsersJoined:
  83. sendOrCancel(ctx, ch, s.ChatUpdateBuddyArrived(v, chatID))
  84. case wire.SNAC_0x0E_0x06_ChatChannelMsgToClient:
  85. sendOrCancel(ctx, ch, s.ChatIn(ctx, me, v, chatID))
  86. default:
  87. s.Logger.DebugContext(ctx, fmt.Sprintf("unsupported snac. foodgroup: %s subgroup: %s",
  88. wire.FoodGroupName(snac.Frame.FoodGroup),
  89. wire.SubGroupName(snac.Frame.FoodGroup, snac.Frame.SubGroup)))
  90. }
  91. }
  92. }
  93. }
  94. // ChatIn handles the CHAT_IN and ENC_CHAT_IN TOC commands.
  95. //
  96. // From the TiK documentation:
  97. //
  98. // A chat message was sent in a chat room.
  99. //
  100. // From the BlueTOC documentation:
  101. //
  102. // This command received instead of CHAT_IN. It is similar to TOC 1.0 except there are a two new parameters.
  103. // One of them is language; the other is unknown but is usually "A"
  104. //
  105. // Command syntax: CHAT_IN:<Chat Room Id>:<Source User>:<Whisper? T/F>:<Message>
  106. // Command syntax: CHAT_IN_ENC:<chatroom id>:<user>:<whisper T/F>:<???>:en:<message>
  107. func (s OSCARProxy) ChatIn(ctx context.Context, me *state.SessionInstance, snac wire.SNAC_0x0E_0x06_ChatChannelMsgToClient, chatID int) []string {
  108. b, ok := snac.Bytes(wire.ChatTLVSenderInformation)
  109. if !ok {
  110. return s.runtimeErr(ctx, errors.New("snac.Bytes: missing wire.ChatTLVSenderInformation"))
  111. }
  112. u := wire.TLVUserInfo{}
  113. err := wire.UnmarshalBE(&u, bytes.NewReader(b))
  114. if err != nil {
  115. return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalBE: %w", err))
  116. }
  117. b, ok = snac.Bytes(wire.ChatTLVMessageInfo)
  118. if !ok {
  119. return s.runtimeErr(ctx, errors.New("snac.Bytes: missing wire.ChatTLVMessageInfo"))
  120. }
  121. text, err := wire.UnmarshalChatMessageText(b)
  122. if err != nil {
  123. return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalChatMessageText: %w", err))
  124. }
  125. if me.SupportsTOC2MsgEnc() {
  126. return []string{fmt.Sprintf("CHAT_IN_ENC:%d:%s:F:A:en:%s", chatID, u.ScreenName, text)}
  127. }
  128. return []string{fmt.Sprintf("CHAT_IN:%d:%s:F:%s", chatID, u.ScreenName, text)}
  129. }
  130. // ChatUpdateBuddyArrived handles the CHAT_UPDATE_BUDDY TOC command for chat
  131. // room arrival events.
  132. //
  133. // From the TiK documentation:
  134. //
  135. // This one command handles arrival/departs from a chat room. The very first
  136. // message of this type for each chat room contains the users already in the
  137. // room.
  138. //
  139. // Command syntax: CHAT_UPDATE_BUDDY:<Chat Room Id>:<Inside? T/F>:<User 1>:<User 2>...
  140. func (s OSCARProxy) ChatUpdateBuddyArrived(snac wire.SNAC_0x0E_0x03_ChatUsersJoined, chatID int) []string {
  141. users := make([]string, 0, len(snac.Users))
  142. for _, u := range snac.Users {
  143. users = append(users, u.ScreenName)
  144. }
  145. return []string{fmt.Sprintf("CHAT_UPDATE_BUDDY:%d:T:%s", chatID, strings.Join(users, ":"))}
  146. }
  147. // ChatUpdateBuddyLeft handles the CHAT_UPDATE_BUDDY TOC command for chat
  148. // room departure events.
  149. //
  150. // From the TiK documentation:
  151. //
  152. // This one command handles arrival/departs from a chat room. The very first
  153. // message of this type for each chat room contains the users already in the
  154. // room.
  155. //
  156. // Command syntax: CHAT_UPDATE_BUDDY:<Chat Room Id>:<Inside? T/F>:<User 1>:<User 2>...
  157. func (s OSCARProxy) ChatUpdateBuddyLeft(snac wire.SNAC_0x0E_0x04_ChatUsersLeft, chatID int) []string {
  158. users := make([]string, 0, len(snac.Users))
  159. for _, u := range snac.Users {
  160. users = append(users, u.ScreenName)
  161. }
  162. return []string{fmt.Sprintf("CHAT_UPDATE_BUDDY:%d:F:%s", chatID, strings.Join(users, ":"))}
  163. }
  164. // Eviled handles the EVILED TOC command.
  165. //
  166. // From the TiK documentation:
  167. //
  168. // The user was just eviled.
  169. //
  170. // Command syntax: EVILED:<new evil>:<name of eviler, blank if anonymous>
  171. func (s OSCARProxy) Eviled(snac wire.SNAC_0x01_0x10_OServiceEvilNotification) []string {
  172. warning := fmt.Sprintf("%d", snac.NewEvil/10)
  173. who := ""
  174. if snac.Snitcher != nil {
  175. who = snac.Snitcher.ScreenName
  176. }
  177. return []string{fmt.Sprintf("EVILED:%s:%s", warning, who)}
  178. }
  179. // IMIn handles incoming ICBM channel messages and returns one of: IM_IN (TOC1), IM_IN2 or
  180. // IM_IN_ENC2 (TOC2), or for rendezvous channel CHAT_INVITE or RVOUS_PROPOSE.
  181. //
  182. // From the TiK documentation (TOC1 IM_IN):
  183. //
  184. // Receive an IM from someone. Everything after the third colon is the
  185. // incoming message, including other colons.
  186. //
  187. // TOC2 clients receive IM_IN2 (same structure as IM_IN with an extra field) or
  188. // IM_IN_ENC2 when the client supports encoded messages (BlueTOC/BizTOCSock documentation).
  189. // For ICBM rendezvous (chat invite, file transfer), this returns CHAT_INVITE or RVOUS_PROPOSE
  190. // instead (see convertICBMRendezvous).
  191. //
  192. // Command syntax: IM_IN:<Source User>:<Auto Response T/F?>:<Message>
  193. // Command syntax: IM_IN2:<Source User>:<Auto Response T/F?>:<Whisper?>:<Message>
  194. // Command syntax: IM_IN_ENC2:<User>:<Auto>:<???>:<???>:<User Class>:<???>:<???>:<Language>:<Message>
  195. func (s OSCARProxy) IMIn(ctx context.Context, chatRegistry *ChatRegistry, me *state.SessionInstance, snac wire.SNAC_0x04_0x07_ICBMChannelMsgToClient) []string {
  196. switch snac.ChannelID {
  197. case wire.ICBMChannelIM:
  198. return []string{s.convertICBMInstantMsg(ctx, me, snac)}
  199. case wire.ICBMChannelRendezvous:
  200. return []string{s.convertICBMRendezvous(ctx, chatRegistry, snac)}
  201. default:
  202. s.Logger.DebugContext(ctx, "received unsupported ICBM channel message", "channel_id", snac.ChannelID)
  203. return []string{}
  204. }
  205. }
  206. // convertICBMInstantMsg converts an ICBM instant message SNAC to a TOC IM_IN or TOC2 IM_IN2, or TOC2Enhanced IM_IN_ENC2 response.
  207. func (s OSCARProxy) convertICBMInstantMsg(ctx context.Context, me *state.SessionInstance, snac wire.SNAC_0x04_0x07_ICBMChannelMsgToClient) string {
  208. buf, ok := snac.TLVRestBlock.Bytes(wire.ICBMTLVAOLIMData)
  209. if !ok {
  210. return s.runtimeErr(ctx, errors.New("TLVRestBlock.Bytes: missing wire.ICBMTLVAOLIMData"))[0]
  211. }
  212. txt, err := wire.UnmarshalICBMMessageText(buf)
  213. if err != nil {
  214. return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalICBMMessageText: %w", err))[0]
  215. }
  216. autoResp := "F"
  217. if _, isAutoReply := snac.TLVRestBlock.Bytes(wire.ICBMTLVAutoResponse); isAutoReply {
  218. autoResp = "T"
  219. }
  220. if me.SupportsTOC2MsgEnc() {
  221. uFlags, hasVal := snac.TLVUserInfo.TLVList.Uint16BE(wire.OServiceUserInfoUserFlags)
  222. if !hasVal {
  223. s.Logger.DebugContext(ctx, "missing wire.OServiceUserInfoUserFlags in ICBM message")
  224. return ""
  225. }
  226. ucArray := userClassString(uFlags, snac.IsAway())
  227. // from a packet dump found in this russian zine: https://xn--lcss68aj21b.xn--w8je.xn--tckwe/books/xakep/spec65.pdf
  228. // interesting that "L" is a value, not sure what it's for.
  229. return fmt.Sprintf("IM_IN_ENC2:%s:%s:F:T:%s:F:L:en:%s", snac.ScreenName, autoResp, ucArray, txt)
  230. }
  231. if me.IsTOC2() {
  232. return fmt.Sprintf("IM_IN2:%s:%s:%s:%s", snac.ScreenName, autoResp, "F", txt)
  233. }
  234. return fmt.Sprintf("IM_IN:%s:%s:%s", snac.ScreenName, autoResp, txt)
  235. }
  236. // convertICBMRendezvous converts an ICBM rendezvous SNAC to a TOC response.
  237. // - if chat, return CHAT_INVITE
  238. // - file transfer, return RVOUS_PROPOSE
  239. // - don't respond for other rendezvous types
  240. func (s OSCARProxy) convertICBMRendezvous(ctx context.Context, chatRegistry *ChatRegistry, snac wire.SNAC_0x04_0x07_ICBMChannelMsgToClient) string {
  241. rdinfo, has := snac.TLVRestBlock.Bytes(wire.ICBMTLVData)
  242. if !has {
  243. return s.runtimeErr(ctx, errors.New("TLVRestBlock.Bytes: missing rendezvous block"))[0]
  244. }
  245. frag := wire.ICBMCh2Fragment{}
  246. if err := wire.UnmarshalBE(&frag, bytes.NewReader(rdinfo)); err != nil {
  247. return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalBE: %w", err))[0]
  248. }
  249. if frag.Type != wire.ICBMRdvMessagePropose {
  250. s.Logger.DebugContext(ctx, "can't convert ICBM rendezvous message to TOC response", "rdv_type", frag.Type)
  251. return ""
  252. }
  253. switch uuid.UUID(frag.Capability) {
  254. case wire.CapChat:
  255. prompt, ok := frag.Bytes(wire.ICBMRdvTLVTagsInvitation)
  256. if !ok {
  257. return s.runtimeErr(ctx, errors.New("frag.Bytes: missing chat invite prompt"))[0]
  258. }
  259. svcData, ok := frag.Bytes(wire.ICBMRdvTLVTagsSvcData)
  260. if !ok || svcData == nil {
  261. return s.runtimeErr(ctx, errors.New("frag.Bytes: missing room info"))[0]
  262. }
  263. roomInfo := wire.ICBMRoomInfo{}
  264. if err := wire.UnmarshalBE(&roomInfo, bytes.NewReader(svcData)); err != nil {
  265. return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalBE: %w", err))[0]
  266. }
  267. cookie := strings.Split(roomInfo.Cookie, "-") // make this safe
  268. if len(cookie) < 3 {
  269. return s.runtimeErr(ctx, errors.New("roomInfo.Cookie: malformed cookie, could not get room name"))[0]
  270. }
  271. roomName := cookie[2]
  272. chatID := chatRegistry.Add(roomInfo)
  273. return fmt.Sprintf("CHAT_INVITE:%s:%d:%s:%s", roomName, chatID, snac.ScreenName, prompt)
  274. case wire.CapFileTransfer:
  275. user := snac.TLVUserInfo.ScreenName
  276. capability := strings.ToUpper(wire.CapFileTransfer.String()) // TiK requires upper-case UUID characters
  277. cookie := base64.StdEncoding.EncodeToString(frag.Cookie[:])
  278. seq, _ := frag.Uint16BE(wire.ICBMRdvTLVTagsSeqNum)
  279. rvousIP := "0.0.0.0"
  280. if ip, ok := frag.Bytes(wire.ICBMRdvTLVTagsRdvIP); ok && len(ip) == 4 {
  281. rvousIP = net.IPv4(ip[0], ip[1], ip[2], ip[3]).String()
  282. }
  283. proposerIP := "0.0.0.0"
  284. if ip, ok := frag.Bytes(wire.ICBMRdvTLVTagsRequesterIP); ok && len(ip) == 4 {
  285. proposerIP = net.IPv4(ip[0], ip[1], ip[2], ip[3]).String()
  286. }
  287. verifiedIP := "0.0.0.0"
  288. if ip, ok := frag.Bytes(wire.ICBMRdvTLVTagsVerifiedIP); ok && len(ip) == 4 {
  289. verifiedIP = net.IPv4(ip[0], ip[1], ip[2], ip[3]).String()
  290. }
  291. rvousPort, _ := frag.Uint16BE(wire.ICBMRdvTLVTagsPort)
  292. var fileMetadata string
  293. if f, ok := frag.Bytes(wire.ICBMRdvTLVTagsSvcData); ok {
  294. // remove sequence of null bytes from the end that causes TiK file open
  295. // dialog to crash
  296. f = bytes.TrimRight(f, "\x00")
  297. fileMetadata = base64.StdEncoding.EncodeToString(f)
  298. }
  299. return fmt.Sprintf("RVOUS_PROPOSE:%s:%s:%s:%d:%s:%s:%s:%d:%d:%s",
  300. user, capability, cookie, seq, rvousIP, proposerIP, verifiedIP, rvousPort, wire.ICBMRdvTLVTagsSvcData, fileMetadata)
  301. default:
  302. s.Logger.DebugContext(ctx, "received rendezvous ICBM for unsupported capability", "capability", wire.CapChat)
  303. return ""
  304. }
  305. }
  306. // UpdateBuddyArrival handles the UPDATE_BUDDY TOC command for buddy arrival events.
  307. //
  308. // From the TiK documentation:
  309. //
  310. // This one command handles arrival/depart/updates. Evil Amount is a percentage, Signon Time is UNIX epoc, idle time is in minutes, UC (User Class) is a two/three character string.
  311. // - uc[0]
  312. // - ' ' - Ignore
  313. // - 'A' - On AOL
  314. // - uc[1]
  315. // - ' ' - Ignore
  316. // - 'A' - Oscar Admin
  317. // - 'U' - Oscar Unconfirmed
  318. // - 'O' - Oscar Normal
  319. // - uc[2]
  320. // - '\0' - Ignore
  321. // - ' ' - Ignore
  322. // - 'U' - The user has set their unavailable flag.
  323. //
  324. // Command syntax: UPDATE_BUDDY:<Buddy User>:<Online? T/F>:<Evil Amount>:<Signon Time>:<IdleTime>:<UC>
  325. //
  326. // For TOC2 this sends UPDATE_BUDDY2 with the same fields (plus a trailing field). When
  327. // the buddy has capabilities, BUDDY_CAPS2 is also sent (see userInfoToBuddyCaps).
  328. func (s OSCARProxy) UpdateBuddyArrival(snac wire.SNAC_0x03_0x0B_BuddyArrived, me *state.SessionInstance) []string {
  329. return []string{userInfoToUpdateBuddy(snac.TLVUserInfo, me), userInfoToBuddyCaps(snac.TLVUserInfo, me, s.Logger)}
  330. }
  331. // UpdateBuddyDeparted handles the UPDATE_BUDDY TOC command for buddy departure events.
  332. //
  333. // From the TiK documentation:
  334. //
  335. // This one command handles arrival/depart/updates. Evil Amount is a
  336. // percentage, Signon Time is UNIX epoc, idle time is in minutes, UC (User
  337. // Class) is a two/three character string.
  338. // - uc[0]
  339. // - ' ' - Ignore
  340. // - 'A' - On AOL
  341. // - uc[1]
  342. // - ' ' - Ignore
  343. // - 'A' - Oscar Admin
  344. // - 'U' - Oscar Unconfirmed
  345. // - 'O' - Oscar Normal
  346. // - uc[2]
  347. // - '\0' - Ignore
  348. // - ' ' - Ignore
  349. // - 'U' - The user has set their unavailable flag.
  350. //
  351. // Command syntax: UPDATE_BUDDY:<Buddy User>:<Online? T/F>:<Evil Amount>:<Signon Time>:<IdleTime>:<UC>
  352. // TOC2 uses UPDATE_BUDDY2 with the same fields.
  353. func (s OSCARProxy) UpdateBuddyDeparted(snac wire.SNAC_0x03_0x0C_BuddyDeparted, me *state.SessionInstance) []string {
  354. if me.IsTOC2() {
  355. return []string{fmt.Sprintf("UPDATE_BUDDY2:%s:F:0:0:0: :", snac.ScreenName)}
  356. }
  357. return []string{fmt.Sprintf("UPDATE_BUDDY:%s:F:0:0:0: ", snac.ScreenName)}
  358. }
  359. // Inserted2 handles the INSERTED2 TOC2 server-to-client notifications.
  360. //
  361. // From the BlueTOC documentation:
  362. //
  363. // Sent whenever the buddy list is modified from a different location (e.g. logged
  364. // in twice). Dynamic updates when items are added to the buddy list.
  365. //
  366. // INSERTED2:g:<group name>
  367. // A new group has been added to the buddy list.
  368. //
  369. // INSERTED2:b:<alias>:<username>:<group>
  370. // A new screenname has been added.
  371. //
  372. // INSERTED2:d:<username>
  373. // Somebody has been added to the deny list.
  374. //
  375. // INSERTED2:p:<username>
  376. // Somebody has been added to the permit list.
  377. //
  378. // Inserted2 is invoked when this session receives FeedbagUpdateItem (e.g. list
  379. // modified from another client). The feedbag is queried when adding buddies to
  380. // resolve GroupID to group name; buddy alias comes from TLV.
  381. func (s OSCARProxy) Inserted2(ctx context.Context, me *state.SessionInstance, snac wire.SNAC_0x13_0x09_FeedbagUpdateItem) []string {
  382. var out []string
  383. groupNameByID := make(map[uint16]string)
  384. hasBuddy := false
  385. for _, item := range snac.Items {
  386. if item.ClassID == wire.FeedbagClassIdBuddy {
  387. hasBuddy = true
  388. break
  389. }
  390. }
  391. if hasBuddy {
  392. fb, err := s.FeedbagManager.Feedbag(ctx, me.IdentScreenName())
  393. if err != nil {
  394. s.Logger.DebugContext(ctx, "Inserted2: feedbag lookup failed", "err", err)
  395. return nil
  396. }
  397. for _, item := range fb {
  398. if item.ClassID == wire.FeedbagClassIdGroup {
  399. groupNameByID[item.GroupID] = item.Name
  400. }
  401. }
  402. }
  403. for _, item := range snac.Items {
  404. switch item.ClassID {
  405. case wire.FeedbagClassIdGroup:
  406. out = append(out, fmt.Sprintf("INSERTED2:g:%s", item.Name))
  407. case wire.FeedbagClassIdBuddy:
  408. group := groupNameByID[item.GroupID]
  409. if group == "" {
  410. group = "Buddies"
  411. }
  412. alias := ""
  413. if b, ok := item.Bytes(wire.FeedbagAttributesAlias); ok {
  414. alias = string(b)
  415. }
  416. out = append(out, fmt.Sprintf("INSERTED2:b:%s:%s:%s", alias, item.Name, group))
  417. case wire.FeedbagClassIDDeny:
  418. out = append(out, fmt.Sprintf("INSERTED2:d:%s", item.Name))
  419. case wire.FeedbagClassIDPermit:
  420. out = append(out, fmt.Sprintf("INSERTED2:p:%s", item.Name))
  421. }
  422. }
  423. return out
  424. }
  425. // Deleted2 handles the DELETED2 TOC2 server-to-client notifications.
  426. //
  427. // From the BlueTOC documentation:
  428. //
  429. // Sent whenever the buddy list is modified from a different location. Dynamic
  430. // updates when items are removed from the buddy list.
  431. //
  432. // DELETED2:g:<group name>
  433. // A group has been deleted from the buddy list.
  434. //
  435. // DELETED2:b:<username>:<group>
  436. // A user has been deleted from the buddy list.
  437. //
  438. // DELETED2:d:<username>
  439. // A user has been removed from the deny list.
  440. //
  441. // DELETED2:p:<username>
  442. // A user has been removed from the permit list.
  443. //
  444. // Deleted2 is invoked when this session receives FeedbagDeleteItem (e.g. list
  445. // modified from another client). The feedbag is queried when deleting buddies to
  446. // resolve GroupID to group name.
  447. func (s OSCARProxy) Deleted2(ctx context.Context, me *state.SessionInstance, snac wire.SNAC_0x13_0x0A_FeedbagDeleteItem) []string {
  448. var out []string
  449. groupNameByID := make(map[uint16]string)
  450. hasBuddy := false
  451. for _, item := range snac.Items {
  452. if item.ClassID == wire.FeedbagClassIdBuddy {
  453. hasBuddy = true
  454. break
  455. }
  456. }
  457. if hasBuddy {
  458. fb, err := s.FeedbagManager.Feedbag(ctx, me.IdentScreenName())
  459. if err != nil {
  460. s.Logger.DebugContext(ctx, "Deleted2: feedbag lookup failed", "err", err)
  461. return nil
  462. }
  463. for _, item := range fb {
  464. if item.ClassID == wire.FeedbagClassIdGroup {
  465. groupNameByID[item.GroupID] = item.Name
  466. }
  467. }
  468. }
  469. for _, item := range snac.Items {
  470. switch item.ClassID {
  471. case wire.FeedbagClassIdGroup:
  472. out = append(out, fmt.Sprintf("DELETED2:g:%s", item.Name))
  473. case wire.FeedbagClassIdBuddy:
  474. group := groupNameByID[item.GroupID]
  475. if group == "" {
  476. group = "Buddies"
  477. }
  478. out = append(out, fmt.Sprintf("DELETED2:b:%s:%s", item.Name, group))
  479. case wire.FeedbagClassIDDeny:
  480. out = append(out, fmt.Sprintf("DELETED2:d:%s", item.Name))
  481. case wire.FeedbagClassIDPermit:
  482. out = append(out, fmt.Sprintf("DELETED2:p:%s", item.Name))
  483. }
  484. }
  485. return out
  486. }
  487. // ClientEvent handles the CLIENT_EVENT2 TOC2 command.
  488. //
  489. // From BizTOCSock documentation:
  490. //
  491. // I discovered this a while ago, but this is the typing status of a user in IMs, much
  492. // like what AIM does. It is only sent while you're currently being IMed by someone.
  493. // There are only three codes as I know of, but I believe there is one for "User is
  494. // recording..." If it were one, it would probably be code 3.
  495. // 0 = User is doing nothing
  496. // 1 = User has entered text
  497. // 2 = User is currently typing
  498. //
  499. // Command syntax: CLIENT_EVENT2:<Buddy User>:<Typing Status>
  500. func (s OSCARProxy) ClientEvent(snac wire.SNAC_0x04_0x14_ICBMClientEvent) []string {
  501. return []string{fmt.Sprintf("CLIENT_EVENT2:%s:%d", snac.ScreenName, snac.Event)}
  502. }
  503. // userClassString generates the 3-character user class (UC) string based on user flags and away status.
  504. func userClassString(uFlags uint16, isAway bool) string {
  505. uc := [3]string{" ", " ", " "}
  506. if hasFlag(uFlags, wire.OServiceUserFlagAOL) {
  507. uc[0] = "A"
  508. }
  509. if hasFlag(uFlags, wire.OServiceUserFlagAdministrator) {
  510. uc[1] = "A"
  511. } else if hasFlag(uFlags, wire.OServiceUserFlagWireless) {
  512. uc[1] = "C"
  513. } else if hasFlag(uFlags, wire.OServiceUserFlagUnconfirmed) {
  514. uc[1] = "U"
  515. } else if hasFlag(uFlags, wire.OServiceUserFlagOSCARFree) {
  516. uc[1] = "O"
  517. }
  518. if isAway {
  519. uc[2] = "U"
  520. }
  521. return strings.Join(uc[:], "")
  522. }
  523. func sendOrCancel(ctx context.Context, ch chan<- []string, msg []string) {
  524. select {
  525. case <-ctx.Done():
  526. return
  527. case ch <- msg:
  528. return
  529. }
  530. }
  531. // userInfoToUpdateBuddy creates an UPDATE_BUDDY or UPDATE_BUDDY2 server reply from a User
  532. // Info TLV.
  533. func userInfoToUpdateBuddy(snac wire.TLVUserInfo, me *state.SessionInstance) string {
  534. online, _ := snac.Uint32BE(wire.OServiceUserInfoSignonTOD)
  535. idle, _ := snac.Uint16BE(wire.OServiceUserInfoIdleTime)
  536. uFlags, _ := snac.TLVList.Uint16BE(wire.OServiceUserInfoUserFlags)
  537. uc := userClassString(uFlags, snac.IsAway())
  538. warning := fmt.Sprintf("%d", snac.WarningLevel/10)
  539. if me.IsTOC2() {
  540. return fmt.Sprintf("UPDATE_BUDDY2:%s:%s:%s:%d:%d:%s:", snac.ScreenName, "T", warning, online, idle, uc)
  541. }
  542. return fmt.Sprintf("UPDATE_BUDDY:%s:%s:%s:%d:%d:%s", snac.ScreenName, "T", warning, online, idle, uc)
  543. }
  544. // hasFlag checks if a specific flag is set in the bitmask.
  545. func hasFlag[T ~uint16 | ~uint8](bitmask, flag T) bool {
  546. return (bitmask & flag) == flag
  547. }
  548. // userInfoToBuddyCaps creates a BUDDY_CAPS2 server-to-client message from a User Info TLV.
  549. //
  550. // From the BizTOCSock documentation:
  551. //
  552. // These are the buddies capabilities, such as Chat, Live Video, Direct Connect, etc.
  553. // They are sent with every UPDATE_BUDDY2. If a user updates to where they can use
  554. // Direct Connect, you will get sent both packets.
  555. //
  556. // Format: BUDDY_CAPS2:<User>:<Cap1>,<Cap2>,...
  557. func userInfoToBuddyCaps(snac wire.TLVUserInfo, me *state.SessionInstance, logger *slog.Logger) string {
  558. if !me.IsTOC2() {
  559. return ""
  560. }
  561. b, hasCaps := snac.TLVList.Bytes(wire.OServiceUserInfoOscarCaps)
  562. if !hasCaps {
  563. logger.DebugContext(context.Background(), "userInfoToBuddyCaps: no buddy caps found")
  564. return ""
  565. }
  566. if len(b)%16 != 0 {
  567. logger.DebugContext(context.Background(), "userInfoToBuddyCaps: buddy caps length not divisible by 16")
  568. return ""
  569. }
  570. var capStrings []string
  571. for i := 0; i < len(b); i += 16 {
  572. var c [16]byte
  573. copy(c[:], b[i:i+16])
  574. uid := uuid.UUID(c)
  575. capStrings = append(capStrings, uid.String())
  576. }
  577. clientCaps := strings.Join(capStrings, ",")
  578. return fmt.Sprintf("BUDDY_CAPS2:%s:%s", snac.ScreenName, clientCaps)
  579. }