feat: add timezone support for schedule and user runtime (#282)

This commit is contained in:
Yiming Qi
2026-03-26 01:32:02 +08:00
committed by GitHub
parent 3a7f5200ed
commit 03ba13e7e5
51 changed files with 793 additions and 100 deletions
+39 -5
View File
@@ -15,6 +15,7 @@ import (
"github.com/memohai/memoh/internal/db"
"github.com/memohai/memoh/internal/db/sqlc"
tzutil "github.com/memohai/memoh/internal/timezone"
)
// Service provides bot CRUD and membership management.
@@ -108,6 +109,10 @@ func (s *Service) Create(ctx context.Context, ownerUserID string, req CreateBotR
if req.IsActive != nil {
isActive = *req.IsActive
}
timezoneValue, err := normalizeOptionalTimezone(req.Timezone)
if err != nil {
return Bot{}, err
}
metadata := req.Metadata
if metadata == nil {
metadata = map[string]any{}
@@ -120,6 +125,7 @@ func (s *Service) Create(ctx context.Context, ownerUserID string, req CreateBotR
OwnerUserID: ownerUUID,
DisplayName: pgtype.Text{String: displayName, Valid: displayName != ""},
AvatarUrl: pgtype.Text{String: avatarURL, Valid: avatarURL != ""},
Timezone: timezoneValue,
IsActive: isActive,
Metadata: payload,
Status: BotStatusCreating,
@@ -222,6 +228,13 @@ func (s *Service) Update(ctx context.Context, botID string, req UpdateBotRequest
if req.IsActive != nil {
isActive = *req.IsActive
}
timezoneValue := existing.Timezone
if req.Timezone != nil {
timezoneValue, err = normalizeOptionalTimezone(req.Timezone)
if err != nil {
return Bot{}, err
}
}
if req.Metadata != nil {
metadata = req.Metadata
}
@@ -236,6 +249,7 @@ func (s *Service) Update(ctx context.Context, botID string, req UpdateBotRequest
ID: botUUID,
DisplayName: pgtype.Text{String: displayName, Valid: displayName != ""},
AvatarUrl: pgtype.Text{String: avatarURL, Valid: avatarURL != ""},
Timezone: timezoneValue,
IsActive: isActive,
Metadata: payload,
})
@@ -421,15 +435,15 @@ func asSQLCBot(v any) sqlc.Bot {
case sqlc.Bot:
return r
case sqlc.CreateBotRow:
return sqlc.Bot{ID: r.ID, OwnerUserID: r.OwnerUserID, DisplayName: r.DisplayName, AvatarUrl: r.AvatarUrl, IsActive: r.IsActive, Status: r.Status, MaxContextLoadTime: r.MaxContextLoadTime, MaxContextTokens: r.MaxContextTokens, Language: r.Language, ReasoningEnabled: r.ReasoningEnabled, ReasoningEffort: r.ReasoningEffort, ChatModelID: r.ChatModelID, SearchProviderID: r.SearchProviderID, MemoryProviderID: r.MemoryProviderID, HeartbeatEnabled: r.HeartbeatEnabled, HeartbeatInterval: r.HeartbeatInterval, HeartbeatPrompt: r.HeartbeatPrompt, Metadata: r.Metadata, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt}
return sqlc.Bot{ID: r.ID, OwnerUserID: r.OwnerUserID, DisplayName: r.DisplayName, AvatarUrl: r.AvatarUrl, Timezone: r.Timezone, IsActive: r.IsActive, Status: r.Status, MaxContextLoadTime: r.MaxContextLoadTime, MaxContextTokens: r.MaxContextTokens, Language: r.Language, ReasoningEnabled: r.ReasoningEnabled, ReasoningEffort: r.ReasoningEffort, ChatModelID: r.ChatModelID, SearchProviderID: r.SearchProviderID, MemoryProviderID: r.MemoryProviderID, HeartbeatEnabled: r.HeartbeatEnabled, HeartbeatInterval: r.HeartbeatInterval, HeartbeatPrompt: r.HeartbeatPrompt, Metadata: r.Metadata, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt}
case sqlc.GetBotByIDRow:
return sqlc.Bot{ID: r.ID, OwnerUserID: r.OwnerUserID, DisplayName: r.DisplayName, AvatarUrl: r.AvatarUrl, IsActive: r.IsActive, Status: r.Status, MaxContextLoadTime: r.MaxContextLoadTime, MaxContextTokens: r.MaxContextTokens, Language: r.Language, ReasoningEnabled: r.ReasoningEnabled, ReasoningEffort: r.ReasoningEffort, ChatModelID: r.ChatModelID, SearchProviderID: r.SearchProviderID, MemoryProviderID: r.MemoryProviderID, HeartbeatEnabled: r.HeartbeatEnabled, HeartbeatInterval: r.HeartbeatInterval, HeartbeatPrompt: r.HeartbeatPrompt, Metadata: r.Metadata, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt}
return sqlc.Bot{ID: r.ID, OwnerUserID: r.OwnerUserID, DisplayName: r.DisplayName, AvatarUrl: r.AvatarUrl, Timezone: r.Timezone, IsActive: r.IsActive, Status: r.Status, MaxContextLoadTime: r.MaxContextLoadTime, MaxContextTokens: r.MaxContextTokens, Language: r.Language, ReasoningEnabled: r.ReasoningEnabled, ReasoningEffort: r.ReasoningEffort, ChatModelID: r.ChatModelID, SearchProviderID: r.SearchProviderID, MemoryProviderID: r.MemoryProviderID, HeartbeatEnabled: r.HeartbeatEnabled, HeartbeatInterval: r.HeartbeatInterval, HeartbeatPrompt: r.HeartbeatPrompt, CompactionEnabled: r.CompactionEnabled, CompactionThreshold: r.CompactionThreshold, CompactionModelID: r.CompactionModelID, Metadata: r.Metadata, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt}
case sqlc.ListBotsByOwnerRow:
return sqlc.Bot{ID: r.ID, OwnerUserID: r.OwnerUserID, DisplayName: r.DisplayName, AvatarUrl: r.AvatarUrl, IsActive: r.IsActive, Status: r.Status, MaxContextLoadTime: r.MaxContextLoadTime, MaxContextTokens: r.MaxContextTokens, Language: r.Language, ReasoningEnabled: r.ReasoningEnabled, ReasoningEffort: r.ReasoningEffort, ChatModelID: r.ChatModelID, SearchProviderID: r.SearchProviderID, MemoryProviderID: r.MemoryProviderID, HeartbeatEnabled: r.HeartbeatEnabled, HeartbeatInterval: r.HeartbeatInterval, HeartbeatPrompt: r.HeartbeatPrompt, Metadata: r.Metadata, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt}
return sqlc.Bot{ID: r.ID, OwnerUserID: r.OwnerUserID, DisplayName: r.DisplayName, AvatarUrl: r.AvatarUrl, Timezone: r.Timezone, IsActive: r.IsActive, Status: r.Status, MaxContextLoadTime: r.MaxContextLoadTime, MaxContextTokens: r.MaxContextTokens, Language: r.Language, ReasoningEnabled: r.ReasoningEnabled, ReasoningEffort: r.ReasoningEffort, ChatModelID: r.ChatModelID, SearchProviderID: r.SearchProviderID, MemoryProviderID: r.MemoryProviderID, HeartbeatEnabled: r.HeartbeatEnabled, HeartbeatInterval: r.HeartbeatInterval, HeartbeatPrompt: r.HeartbeatPrompt, Metadata: r.Metadata, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt}
case sqlc.UpdateBotProfileRow:
return sqlc.Bot{ID: r.ID, OwnerUserID: r.OwnerUserID, DisplayName: r.DisplayName, AvatarUrl: r.AvatarUrl, IsActive: r.IsActive, Status: r.Status, MaxContextLoadTime: r.MaxContextLoadTime, MaxContextTokens: r.MaxContextTokens, Language: r.Language, ReasoningEnabled: r.ReasoningEnabled, ReasoningEffort: r.ReasoningEffort, ChatModelID: r.ChatModelID, SearchProviderID: r.SearchProviderID, MemoryProviderID: r.MemoryProviderID, HeartbeatEnabled: r.HeartbeatEnabled, HeartbeatInterval: r.HeartbeatInterval, HeartbeatPrompt: r.HeartbeatPrompt, Metadata: r.Metadata, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt}
return sqlc.Bot{ID: r.ID, OwnerUserID: r.OwnerUserID, DisplayName: r.DisplayName, AvatarUrl: r.AvatarUrl, Timezone: r.Timezone, IsActive: r.IsActive, Status: r.Status, MaxContextLoadTime: r.MaxContextLoadTime, MaxContextTokens: r.MaxContextTokens, Language: r.Language, ReasoningEnabled: r.ReasoningEnabled, ReasoningEffort: r.ReasoningEffort, ChatModelID: r.ChatModelID, SearchProviderID: r.SearchProviderID, MemoryProviderID: r.MemoryProviderID, HeartbeatEnabled: r.HeartbeatEnabled, HeartbeatInterval: r.HeartbeatInterval, HeartbeatPrompt: r.HeartbeatPrompt, Metadata: r.Metadata, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt}
case sqlc.UpdateBotOwnerRow:
return sqlc.Bot{ID: r.ID, OwnerUserID: r.OwnerUserID, DisplayName: r.DisplayName, AvatarUrl: r.AvatarUrl, IsActive: r.IsActive, Status: r.Status, MaxContextLoadTime: r.MaxContextLoadTime, MaxContextTokens: r.MaxContextTokens, Language: r.Language, ReasoningEnabled: r.ReasoningEnabled, ReasoningEffort: r.ReasoningEffort, ChatModelID: r.ChatModelID, SearchProviderID: r.SearchProviderID, MemoryProviderID: r.MemoryProviderID, HeartbeatEnabled: r.HeartbeatEnabled, HeartbeatInterval: r.HeartbeatInterval, HeartbeatPrompt: r.HeartbeatPrompt, Metadata: r.Metadata, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt}
return sqlc.Bot{ID: r.ID, OwnerUserID: r.OwnerUserID, DisplayName: r.DisplayName, AvatarUrl: r.AvatarUrl, Timezone: r.Timezone, IsActive: r.IsActive, Status: r.Status, MaxContextLoadTime: r.MaxContextLoadTime, MaxContextTokens: r.MaxContextTokens, Language: r.Language, ReasoningEnabled: r.ReasoningEnabled, ReasoningEffort: r.ReasoningEffort, ChatModelID: r.ChatModelID, SearchProviderID: r.SearchProviderID, MemoryProviderID: r.MemoryProviderID, HeartbeatEnabled: r.HeartbeatEnabled, HeartbeatInterval: r.HeartbeatInterval, HeartbeatPrompt: r.HeartbeatPrompt, Metadata: r.Metadata, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt}
default:
return sqlc.Bot{}
}
@@ -444,6 +458,10 @@ func toBot(row sqlc.Bot) (Bot, error) {
if row.AvatarUrl.Valid {
avatarURL = row.AvatarUrl.String
}
timezoneName := ""
if row.Timezone.Valid {
timezoneName = row.Timezone.String
}
metadata, err := decodeMetadata(row.Metadata)
if err != nil {
return Bot{}, err
@@ -461,6 +479,7 @@ func toBot(row sqlc.Bot) (Bot, error) {
OwnerUserID: row.OwnerUserID.String(),
DisplayName: displayName,
AvatarURL: avatarURL,
Timezone: timezoneName,
IsActive: row.IsActive,
Status: strings.TrimSpace(row.Status),
CheckState: BotCheckStateUnknown,
@@ -485,6 +504,21 @@ func decodeMetadata(payload []byte) (map[string]any, error) {
return data, nil
}
func normalizeOptionalTimezone(raw *string) (pgtype.Text, error) {
if raw == nil {
return pgtype.Text{}, nil
}
normalized := strings.TrimSpace(*raw)
if normalized == "" {
return pgtype.Text{}, nil
}
loc, _, err := tzutil.Resolve(normalized)
if err != nil {
return pgtype.Text{}, fmt.Errorf("invalid timezone: %w", err)
}
return pgtype.Text{String: loc.String(), Valid: true}, nil
}
func (s *Service) attachCheckSummary(ctx context.Context, bot *Bot, row sqlc.Bot) error {
checks, err := s.buildRuntimeChecks(ctx, row, false)
if err != nil {
+21 -20
View File
@@ -41,7 +41,7 @@ func (d *fakeDBTX) QueryRow(ctx context.Context, sql string, args ...any) pgx.Ro
}
// makeBotRow creates a fakeRow that populates a sqlc.GetBotByIDRow via Scan.
// Column order: id, owner_user_id, display_name, avatar_url, is_active, status,
// Column order: id, owner_user_id, display_name, avatar_url, timezone, is_active, status,
// max_context_load_time, max_context_tokens, language,
// reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id,
// heartbeat_enabled, heartbeat_interval, heartbeat_prompt,
@@ -50,32 +50,33 @@ func (d *fakeDBTX) QueryRow(ctx context.Context, sql string, args ...any) pgx.Ro
func makeBotRow(botID, ownerUserID pgtype.UUID) *fakeRow {
return &fakeRow{
scanFunc: func(dest ...any) error {
if len(dest) < 23 {
if len(dest) < 24 {
return pgx.ErrNoRows
}
*dest[0].(*pgtype.UUID) = botID
*dest[1].(*pgtype.UUID) = ownerUserID
*dest[2].(*pgtype.Text) = pgtype.Text{String: "test-bot", Valid: true}
*dest[3].(*pgtype.Text) = pgtype.Text{}
*dest[4].(*bool) = true
*dest[5].(*string) = BotStatusReady
*dest[6].(*int32) = 30 // MaxContextLoadTime
*dest[7].(*int32) = 4096 // MaxContextTokens
*dest[8].(*string) = "en" // Language
*dest[9].(*bool) = false // ReasoningEnabled
*dest[10].(*string) = "medium" // ReasoningEffort
*dest[11].(*pgtype.UUID) = pgtype.UUID{} // ChatModelID
*dest[12].(*pgtype.UUID) = pgtype.UUID{} // SearchProviderID
*dest[13].(*pgtype.UUID) = pgtype.UUID{} // MemoryProviderID
*dest[14].(*bool) = false // HeartbeatEnabled
*dest[15].(*int32) = 30 // HeartbeatInterval
*dest[16].(*string) = "" // HeartbeatPrompt
*dest[17].(*bool) = false // CompactionEnabled
*dest[18].(*int32) = 100000 // CompactionThreshold
*dest[19].(*pgtype.UUID) = pgtype.UUID{} // CompactionModelID
*dest[20].(*[]byte) = []byte(`{}`)
*dest[21].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
*dest[4].(*pgtype.Text) = pgtype.Text{}
*dest[5].(*bool) = true
*dest[6].(*string) = BotStatusReady
*dest[7].(*int32) = 30 // MaxContextLoadTime
*dest[8].(*int32) = 4096 // MaxContextTokens
*dest[9].(*string) = "en" // Language
*dest[10].(*bool) = false // ReasoningEnabled
*dest[11].(*string) = "medium" // ReasoningEffort
*dest[12].(*pgtype.UUID) = pgtype.UUID{} // ChatModelID
*dest[13].(*pgtype.UUID) = pgtype.UUID{} // SearchProviderID
*dest[14].(*pgtype.UUID) = pgtype.UUID{} // MemoryProviderID
*dest[15].(*bool) = false // HeartbeatEnabled
*dest[16].(*int32) = 30 // HeartbeatInterval
*dest[17].(*string) = "" // HeartbeatPrompt
*dest[18].(*bool) = false // CompactionEnabled
*dest[19].(*int32) = 100000 // CompactionThreshold
*dest[20].(*pgtype.UUID) = pgtype.UUID{} // CompactionModelID
*dest[21].(*[]byte) = []byte(`{}`)
*dest[22].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
*dest[23].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
return nil
},
}
+3
View File
@@ -11,6 +11,7 @@ type Bot struct {
OwnerUserID string `json:"owner_user_id"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url,omitempty"`
Timezone string `json:"timezone,omitempty"`
IsActive bool `json:"is_active"`
Status string `json:"status"`
CheckState string `json:"check_state"`
@@ -36,6 +37,7 @@ type BotCheck struct {
type CreateBotRequest struct {
DisplayName string `json:"display_name,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
Timezone *string `json:"timezone,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
@@ -44,6 +46,7 @@ type CreateBotRequest struct {
type UpdateBotRequest struct {
DisplayName *string `json:"display_name,omitempty"`
AvatarURL *string `json:"avatar_url,omitempty"`
Timezone *string `json:"timezone,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}