mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
c591af14b0
* feat: bot inbox * feat: unified header * fix: missing tool_call usage * feat: add group name in header
285 lines
6.3 KiB
Go
285 lines
6.3 KiB
Go
package inbox
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"github.com/memohai/memoh/internal/db"
|
|
"github.com/memohai/memoh/internal/db/sqlc"
|
|
)
|
|
|
|
type Service struct {
|
|
queries *sqlc.Queries
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func NewService(log *slog.Logger, queries *sqlc.Queries) *Service {
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
return &Service{
|
|
queries: queries,
|
|
logger: log.With(slog.String("service", "inbox")),
|
|
}
|
|
}
|
|
|
|
type Item struct {
|
|
ID string `json:"id"`
|
|
BotID string `json:"bot_id"`
|
|
Source string `json:"source"`
|
|
Content map[string]any `json:"content"`
|
|
IsRead bool `json:"is_read"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
ReadAt time.Time `json:"read_at,omitempty"`
|
|
}
|
|
|
|
type CreateRequest struct {
|
|
BotID string `json:"bot_id"`
|
|
Source string `json:"source"`
|
|
Content map[string]any `json:"content"`
|
|
}
|
|
|
|
type ListFilter struct {
|
|
IsRead *bool `json:"is_read,omitempty"`
|
|
Source string `json:"source,omitempty"`
|
|
Limit int `json:"limit"`
|
|
Offset int `json:"offset"`
|
|
}
|
|
|
|
type SearchRequest struct {
|
|
Query string `json:"query"`
|
|
StartTime *time.Time `json:"start_time,omitempty"`
|
|
EndTime *time.Time `json:"end_time,omitempty"`
|
|
IncludeRead *bool `json:"include_read,omitempty"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
|
|
type CountResult struct {
|
|
Unread int64 `json:"unread"`
|
|
Total int64 `json:"total"`
|
|
}
|
|
|
|
func (s *Service) Create(ctx context.Context, req CreateRequest) (Item, error) {
|
|
botUUID, err := db.ParseUUID(req.BotID)
|
|
if err != nil {
|
|
return Item{}, err
|
|
}
|
|
content, err := json.Marshal(req.Content)
|
|
if err != nil {
|
|
return Item{}, err
|
|
}
|
|
|
|
row, err := s.queries.CreateInboxItem(ctx, sqlc.CreateInboxItemParams{
|
|
BotID: botUUID,
|
|
Source: req.Source,
|
|
Content: content,
|
|
})
|
|
if err != nil {
|
|
return Item{}, err
|
|
}
|
|
return rowToItem(row), nil
|
|
}
|
|
|
|
func (s *Service) GetByID(ctx context.Context, botID, itemID string) (Item, error) {
|
|
botUUID, err := db.ParseUUID(botID)
|
|
if err != nil {
|
|
return Item{}, err
|
|
}
|
|
itemUUID, err := db.ParseUUID(itemID)
|
|
if err != nil {
|
|
return Item{}, err
|
|
}
|
|
row, err := s.queries.GetInboxItemByID(ctx, sqlc.GetInboxItemByIDParams{
|
|
ID: itemUUID,
|
|
BotID: botUUID,
|
|
})
|
|
if err != nil {
|
|
return Item{}, err
|
|
}
|
|
return rowToItem(row), nil
|
|
}
|
|
|
|
func (s *Service) List(ctx context.Context, botID string, filter ListFilter) ([]Item, error) {
|
|
botUUID, err := db.ParseUUID(botID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
limit := filter.Limit
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
if limit > 500 {
|
|
limit = 500
|
|
}
|
|
rows, err := s.queries.ListInboxItems(ctx, sqlc.ListInboxItemsParams{
|
|
BotID: botUUID,
|
|
IsRead: boolOrNull(filter.IsRead),
|
|
Source: textOrNull(filter.Source),
|
|
MaxCount: int32(limit),
|
|
ItemOffset: int32(filter.Offset),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return rowsToItems(rows), nil
|
|
}
|
|
|
|
func (s *Service) ListUnread(ctx context.Context, botID string, limit int) ([]Item, error) {
|
|
botUUID, err := db.ParseUUID(botID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
rows, err := s.queries.ListUnreadInboxItems(ctx, sqlc.ListUnreadInboxItemsParams{
|
|
BotID: botUUID,
|
|
MaxCount: int32(limit),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return rowsToItems(rows), nil
|
|
}
|
|
|
|
func (s *Service) MarkRead(ctx context.Context, botID string, ids []string) error {
|
|
botUUID, err := db.ParseUUID(botID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pgIDs := make([]pgtype.UUID, 0, len(ids))
|
|
for _, id := range ids {
|
|
pgID, err := db.ParseUUID(id)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
pgIDs = append(pgIDs, pgID)
|
|
}
|
|
if len(pgIDs) == 0 {
|
|
return nil
|
|
}
|
|
return s.queries.MarkInboxItemsRead(ctx, sqlc.MarkInboxItemsReadParams{
|
|
BotID: botUUID,
|
|
Ids: pgIDs,
|
|
})
|
|
}
|
|
|
|
func (s *Service) Search(ctx context.Context, botID string, req SearchRequest) ([]Item, error) {
|
|
botUUID, err := db.ParseUUID(botID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
limit := req.Limit
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
params := sqlc.SearchInboxItemsParams{
|
|
BotID: botUUID,
|
|
Query: pgtype.Text{String: req.Query, Valid: true},
|
|
MaxCount: int32(limit),
|
|
}
|
|
if req.StartTime != nil {
|
|
params.StartTime = pgtype.Timestamptz{Time: *req.StartTime, Valid: true}
|
|
}
|
|
if req.EndTime != nil {
|
|
params.EndTime = pgtype.Timestamptz{Time: *req.EndTime, Valid: true}
|
|
}
|
|
if req.IncludeRead != nil {
|
|
params.IncludeRead = pgtype.Bool{Bool: *req.IncludeRead, Valid: true}
|
|
}
|
|
rows, err := s.queries.SearchInboxItems(ctx, params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return rowsToItems(rows), nil
|
|
}
|
|
|
|
func (s *Service) Count(ctx context.Context, botID string) (CountResult, error) {
|
|
botUUID, err := db.ParseUUID(botID)
|
|
if err != nil {
|
|
return CountResult{}, err
|
|
}
|
|
unread, err := s.queries.CountUnreadInboxItems(ctx, botUUID)
|
|
if err != nil {
|
|
return CountResult{}, err
|
|
}
|
|
total, err := s.queries.CountInboxItems(ctx, botUUID)
|
|
if err != nil {
|
|
return CountResult{}, err
|
|
}
|
|
return CountResult{Unread: unread, Total: total}, nil
|
|
}
|
|
|
|
func (s *Service) Delete(ctx context.Context, botID, itemID string) error {
|
|
botUUID, err := db.ParseUUID(botID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
itemUUID, err := db.ParseUUID(itemID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.queries.DeleteInboxItem(ctx, sqlc.DeleteInboxItemParams{
|
|
ID: itemUUID,
|
|
BotID: botUUID,
|
|
})
|
|
}
|
|
|
|
// --- conversion helpers ---
|
|
|
|
func rowToItem(row sqlc.BotInbox) Item {
|
|
var content map[string]any
|
|
if len(row.Content) > 0 {
|
|
_ = json.Unmarshal(row.Content, &content)
|
|
}
|
|
if content == nil {
|
|
content = map[string]any{}
|
|
}
|
|
return Item{
|
|
ID: pgUUIDToString(row.ID),
|
|
BotID: pgUUIDToString(row.BotID),
|
|
Source: row.Source,
|
|
Content: content,
|
|
IsRead: row.IsRead,
|
|
CreatedAt: db.TimeFromPg(row.CreatedAt),
|
|
ReadAt: db.TimeFromPg(row.ReadAt),
|
|
}
|
|
}
|
|
|
|
func rowsToItems(rows []sqlc.BotInbox) []Item {
|
|
items := make([]Item, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, rowToItem(row))
|
|
}
|
|
return items
|
|
}
|
|
|
|
func pgUUIDToString(id pgtype.UUID) string {
|
|
if !id.Valid {
|
|
return ""
|
|
}
|
|
return uuid.UUID(id.Bytes).String()
|
|
}
|
|
|
|
func textOrNull(s string) pgtype.Text {
|
|
if s == "" {
|
|
return pgtype.Text{}
|
|
}
|
|
return pgtype.Text{String: s, Valid: true}
|
|
}
|
|
|
|
func boolOrNull(b *bool) pgtype.Bool {
|
|
if b == nil {
|
|
return pgtype.Bool{}
|
|
}
|
|
return pgtype.Bool{Bool: *b, Valid: true}
|
|
}
|