228 lines
6.9 KiB
Go
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
|
|
}
|