feat(channels/webhook): 入站回调渠道插件(JSON/Form 解析 + HMAC 签名 + InboundHandler 供 gateway 挂载,主动推送不支持)
This commit is contained in:
1 parent
7adaf490c7
commit
0b036645bb
5 files changed
+1193
No files matched your search
@@ -0,0 +1,227 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user