Files

228 lines
6.9 KiB
Go

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
}