package hotline
import (
+ "bufio"
+ "bytes"
"encoding/binary"
- "github.com/jhalter/mobius/concat"
+ "errors"
+ "io"
+ "slices"
)
-const fieldError = 100
-const fieldData = 101
-const fieldUserName = 102
-const fieldUserID = 103
-const fieldUserIconID = 104
-const fieldUserLogin = 105
-const fieldUserPassword = 106
-const fieldRefNum = 107
-const fieldTransferSize = 108
-const fieldChatOptions = 109
-const fieldUserAccess = 110
-
-// const fieldUserAlias = 111 TODO: implement
-const fieldUserFlags = 112
-const fieldOptions = 113
-const fieldChatID = 114
-const fieldChatSubject = 115
-const fieldWaitingCount = 116
-const fieldVersion = 160
-const fieldCommunityBannerID = 161
-const fieldServerName = 162
-const fieldFileNameWithInfo = 200
-const fieldFileName = 201
-const fieldFilePath = 202
-const fieldFileTypeString = 205
-const fieldFileCreatorString = 206
-const fieldFileSize = 207
-const fieldFileCreateDate = 208
-const fieldFileModifyDate = 209
-const fieldFileComment = 210
-const fieldFileNewName = 211
-const fieldFileNewPath = 212
-const fieldFileType = 213
-const fieldQuotingMsg = 214 // Defined but unused in the Hotline Protocol spec
-const fieldAutomaticResponse = 215
-const fieldFolderItemCount = 220
-const fieldUsernameWithInfo = 300
-const fieldNewsArtListData = 321
-const fieldNewsCatName = 322
-const fieldNewsCatListData15 = 323
-const fieldNewsPath = 325
-const fieldNewsArtID = 326
-const fieldNewsArtDataFlav = 327
-const fieldNewsArtTitle = 328
-const fieldNewsArtPoster = 329
-const fieldNewsArtDate = 330
-const fieldNewsArtPrevArt = 331
-const fieldNewsArtNextArt = 332
-const fieldNewsArtData = 333
-const fieldNewsArtFlags = 334
-const fieldNewsArtParentArt = 335
-const fieldNewsArt1stChildArt = 336
-const fieldNewsArtRecurseDel = 337
+// List of Hotline protocol field types taken from the official 1.9 protocol document
+var (
+ FieldError = [2]byte{0x00, 0x64} // 100
+ FieldData = [2]byte{0x00, 0x65} // 101
+ FieldUserName = [2]byte{0x00, 0x66} // 102
+ FieldUserID = [2]byte{0x00, 0x67} // 103
+ FieldUserIconID = [2]byte{0x00, 0x68} // 104
+ FieldUserLogin = [2]byte{0x00, 0x69} // 105
+ FieldUserPassword = [2]byte{0x00, 0x6A} // 106
+ FieldRefNum = [2]byte{0x00, 0x6B} // 107
+ FieldTransferSize = [2]byte{0x00, 0x6C} // 108
+ FieldChatOptions = [2]byte{0x00, 0x6D} // 109
+ FieldUserAccess = [2]byte{0x00, 0x6E} // 110
+ FieldUserFlags = [2]byte{0x00, 0x70} // 112
+ FieldOptions = [2]byte{0x00, 0x71} // 113
+ FieldChatID = [2]byte{0x00, 0x72} // 114
+ FieldChatSubject = [2]byte{0x00, 0x73} // 115
+ FieldWaitingCount = [2]byte{0x00, 0x74} // 116
+ FieldBannerType = [2]byte{0x00, 0x98} // 152
+ FieldNoServerAgreement = [2]byte{0x00, 0x98} // 152
+ FieldVersion = [2]byte{0x00, 0xA0} // 160
+ FieldCommunityBannerID = [2]byte{0x00, 0xA1} // 161
+ FieldServerName = [2]byte{0x00, 0xA2} // 162
+ FieldFileNameWithInfo = [2]byte{0x00, 0xC8} // 200
+ FieldFileName = [2]byte{0x00, 0xC9} // 201
+ FieldFilePath = [2]byte{0x00, 0xCA} // 202
+ FieldFileResumeData = [2]byte{0x00, 0xCB} // 203
+ FieldFileTransferOptions = [2]byte{0x00, 0xCC} // 204
+ FieldFileTypeString = [2]byte{0x00, 0xCD} // 205
+ FieldFileCreatorString = [2]byte{0x00, 0xCE} // 206
+ FieldFileSize = [2]byte{0x00, 0xCF} // 207
+ FieldFileCreateDate = [2]byte{0x00, 0xD0} // 208
+ FieldFileModifyDate = [2]byte{0x00, 0xD1} // 209
+ FieldFileComment = [2]byte{0x00, 0xD2} // 210
+ FieldFileNewName = [2]byte{0x00, 0xD3} // 211
+ FieldFileNewPath = [2]byte{0x00, 0xD4} // 212
+ FieldFileType = [2]byte{0x00, 0xD5} // 213
+ FieldQuotingMsg = [2]byte{0x00, 0xD6} // 214
+ FieldAutomaticResponse = [2]byte{0x00, 0xD7} // 215
+ FieldFolderItemCount = [2]byte{0x00, 0xDC} // 220
+ FieldUsernameWithInfo = [2]byte{0x01, 0x2C} // 300
+ FieldNewsArtListData = [2]byte{0x01, 0x41} // 321
+ FieldNewsCatName = [2]byte{0x01, 0x42} // 322
+ FieldNewsCatListData15 = [2]byte{0x01, 0x43} // 323
+ FieldNewsPath = [2]byte{0x01, 0x45} // 325
+ FieldNewsArtID = [2]byte{0x01, 0x46} // 326
+ FieldNewsArtDataFlav = [2]byte{0x01, 0x47} // 327
+ FieldNewsArtTitle = [2]byte{0x01, 0x48} // 328
+ FieldNewsArtPoster = [2]byte{0x01, 0x49} // 329
+ FieldNewsArtDate = [2]byte{0x01, 0x4A} // 330
+ FieldNewsArtPrevArt = [2]byte{0x01, 0x4B} // 331
+ FieldNewsArtNextArt = [2]byte{0x01, 0x4C} // 332
+ FieldNewsArtData = [2]byte{0x01, 0x4D} // 333
+ FieldNewsArtParentArt = [2]byte{0x01, 0x4F} // 335
+ FieldNewsArt1stChildArt = [2]byte{0x01, 0x50} // 336
+ FieldNewsArtRecurseDel = [2]byte{0x01, 0x51} // 337
+
+ // These fields are documented, but seemingly unused.
+ // FieldUserAlias = [2]byte{0x00, 0x6F} // 111
+ // FieldNewsArtFlags = [2]byte{0x01, 0x4E} // 334
+)
type Field struct {
- ID []byte // Type of field
- FieldSize []byte // Size of the data part
- Data []byte // Actual field content
+ Type [2]byte // Type of field
+ FieldSize [2]byte // Size of the data field
+ Data []byte // Field data
+
+ readOffset int // Internal offset to track read progress
}
-type requiredField struct {
- ID int
- minLen int
- maxLen int
+func NewField(fieldType [2]byte, data []byte) Field {
+ f := Field{
+ Type: fieldType,
+ Data: make([]byte, len(data)),
+ }
+
+ // Copy instead of assigning to avoid data race when the field is read in another go routine.
+ copy(f.Data, data)
+
+ binary.BigEndian.PutUint16(f.FieldSize[:], uint16(len(data)))
+ return f
}
-func NewField(id uint16, data []byte) Field {
- idBytes := make([]byte, 2)
- binary.BigEndian.PutUint16(idBytes, id)
+// fieldScanner implements bufio.SplitFunc for parsing byte slices into complete tokens
+func fieldScanner(data []byte, _ bool) (advance int, token []byte, err error) {
+ if len(data) < minFieldLen {
+ return 0, nil, nil
+ }
- bs := make([]byte, 2)
- binary.BigEndian.PutUint16(bs, uint16(len(data)))
+ // neededSize represents the length of bytes that are part of the field token.
+ neededSize := minFieldLen + int(binary.BigEndian.Uint16(data[2:4]))
+ if neededSize > len(data) {
+ return 0, nil, nil
+ }
+
+ return neededSize, data[0:neededSize], nil
+}
- return Field{
- ID: idBytes,
- FieldSize: bs,
- Data: data,
+// DecodeInt decodes the field bytes to an int.
+// The official Hotline clients will send uint32s as 2 bytes if possible, but
+// some third party clients such as Frogblast and Heildrun will always send 4 bytes
+func (f *Field) DecodeInt() (int, error) {
+ switch len(f.Data) {
+ case 2:
+ return int(binary.BigEndian.Uint16(f.Data)), nil
+ case 4:
+ return int(binary.BigEndian.Uint32(f.Data)), nil
}
+
+ return 0, errors.New("unknown byte length")
+}
+
+func (f *Field) DecodeObfuscatedString() string {
+ return string(encodeString(f.Data))
}
-func (f Field) Payload() []byte {
- return concat.Slices(f.ID, f.FieldSize, f.Data)
+// DecodeNewsPath decodes the field data to a news path.
+// Example News Path data for a Category nested under two Bundles:
+// 00000000 00 03 00 00 10 54 6f 70 20 4c 65 76 65 6c 20 42 |.....Top Level B|
+// 00000010 75 6e 64 6c 65 00 00 13 53 65 63 6f 6e 64 20 4c |undle...Second L|
+// 00000020 65 76 65 6c 20 42 75 6e 64 6c 65 00 00 0f 4e 65 |evel Bundle...Ne|
+// 00000030 73 74 65 64 20 43 61 74 65 67 6f 72 79 |sted Category|
+func (f *Field) DecodeNewsPath() ([]string, error) {
+ if len(f.Data) == 0 {
+ return []string{}, nil
+ }
+
+ pathCount := binary.BigEndian.Uint16(f.Data[0:2])
+
+ scanner := bufio.NewScanner(bytes.NewReader(f.Data[2:]))
+ scanner.Split(newsPathScanner)
+
+ var paths []string
+
+ for i := uint16(0); i < pathCount; i++ {
+ scanner.Scan()
+ paths = append(paths, scanner.Text())
+ }
+
+ return paths, nil
+}
+
+// Read implements io.Reader for Field
+func (f *Field) Read(p []byte) (int, error) {
+ buf := slices.Concat(f.Type[:], f.FieldSize[:], f.Data)
+
+ if f.readOffset >= len(buf) {
+ return 0, io.EOF // All bytes have been read
+ }
+
+ n := copy(p, buf[f.readOffset:])
+ f.readOffset += n
+
+ return n, nil
+}
+
+// Write implements io.Writer for Field
+func (f *Field) Write(p []byte) (int, error) {
+ if len(p) < minFieldLen {
+ return 0, errors.New("input slice too short")
+ }
+
+ copy(f.Type[:], p[0:2])
+ copy(f.FieldSize[:], p[2:4])
+
+ dataSize := int(binary.BigEndian.Uint16(f.FieldSize[:]))
+ if len(p) < minFieldLen+dataSize {
+ return 0, errors.New("input slice too short for data size")
+ }
+
+ f.Data = make([]byte, dataSize)
+ copy(f.Data, p[4:4+dataSize])
+
+ return minFieldLen + dataSize, nil
+}
+
+func getField(id [2]byte, fields *[]Field) *Field {
+ for _, field := range *fields {
+ if id == field.Type {
+ return &field
+ }
+ }
+ return nil
}