Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion server/cmd/server/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -386,14 +386,30 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
// (inbound pipeline seams) is all it takes to add the platform
// to the engine — no engine edit.
connector, connectorLabel := buildLarkConnector(installSvc, larkClient)
var inboundMedia *lark.InboundMediaService
if downloader, ok := larkClient.(lark.MessageResourceDownloader); ok {
mediaSvc, mediaErr := lark.NewInboundMediaService(
downloader,
installSvc,
h.Storage,
lark.InboundMediaServiceConfig{Logger: slog.Default()},
)
if mediaErr != nil {
slog.Error("lark inbound media init failed", "error", mediaErr)
} else {
inboundMedia = mediaSvc
}
} else {
slog.Error("lark inbound media disabled: API client cannot download message resources")
}
lark.RegisterFeishu(channelRegistry, lark.FeishuChannelDeps{
Connector: connector,
APIClient: larkClient,
Credentials: installSvc,
Logger: slog.Default(),
})
channelRouter.Register(channel.TypeFeishu, lark.NewFeishuResolverSet(
cs, feishuSession, auditLogger, resolverReplier, typingIndicator,
cs, feishuSession, auditLogger, resolverReplier, typingIndicator, inboundMedia,
))
slog.Info("lark inbound pipeline wired", "connector", connectorLabel)

