diff --git a/internal/im/identity_test.go b/internal/im/identity_test.go
new file mode 100644
index 00000000..26b49040
--- /dev/null
+++ b/internal/im/identity_test.go
@@ -0,0 +1,38 @@
+package im
+
+import (
+ "context"
+ "testing"
+
+ "github.com/Tencent/WeKnora/internal/types"
+)
+
+func TestWithIMIdentity(t *testing.T) {
+ const tenantID uint64 = 42
+ ctx := withIMIdentity(context.Background(), tenantID)
+
+ gotTenant, ok := types.TenantIDFromContext(ctx)
+ if !ok || gotTenant != tenantID {
+ t.Fatalf("TenantID = %d (ok=%v), want %d", gotTenant, ok, tenantID)
+ }
+
+ userID, ok := types.UserIDFromContext(ctx)
+ if !ok || userID == "" {
+ t.Fatalf("UserID = %q (ok=%v), want non-empty synthetic user", userID, ok)
+ }
+ if want := "system-42"; userID != want {
+ t.Fatalf("UserID = %q, want %q", userID, want)
+ }
+
+ // The synthetic shape must be recognised so RBAC code does not record it
+ // as a resource creator.
+ if !types.IsSyntheticUserID(userID) {
+ t.Fatalf("IsSyntheticUserID(%q) = false, want true", userID)
+ }
+
+ // Non-empty UserID is the gate the shared-KB resolution relies on; without
+ // it Organization-shared KBs are silently skipped on the IM path.
+ if role := types.TenantRoleFromContext(ctx); role != types.TenantRoleViewer {
+ t.Fatalf("TenantRole = %v, want %v", role, types.TenantRoleViewer)
+ }
+}
diff --git a/internal/im/service.go b/internal/im/service.go
index 75736e45..8426b132 100644
--- a/internal/im/service.go
+++ b/internal/im/service.go
@@ -450,6 +450,21 @@ func formatQuotedContext(quote *QuotedMessage) string {
return label + "\n\n" + content + "\n"
}
+// withIMIdentity injects a synthetic caller identity into the context for IM
+// callbacks. IM platforms verify their own signatures and bypass the auth
+// middleware, so the downstream QA pipeline would otherwise see an empty
+// UserID/TenantRole. Mirroring the API-key path's "system-" synthetic
+// user (recognised by types.IsSyntheticUserID) lets Organization-shared
+// knowledge bases be merged and resolved correctly, since the shared-KB code
+// gates on a non-empty UserID. Viewer is the least privilege sufficient to
+// retrieve shared KBs.
+func withIMIdentity(ctx context.Context, tenantID uint64) context.Context {
+ ctx = context.WithValue(ctx, types.TenantIDContextKey, tenantID)
+ ctx = context.WithValue(ctx, types.UserIDContextKey, fmt.Sprintf("system-%d", tenantID))
+ ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleViewer)
+ return ctx
+}
+
func buildIMQARequest(
session *types.Session,
query string,
@@ -1126,8 +1141,8 @@ func (s *Service) HandleMessage(ctx context.Context, msg *IncomingMessage, chann
if err != nil {
return fmt.Errorf("get tenant: %w", err)
}
- sessionCtx := context.WithValue(ctx, types.TenantIDContextKey, tenantID)
- sessionCtx = context.WithValue(sessionCtx, types.TenantInfoContextKey, tenant)
+ sessionCtx := context.WithValue(ctx, types.TenantInfoContextKey, tenant)
+ sessionCtx = withIMIdentity(sessionCtx, tenantID)
// 2. Resolve or create a WeKnora session
channelSession, err := s.resolveSession(sessionCtx, msg, tenantID, agentID, channelID, channel.SessionMode)