mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
feat: add timezone support for schedule and user runtime (#282)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user