package webhook import ( "encoding/json" "fmt" "mime" "net/url" "strings" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) // jsonPayload is the normalized wire shape shared by the JSON and form decoders. // Field names are lowerCamelCase to match the project's API convention; form // values use the same keys. Missing fields fall back to safe defaults. type jsonPayload struct { MessageID string `json:"messageId"` ChannelType string `json:"channelType"` SenderID string `json:"senderId"` SenderName string `json:"senderName"` Content string `json:"content"` ContentType string `json:"contentType"` Media []jsonMedia `json:"media"` Mentions []jsonMention `json:"mentions"` ReplyToID string `json:"replyToId"` ReplyToText string `json:"replyToText"` GroupID string `json:"groupId"` GroupName string `json:"groupName"` ThreadID string `json:"threadId"` Timestamp string `json:"timestamp"` } // jsonMedia is a single attachment carried by an inbound message. type jsonMedia struct { Kind string `json:"kind"` URL string `json:"url"` Name string `json:"name"` Size int64 `json:"size"` MIMEType string `json:"mimeType"` } // jsonMention is a reference to a user inside the message body. type jsonMention struct { UserID string `json:"userId"` UserName string `json:"userName"` } // parseInbound dispatches on the request Content-Type to select the payload // decoder. An empty or unparseable content type defaults to JSON, since JSON is // the common callback encoding. func parseInbound(contentType string, body []byte) (adapter.UnifiedMessage, error) { mediaType, _, err := mime.ParseMediaType(contentType) if err != nil { if strings.TrimSpace(contentType) == "" { return parseJSON(body) } return adapter.UnifiedMessage{}, fmt.Errorf("webhook: bad content type %q: %w", contentType, err) } switch mediaType { case "application/json": return parseJSON(body) case "application/x-www-form-urlencoded": return parseForm(body) default: return adapter.UnifiedMessage{}, fmt.Errorf("webhook: unsupported content type %q", mediaType) } } // parseJSON decodes a JSON callback body into a UnifiedMessage. func parseJSON(body []byte) (adapter.UnifiedMessage, error) { var p jsonPayload if err := json.Unmarshal(body, &p); err != nil { return adapter.UnifiedMessage{}, fmt.Errorf("webhook: decode json: %w", err) } return p.toUnified(), nil } // parseForm decodes an application/x-www-form-urlencoded callback body into a // UnifiedMessage. Scalar fields map to form keys; media and mentions are // carried as JSON-encoded values, and a single mediaUrl/mediaKind pair is // accepted as a shorthand. func parseForm(body []byte) (adapter.UnifiedMessage, error) { values, err := url.ParseQuery(string(body)) if err != nil { return adapter.UnifiedMessage{}, fmt.Errorf("webhook: decode form: %w", err) } p := jsonPayload{ MessageID: values.Get("messageId"), ChannelType: values.Get("channelType"), SenderID: values.Get("senderId"), SenderName: values.Get("senderName"), Content: values.Get("content"), ContentType: values.Get("contentType"), ReplyToID: values.Get("replyToId"), ReplyToText: values.Get("replyToText"), GroupID: values.Get("groupId"), GroupName: values.Get("groupName"), ThreadID: values.Get("threadId"), Timestamp: values.Get("timestamp"), } if err := decodeJSONField(values.Get("media"), &p.Media); err != nil { return adapter.UnifiedMessage{}, fmt.Errorf("webhook: decode media: %w", err) } if err := decodeJSONField(values.Get("mentions"), &p.Mentions); err != nil { return adapter.UnifiedMessage{}, fmt.Errorf("webhook: decode mentions: %w", err) } if len(p.Media) == 0 { if u := values.Get("mediaUrl"); u != "" { p.Media = []jsonMedia{{ Kind: values.Get("mediaKind"), URL: u, Name: values.Get("mediaName"), MIMEType: values.Get("mediaMimeType"), }} } } return p.toUnified(), nil } // decodeJSONField decodes a JSON-encoded form value into v when present. func decodeJSONField(s string, v any) error { if s == "" { return nil } return json.Unmarshal([]byte(s), v) } // toUnified converts the wire payload into the normalized UnifiedMessage, // applying platform/direct/text defaults and attaching the strongly typed actor // identity metadata. It does not validate; callers run Validate separately so // an invalid payload yields a clear HTTP error. func (p jsonPayload) toUnified() adapter.UnifiedMessage { ct := adapter.ChannelType(p.ChannelType) if ct == "" { ct = adapter.ChannelDirect } contentType := adapter.ContentType(p.ContentType) if contentType == "" { contentType = adapter.ContentText } channelID := p.SenderID if ct == adapter.ChannelGroup { channelID = p.GroupID } return adapter.UnifiedMessage{ Platform: PlatformName, ChannelID: channelID, ChannelType: ct, SenderID: p.SenderID, SenderName: p.SenderName, Sender: buildActor(p), Content: p.Content, ContentType: contentType, Media: toMedia(p.Media), Mentions: toMentions(p.Mentions), ReplyToID: p.ReplyToID, ReplyToText: p.ReplyToText, GroupID: p.GroupID, GroupName: p.GroupName, ThreadID: p.ThreadID, MessageID: p.MessageID, Timestamp: parseTimestamp(p.Timestamp), } } // buildActor derives the strongly typed actor metadata. The adapter fills // identity fields but leaves Role as RoleUnknown so owner/member resolution // stays with the strategy engine, per the identity red line. func buildActor(p jsonPayload) adapter.Actor { actor := adapter.Actor{ Role: adapter.RoleUnknown, UserID: p.SenderID, DisplayName: p.SenderName, } if p.GroupID != "" { actor.IsGroup = true actor.GroupID = p.GroupID actor.GroupName = p.GroupName } return actor } // toMedia converts the wire media list into the normalized Media slice. func toMedia(list []jsonMedia) []adapter.Media { if len(list) == 0 { return nil } out := make([]adapter.Media, 0, len(list)) for _, im := range list { out = append(out, adapter.Media{ Kind: adapter.MediaKind(im.Kind), URL: im.URL, Name: im.Name, Size: im.Size, MIMEType: im.MIMEType, }) } return out } // toMentions converts the wire mention list into the normalized Mention slice. func toMentions(list []jsonMention) []adapter.Mention { if len(list) == 0 { return nil } out := make([]adapter.Mention, 0, len(list)) for _, im := range list { out = append(out, adapter.Mention{UserID: im.UserID, UserName: im.UserName}) } return out } // parseTimestamp parses an RFC3339 timestamp, returning the zero time on any // invalid or empty value so downstream code can decide how to handle it. func parseTimestamp(s string) time.Time { if s == "" { return time.Time{} } t, err := time.Parse(time.RFC3339Nano, s) if err != nil { return time.Time{} } return t }