Expand Down
1 change: 1 addition & 0 deletions server/internal/integrations/channel/engine/resolvers.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ type EnsureSessionParams struct {
type AppendParams struct {
SessionID pgtype.UUID
Sender pgtype.UUID
Installation ResolvedInstallation
InstallationID pgtype.UUID
Message channel.InboundMessage
ClaimToken pgtype.UUID
Expand Down
1 change: 1 addition & 0 deletions server/internal/integrations/channel/engine/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ func (r *Router) processClaimed(ctx context.Context, set ResolverSet, msg channe
appendRes, err := set.Session.AppendMessage(ctx, AppendParams{
SessionID: sessionID,
Sender: identity.UserID,
Installation: inst,
InstallationID: inst.ID,
Message: msg,
ClaimToken: claimToken,
Expand Down
58 changes: 56 additions & 2 deletions server/internal/integrations/channel/engine/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"

"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
Expand Down Expand Up @@ -42,6 +43,8 @@ type SessionQueries interface {
CreateChatSession(ctx context.Context, arg db.CreateChatSessionParams) (db.ChatSession, error)
CreateChannelChatSessionBinding(ctx context.Context, arg db.CreateChannelChatSessionBindingParams) (db.ChannelChatSessionBinding, error)
CreateChatMessage(ctx context.Context, arg db.CreateChatMessageParams) (db.ChatMessage, error)
CreateAttachment(ctx context.Context, arg db.CreateAttachmentParams) (db.Attachment, error)
LinkAttachmentsToChatMessage(ctx context.Context, arg db.LinkAttachmentsToChatMessageParams) ([]pgtype.UUID, error)
TouchChatSession(ctx context.Context, id pgtype.UUID) error
GetMostRecentUserChatMessage(ctx context.Context, chatSessionID pgtype.UUID) (db.ChatMessage, error)
UpdateChannelChatSessionBindingReplyTarget(ctx context.Context, arg db.UpdateChannelChatSessionBindingReplyTargetParams) error
Expand Down Expand Up @@ -71,6 +74,12 @@ func (a dbSessionQueries) CreateChannelChatSessionBinding(ctx context.Context, a
func (a dbSessionQueries) CreateChatMessage(ctx context.Context, arg db.CreateChatMessageParams) (db.ChatMessage, error) {
return a.q.CreateChatMessage(ctx, arg)
}
func (a dbSessionQueries) CreateAttachment(ctx context.Context, arg db.CreateAttachmentParams) (db.Attachment, error) {
return a.q.CreateAttachment(ctx, arg)
}
func (a dbSessionQueries) LinkAttachmentsToChatMessage(ctx context.Context, arg db.LinkAttachmentsToChatMessageParams) ([]pgtype.UUID, error) {
return a.q.LinkAttachmentsToChatMessage(ctx, arg)
}
func (a dbSessionQueries) TouchChatSession(ctx context.Context, id pgtype.UUID) error {
return a.q.TouchChatSession(ctx, id)
}
Expand Down Expand Up @@ -243,12 +252,14 @@ func (s *ChatSession) createSessionAndBinding(ctx context.Context, in EnsureSess
type AppendInput struct {
SessionID pgtype.UUID
Sender pgtype.UUID
WorkspaceID pgtype.UUID
InstallationID pgtype.UUID
Body string
CommandText string
MessageID string
ThreadID string
ClaimToken pgtype.UUID
MediaRefs []channel.MediaRef
}

// AppendUserMessage writes the user message into the chat_session (touching it
Expand All @@ -257,6 +268,9 @@ type AppendInput struct {
// ErrClaimLost when a concurrent reclaim rotated the dedup token mid-flight, in
// which case the whole transaction rolls back (no chat_message lands).
func (s *ChatSession) AppendUserMessage(ctx context.Context, in AppendInput) (AppendResult, error) {
if len(in.MediaRefs) > 0 && !in.WorkspaceID.Valid {
return AppendResult{}, errors.New("append user message: workspace_id is required for media")
}
tx, err := s.tx.Begin(ctx)
if err != nil {
return AppendResult{}, fmt.Errorf("begin tx: %w", err)
Expand All @@ -280,13 +294,53 @@ func (s *ChatSession) AppendUserMessage(ctx context.Context, in AppendInput) (Ap
}
}

if _, err := qtx.CreateChatMessage(ctx, db.CreateChatMessageParams{
message, err := qtx.CreateChatMessage(ctx, db.CreateChatMessageParams{
ChatSessionID: in.SessionID,
Role: "user",
Content: in.Body,
}); err != nil {
})
if err != nil {
return AppendResult{}, fmt.Errorf("create chat message: %w", err)
}

if len(in.MediaRefs) > 0 {
attachmentIDs := make([]pgtype.UUID, 0, len(in.MediaRefs))
for _, media := range in.MediaRefs {
id, err := uuid.NewV7()
if err != nil {
return AppendResult{}, fmt.Errorf("create attachment id: %w", err)
}
attachmentID := pgtype.UUID{Bytes: id, Valid: true}
if _, err := qtx.CreateAttachment(ctx, db.CreateAttachmentParams{
ID: attachmentID,
WorkspaceID: in.WorkspaceID,
ChatSessionID: in.SessionID,
UploaderType: "member",
UploaderID: in.Sender,
Filename: media.Filename,
Url: media.URL,
ContentType: media.MimeType,
SizeBytes: media.SizeBytes,
}); err != nil {
return AppendResult{}, fmt.Errorf("create chat attachment: %w", err)
}
attachmentIDs = append(attachmentIDs, attachmentID)
}
bound, err := qtx.LinkAttachmentsToChatMessage(ctx, db.LinkAttachmentsToChatMessageParams{
ChatMessageID: message.ID,
ChatSessionID: in.SessionID,
WorkspaceID: in.WorkspaceID,
UploaderType: "member",
UploaderID: in.Sender,
AttachmentIds: attachmentIDs,
})
if err != nil {
return AppendResult{}, fmt.Errorf("link chat attachments: %w", err)
}
if len(bound) != len(attachmentIDs) {
return AppendResult{}, fmt.Errorf("link chat attachments: linked %d of %d", len(bound), len(attachmentIDs))
}
}
if err := qtx.TouchChatSession(ctx, in.SessionID); err != nil {
return AppendResult{}, fmt.Errorf("touch chat session: %w", err)
}
Expand Down
53 changes: 52 additions & 1 deletion server/internal/integrations/channel/engine/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,11 @@ func (fakeTxStarter) Begin(context.Context) (pgx.Tx, error) { return fakeTx{}, n
type fakeSessionQueries struct {
bindings map[string]pgtype.UUID
nextSession byte
nextMessage byte
createdSessions int
messages []string
attachments []db.CreateAttachmentParams
linked []pgtype.UUID
touched int
replyTargets int
lockedWorkspace int // count of LockWorkspaceForChatSessionCreate calls
Expand Down Expand Up @@ -89,7 +92,18 @@ func (f *fakeSessionQueries) CreateChannelChatSessionBinding(_ context.Context,

func (f *fakeSessionQueries) CreateChatMessage(_ context.Context, arg db.CreateChatMessageParams) (db.ChatMessage, error) {
f.messages = append(f.messages, arg.Content)
return db.ChatMessage{}, nil
f.nextMessage++
return db.ChatMessage{ID: uid(f.nextMessage + 100)}, nil
}

func (f *fakeSessionQueries) CreateAttachment(_ context.Context, arg db.CreateAttachmentParams) (db.Attachment, error) {
f.attachments = append(f.attachments, arg)
return db.Attachment{ID: arg.ID}, nil
}

func (f *fakeSessionQueries) LinkAttachmentsToChatMessage(_ context.Context, arg db.LinkAttachmentsToChatMessageParams) ([]pgtype.UUID, error) {
f.linked = append(f.linked, arg.AttachmentIds...)
return arg.AttachmentIds, nil
}

func (f *fakeSessionQueries) TouchChatSession(context.Context, pgtype.UUID) error {
Expand Down Expand Up @@ -238,6 +252,43 @@ func TestAppendUserMessage_PlainText(t *testing.T) {
}
}

func TestAppendUserMessage_CreatesAndLinksInboundMedia(t *testing.T) {
f := newFake()
s := newTestSession(f)
_, err := s.AppendUserMessage(context.Background(), AppendInput{
SessionID: uid(1),
Sender: uid(7),
WorkspaceID: uid(8),
Body: "[Image]",
MediaRefs: []channel.MediaRef{{
Type: channel.MsgTypeImage,
StorageKey: "workspaces/ws/channels/feishu/image.png",
URL: "https://storage.example/image.png",
Filename: "feishu-image.png",
MimeType: "image/png",
SizeBytes: 4,
}},
})
if err != nil {
t.Fatalf("AppendUserMessage: %v", err)
}
if len(f.attachments) != 1 {
t.Fatalf("created attachments = %d, want 1", len(f.attachments))
}
got := f.attachments[0]
if got.WorkspaceID != uid(8) || got.ChatSessionID != uid(1) ||
got.UploaderType != "member" || got.UploaderID != uid(7) {
t.Errorf("attachment ownership mapping wrong: %+v", got)
}
if got.Filename != "feishu-image.png" || got.ContentType != "image/png" ||
got.SizeBytes != 4 || got.Url != "https://storage.example/image.png" {
t.Errorf("attachment metadata mapping wrong: %+v", got)
}
if len(f.linked) != 1 || f.linked[0] != got.ID {
t.Errorf("linked attachment ids = %v, want [%v]", f.linked, got.ID)
}
}

func TestAppendUserMessage_NoReplyTargetWithoutMessageID(t *testing.T) {
f := newFake()
s := newTestSession(f)
Expand Down
4 changes: 4 additions & 0 deletions server/internal/integrations/channel/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ type MediaRef struct {
Type MsgType
// StorageKey locates the persisted object in Multica object storage.
StorageKey string
// URL is the storage backend's durable object URL. It is persisted on the
// attachment row; clients still download through the authenticated
// attachment endpoint rather than consuming this URL directly.
URL string
// Filename is the original display name, when the platform supplies
// one.
Filename string
Expand Down
16 changes: 16 additions & 0 deletions server/internal/integrations/lark/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,22 @@ type APIClient interface {
DeleteMessageReaction(ctx context.Context, p DeleteReactionParams) error
}

// MessageResourceDownloader is the binary-resource slice of the Lark API.
// Kept separate from APIClient so outbound-only fakes do not need to implement
// an inbound attachment method they never exercise.
type MessageResourceDownloader interface {
DownloadMessageResource(ctx context.Context, creds InstallationCredentials, messageID, fileKey, resourceType string) (MessageResource, error)
}

// MessageResource is one binary object downloaded from a Lark message.
// ResourceType is supplied by the caller ("image" or "file"); the response
// headers provide the remaining metadata.
type MessageResource struct {
Data []byte
Filename string
ContentType string
}

// ListMessagesParams selects a bounded, recent window of messages in a
// single Lark chat for the group-context prefetch. Only the fields the
// enricher needs today are exposed (ChatID, ThreadID, PageSize, EndTime);
Expand Down
14 changes: 7 additions & 7 deletions server/internal/integrations/lark/content_flatten.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,12 @@ import (
// REST item) carry the mentions array differently — only the caller
// knows which one applies — so flattening stays mention-agnostic.
//
// Non-text media types render as a stable bracketed placeholder so the
// agent sees that *something* was attached without us downloading the
// binary. Attachment ingestion is explicitly out of scope (tracked as a
// separate attachment-pipeline issue), and merge_forward is intercepted
// by the enricher before it reaches here (expanding it needs an HTTP
// round-trip); the inline placeholder is only a fallback for a forward
// nested inside another forward.
// Non-text media types render as a stable bracketed placeholder. Top-level
// inbound images and image spans in inbound posts are downloaded later by
// InboundMediaService; quoted/forwarded media and the other media kinds remain
// placeholders. merge_forward is intercepted by the enricher before it reaches
// here because expansion needs an HTTP round-trip; the inline placeholder is
// only a fallback for a forward nested inside another forward.
func flattenContent(msgType, rawContent string) string {
switch msgType {
case "text":
Expand Down Expand Up @@ -80,6 +79,7 @@ type larkPostSpan struct {
Href string `json:"href"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
ImageKey string `json:"image_key"`
}

// flattenPostContent flattens a received `post` body.content into plain
Expand Down
30 changes: 27 additions & 3 deletions server/internal/integrations/lark/feishu_resolvers.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,12 @@ func larkMsgFromRaw(msg channel.InboundMessage) (InboundMessage, error) {
// shared session service, audit logger, and (optional) outbound replier +
// typing indicator. Feishu is just another consumer of the channel-agnostic
// engine.ChatSession — there is no Feishu-specific session implementation.
func NewFeishuResolverSet(store *ChannelStore, session *engine.ChatSession, audit AuditLogger, replier OutcomeReplier, typing *TypingIndicatorManager) engine.ResolverSet {
func NewFeishuResolverSet(store *ChannelStore, session *engine.ChatSession, audit AuditLogger, replier OutcomeReplier, typing *TypingIndicatorManager, media *InboundMediaService) engine.ResolverSet {
set := engine.ResolverSet{
Installation: &feishuInstallationResolver{store: store},
Identity: &feishuIdentityResolver{store: store},
Dedup: &feishuDeduper{store: store},
Session: &feishuSessionBinder{session: session},
Session: &feishuSessionBinder{session: session, media: media},
Audit: &feishuAuditor{audit: audit},
OriginType: originFeishuChat,
}
Expand Down Expand Up @@ -159,7 +159,10 @@ type chatSession interface {
AppendUserMessage(ctx context.Context, in engine.AppendInput) (engine.AppendResult, error)
}

type feishuSessionBinder struct{ session chatSession }
type feishuSessionBinder struct {
session chatSession
media *InboundMediaService
}

// larkBindingConfig is the opaque outbound routing persisted on the chat
// binding's config when the binding key is a composite (Lark topic): the real
Expand Down Expand Up @@ -206,15 +209,36 @@ func (r *feishuSessionBinder) AppendMessage(ctx context.Context, p engine.Append
if err != nil {
return engine.AppendResult{}, err
}
mediaRefs := p.Message.MediaRefs
if r.media != nil && (lm.MessageType == "image" || lm.MessageType == "post") {
inst, ok := p.Installation.Platform.(Installation)
if !ok {
return engine.AppendResult{}, errors.New("lark: resolved installation platform has unexpected type")
}
refs, err := r.media.IngestMessageImages(
ctx,
inst,
p.Installation.WorkspaceID,
p.Message.MessageID,
lm.MessageType,
lm.RawContent,
)
if err != nil {
return engine.AppendResult{}, err
}
mediaRefs = append(mediaRefs, refs...)
}
return r.session.AppendUserMessage(ctx, engine.AppendInput{
SessionID: p.SessionID,
Sender: p.Sender,
WorkspaceID: p.Installation.WorkspaceID,
InstallationID: p.InstallationID,
Body: p.Message.Text,
CommandText: lm.CommandBody,
MessageID: p.Message.MessageID,
ThreadID: p.Message.Source.ThreadID,
ClaimToken: p.ClaimToken,
MediaRefs: mediaRefs,
})
}

Expand Down
Loading
Loading