Răsfoiți Sursa

toc: implement rendez-vous commands

wip: RVOUS_PROPOSE

wip: RVOUS_PROPOSE working

wip: toc_rvous_accept

toc: toc_rvous_cancel

toc: ignore unsupported ICBM channels and rendezvous types

set client IP in rvous fragment

toc: pass rendezvous ips to RVOUS_PROPOSE

toc: trim null bytes from file metadata

toc: write unit tests for rvous in foodgroup pkg

toc: unit test RVOUS_PROPOSE

toc: write unit tests for toc_rvous_cancel and toc_rvous_accept

toc: fix arm7 compilation error

toc: constants
Mike 1 an în urmă
părinte
comite
430e59db6a

+ 34 - 0
foodgroup/icbm.go

@@ -1,6 +1,7 @@
 package foodgroup
 
 import (
+	"bytes"
 	"context"
 	"fmt"
 	"time"
@@ -141,6 +142,11 @@ func (s ICBMService) ChannelMsgToHost(ctx context.Context, sess *state.Session,
 			// on macOS client v4.0.9.
 			continue
 		}
+		if clientIM.ChannelID == wire.ICBMChannelRendezvous && tlv.Tag == wire.ICBMTLVData {
+			if tlv, err = addExternalIP(sess, tlv); err != nil {
+				return nil, fmt.Errorf("addExternalIP: %w", err)
+			}
+		}
 		clientIM.Append(tlv)
 	}
 
@@ -172,6 +178,34 @@ func (s ICBMService) ChannelMsgToHost(ctx context.Context, sess *state.Session,
 	}, nil
 }
 
+// addExternalIP appends the client's IP address to the TLV if it's an ICBM
+// rendezvous proposal/accept message.
+func addExternalIP(sess *state.Session, tlv wire.TLV) (wire.TLV, error) {
+	frag := wire.ICBMCh2Fragment{}
+	if err := wire.UnmarshalBE(&frag, bytes.NewReader(tlv.Value)); err != nil {
+		return tlv, fmt.Errorf("wire.UnmarshalBE: %w", err)
+	}
+	if frag.Type != wire.ICBMRdvMessagePropose {
+		return tlv, nil
+	}
+	if frag.HasTag(wire.ICBMRdvTLVTagsRequesterIP) && sess.RemoteAddr() != nil && sess.RemoteAddr().Addr().Is4() {
+		ip := sess.RemoteAddr().Addr()
+		// replace the IP set by the client with the actual IP seen by the
+		// server. unlike AOL’s original behavior, this allows NATed clients
+		// to use rendezvous by replacing their LAN IP with the correct
+		// external IP.
+		frag.Replace(wire.NewTLVBE(wire.ICBMRdvTLVTagsRequesterIP, ip.AsSlice()))
+		// append the client’s IP as seen by the server. the recipient uses
+		// this to verify that the sender’s claimed IP matches what the server
+		// detects. although redundant since we override the requester IP
+		// above, it remains required for client compatibility.
+		frag.Append(wire.NewTLVBE(wire.ICBMRdvTLVTagsVerifiedIP, ip.AsSlice()))
+		return wire.NewTLVBE(tlv.Tag, frag), nil
+	}
+
+	return tlv, nil
+}
+
 // ClientEvent relays SNAC wire.ICBMClientEvent typing events from the
 // sender to the recipient.
 func (s ICBMService) ClientEvent(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error {

+ 258 - 0
foodgroup/icbm_test.go

@@ -1,6 +1,8 @@
 package foodgroup
 
 import (
+	"net"
+	"net/netip"
 	"testing"
 	"time"
 
@@ -65,6 +67,7 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 									SubGroup:  wire.ICBMChannelMsgToClient,
 								},
 								Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+									ChannelID:   wire.ICBMChannelIM,
 									TLVUserInfo: newTestSession("sender-screen-name", sessOptWarning(10)).TLVUserInfo(),
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
@@ -89,6 +92,7 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 					RequestID: 1234,
 				},
 				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelIM,
 					ScreenName: "recipient-screen-name",
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
@@ -111,6 +115,7 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 					RequestID: 1234,
 				},
 				Body: wire.SNAC_0x04_0x0C_ICBMHostAck{
+					ChannelID:  wire.ICBMChannelIM,
 					ScreenName: "recipient-screen-name",
 				},
 			},
