legacy_message_bridge.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694
  1. package icq_legacy
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/binary"
  6. "fmt"
  7. "log/slog"
  8. "net"
  9. "strconv"
  10. "strings"
  11. "unicode/utf8"
  12. "github.com/mk6i/open-oscar-server/wire"
  13. )
  14. // LegacyMessageBridge implements LegacyMessageSender by routing
  15. // messages through the ProtocolDispatcher to the appropriate legacy handler.
  16. // This bridges the OSCAR->legacy gap: when an OSCAR/AIM user sends a message
  17. // to a UIN connected via legacy protocol, the ICBM service uses this bridge
  18. // to deliver the message instead of storing it offline.
  19. //
  20. // All protocol conversion logic (UIN validation, status mapping, character
  21. // encoding) lives here to minimize impact on the main OSCAR codebase.
  22. type LegacyMessageBridge struct {
  23. sessions *LegacySessionManager
  24. dispatcher *ProtocolDispatcher
  25. userFinder ICQUserFinder
  26. logger *slog.Logger
  27. }
  28. // NewLegacyMessageBridge creates a new bridge for OSCAR->legacy message delivery.
  29. func NewLegacyMessageBridge(sessions *LegacySessionManager, dispatcher *ProtocolDispatcher, userFinder ICQUserFinder, logger *slog.Logger) *LegacyMessageBridge {
  30. return &LegacyMessageBridge{
  31. sessions: sessions,
  32. dispatcher: dispatcher,
  33. userFinder: userFinder,
  34. logger: logger,
  35. }
  36. }
  37. // SendMessage delivers a message to a legacy client identified by UIN.
  38. // Returns nil if the message was delivered, or an error if the user is not
  39. // online via legacy protocol (caller should fall through to offline storage).
  40. func (b *LegacyMessageBridge) SendMessage(uin uint32, fromUIN uint32, msgType uint16, message string) error {
  41. session := b.sessions.GetSession(uin)
  42. if session == nil {
  43. return fmt.Errorf("UIN %d not online via legacy protocol", uin)
  44. }
  45. // Convert message text from OSCAR encoding (UTF-8/Unicode) to legacy
  46. // single-byte encoding that old ICQ clients can display.
  47. converted := utf8ToLatin1(message)
  48. return b.dispatcher.SendOnlineMessage(session, fromUIN, msgType, converted)
  49. }
  50. // SendStatusUpdate sends a status change notification to a legacy client.
  51. func (b *LegacyMessageBridge) SendStatusUpdate(uin uint32, targetUIN uint32, status uint32) error {
  52. session := b.sessions.GetSession(uin)
  53. if session == nil {
  54. return fmt.Errorf("UIN %d not online via legacy protocol", uin)
  55. }
  56. return b.dispatcher.SendStatusChange(session, targetUIN, status)
  57. }
  58. // SendUserOnline sends a user online notification to a legacy client.
  59. func (b *LegacyMessageBridge) SendUserOnline(uin uint32, targetUIN uint32, status uint32, ip net.IP, port uint16) error {
  60. session := b.sessions.GetSession(uin)
  61. if session == nil {
  62. return fmt.Errorf("UIN %d not online via legacy protocol", uin)
  63. }
  64. return b.dispatcher.SendUserOnline(session, targetUIN, status)
  65. }
  66. // SendUserOffline sends a user offline notification to a legacy client.
  67. func (b *LegacyMessageBridge) SendUserOffline(uin uint32, targetUIN uint32) error {
  68. session := b.sessions.GetSession(uin)
  69. if session == nil {
  70. return fmt.Errorf("UIN %d not online via legacy protocol", uin)
  71. }
  72. return b.dispatcher.SendUserOffline(session, targetUIN)
  73. }
  74. // StartOSCARMessagePump starts a goroutine that drains OSCAR SNAC messages
  75. // from a legacy session's unified SessionInstance and converts them into
  76. // legacy protocol packets.
  77. //
  78. // When an OSCAR client changes status or goes online/offline, the buddy
  79. // notification system (BroadcastBuddyArrived/BroadcastBuddyDeparted) sends
  80. // SNAC messages to ALL sessions in InMemorySessionManager - including legacy
  81. // sessions. But legacy clients communicate over UDP, not TCP FLAP, so nobody
  82. // reads those SNACs. This pump consumes them and translates:
  83. // - BuddyArrived -> SendUserOnline
  84. // - BuddyDeparted -> SendUserOffline
  85. // - ICBMChannelMsgToClient -> SendOnlineMessage (OSCAR->legacy IM delivery)
  86. func (b *LegacyMessageBridge) StartOSCARMessagePump(session *LegacySession) {
  87. if session == nil || session.Instance == nil {
  88. b.logger.Warn("StartOSCARMessagePump: nil session or instance, pump NOT started")
  89. return
  90. }
  91. b.logger.Info("StartOSCARMessagePump: starting pump",
  92. "uin", session.UIN,
  93. "version", session.Version,
  94. "instance_closed", session.Instance.Closed() == nil,
  95. )
  96. go func() {
  97. instance := session.Instance
  98. b.logger.Info("OSCAR message pump goroutine started",
  99. "uin", session.UIN,
  100. )
  101. for {
  102. select {
  103. case <-instance.Closed():
  104. b.logger.Info("OSCAR message pump: instance closed, exiting",
  105. "uin", session.UIN,
  106. )
  107. return
  108. case msg, ok := <-instance.ReceiveMessage():
  109. if !ok {
  110. b.logger.Info("OSCAR message pump: channel closed, exiting",
  111. "uin", session.UIN,
  112. )
  113. return
  114. }
  115. b.logger.Info("OSCAR message pump: received SNAC",
  116. "uin", session.UIN,
  117. "food_group", msg.Frame.FoodGroup,
  118. "sub_group", msg.Frame.SubGroup,
  119. )
  120. b.handleOSCARMessage(session, msg)
  121. }
  122. }
  123. }()
  124. }
  125. // handleOSCARMessage processes a single OSCAR SNAC message destined for a
  126. // legacy session and converts it to the appropriate legacy protocol packet.
  127. func (b *LegacyMessageBridge) handleOSCARMessage(session *LegacySession, msg wire.SNACMessage) {
  128. switch {
  129. case msg.Frame.FoodGroup == wire.Buddy && msg.Frame.SubGroup == wire.BuddyArrived:
  130. b.handleBuddyArrived(session, msg)
  131. case msg.Frame.FoodGroup == wire.Buddy && msg.Frame.SubGroup == wire.BuddyDeparted:
  132. b.handleBuddyDeparted(session, msg)
  133. case msg.Frame.FoodGroup == wire.ICBM && msg.Frame.SubGroup == wire.ICBMChannelMsgToClient:
  134. b.handleICBMMessage(session, msg)
  135. case msg.Frame.FoodGroup == wire.Feedbag && msg.Frame.SubGroup == wire.FeedbagRequestAuthorizeToClient:
  136. b.handleAuthRequest(session, msg)
  137. case msg.Frame.FoodGroup == wire.Feedbag && msg.Frame.SubGroup == wire.FeedbagRespondAuthorizeToClient:
  138. b.handleAuthResponse(session, msg)
  139. default:
  140. // Silently ignore other SNAC types - legacy clients don't need them.
  141. // This includes typing notifications, rate limit updates, etc.
  142. }
  143. }
  144. // handleBuddyArrived converts an OSCAR BuddyArrived SNAC into a legacy
  145. // user online notification.
  146. func (b *LegacyMessageBridge) handleBuddyArrived(session *LegacySession, msg wire.SNACMessage) {
  147. arrived, ok := msg.Body.(wire.SNAC_0x03_0x0B_BuddyArrived)
  148. if !ok {
  149. return
  150. }
  151. uin, ok := parseUIN(arrived.ScreenName)
  152. if !ok {
  153. return // AIM screen name - legacy clients can't handle non-numeric identifiers
  154. }
  155. if !session.IsContact(uin) {
  156. return
  157. }
  158. oscarStatus, _ := arrived.Uint32BE(wire.OServiceUserInfoStatus)
  159. legacyStatus := oscarStatusToLegacy(oscarStatus)
  160. b.logger.Debug("OSCAR->legacy buddy arrived",
  161. "to_uin", session.UIN,
  162. "arrived_uin", uin,
  163. "oscar_status", fmt.Sprintf("0x%08X", oscarStatus),
  164. "legacy_status", fmt.Sprintf("0x%08X", legacyStatus),
  165. )
  166. // Use SendUserOnline for the first arrival, SendStatusChange for
  167. // subsequent status updates. V5 clients use different packet types
  168. // (SRV_USER_ONLINE vs SRV_USER_STATUS) and only update the status
  169. // icon when the correct packet type is used.
  170. if session.MarkContactOnline(uin) {
  171. // Already known online — this is a status change
  172. if err := b.dispatcher.SendStatusChange(session, uin, legacyStatus); err != nil {
  173. b.logger.Debug("failed to send status change to legacy client",
  174. "to_uin", session.UIN,
  175. "changed_uin", uin,
  176. "err", err,
  177. )
  178. }
  179. } else {
  180. // First time seeing this contact online
  181. if err := b.dispatcher.SendUserOnline(session, uin, legacyStatus); err != nil {
  182. b.logger.Debug("failed to send user online to legacy client",
  183. "to_uin", session.UIN,
  184. "online_uin", uin,
  185. "err", err,
  186. )
  187. }
  188. }
  189. }
  190. // handleBuddyDeparted converts an OSCAR BuddyDeparted SNAC into a legacy
  191. // user offline notification.
  192. func (b *LegacyMessageBridge) handleBuddyDeparted(session *LegacySession, msg wire.SNACMessage) {
  193. departed, ok := msg.Body.(wire.SNAC_0x03_0x0C_BuddyDeparted)
  194. if !ok {
  195. return
  196. }
  197. uin, ok := parseUIN(departed.ScreenName)
  198. if !ok {
  199. return
  200. }
  201. if !session.IsContact(uin) {
  202. return
  203. }
  204. b.logger.Debug("OSCAR->legacy buddy departed",
  205. "to_uin", session.UIN,
  206. "departed_uin", uin,
  207. )
  208. session.MarkContactOffline(uin)
  209. if err := b.dispatcher.SendUserOffline(session, uin); err != nil {
  210. b.logger.Debug("failed to send user offline to legacy client",
  211. "to_uin", session.UIN,
  212. "offline_uin", uin,
  213. "err", err,
  214. )
  215. }
  216. }
  217. // buildLegacyAuthFields looks up a user's profile and returns FE-delimited
  218. // fields (nick, first, last, email) truncated to legacy ICQ limits.
  219. func (b *LegacyMessageBridge) buildLegacyAuthFields(uin uint32) (nick, firstName, lastName, email string) {
  220. nick = fmt.Sprintf("%d", uin)
  221. if user, err := b.userFinder.FindByUIN(context.Background(), uin); err == nil {
  222. if user.ICQInfo.Basic.Nickname != "" {
  223. nick = user.ICQInfo.Basic.Nickname
  224. }
  225. firstName = user.ICQInfo.Basic.FirstName
  226. lastName = user.ICQInfo.Basic.LastName
  227. email = user.ICQInfo.Basic.EmailAddress
  228. }
  229. if len(nick) > 20 {
  230. nick = nick[:20]
  231. }
  232. if len(firstName) > 30 {
  233. firstName = firstName[:30]
  234. }
  235. if len(lastName) > 30 {
  236. lastName = lastName[:30]
  237. }
  238. if len(email) > 50 {
  239. email = email[:50]
  240. }
  241. return
  242. }
  243. // handleAuthRequest converts an OSCAR FeedbagRequestAuthorizeToClient (0x13,0x19)
  244. // into a legacy ICQ auth request message (type 0x06).
  245. func (b *LegacyMessageBridge) handleAuthRequest(session *LegacySession, msg wire.SNACMessage) {
  246. body, ok := msg.Body.(wire.SNAC_0x13_0x18_FeedbagRequestAuthorizationToHost)
  247. if !ok {
  248. return
  249. }
  250. fromUIN, ok := parseUIN(body.ScreenName)
  251. if !ok {
  252. return
  253. }
  254. nick, first, last, email := b.buildLegacyAuthFields(fromUIN)
  255. reason := utf8ToLatin1(body.Reason)
  256. text := fmt.Sprintf("%s\xFE%s\xFE%s\xFE%s\xFE1\xFE%s", nick, first, last, email, reason)
  257. b.logger.Debug("OSCAR->legacy auth request",
  258. "to_uin", session.UIN,
  259. "from_uin", fromUIN,
  260. )
  261. if err := b.dispatcher.SendOnlineMessage(session, fromUIN, ICQLegacyMsgAuthReq, text); err != nil {
  262. b.logger.Debug("failed to deliver auth request to legacy client",
  263. "to_uin", session.UIN,
  264. "from_uin", fromUIN,
  265. "err", err,
  266. )
  267. }
  268. }
  269. // handleAuthResponse converts an OSCAR FeedbagRespondAuthorizeToClient (0x13,0x1B)
  270. // into a legacy ICQ auth grant (0x08) or deny (0x07) message.
  271. func (b *LegacyMessageBridge) handleAuthResponse(session *LegacySession, msg wire.SNACMessage) {
  272. body, ok := msg.Body.(wire.SNAC_0x13_0x1B_FeedbagRespondAuthorizeToClient)
  273. if !ok {
  274. return
  275. }
  276. fromUIN, ok := parseUIN(body.ScreenName)
  277. if !ok {
  278. return
  279. }
  280. nick, first, last, email := b.buildLegacyAuthFields(fromUIN)
  281. var msgType uint16
  282. var text string
  283. if body.Accepted == 1 {
  284. msgType = ICQLegacyMsgAuthGrant
  285. text = fmt.Sprintf("%s\xFE%s\xFE%s\xFE%s\xFE", nick, first, last, email)
  286. } else {
  287. msgType = ICQLegacyMsgAuthDeny
  288. reason := utf8ToLatin1(body.Reason)
  289. text = fmt.Sprintf("%s\xFE%s\xFE%s\xFE%s\xFE%s", nick, first, last, email, reason)
  290. }
  291. b.logger.Debug("OSCAR->legacy auth response",
  292. "to_uin", session.UIN,
  293. "from_uin", fromUIN,
  294. "accepted", body.Accepted,
  295. )
  296. if err := b.dispatcher.SendOnlineMessage(session, fromUIN, msgType, text); err != nil {
  297. b.logger.Debug("failed to deliver auth response to legacy client",
  298. "to_uin", session.UIN,
  299. "from_uin", fromUIN,
  300. "err", err,
  301. )
  302. }
  303. }
  304. // handleICBMMessage converts an OSCAR ICBM channel message into a legacy
  305. // online message. This handles the case where the OSCAR relay system delivers
  306. // a message to a legacy session's SNAC queue (e.g., from another OSCAR user
  307. // who has this legacy user's UIN as a buddy).
  308. func (b *LegacyMessageBridge) handleICBMMessage(session *LegacySession, msg wire.SNACMessage) {
  309. clientMsg, ok := msg.Body.(wire.SNAC_0x04_0x07_ICBMChannelMsgToClient)
  310. if !ok {
  311. b.logger.Warn("handleICBMMessage: body type assertion failed",
  312. "uin", session.UIN,
  313. "body_type", fmt.Sprintf("%T", msg.Body),
  314. )
  315. return
  316. }
  317. b.logger.Debug("handleICBMMessage: received",
  318. "uin", session.UIN,
  319. "channel_id", clientMsg.ChannelID,
  320. "from_screen_name", clientMsg.ScreenName,
  321. "tlv_count", len(clientMsg.TLVList),
  322. )
  323. // Handle channel 1 (IM) and channel 4 (ICQ) messages
  324. // ICQ 2003b and later send ICQ-to-ICQ messages on channel 4
  325. if clientMsg.ChannelID != wire.ICBMChannelIM && clientMsg.ChannelID != wire.ICBMChannelICQ {
  326. b.logger.Debug("handleICBMMessage: skipping unsupported channel",
  327. "uin", session.UIN,
  328. "channel_id", clientMsg.ChannelID,
  329. )
  330. return
  331. }
  332. fromUIN, ok := parseUIN(clientMsg.ScreenName)
  333. if !ok {
  334. b.logger.Debug("handleICBMMessage: parseUIN failed",
  335. "uin", session.UIN,
  336. "screen_name", clientMsg.ScreenName,
  337. )
  338. return // AIM screen name - can't represent as legacy UIN
  339. }
  340. // Extract message text and type from ICBM TLVs
  341. var msgType uint16
  342. var text string
  343. if clientMsg.ChannelID == wire.ICBMChannelICQ {
  344. // Channel 4: extract ICBMCh4Message which has message type
  345. payload, hasPayload := clientMsg.Bytes(wire.ICBMTLVData)
  346. if hasPayload {
  347. ch4Msg := wire.ICBMCh4Message{}
  348. if err := wire.UnmarshalLE(&ch4Msg, bytes.NewBuffer(payload)); err == nil {
  349. msgType = uint16(ch4Msg.MessageType)
  350. text = ch4Msg.Message
  351. }
  352. }
  353. } else {
  354. // Fallback to channel 1 text extraction
  355. text = extractAndConvertICBMText(clientMsg)
  356. msgType = ICQLegacyMsgText
  357. }
  358. if text == "" && msgType == 0 {
  359. b.logger.Debug("handleICBMMessage: no message content",
  360. "uin", session.UIN,
  361. "from_uin", fromUIN,
  362. )
  363. return
  364. }
  365. // For auth grant (0x08), text may be empty — that's OK
  366. if msgType == 0 {
  367. msgType = ICQLegacyMsgText
  368. }
  369. b.logger.Debug("OSCAR->legacy ICBM message",
  370. "to_uin", session.UIN,
  371. "from_uin", fromUIN,
  372. "msg_type", fmt.Sprintf("0x%04X", msgType),
  373. "text_len", len(text),
  374. )
  375. if err := b.dispatcher.SendOnlineMessage(session, fromUIN, msgType, text); err != nil {
  376. b.logger.Debug("failed to deliver ICBM to legacy client",
  377. "to_uin", session.UIN,
  378. "from_uin", fromUIN,
  379. "err", err,
  380. )
  381. }
  382. }
  383. // ---------------------------------------------------------------------------
  384. // UIN validation
  385. // ---------------------------------------------------------------------------
  386. // parseUIN converts an OSCAR screen name to a numeric UIN.
  387. // Returns (0, false) if the screen name is not a valid numeric UIN.
  388. // Legacy ICQ clients can only process events with numeric UINs - AIM screen
  389. // names like "CoolDude99" must be silently dropped.
  390. func parseUIN(screenName string) (uint32, bool) {
  391. // Strip spaces (OSCAR normalizes screen names by removing spaces)
  392. s := strings.ReplaceAll(screenName, " ", "")
  393. if s == "" {
  394. return 0, false
  395. }
  396. v, err := strconv.ParseUint(s, 10, 32)
  397. if err != nil || v == 0 {
  398. return 0, false
  399. }
  400. return uint32(v), true
  401. }
  402. // ---------------------------------------------------------------------------
  403. // Status mapping (OSCAR <-> legacy ICQ)
  404. // ---------------------------------------------------------------------------
  405. // oscarStatusToLegacy converts OSCAR status flags to legacy ICQ status value.
  406. // The bit patterns are nearly identical between OSCAR and legacy ICQ, but we
  407. // do an explicit mapping to be safe against future divergence.
  408. //
  409. // OSCAR status bits (wire/snacs.go):
  410. //
  411. // 0x0000 = Available -> 0x00000000 ICQLegacyStatusOnline
  412. // 0x0001 = Away -> 0x00000001 ICQLegacyStatusAway
  413. // 0x0002 = DND -> 0x00000002 ICQLegacyStatusDND
  414. // 0x0004 = Out/NA -> 0x00000004 ICQLegacyStatusNA
  415. // 0x0010 = Busy -> 0x00000010 ICQLegacyStatusOccupied
  416. // 0x0020 = Chat/FFC -> 0x00000020 ICQLegacyStatusFFC
  417. // 0x0100 = Invisible -> 0x00000100 ICQLegacyStatusInvisible
  418. func oscarStatusToLegacy(oscarStatus uint32) uint32 {
  419. var legacyStatus uint32
  420. // ICQ 2003b sends combined status bits:
  421. // Available = 0x00
  422. // FFC = 0x20
  423. // Away = 0x01
  424. // N/A = 0x05 (Away|Out)
  425. // Occupied = 0x11 (Away|Busy)
  426. // DND = 0x13 (Away|DND|Busy)
  427. // Match exact combined values first, then fall back to bitmask.
  428. statusByte := oscarStatus & 0xFF
  429. switch statusByte {
  430. case 0x00:
  431. legacyStatus = ICQLegacyStatusOnline
  432. case 0x01:
  433. legacyStatus = ICQLegacyStatusAway
  434. case 0x02:
  435. legacyStatus = ICQLegacyStatusDND
  436. case 0x04:
  437. legacyStatus = ICQLegacyStatusNA
  438. case 0x05: // Away|Out -> N/A
  439. legacyStatus = ICQLegacyStatusNA
  440. case 0x10:
  441. legacyStatus = ICQLegacyStatusOccupied
  442. case 0x11: // Away|Busy -> Occupied
  443. legacyStatus = ICQLegacyStatusOccupied
  444. case 0x13: // Away|DND|Busy -> DND
  445. legacyStatus = ICQLegacyStatusDND
  446. case 0x20:
  447. legacyStatus = ICQLegacyStatusFFC
  448. default:
  449. // Fallback: check bits from most to least specific
  450. switch {
  451. case statusByte&0x20 != 0:
  452. legacyStatus = ICQLegacyStatusFFC
  453. case statusByte&0x02 != 0 && statusByte&0x10 != 0:
  454. legacyStatus = ICQLegacyStatusDND
  455. case statusByte&0x10 != 0:
  456. legacyStatus = ICQLegacyStatusOccupied
  457. case statusByte&0x04 != 0:
  458. legacyStatus = ICQLegacyStatusNA
  459. case statusByte&0x02 != 0:
  460. legacyStatus = ICQLegacyStatusDND
  461. case statusByte&0x01 != 0:
  462. legacyStatus = ICQLegacyStatusAway
  463. default:
  464. legacyStatus = ICQLegacyStatusOnline
  465. }
  466. }
  467. // Map flags
  468. if oscarStatus&wire.OServiceUserStatusInvisible != 0 {
  469. legacyStatus |= ICQLegacyStatusInvisible
  470. }
  471. if oscarStatus&wire.OServiceUserStatusWebAware != 0 {
  472. legacyStatus |= ICQLegacyStatusFlagWebAware
  473. }
  474. if oscarStatus&wire.OServiceUserStatusBirthday != 0 {
  475. legacyStatus |= ICQLegacyStatusFlagBirthday
  476. }
  477. if oscarStatus&wire.OServiceUserStatusDirectRequireAuth != 0 {
  478. legacyStatus |= ICQLegacyStatusFlagDCAuth
  479. }
  480. return legacyStatus
  481. }
  482. // ---------------------------------------------------------------------------
  483. // Character encoding conversion
  484. // ---------------------------------------------------------------------------
  485. // extractAndConvertICBMText extracts message text from an OSCAR ICBM message,
  486. // handling charset conversion from OSCAR encoding to legacy-compatible text.
  487. //
  488. // OSCAR messages can use three charsets (ICBMCh1Message.Charset):
  489. // - 0x0000 (ASCII): single-byte, no conversion needed
  490. // - 0x0002 (Unicode): UCS-2 big-endian, must be converted to single-byte
  491. // - 0x0003 (Latin-1): single-byte, no conversion needed
  492. //
  493. // Legacy ICQ clients (V2-V5) expect single-byte text. If the message is
  494. // Unicode, we convert to Latin-1 with best-effort transliteration for
  495. // characters outside the Latin-1 range.
  496. func extractAndConvertICBMText(clientMsg wire.SNAC_0x04_0x07_ICBMChannelMsgToClient) string {
  497. switch clientMsg.ChannelID {
  498. case wire.ICBMChannelIM:
  499. return extractChannel1Text(clientMsg)
  500. case wire.ICBMChannelICQ:
  501. return extractChannel4Text(clientMsg)
  502. default:
  503. return ""
  504. }
  505. }
  506. // extractChannel1Text extracts text from channel 1 (AIM IM) messages.
  507. // Format: TLV 0x0002 (AOLIMData) containing ICBM fragments.
  508. func extractChannel1Text(clientMsg wire.SNAC_0x04_0x07_ICBMChannelMsgToClient) string {
  509. payload, hasPayload := clientMsg.Bytes(wire.ICBMTLVAOLIMData)
  510. if !hasPayload {
  511. return ""
  512. }
  513. var frags []wire.ICBMCh1Fragment
  514. if err := wire.UnmarshalBE(&frags, bytes.NewBuffer(payload)); err != nil {
  515. return ""
  516. }
  517. for _, frag := range frags {
  518. if frag.ID != 1 { // 1 = message text
  519. continue
  520. }
  521. msg := wire.ICBMCh1Message{}
  522. if err := wire.UnmarshalBE(&msg, bytes.NewBuffer(frag.Payload)); err != nil {
  523. continue
  524. }
  525. switch msg.Charset {
  526. case wire.ICBMMessageEncodingUnicode:
  527. return ucs2BEToLatin1(msg.Text)
  528. default:
  529. text := string(msg.Text)
  530. if strings.Contains(text, "<") {
  531. return stripHTMLSimple(text)
  532. }
  533. return text
  534. }
  535. }
  536. return ""
  537. }
  538. // extractChannel4Text extracts text from channel 4 (ICQ) messages.
  539. // Format: TLV 0x0005 (ICBMTLVData) containing ICBMCh4Message (little-endian).
  540. func extractChannel4Text(clientMsg wire.SNAC_0x04_0x07_ICBMChannelMsgToClient) string {
  541. payload, hasPayload := clientMsg.Bytes(wire.ICBMTLVData)
  542. if !hasPayload {
  543. return ""
  544. }
  545. msg := wire.ICBMCh4Message{}
  546. if err := wire.UnmarshalLE(&msg, bytes.NewBuffer(payload)); err != nil {
  547. return ""
  548. }
  549. text := msg.Message
  550. if strings.Contains(text, "<") {
  551. return stripHTMLSimple(text)
  552. }
  553. return text
  554. }
  555. // ucs2BEToLatin1 converts UCS-2 big-endian encoded bytes to a Latin-1 string.
  556. // Characters outside the Latin-1 range (U+0000-U+00FF) are replaced with '?'.
  557. func ucs2BEToLatin1(data []byte) string {
  558. if len(data) < 2 {
  559. return ""
  560. }
  561. var result []byte
  562. for i := 0; i+1 < len(data); i += 2 {
  563. codepoint := binary.BigEndian.Uint16(data[i : i+2])
  564. if codepoint == 0 {
  565. continue // skip null
  566. }
  567. if codepoint <= 0xFF {
  568. result = append(result, byte(codepoint))
  569. } else {
  570. result = append(result, '?') // outside Latin-1 range
  571. }
  572. }
  573. return string(result)
  574. }
  575. // utf8ToLatin1 converts a UTF-8 string to Latin-1 (ISO 8859-1).
  576. // Characters outside the Latin-1 range are replaced with '?'.
  577. // This is used when delivering messages from OSCAR clients (which use UTF-8
  578. // internally in Go strings) to legacy ICQ clients that expect single-byte text.
  579. func utf8ToLatin1(s string) string {
  580. // Fast path: if all bytes are ASCII, no conversion needed
  581. if !utf8.ValidString(s) || isASCII(s) {
  582. return s
  583. }
  584. var result []byte
  585. for _, r := range s {
  586. if r <= 0xFF {
  587. result = append(result, byte(r))
  588. } else {
  589. result = append(result, '?')
  590. }
  591. }
  592. return string(result)
  593. }
  594. // isASCII returns true if all bytes in the string are 7-bit ASCII.
  595. func isASCII(s string) bool {
  596. for i := 0; i < len(s); i++ {
  597. if s[i] > 0x7F {
  598. return false
  599. }
  600. }
  601. return true
  602. }
  603. // stripHTMLSimple removes HTML tags from text. This is a minimal
  604. // implementation for the bridge - AIM clients send HTML-formatted messages
  605. // that legacy ICQ clients can't render.
  606. func stripHTMLSimple(s string) string {
  607. var result strings.Builder
  608. inTag := false
  609. for _, r := range s {
  610. switch {
  611. case r == '<':
  612. inTag = true
  613. case r == '>':
  614. inTag = false
  615. case !inTag:
  616. result.WriteRune(r)
  617. }
  618. }
  619. return result.String()
  620. }
  621. // Compile-time check that LegacyMessageBridge implements LegacyMessageSender.
  622. var _ LegacyMessageSender = (*LegacyMessageBridge)(nil)