diff --git a/internal/store/context.go b/internal/store/context.go index 1a5b0d10..12dc8d27 100644 --- a/internal/store/context.go +++ b/internal/store/context.go @@ -200,6 +200,25 @@ func SenderIDFromContext(ctx context.Context) string { return "" } +// ActorIDFromContext returns the acting principal — the entity performing +// the action. In group chats (Telegram/Discord) this is the individual +// sender; in DMs / HTTP / cron it is the same as UserIDFromContext. +// +// Use for: +// - permission checks (file writers, role gates) +// - audit trails (initiated_by, owner_id) +// - ownership fields (skill publisher, cron owner) +// +// DO NOT use for: +// - memory / KG / session scope (use UserIDFromContext / MemoryUserID / KGUserID) +// - file-system or per-scope isolation (scope = group principal on purpose) +func ActorIDFromContext(ctx context.Context) string { + if sid := SenderIDFromContext(ctx); sid != "" { + return sid + } + return UserIDFromContext(ctx) +} + // WithSelfEvolve returns a new context with the self-evolve flag. func WithSelfEvolve(ctx context.Context, v bool) context.Context { return context.WithValue(ctx, SelfEvolveKey, v) diff --git a/internal/store/context_actor_id_test.go b/internal/store/context_actor_id_test.go new file mode 100644 index 00000000..de9bd7c5 --- /dev/null +++ b/internal/store/context_actor_id_test.go @@ -0,0 +1,46 @@ +package store + +import ( + "context" + "testing" +) + +func TestActorIDFromContext_SenderSetReturnsSender(t *testing.T) { + ctx := WithUserID(context.Background(), "group:telegram:-100123") + ctx = WithSenderID(ctx, "42") + if got := ActorIDFromContext(ctx); got != "42" { + t.Errorf("sender_set: got %q, want %q", got, "42") + } +} + +func TestActorIDFromContext_SenderEmptyFallbackToUser(t *testing.T) { + ctx := WithUserID(context.Background(), "user-7") + if got := ActorIDFromContext(ctx); got != "user-7" { + t.Errorf("fallback_to_user: got %q, want %q", got, "user-7") + } +} + +func TestActorIDFromContext_BothSetSenderWins(t *testing.T) { + ctx := WithUserID(context.Background(), "group:discord:G|U") + ctx = WithSenderID(ctx, "123456") + if got := ActorIDFromContext(ctx); got != "123456" { + t.Errorf("both_set_sender_wins: got %q, want %q", got, "123456") + } +} + +func TestActorIDFromContext_NeitherSetReturnsEmpty(t *testing.T) { + ctx := context.Background() + if got := ActorIDFromContext(ctx); got != "" { + t.Errorf("neither_set: got %q, want %q", got, "") + } +} + +// SenderID with "|" delimiter (Discord guild:user|user format) is returned as-is — +// ActorIDFromContext is not responsible for numeric split; callers do that themselves +// (see config_permission_store.go:68 strings.SplitN(senderID, "|", 2)[0]). +func TestActorIDFromContext_DelimitedSenderReturnedAsIs(t *testing.T) { + ctx := WithSenderID(context.Background(), "42|extra") + if got := ActorIDFromContext(ctx); got != "42|extra" { + t.Errorf("delimited_sender_as_is: got %q, want %q", got, "42|extra") + } +}