@@ -152,6 +157,7 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 									SubGroup:  wire.ICBMChannelMsgToClient,
 								},
 								Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+									ChannelID:   wire.ICBMChannelIM,
 									TLVUserInfo: newTestSession("sender-screen-name", sessOptWarning(10)).TLVUserInfo(),
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
@@ -176,6 +182,7 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 					RequestID: 1234,
 				},
 				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelIM,
 					ScreenName: "recipient-screen-name",
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
@@ -220,6 +227,7 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 					RequestID: 1234,
 				},
 				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelIM,
 					ScreenName: "recipient-screen-name",
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
@@ -273,6 +281,7 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 					RequestID: 1234,
 				},
 				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelIM,
 					ScreenName: "recipient-screen-name",
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
@@ -331,6 +340,7 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 					RequestID: 1234,
 				},
 				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelIM,
 					ScreenName: "recipient-screen-name",
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
@@ -361,6 +371,7 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 					RequestID: 1234,
 				},
 				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelIM,
 					ScreenName: "22222222",
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
@@ -415,6 +426,7 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 						{
 							offlineMessageIn: state.OfflineMessage{
 								Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+									ChannelID:  wire.ICBMChannelIM,
 									ScreenName: "22222222",
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
@@ -432,6 +444,252 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "send rendezvous request for file transfer, expect IP TLV override",
+			senderSession: newTestSession("sender-screen-name", sessOptWarning(10),
+				sessRemoteAddr(netip.AddrPortFrom(netip.MustParseAddr("129.168.0.1"), 0))),
+			mockParams: mockParams{
+				buddyListRetrieverParams: buddyListRetrieverParams{
+					relationshipParams: relationshipParams{
+						{
+							me:   state.NewIdentScreenName("sender-screen-name"),
+							them: state.NewIdentScreenName("recipient-screen-name"),
+							result: state.Relationship{
+								User:          state.NewIdentScreenName("recipient-screen-name"),
+								BlocksYou:     false,
+								YouBlock:      false,
+								IsOnTheirList: false,
+								IsOnYourList:  false,
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							result:     newTestSession("recipient-screen-name", sessOptWarning(20)),
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICBM,
+									SubGroup:  wire.ICBMChannelMsgToClient,
+								},
+								Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+									ChannelID:   wire.ICBMChannelRendezvous,
+									TLVUserInfo: newTestSession("sender-screen-name", sessOptWarning(10)).TLVUserInfo(),
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.ICBMTLVWantEvents, []byte{}),
+											wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
+												Type:       wire.ICBMRdvMessagePropose,
+												Capability: wire.CapFileTransfer,
+												TLVRestBlock: wire.TLVRestBlock{
+													TLVList: wire.TLVList{
+														wire.NewTLVBE(wire.ICBMRdvTLVTagsPort, uint16(4000)),
+														wire.NewTLVBE(wire.ICBMRdvTLVTagsRequesterIP, net.ParseIP("129.168.0.1").To4()),
+														wire.NewTLVBE(wire.ICBMRdvTLVTagsVerifiedIP, net.ParseIP("129.168.0.1").To4()),
+													},
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelRendezvous,
+					ScreenName: "recipient-screen-name",
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
+								Type:       wire.ICBMRdvMessagePropose,
+								Capability: wire.CapFileTransfer,
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ICBMRdvTLVTagsPort, uint16(4000)),
+										wire.NewTLVBE(wire.ICBMRdvTLVTagsRequesterIP, net.ParseIP("127.0.0.1").To4()),
+									},
+								},
+							}),
+						},
+					},
+				},
+			},
+			expectOutput: nil,
+		},
+		{
+			name: "send rendezvous rejection for file transfer, expect no IP TLV override",
+			senderSession: newTestSession("sender-screen-name", sessOptWarning(10),
+				sessRemoteAddr(netip.AddrPortFrom(netip.MustParseAddr("129.168.0.1"), 0))),
+			mockParams: mockParams{
+				buddyListRetrieverParams: buddyListRetrieverParams{
+					relationshipParams: relationshipParams{
+						{
+							me:   state.NewIdentScreenName("sender-screen-name"),
+							them: state.NewIdentScreenName("recipient-screen-name"),
+							result: state.Relationship{
+								User:          state.NewIdentScreenName("recipient-screen-name"),
+								BlocksYou:     false,
+								YouBlock:      false,
+								IsOnTheirList: false,
+								IsOnYourList:  false,
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							result:     newTestSession("recipient-screen-name", sessOptWarning(20)),
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICBM,
+									SubGroup:  wire.ICBMChannelMsgToClient,
+								},
+								Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+									ChannelID:   wire.ICBMChannelRendezvous,
+									TLVUserInfo: newTestSession("sender-screen-name", sessOptWarning(10)).TLVUserInfo(),
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.ICBMTLVWantEvents, []byte{}),
+											wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
+												Type:       wire.ICBMRdvMessageCancel,
+												Capability: wire.CapFileTransfer,
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelRendezvous,
+					ScreenName: "recipient-screen-name",
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
+								Type:       wire.ICBMRdvMessageCancel,
+								Capability: wire.CapFileTransfer,
+							}),
+						},
+					},
+				},
+			},
+			expectOutput: nil,
+		},
+		{
+			name:          "send rendezvous request for file transfer without IP in session, expect no IP TLV override",
+			senderSession: newTestSession("sender-screen-name", sessOptWarning(10)),
+			mockParams: mockParams{
+				buddyListRetrieverParams: buddyListRetrieverParams{
+					relationshipParams: relationshipParams{
+						{
+							me:   state.NewIdentScreenName("sender-screen-name"),
+							them: state.NewIdentScreenName("recipient-screen-name"),
+							result: state.Relationship{
+								User:          state.NewIdentScreenName("recipient-screen-name"),
+								BlocksYou:     false,
+								YouBlock:      false,
+								IsOnTheirList: false,
+								IsOnYourList:  false,
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							result:     newTestSession("recipient-screen-name", sessOptWarning(20)),
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICBM,
+									SubGroup:  wire.ICBMChannelMsgToClient,
+								},
+								Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+									ChannelID:   wire.ICBMChannelRendezvous,
+									TLVUserInfo: newTestSession("sender-screen-name", sessOptWarning(10)).TLVUserInfo(),
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.ICBMTLVWantEvents, []byte{}),
+											wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
+												Type:       wire.ICBMRdvMessagePropose,
+												Capability: wire.CapFileTransfer,
+												TLVRestBlock: wire.TLVRestBlock{
+													TLVList: wire.TLVList{
+														wire.NewTLVBE(wire.ICBMRdvTLVTagsPort, uint16(4000)),
+														wire.NewTLVBE(wire.ICBMRdvTLVTagsRequesterIP, net.ParseIP("127.0.0.1").To4()),
+													},
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelRendezvous,
+					ScreenName: "recipient-screen-name",
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
+								Type:       wire.ICBMRdvMessagePropose,
+								Capability: wire.CapFileTransfer,
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ICBMRdvTLVTagsPort, uint16(4000)),
+										wire.NewTLVBE(wire.ICBMRdvTLVTagsRequesterIP, net.ParseIP("127.0.0.1").To4()),
+									},
+								},
+							}),
+						},
+					},
+				},
+			},
+			expectOutput: nil,
+		},
 	}
 
 	for _, tc := range cases {

+ 8 - 0
foodgroup/test_helpers.go

@@ -2,6 +2,7 @@ package foodgroup
 
 import (
 	"net/mail"
+	"net/netip"
 	"time"
 
 	"github.com/stretchr/testify/mock"
@@ -767,6 +768,13 @@ func sessClientID(clientID string) func(session *state.Session) {
 	}
 }
 
+// sessRemoteAddr sets the client's ip address / port
+func sessRemoteAddr(remoteAddr netip.AddrPort) func(session *state.Session) {
+	return func(session *state.Session) {
+		session.SetRemoteAddr(&remoteAddr)
+	}
+}
+
 // newTestSession creates a session object with 0 or more functional options
 // applied
 func newTestSession(screenName state.DisplayScreenName, options ...func(session *state.Session)) *state.Session {

+ 127 - 16
server/toc/cmd_client.go

@@ -3,6 +3,7 @@ package toc
 import (
 	"bytes"
 	"context"
+	"encoding/base64"
 	"encoding/csv"
 	"encoding/hex"
 	"errors"
@@ -19,11 +20,6 @@ import (
 	"github.com/mk6i/retro-aim-server/wire"
 )
 
-var (
-	// capChat is the UUID that represents an OSCAR client's ability to chat
-	capChat = uuid.MustParse("748F2420-6287-11D1-8222-444553540000")
-)
-
 // NewChatRegistry creates a new ChatRegistry instances.
 func NewChatRegistry() *ChatRegistry {
 	chatRegistry := &ChatRegistry{
@@ -231,6 +227,10 @@ func (s OSCARProxy) RecvClientCmd(
 		return s.GetDirSearchURL(ctx, sessBOS, payload), true
 	case "toc_get_dir":
 		return s.GetDirURL(ctx, sessBOS, payload), true
+	case "toc_rvous_accept":
+		return s.RvousAccept(ctx, sessBOS, payload), true
+	case "toc_rvous_cancel":
+		return s.RvousCancel(ctx, sessBOS, payload), true
 	}
 
 	s.Logger.ErrorContext(ctx, fmt.Sprintf("unsupported TOC command %s", cmd))
@@ -500,16 +500,16 @@ func (s OSCARProxy) ChatInvite(ctx context.Context, me *state.Session, chatRegis
 			ScreenName: guest,
 			TLVRestBlock: wire.TLVRestBlock{
 				TLVList: wire.TLVList{
-					wire.NewTLVBE(0x05, wire.ICBMCh2Fragment{
-						Type:       0,
-						Capability: capChat,
+					wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
+						Type:       wire.ICBMRdvMessagePropose,
+						Capability: wire.CapChat,
 						TLVRestBlock: wire.TLVRestBlock{
 							TLVList: wire.TLVList{
-								wire.NewTLVBE(10, uint16(1)),
-								wire.NewTLVBE(12, msg),
-								wire.NewTLVBE(13, "us-ascii"),
-								wire.NewTLVBE(14, "en"),
-								wire.NewTLVBE(10001, roomInfo),
+								wire.NewTLVBE(wire.ICBMRdvTLVTagsSeqNum, uint16(1)),
+								wire.NewTLVBE(wire.ICBMRdvTLVTagsInvitation, msg),
+								wire.NewTLVBE(wire.ICBMRdvTLVTagsInviteMIMECharset, "us-ascii"),
+								wire.NewTLVBE(wire.ICBMRdvTLVTagsInviteMIMELang, "en"),
+								wire.NewTLVBE(wire.ICBMRdvTLVTagsSvcData, roomInfo),
 							},
 						},
 					}),
@@ -1071,7 +1071,7 @@ func (s OSCARProxy) InitDone(ctx context.Context, sess *state.Session, cmd []byt
 //
 //	Remove buddies from your buddy list. This does not change your saved config.
 //
-// Command syntax:
+// Command syntax: toc_remove_buddy <Buddy User 1> [<Buddy User2> [<Buddy User 3> [...]]]
 func (s OSCARProxy) RemoveBuddy(ctx context.Context, me *state.Session, cmd []byte) string {
 	users, err := parseArgs(cmd, "toc_remove_buddy")
 	if err != nil {
@@ -1091,6 +1091,117 @@ func (s OSCARProxy) RemoveBuddy(ctx context.Context, me *state.Session, cmd []by
 	return ""
 }
 
+// RvousAccept handles the toc_rvous_accept TOC command.
+//
+// From the TiK documentation:
+//
+//	Accept a rendezvous proposal from the user <nick>. <cookie> is the cookie
+//	from the RVOUS_PROPOSE message. <service> is the UUID the proposal was for.
+//	<tlvlist> contains a list of tlv tags followed by base64 encoded values.
+//
+// Note: This method does not actually process the TLV list param, as it's not
+// passed in the TiK client, the reference implementation.
+//
+// Command syntax: toc_rvous_accept <nick> <cookie> <service>
+func (s OSCARProxy) RvousAccept(ctx context.Context, me *state.Session, cmd []byte) string {
+	var nick, cookie, service string
+
+	if _, err := parseArgs(cmd, "toc_rvous_accept", &nick, &cookie, &service); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	cbytes, err := base64.StdEncoding.DecodeString(cookie)
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("base64.Decode: %w", err))
+	}
+
+	var arr [8]byte
+	copy(arr[:], cbytes) // copy slice into array
+
+	svcUUID, err := uuid.Parse(service)
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("uuid.Parse: %w", err))
+	}
+
+	snac := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+		ChannelID:  wire.ICBMChannelRendezvous,
+		ScreenName: nick,
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
+					Type:       wire.ICBMRdvMessageAccept,
+					Cookie:     arr,
+					Capability: svcUUID,
+				}),
+			},
+		},
+	}
+
+	if _, err = s.ICBMService.ChannelMsgToHost(ctx, me, wire.SNACFrame{}, snac); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("ICBMService.ChannelMsgToHost: %w", err))
+	}
+
+	return ""
+}
+
+// RvousCancel handles the toc_rvous_cancel TOC command.
+//
+// From the TiK documentation:
+//
+//	Cancel a rendezvous proposal from the user <nick>. <cookie> is the cookie
+//	from the RVOUS_PROPOSE message. <service> is the UUID the proposal was for.
+//	<tlvlist> contains a list of tlv tags followed by base64 encoded values.
+//
+// Note: This method does not actually process the TLV list param, as it's not
+// passed in the TiK client, the reference implementation.
+//
+// Command syntax: toc_rvous_cancel <nick> <cookie> <service>
+func (s OSCARProxy) RvousCancel(ctx context.Context, me *state.Session, cmd []byte) string {
+	var nick, cookie, service string
+
+	if _, err := parseArgs(cmd, "toc_rvous_cancel", &nick, &cookie, &service); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	cbytes, err := base64.StdEncoding.DecodeString(cookie)
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("base64.Decode: %w", err))
+	}
+
+	var arr [8]byte
+	copy(arr[:], cbytes) // copy slice into array
+
+	svcUUID, err := uuid.Parse(service)
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("uuid.Parse: %w", err))
+	}
+
+	snac := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+		ChannelID:  wire.ICBMChannelRendezvous,
+		ScreenName: nick,
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
+					Type:       wire.ICBMRdvMessageCancel,
+					Cookie:     arr,
+					Capability: svcUUID,
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICBMRdvTLVTagsCancelReason, wire.ICBMRdvCancelReasonsUserCancel),
+						},
+					},
+				}),
+			},
+		},
+	}
+
+	if _, err = s.ICBMService.ChannelMsgToHost(ctx, me, wire.SNACFrame{}, snac); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("ICBMService.ChannelMsgToHost: %w", err))
+	}
+
+	return ""
+}
+
 // SendIM handles the toc_send_im TOC command.
 //
 // From the TiK documentation:
