package postgres import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strings" "github.com/jackc/pgx/v5" "telesrv/internal/domain" ) func (s *BotStore) withBotInfoBumpRawTx(ctx context.Context, botUserID int64, fn func(pgx.Tx) error) (int, error) { beginner, ok := s.db.(txBeginner) if !ok { return 0, fmt.Errorf("bot mini app update: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return 0, fmt.Errorf("bot mini app update: begin: %w", err) } defer func() { _ = tx.Rollback(ctx) }() if err := fn(tx); err != nil { return 0, err } ver, err := s.q.WithTx(tx).BumpBotInfoVersion(ctx, botUserID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return 0, domain.ErrBotNotFound } return 0, fmt.Errorf("bot mini app update: bump version: %w", err) } if err := tx.Commit(ctx); err != nil { return 0, fmt.Errorf("bot mini app update: commit: %w", err) } return int(ver), nil } func (s *BotStore) enrichBotProfile(ctx context.Context, profile domain.BotProfile) (domain.BotProfile, error) { if profile.BotUserID == 0 { return profile, nil } var hasMain, hasAttach, hasPreview bool if err := s.db.QueryRow(ctx, ` SELECT EXISTS(SELECT 1 FROM bot_apps WHERE bot_user_id=$1 AND is_main), EXISTS(SELECT 1 FROM attach_menu_bots WHERE bot_user_id=$1), EXISTS(SELECT 1 FROM bot_app_preview_media WHERE bot_user_id=$1)`, profile.BotUserID). Scan(&hasMain, &hasAttach, &hasPreview); err != nil { return profile, fmt.Errorf("enrich bot profile: flags: %w", err) } profile.HasMainApp = hasMain profile.HasAttachMenu = hasAttach profile.HasPreviewMedias = hasPreview settings, found, err := s.GetBotAppSettings(ctx, profile.BotUserID) if err != nil { return profile, err } if found { profile.AppSettings = &settings } return profile, nil } func (s *BotStore) UpsertBotApp(ctx context.Context, app domain.BotApp) (domain.BotApp, int, error) { if app.BotUserID == 0 || app.ID == 0 || app.AccessHash == 0 || strings.TrimSpace(app.ShortName) == "" || strings.TrimSpace(app.URL) == "" { return domain.BotApp{}, 0, domain.ErrBotAppInvalid } app.ShortName = strings.ToLower(strings.TrimSpace(app.ShortName)) version, err := s.withBotInfoBumpRawTx(ctx, app.BotUserID, func(tx pgx.Tx) error { if app.Main { if _, err := tx.Exec(ctx, `UPDATE bot_apps SET is_main=false, updated_at=now() WHERE bot_user_id=$1 AND id<>$2 AND is_main`, app.BotUserID, app.ID); err != nil { return fmt.Errorf("upsert bot app: clear main: %w", err) } } _, err := tx.Exec(ctx, ` INSERT INTO bot_apps ( id, bot_user_id, short_name, title, description, url, photo_id, document_id, access_hash, hash, inactive, request_write_access, has_settings, is_main ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT (id) DO UPDATE SET short_name=EXCLUDED.short_name, title=EXCLUDED.title, description=EXCLUDED.description, url=EXCLUDED.url, photo_id=EXCLUDED.photo_id, document_id=EXCLUDED.document_id, access_hash=EXCLUDED.access_hash, hash=EXCLUDED.hash, inactive=EXCLUDED.inactive, request_write_access=EXCLUDED.request_write_access, has_settings=EXCLUDED.has_settings, is_main=EXCLUDED.is_main, updated_at=now()`, app.ID, app.BotUserID, app.ShortName, app.Title, app.Description, app.URL, app.PhotoID, app.DocumentID, app.AccessHash, app.Hash, app.Inactive, app.RequestWriteAccess, app.HasSettings, app.Main) if err != nil { if isUniqueViolation(err) { return domain.ErrBotAppInvalid } return fmt.Errorf("upsert bot app: %w", err) } return nil }) if err != nil { return domain.BotApp{}, 0, err } return app, version, nil } func (s *BotStore) GetBotAppByID(ctx context.Context, appID, accessHash int64) (domain.BotApp, bool, error) { if appID == 0 || accessHash == 0 { return domain.BotApp{}, false, nil } row := s.db.QueryRow(ctx, botAppSelectSQL()+` WHERE id=$1 AND access_hash=$2`, appID, accessHash) return scanBotApp(row) } func (s *BotStore) GetBotAppByShortName(ctx context.Context, botUserID int64, shortName string) (domain.BotApp, bool, error) { shortName = strings.ToLower(strings.TrimSpace(shortName)) if botUserID == 0 || shortName == "" { return domain.BotApp{}, false, nil } row := s.db.QueryRow(ctx, botAppSelectSQL()+` WHERE bot_user_id=$1 AND lower(short_name)=lower($2)`, botUserID, shortName) return scanBotApp(row) } func (s *BotStore) GetMainBotApp(ctx context.Context, botUserID int64) (domain.BotApp, bool, error) { if botUserID == 0 { return domain.BotApp{}, false, nil } row := s.db.QueryRow(ctx, botAppSelectSQL()+` WHERE bot_user_id=$1 AND is_main LIMIT 1`, botUserID) return scanBotApp(row) } func (s *BotStore) ListBotApps(ctx context.Context, botUserID int64) ([]domain.BotApp, error) { rows, err := s.db.Query(ctx, botAppSelectSQL()+` WHERE bot_user_id=$1 ORDER BY is_main DESC, short_name, id`, botUserID) if err != nil { return nil, fmt.Errorf("list bot apps: %w", err) } defer rows.Close() var out []domain.BotApp for rows.Next() { app, err := scanBotAppRows(rows) if err != nil { return nil, err } out = append(out, app) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list bot apps: %w", err) } return out, nil } func (s *BotStore) GetBotAppSettings(ctx context.Context, botUserID int64) (domain.BotAppSettings, bool, error) { var settings domain.BotAppSettings var bg, bgDark, header, headerDark sql.NullInt64 err := s.db.QueryRow(ctx, ` SELECT placeholder_path, background_color, background_dark_color, header_color, header_dark_color FROM bot_app_settings WHERE bot_user_id=$1`, botUserID). Scan(&settings.PlaceholderPath, &bg, &bgDark, &header, &headerDark) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.BotAppSettings{}, false, nil } return domain.BotAppSettings{}, false, fmt.Errorf("get bot app settings: %w", err) } settings.HasBackgroundColor = bg.Valid settings.HasBackgroundDark = bgDark.Valid settings.HasHeaderColor = header.Valid settings.HasHeaderDarkColor = headerDark.Valid if bg.Valid { settings.BackgroundColor = int(bg.Int64) } if bgDark.Valid { settings.BackgroundDarkColor = int(bgDark.Int64) } if header.Valid { settings.HeaderColor = int(header.Int64) } if headerDark.Valid { settings.HeaderDarkColor = int(headerDark.Int64) } return settings, true, nil } func (s *BotStore) UpsertBotAppSettings(ctx context.Context, botUserID int64, settings domain.BotAppSettings) (int, error) { return s.withBotInfoBumpRawTx(ctx, botUserID, func(tx pgx.Tx) error { _, err := tx.Exec(ctx, ` INSERT INTO bot_app_settings ( bot_user_id, placeholder_path, background_color, background_dark_color, header_color, header_dark_color ) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (bot_user_id) DO UPDATE SET placeholder_path=EXCLUDED.placeholder_path, background_color=EXCLUDED.background_color, background_dark_color=EXCLUDED.background_dark_color, header_color=EXCLUDED.header_color, header_dark_color=EXCLUDED.header_dark_color, updated_at=now()`, botUserID, settings.PlaceholderPath, nullableBotAppInt(settings.BackgroundColor, settings.HasBackgroundColor), nullableBotAppInt(settings.BackgroundDarkColor, settings.HasBackgroundDark), nullableBotAppInt(settings.HeaderColor, settings.HasHeaderColor), nullableBotAppInt(settings.HeaderDarkColor, settings.HasHeaderDarkColor)) if err != nil { return fmt.Errorf("upsert bot app settings: %w", err) } return nil }) } func (s *BotStore) ListBotAppPreviewMedia(ctx context.Context, botUserID, appID int64) ([]domain.BotAppPreviewMedia, error) { rows, err := s.db.Query(ctx, ` SELECT id, bot_user_id, app_id, position, photo_id, document_id FROM bot_app_preview_media WHERE bot_user_id=$1 AND app_id=$2 ORDER BY position, id`, botUserID, appID) if err != nil { return nil, fmt.Errorf("list bot app preview media: %w", err) } defer rows.Close() var out []domain.BotAppPreviewMedia for rows.Next() { var media domain.BotAppPreviewMedia if err := rows.Scan(&media.ID, &media.BotUserID, &media.AppID, &media.Position, &media.PhotoID, &media.DocumentID); err != nil { return nil, err } out = append(out, media) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list bot app preview media: %w", err) } return out, nil } func (s *BotStore) UpsertBotAppPreviewMedia(ctx context.Context, media domain.BotAppPreviewMedia) (domain.BotAppPreviewMedia, int, error) { if media.BotUserID == 0 || media.AppID == 0 || (media.PhotoID == 0 && media.DocumentID == 0) { return domain.BotAppPreviewMedia{}, 0, domain.ErrBotAppInvalid } version, err := s.withBotInfoBumpRawTx(ctx, media.BotUserID, func(tx pgx.Tx) error { if media.Position <= 0 { if err := tx.QueryRow(ctx, `SELECT COALESCE(MAX(position), 0) + 1 FROM bot_app_preview_media WHERE app_id=$1`, media.AppID).Scan(&media.Position); err != nil { return fmt.Errorf("upsert bot preview media: next position: %w", err) } } if media.ID == 0 { return tx.QueryRow(ctx, ` INSERT INTO bot_app_preview_media (bot_user_id, app_id, position, photo_id, document_id) VALUES ($1,$2,$3,$4,$5) RETURNING id`, media.BotUserID, media.AppID, media.Position, media.PhotoID, media.DocumentID).Scan(&media.ID) } _, err := tx.Exec(ctx, ` INSERT INTO bot_app_preview_media (id, bot_user_id, app_id, position, photo_id, document_id) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (id) DO UPDATE SET position=EXCLUDED.position, photo_id=EXCLUDED.photo_id, document_id=EXCLUDED.document_id, updated_at=now()`, media.ID, media.BotUserID, media.AppID, media.Position, media.PhotoID, media.DocumentID) return err }) if err != nil { return domain.BotAppPreviewMedia{}, 0, err } return media, version, nil } func (s *BotStore) DeleteBotAppPreviewMedia(ctx context.Context, botUserID, appID, mediaID int64) (int, error) { return s.withBotInfoBumpRawTx(ctx, botUserID, func(tx pgx.Tx) error { tag, err := tx.Exec(ctx, `DELETE FROM bot_app_preview_media WHERE bot_user_id=$1 AND app_id=$2 AND id=$3`, botUserID, appID, mediaID) if err != nil { return fmt.Errorf("delete bot app preview media: %w", err) } if tag.RowsAffected() == 0 { return domain.ErrBotAppInvalid } return nil }) } func (s *BotStore) ReorderBotAppPreviewMedia(ctx context.Context, botUserID, appID int64, mediaIDs []int64) (int, error) { return s.withBotInfoBumpRawTx(ctx, botUserID, func(tx pgx.Tx) error { for i, id := range mediaIDs { tag, err := tx.Exec(ctx, `UPDATE bot_app_preview_media SET position=$1, updated_at=now() WHERE bot_user_id=$2 AND app_id=$3 AND id=$4`, -(i + 1), botUserID, appID, id) if err != nil { return fmt.Errorf("reorder bot preview media: stage: %w", err) } if tag.RowsAffected() == 0 { return domain.ErrBotAppInvalid } } for i, id := range mediaIDs { tag, err := tx.Exec(ctx, `UPDATE bot_app_preview_media SET position=$1, updated_at=now() WHERE bot_user_id=$2 AND app_id=$3 AND id=$4`, i+1, botUserID, appID, id) if err != nil { return fmt.Errorf("reorder bot preview media: %w", err) } if tag.RowsAffected() == 0 { return domain.ErrBotAppInvalid } } return nil }) } func (s *BotStore) UpsertAttachMenuBot(ctx context.Context, bot domain.BotAttachMenuBot) (int, error) { if bot.BotUserID == 0 || strings.TrimSpace(bot.ShortName) == "" { return 0, domain.ErrBotAttachMenuInvalid } bot.ShortName = strings.ToLower(strings.TrimSpace(bot.ShortName)) icons, err := json.Marshal(bot.Icons) if err != nil { return 0, fmt.Errorf("upsert attach menu bot: icons: %w", err) } return s.withBotInfoBumpRawTx(ctx, bot.BotUserID, func(tx pgx.Tx) error { _, err := tx.Exec(ctx, ` INSERT INTO attach_menu_bots ( bot_user_id, app_id, short_name, inactive, has_settings, request_write_access, show_in_attach_menu, show_in_side_menu, side_menu_disclaimer_needed, peer_types, icons ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT (bot_user_id) DO UPDATE SET app_id=EXCLUDED.app_id, short_name=EXCLUDED.short_name, inactive=EXCLUDED.inactive, has_settings=EXCLUDED.has_settings, request_write_access=EXCLUDED.request_write_access, show_in_attach_menu=EXCLUDED.show_in_attach_menu, show_in_side_menu=EXCLUDED.show_in_side_menu, side_menu_disclaimer_needed=EXCLUDED.side_menu_disclaimer_needed, peer_types=EXCLUDED.peer_types, icons=EXCLUDED.icons, updated_at=now()`, bot.BotUserID, nullableInt64(bot.AppID), bot.ShortName, bot.Inactive, bot.HasSettings, bot.RequestWriteAccess, bot.ShowInAttachMenu, bot.ShowInSideMenu, bot.SideMenuDisclaimerNeeded, bot.PeerTypes, icons) if err != nil { return fmt.Errorf("upsert attach menu bot: %w", err) } return nil }) } func (s *BotStore) GetAttachMenuBot(ctx context.Context, botUserID int64) (domain.BotAttachMenuBot, bool, error) { row := s.db.QueryRow(ctx, attachMenuBotSelectSQL()+` WHERE bot_user_id=$1`, botUserID) return scanAttachMenuBot(row) } func (s *BotStore) ListAttachMenuBots(ctx context.Context) ([]domain.BotAttachMenuBot, error) { rows, err := s.db.Query(ctx, attachMenuBotSelectSQL()+` ORDER BY inactive, bot_user_id`) if err != nil { return nil, fmt.Errorf("list attach menu bots: %w", err) } defer rows.Close() var out []domain.BotAttachMenuBot for rows.Next() { bot, err := scanAttachMenuBotRows(rows) if err != nil { return nil, err } out = append(out, bot) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list attach menu bots: %w", err) } return out, nil } func (s *BotStore) GetAttachMenuState(ctx context.Context, userID, botUserID int64) (domain.BotAttachMenuState, bool, error) { var state domain.BotAttachMenuState err := s.db.QueryRow(ctx, ` SELECT user_id, bot_user_id, enabled, write_allowed FROM attach_menu_user_states WHERE user_id=$1 AND bot_user_id=$2`, userID, botUserID). Scan(&state.UserID, &state.BotUserID, &state.Enabled, &state.WriteAllowed) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.BotAttachMenuState{}, false, nil } return domain.BotAttachMenuState{}, false, fmt.Errorf("get attach menu state: %w", err) } return state, true, nil } func (s *BotStore) SetAttachMenuState(ctx context.Context, state domain.BotAttachMenuState) (domain.BotAttachMenuState, error) { if state.UserID == 0 || state.BotUserID == 0 || state.UserID == state.BotUserID { return domain.BotAttachMenuState{}, domain.ErrBotAttachMenuInvalid } _, err := s.db.Exec(ctx, ` INSERT INTO attach_menu_user_states (user_id, bot_user_id, enabled, write_allowed) VALUES ($1,$2,$3,$4) ON CONFLICT (user_id, bot_user_id) DO UPDATE SET enabled=EXCLUDED.enabled, write_allowed=attach_menu_user_states.write_allowed OR EXCLUDED.write_allowed, updated_at=now()`, state.UserID, state.BotUserID, state.Enabled, state.WriteAllowed) if err != nil { return domain.BotAttachMenuState{}, fmt.Errorf("set attach menu state: %w", err) } return s.GetAttachMenuStateValue(ctx, state.UserID, state.BotUserID) } func (s *BotStore) GetAttachMenuStateValue(ctx context.Context, userID, botUserID int64) (domain.BotAttachMenuState, error) { state, found, err := s.GetAttachMenuState(ctx, userID, botUserID) if err != nil { return domain.BotAttachMenuState{}, err } if !found { return domain.BotAttachMenuState{}, domain.ErrBotAttachMenuInvalid } return state, nil } func (s *BotStore) SaveRequestedWebViewButton(ctx context.Context, button domain.BotRequestedWebViewButton) error { if button.BotUserID == 0 || button.UserID == 0 || button.WebAppReqID == "" || button.ExpiresAt.IsZero() { return domain.ErrBotRequestedButtonInvalid } _, err := s.db.Exec(ctx, ` INSERT INTO webview_requested_buttons (webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, created_at, expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (webapp_req_id) DO UPDATE SET button_id=EXCLUDED.button_id, text=EXCLUDED.text, peer_type=EXCLUDED.peer_type, max_quantity=EXCLUDED.max_quantity, expires_at=EXCLUDED.expires_at`, button.WebAppReqID, button.BotUserID, button.UserID, button.ButtonID, button.Text, button.PeerType, button.MaxQuantity, button.CreatedAt, button.ExpiresAt) if err != nil { return fmt.Errorf("save requested webview button: %w", err) } return nil } func (s *BotStore) GetRequestedWebViewButton(ctx context.Context, botUserID, userID int64, webAppReqID string) (domain.BotRequestedWebViewButton, bool, error) { _, _ = s.db.Exec(ctx, `DELETE FROM webview_requested_buttons WHERE expires_at <= now()`) var button domain.BotRequestedWebViewButton err := s.db.QueryRow(ctx, ` SELECT webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, created_at, expires_at FROM webview_requested_buttons WHERE bot_user_id=$1 AND user_id=$2 AND webapp_req_id=$3 AND expires_at > now()`, botUserID, userID, webAppReqID). Scan(&button.WebAppReqID, &button.BotUserID, &button.UserID, &button.ButtonID, &button.Text, &button.PeerType, &button.MaxQuantity, &button.CreatedAt, &button.ExpiresAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.BotRequestedWebViewButton{}, false, nil } return domain.BotRequestedWebViewButton{}, false, fmt.Errorf("get requested webview button: %w", err) } return button, true, nil } func (s *BotStore) DeleteRequestedWebViewButton(ctx context.Context, botUserID, userID int64, webAppReqID string) error { _, err := s.db.Exec(ctx, `DELETE FROM webview_requested_buttons WHERE bot_user_id=$1 AND user_id=$2 AND webapp_req_id=$3`, botUserID, userID, webAppReqID) if err != nil { return fmt.Errorf("delete requested webview button: %w", err) } return nil } func (s *BotStore) SetBotEmojiStatusPermission(ctx context.Context, botUserID, userID int64, allowed bool) error { if botUserID == 0 || userID == 0 || botUserID == userID { return domain.ErrBotNotFound } if !allowed { _, err := s.db.Exec(ctx, `DELETE FROM bot_emoji_status_permissions WHERE bot_user_id=$1 AND user_id=$2`, botUserID, userID) return err } _, err := s.db.Exec(ctx, ` INSERT INTO bot_emoji_status_permissions (bot_user_id, user_id, allowed) VALUES ($1,$2,true) ON CONFLICT (bot_user_id, user_id) DO UPDATE SET allowed=true, updated_at=now()`, botUserID, userID) if err != nil { return fmt.Errorf("set bot emoji status permission: %w", err) } return nil } func (s *BotStore) BotEmojiStatusPermission(ctx context.Context, botUserID, userID int64) (bool, error) { var allowed bool err := s.db.QueryRow(ctx, `SELECT allowed FROM bot_emoji_status_permissions WHERE bot_user_id=$1 AND user_id=$2`, botUserID, userID).Scan(&allowed) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return false, nil } return false, fmt.Errorf("bot emoji status permission: %w", err) } return allowed, nil } func (s *BotStore) PutWebViewCustomMethodQuery(ctx context.Context, query domain.BotWebViewCustomMethodQuery) error { if query.ID == "" || query.BotUserID == 0 || query.UserID == 0 || query.CustomMethod == "" || query.ExpiresAt.IsZero() { return domain.ErrBotCustomMethodUnavailable } var params any = json.RawMessage(query.ParamsJSON) if query.ParamsJSON == "" { params = json.RawMessage(`{}`) } _, err := s.db.Exec(ctx, ` INSERT INTO webview_custom_method_queries (id, bot_user_id, user_id, custom_method, params, created_at, expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (id) DO UPDATE SET params=EXCLUDED.params, expires_at=EXCLUDED.expires_at`, query.ID, query.BotUserID, query.UserID, query.CustomMethod, params, query.CreatedAt, query.ExpiresAt) if err != nil { return fmt.Errorf("put webview custom method query: %w", err) } return nil } func botAppSelectSQL() string { return `SELECT id, bot_user_id, short_name, title, description, url, photo_id, document_id, access_hash, hash, inactive, request_write_access, has_settings, is_main FROM bot_apps` } func scanBotApp(row rowScanner) (domain.BotApp, bool, error) { app, err := scanBotAppAny(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.BotApp{}, false, nil } return domain.BotApp{}, false, err } return app, true, nil } func scanBotAppRows(rows pgx.Rows) (domain.BotApp, error) { return scanBotAppAny(rows) } func scanBotAppAny(row rowScanner) (domain.BotApp, error) { var app domain.BotApp if err := row.Scan(&app.ID, &app.BotUserID, &app.ShortName, &app.Title, &app.Description, &app.URL, &app.PhotoID, &app.DocumentID, &app.AccessHash, &app.Hash, &app.Inactive, &app.RequestWriteAccess, &app.HasSettings, &app.Main); err != nil { return domain.BotApp{}, err } return app, nil } func attachMenuBotSelectSQL() string { return `SELECT bot_user_id, COALESCE(app_id,0), short_name, inactive, has_settings, request_write_access, show_in_attach_menu, show_in_side_menu, side_menu_disclaimer_needed, peer_types, icons FROM attach_menu_bots` } func scanAttachMenuBot(row rowScanner) (domain.BotAttachMenuBot, bool, error) { bot, err := scanAttachMenuBotAny(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.BotAttachMenuBot{}, false, nil } return domain.BotAttachMenuBot{}, false, err } return bot, true, nil } func scanAttachMenuBotRows(rows pgx.Rows) (domain.BotAttachMenuBot, error) { return scanAttachMenuBotAny(rows) } func scanAttachMenuBotAny(row rowScanner) (domain.BotAttachMenuBot, error) { var bot domain.BotAttachMenuBot var icons []byte if err := row.Scan(&bot.BotUserID, &bot.AppID, &bot.ShortName, &bot.Inactive, &bot.HasSettings, &bot.RequestWriteAccess, &bot.ShowInAttachMenu, &bot.ShowInSideMenu, &bot.SideMenuDisclaimerNeeded, &bot.PeerTypes, &icons); err != nil { return domain.BotAttachMenuBot{}, err } if len(icons) > 0 { _ = json.Unmarshal(icons, &bot.Icons) } return bot, nil } func nullableBotAppInt(value int, valid bool) any { if !valid { return nil } return value } func nullableInt64(value int64) any { if value == 0 { return nil } return value }