@@ -1199,7 +1310,7 @@ func (s OSCARProxy) SetCaps(ctx context.Context, me *state.Session, cmd []byte)
 		}
 		caps = append(caps, uid)
 	}
-	caps = append(caps, capChat)
+	caps = append(caps, wire.CapChat)
 
 	snac := wire.SNAC_0x02_0x04_LocateSetInfo{
 		TLVRestBlock: wire.TLVRestBlock{
@@ -1532,7 +1643,7 @@ func (s OSCARProxy) Signon(ctx context.Context, cmd []byte) (*state.Session, []s
 	}
 
 	// set chat capability so that... tk
-	sess.SetCaps([][16]byte{capChat})
+	sess.SetCaps([][16]byte{wire.CapChat})
 
 	if err := s.BuddyListRegistry.RegisterBuddyList(sess.IdentScreenName()); err != nil {
 		return nil, []string{s.runtimeErr(ctx, fmt.Errorf("BuddyListRegistry.RegisterBuddyList: %w", err))}

+ 213 - 5
server/toc/cmd_client_test.go

@@ -824,7 +824,7 @@ func TestOSCARProxy_ChatInvite(t *testing.T) {
 									TLVList: wire.TLVList{
 										wire.NewTLVBE(0x05, wire.ICBMCh2Fragment{
 											Type:       0,
-											Capability: capChat,
+											Capability: wire.CapChat,
 											TLVRestBlock: wire.TLVRestBlock{
 												TLVList: wire.TLVList{
 													wire.NewTLVBE(10, uint16(1)),
@@ -872,7 +872,7 @@ func TestOSCARProxy_ChatInvite(t *testing.T) {
 									TLVList: wire.TLVList{
 										wire.NewTLVBE(0x05, wire.ICBMCh2Fragment{
 											Type:       0,
-											Capability: capChat,
+											Capability: wire.CapChat,
 											TLVRestBlock: wire.TLVRestBlock{
 												TLVList: wire.TLVList{
 													wire.NewTLVBE(10, uint16(1)),
@@ -2543,6 +2543,214 @@ func TestOSCARProxy_RemoveBuddy(t *testing.T) {
 	}
 }
 
+func TestOSCARProxy_RvousAccept(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully send rendezvous request",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_rvous_accept them aGFoYWhhaGE= 09461343-4C7F-11D1-8222-444553540000`),
+			mockParams: mockParams{
+				icbmParams: icbmParams{
+					channelMsgToHostParamsICBM: channelMsgToHostParamsICBM{
+						{
+							sender: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+								ChannelID:  wire.ICBMChannelRendezvous,
+								ScreenName: "them",
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
+											Type:       wire.ICBMRdvMessageAccept,
+											Cookie:     [8]byte{'h', 'a', 'h', 'a', 'h', 'a', 'h', 'a'},
+											Capability: wire.CapFileTransfer,
+										}),
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "send rendezvous request, receive error from ICBM service",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_rvous_accept them aGFoYWhhaGE= 09461343-4C7F-11D1-8222-444553540000`),
+			mockParams: mockParams{
+				icbmParams: icbmParams{
+					channelMsgToHostParamsICBM: channelMsgToHostParamsICBM{
+						{
+							sender: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+								ChannelID:  wire.ICBMChannelRendezvous,
+								ScreenName: "them",
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
+											Type:       wire.ICBMRdvMessageAccept,
+											Cookie:     [8]byte{'h', 'a', 'h', 'a', 'h', 'a', 'h', 'a'},
+											Capability: wire.CapFileTransfer,
+										}),
+									},
+								},
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_rvous_accept`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			icbmSvc := newMockICBMService(t)
+			for _, params := range tc.mockParams.channelMsgToHostParamsICBM {
+				icbmSvc.EXPECT().
+					ChannelMsgToHost(ctx, matchSession(params.sender), params.inFrame, params.inBody).
+					Return(params.result, params.err)
+			}
+
+			svc := OSCARProxy{
+				Logger:      slog.Default(),
+				ICBMService: icbmSvc,
+			}
+			msg := svc.RvousAccept(ctx, tc.me, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+		})
+	}
+}
+
+func TestOSCARProxy_RvousCancel(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully send rendezvous cancellation",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_rvous_cancel them aGFoYWhhaGE= 09461343-4C7F-11D1-8222-444553540000`),
+			mockParams: mockParams{
+				icbmParams: icbmParams{
+					channelMsgToHostParamsICBM: channelMsgToHostParamsICBM{
+						{
+							sender: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+								ChannelID:  wire.ICBMChannelRendezvous,
+								ScreenName: "them",
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
+											Type:       wire.ICBMRdvMessageCancel,
+											Cookie:     [8]byte{'h', 'a', 'h', 'a', 'h', 'a', 'h', 'a'},
+											Capability: wire.CapFileTransfer,
+											TLVRestBlock: wire.TLVRestBlock{
+												TLVList: wire.TLVList{
+													wire.NewTLVBE(wire.ICBMRdvTLVTagsCancelReason, wire.ICBMRdvCancelReasonsUserCancel),
+												},
+											},
+										}),
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "send rendezvous cancellation, receive error from ICBM service",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_rvous_cancel them aGFoYWhhaGE= 09461343-4C7F-11D1-8222-444553540000`),
+			mockParams: mockParams{
+				icbmParams: icbmParams{
+					channelMsgToHostParamsICBM: channelMsgToHostParamsICBM{
+						{
+							sender: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+								ChannelID:  wire.ICBMChannelRendezvous,
+								ScreenName: "them",
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
+											Type:       wire.ICBMRdvMessageCancel,
+											Cookie:     [8]byte{'h', 'a', 'h', 'a', 'h', 'a', 'h', 'a'},
+											Capability: wire.CapFileTransfer,
+											TLVRestBlock: wire.TLVRestBlock{
+												TLVList: wire.TLVList{
+													wire.NewTLVBE(wire.ICBMRdvTLVTagsCancelReason, wire.ICBMRdvCancelReasonsUserCancel),
+												},
+											},
+										}),
+									},
+								},
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_rvous_cancel`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			icbmSvc := newMockICBMService(t)
+			for _, params := range tc.mockParams.channelMsgToHostParamsICBM {
+				icbmSvc.EXPECT().
+					ChannelMsgToHost(ctx, matchSession(params.sender), params.inFrame, params.inBody).
+					Return(params.result, params.err)
+			}
+
+			svc := OSCARProxy{
+				Logger:      slog.Default(),
+				ICBMService: icbmSvc,
+			}
+			msg := svc.RvousCancel(ctx, tc.me, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+		})
+	}
+}
+
 func TestOSCARProxy_SendIM(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
@@ -2840,7 +3048,7 @@ func TestOSCARProxy_SetCaps(t *testing.T) {
 										wire.NewTLVBE(wire.LocateTLVTagsInfoCapabilities, []uuid.UUID{
 											uuid.MustParse("09460000-4C7F-11D1-8222-444553540000"),
 											uuid.MustParse("09460001-4C7F-11D1-8222-444553540000"),
-											capChat,
+											wire.CapChat,
 										}),
 									},
 								},
@@ -2864,7 +3072,7 @@ func TestOSCARProxy_SetCaps(t *testing.T) {
 									TLVList: wire.TLVList{
 										wire.NewTLVBE(wire.LocateTLVTagsInfoCapabilities, []uuid.UUID{
 											uuid.MustParse("09460000-4C7F-11D1-8222-444553540000"),
-											capChat,
+											wire.CapChat,
 										}),
 									},
 								},
@@ -3694,7 +3902,7 @@ func TestOSCARProxy_Signon(t *testing.T) {
 		{
 			name: "successfully login",
 			me: newTestSession("me", func(session *state.Session) {
-				session.SetCaps([][16]byte{capChat})
+				session.SetCaps([][16]byte{wire.CapChat})
 			}),
 			givenCmd: []byte(`toc_signon "" "" me "xx` + hex.EncodeToString(roastedPass) + `"`),
 			mockParams: mockParams{

+ 88 - 23
server/toc/cmd_server.go

@@ -3,10 +3,13 @@ package toc
 import (
 	"bytes"
 	"context"
+	"encoding/base64"
 	"errors"
 	"fmt"
+	"net"
 	"strings"
 
+	"github.com/google/uuid"
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
 )
@@ -165,15 +168,57 @@ func (s OSCARProxy) Eviled(snac wire.SNAC_0x01_0x10_OServiceEvilNotification) st
 //
 // Command syntax: IM_IN:<Source User>:<Auto Response T/F?>:<Message>
 func (s OSCARProxy) IMIn(ctx context.Context, chatRegistry *ChatRegistry, snac wire.SNAC_0x04_0x07_ICBMChannelMsgToClient) string {
-	if snac.ChannelID == wire.ICBMChannelRendezvous {
-		rdinfo, has := snac.TLVRestBlock.Bytes(wire.ICBMTLVData)
-		if !has {
-			return s.runtimeErr(ctx, errors.New("TLVRestBlock.Bytes: missing rendezvous block"))
-		}
-		frag := wire.ICBMCh2Fragment{}
-		if err := wire.UnmarshalBE(&frag, bytes.NewReader(rdinfo)); err != nil {
-			return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalBE: %w", err))
-		}
+	switch snac.ChannelID {
+	case wire.ICBMChannelIM:
+		return s.convertICBMInstantMsg(ctx, snac)
+	case wire.ICBMChannelRendezvous:
+		return s.convertICBMRendezvous(ctx, chatRegistry, snac)
+	default:
+		s.Logger.DebugContext(ctx, "received unsupported ICBM channel message", "channel_id", snac.ChannelID)
+		return ""
+	}
+}
+
+// convertICBMInstantMsg converts an ICBM instant message SNAC to a TOC IM_IN response.
+func (s OSCARProxy) convertICBMInstantMsg(ctx context.Context, snac wire.SNAC_0x04_0x07_ICBMChannelMsgToClient) string {
+	buf, ok := snac.TLVRestBlock.Bytes(wire.ICBMTLVAOLIMData)
+	if !ok {
+		return s.runtimeErr(ctx, errors.New("TLVRestBlock.Bytes: missing wire.ICBMTLVAOLIMData"))
+	}
+	txt, err := wire.UnmarshalICBMMessageText(buf)
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalICBMMessageText: %w", err))
+	}
+
+	autoResp := "F"
+	if _, isAutoReply := snac.TLVRestBlock.Bytes(wire.ICBMTLVAutoResponse); isAutoReply {
+		autoResp = "T"
+	}
+
+	return fmt.Sprintf("IM_IN:%s:%s:%s", snac.ScreenName, autoResp, txt)
+}
+
+// convertICBMRendezvous converts an ICBM rendezvous SNAC to a TOC response.
+//   - if chat, return CHAT_INVITE
+//   - file transfer, return RVOUS_PROPOSE
+//   - don't respond for other rendezvous types
+func (s OSCARProxy) convertICBMRendezvous(ctx context.Context, chatRegistry *ChatRegistry, snac wire.SNAC_0x04_0x07_ICBMChannelMsgToClient) string {
+	rdinfo, has := snac.TLVRestBlock.Bytes(wire.ICBMTLVData)
+	if !has {
+		return s.runtimeErr(ctx, errors.New("TLVRestBlock.Bytes: missing rendezvous block"))
+	}
+	frag := wire.ICBMCh2Fragment{}
+	if err := wire.UnmarshalBE(&frag, bytes.NewReader(rdinfo)); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalBE: %w", err))
+	}
+
+	if frag.Type != wire.ICBMRdvMessagePropose {
+		s.Logger.DebugContext(ctx, "can't convert ICBM rendezvous message to TOC response", "rdv_type", frag.Type)
+		return ""
+	}
+
+	switch uuid.UUID(frag.Capability) {
+	case wire.CapChat:
 		prompt, ok := frag.Bytes(wire.ICBMRdvTLVTagsInvitation)
 		if !ok {
 			return s.runtimeErr(ctx, errors.New("frag.Bytes: missing chat invite prompt"))
@@ -198,23 +243,43 @@ func (s OSCARProxy) IMIn(ctx context.Context, chatRegistry *ChatRegistry, snac w
 		chatID := chatRegistry.Add(roomInfo)
 
 		return fmt.Sprintf("CHAT_INVITE:%s:%d:%s:%s", roomName, chatID, snac.ScreenName, prompt)
-	}
+	case wire.CapFileTransfer:
+		user := snac.TLVUserInfo.ScreenName
+		capability := strings.ToUpper(wire.CapFileTransfer.String()) // TiK requires upper-case UUID characters
+		cookie := base64.StdEncoding.EncodeToString(frag.Cookie[:])
+		seq, _ := frag.Uint16BE(wire.ICBMRdvTLVTagsSeqNum)
 
-	buf, ok := snac.TLVRestBlock.Bytes(wire.ICBMTLVAOLIMData)
-	if !ok {
-		return s.runtimeErr(ctx, errors.New("TLVRestBlock.Bytes: missing wire.ICBMTLVAOLIMData"))
-	}
-	txt, err := wire.UnmarshalICBMMessageText(buf)
-	if err != nil {
-		return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalICBMMessageText: %w", err))
-	}
+		rvousIP := "0.0.0.0"
+		if ip, ok := frag.Bytes(wire.ICBMRdvTLVTagsRdvIP); ok && len(ip) == 4 {
+			rvousIP = net.IPv4(ip[0], ip[1], ip[2], ip[3]).String()
+		}
 
-	autoResp := "F"
-	if _, isAutoReply := snac.TLVRestBlock.Bytes(wire.ICBMTLVAutoResponse); isAutoReply {
-		autoResp = "T"
-	}
+		proposerIP := "0.0.0.0"
+		if ip, ok := frag.Bytes(wire.ICBMRdvTLVTagsRequesterIP); ok && len(ip) == 4 {
+			proposerIP = net.IPv4(ip[0], ip[1], ip[2], ip[3]).String()
+		}
 
-	return fmt.Sprintf("IM_IN:%s:%s:%s", snac.ScreenName, autoResp, txt)
+		verifiedIP := "0.0.0.0"
+		if ip, ok := frag.Bytes(wire.ICBMRdvTLVTagsVerifiedIP); ok && len(ip) == 4 {
+			verifiedIP = net.IPv4(ip[0], ip[1], ip[2], ip[3]).String()
+		}
+
+		rvousPort, _ := frag.Uint16BE(wire.ICBMRdvTLVTagsPort)
+
+		var fileMetadata string
+		if f, ok := frag.Bytes(wire.ICBMRdvTLVTagsSvcData); ok {
+			// remove sequence of null bytes from the end that causes TiK file open
+			// dialog to crash
+			f = bytes.TrimRight(f, "\x00")
+			fileMetadata = base64.StdEncoding.EncodeToString(f)
+		}
+
+		return fmt.Sprintf("RVOUS_PROPOSE:%s:%s:%s:%d:%s:%s:%s:%d:%d:%s",
+			user, capability, cookie, seq, rvousIP, proposerIP, verifiedIP, rvousPort, wire.ICBMRdvTLVTagsSvcData, fileMetadata)
+	default:
+		s.Logger.DebugContext(ctx, "received rendezvous ICBM for unsupported capability", "capability", wire.CapChat)
+		return ""
+	}
 }
 
 // UpdateBuddyArrival handles the UPDATE_BUDDY TOC command for buddy arrival events.

+ 33 - 0
server/toc/cmd_server_test.go

@@ -3,6 +3,7 @@ package toc
 import (
 	"context"
 	"log/slog"
+	"net"
 	"sync"
 	"testing"
 
@@ -357,6 +358,7 @@ func TestOSCARProxy_RecvBOS_IMIn(t *testing.T) {
 						TLVList: wire.TLVList{
 							wire.NewTLVBE(wire.ICBMTLVData, []wire.ICBMCh2Fragment{
 								{
+									Capability: wire.CapChat,
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLVBE(wire.ICBMRdvTLVTagsInvitation, "join my chat!"),
@@ -374,6 +376,37 @@ func TestOSCARProxy_RecvBOS_IMIn(t *testing.T) {
 			chatRegistry: NewChatRegistry(),
 			wantCmd:      []byte("CHAT_INVITE:the room:0:them:join my chat!"),
 		},
+		{
+			name: "receive file transfer rendezvous IM",
+			me:   newTestSession("me"),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+					ChannelID:   wire.ICBMChannelRendezvous,
+					TLVUserInfo: newTestSession("them").TLVUserInfo(),
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICBMTLVWantEvents, []byte{}),
+							wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
+								Cookie:     [8]byte{'h', 'a', 'h', 'a', 'h', 'a', 'h', 'a'},
+								Type:       wire.ICBMRdvMessagePropose,
+								Capability: wire.CapFileTransfer,
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ICBMRdvTLVTagsSeqNum, uint16(1)),
+										wire.NewTLVBE(wire.ICBMRdvTLVTagsPort, uint16(4000)),
+										wire.NewTLVBE(wire.ICBMRdvTLVTagsRdvIP, net.ParseIP("129.168.0.1").To4()),
+										wire.NewTLVBE(wire.ICBMRdvTLVTagsRequesterIP, net.ParseIP("129.168.0.2").To4()),
+										wire.NewTLVBE(wire.ICBMRdvTLVTagsVerifiedIP, net.ParseIP("129.168.0.3").To4()),
+										wire.NewTLVBE(wire.ICBMRdvTLVTagsSvcData, []byte{'l', 'o', 'l'}),
+									},
+								},
+							}),
+						},
+					},
+				},
+			},
+			wantCmd: []byte("RVOUS_PROPOSE:them:09461343-4C7F-11D1-8222-444553540000:aGFoYWhhaGE=:1:129.168.0.1:129.168.0.2:129.168.0.3:4000:10001:bG9s"),
+		},
 	}
 
 	for _, tc := range cases {

+ 24 - 1
wire/snacs.go

@@ -4,6 +4,8 @@ import (
 	"bytes"
 	"errors"
 	"fmt"
+
+	"github.com/google/uuid"
 )
 
 //
@@ -107,6 +109,17 @@ const (
 
 )
 
+//
+// Capability IDs
+//
+
+var (
+	// CapChat is the UUID that represents an OSCAR client's ability to chat
+	CapChat = uuid.MustParse("748F2420-6287-11D1-8222-444553540000")
+	// CapFileTransfer is the UUID that represents an OSCAR client's ability to send files
+	CapFileTransfer = uuid.MustParse("09461343-4C7F-11D1-8222-444553540000")
+)
+
 //
 // 0x01: OService
 //
@@ -596,6 +609,16 @@ const (
 	ICBMMsgTypeAutoDND  uint8 = 0xEB // Auto do not disturb message
 	ICBMMsgTypeAutoFFC  uint8 = 0xEC // Auto free for chat message
 
+	ICBMRdvMessagePropose uint16 = 0x00 //	Propose a rendezvous
+	ICBMRdvMessageCancel  uint16 = 0x01 //	Cancel a proposal you generated
+	ICBMRdvMessageAccept  uint16 = 0x02 //	Accept a proposal someone else generated
+	ICBMRdvMessageNak     uint16 = 0x03 //	NOT CURRENTLY DOCUMENTED
+
+	ICBMRdvCancelReasonsUnknown           uint16 = 0x00 // Reason not specified
+	ICBMRdvCancelReasonsUserCancel        uint16 = 0x01 // Recipient user declined
+	ICBMRdvCancelReasonsTimeout           uint16 = 0x02 // Timeout
+	ICBMRdvCancelReasonsAcceptedElsewhere uint16 = 0x03 // Proposal was accepted by a different instance of the user
+
 	ICBMRdvTLVTagsRdvChan             uint16 = 0x0001 //	uint16 (word)	ICBM channel on which the rendezvous is to occur
 	ICBMRdvTLVTagsRdvIP               uint16 = 0x0002 //	uint32 (dword)	IP address proposed for the rendezvous
 	ICBMRdvTLVTagsRequesterIP         uint16 = 0x0003 //	uint32 (dword)	IP address of the proposing client
@@ -634,7 +657,7 @@ type ICBMCh1Fragment struct {
 
 type ICBMCh2Fragment struct {
 	Type       uint16
-	Cookie     uint64
+	Cookie     [8]byte
 	Capability [16]byte
 	TLVRestBlock
 }

+ 10 - 0
wire/tlv.go

@@ -93,6 +93,16 @@ func (s *TLVList) HasTag(tag uint16) bool {
 	return false
 }
 
+// Replace updates the values of TLVs in the list with the same tag as new. If
+// no matching tag is found, the list remains unchanged.
+func (s *TLVList) Replace(new TLV) {
+	for i, old := range *s {
+		if old.Tag == new.Tag {
+			(*s)[i].Value = new.Value
+		}
+	}
+}
+
 // String retrieves the string value associated with the specified tag from the
 // TLVList.
 //

+ 48 - 0
wire/tlv_test.go

@@ -383,3 +383,51 @@ func TestTLVList_ICQString(t *testing.T) {
 		assert.Empty(t, str)
 	})
 }
+
+func TestTLVList_Replace(t *testing.T) {
+	tests := []struct {
+		name        string
+		given       TLVList
+		want        TLVList
+		replacement TLV
+	}{
+		{
+			name: "replace multiple TLVs",
+			given: TLVList{
+				NewTLVLE(0x01, []byte{0x01}),
+				NewTLVLE(0x02, []byte{0x02}),
+				NewTLVLE(0x01, []byte{0x03}),
+				NewTLVLE(0x03, []byte{0x04}),
+			},
+			replacement: NewTLVLE(0x01, []byte{0xAA}),
+			want: TLVList{
+				NewTLVLE(0x01, []byte{0xAA}),
+				NewTLVLE(0x02, []byte{0x02}),
+				NewTLVLE(0x01, []byte{0xAA}),
+				NewTLVLE(0x03, []byte{0x04}),
+			},
+		},
+		{
+			name: "no matching tags",
+			given: TLVList{
+				NewTLVLE(0x01, []byte{0x01}),
+				NewTLVLE(0x02, []byte{0x02}),
+				NewTLVLE(0x01, []byte{0x03}),
+				NewTLVLE(0x03, []byte{0x04}),
+			},
+			replacement: NewTLVLE(0x07, []byte{0xAA}),
+			want: TLVList{
+				NewTLVLE(0x01, []byte{0x01}),
+				NewTLVLE(0x02, []byte{0x02}),
+				NewTLVLE(0x01, []byte{0x03}),
+				NewTLVLE(0x03, []byte{0x04}),
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			tt.given.Replace(tt.replacement)
+			assert.Equal(t, tt.want, tt.given)
+		})
+	}
+}