From 547afd8c0921a7de16f0a5ce8b5d8d09bb2268c8 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 26 Feb 2025 15:24:33 +0200 Subject: introduce run interval --- README.md | 7 +-- internal/config/args.go | 33 ++++++------ internal/config/config.go | 79 ++++++++++++++++++++++++++++ internal/config/secrets.go | 69 ------------------------ internal/main.go | 36 +++++++------ internal/platforms/linkedin/linkedin.go | 4 +- internal/platforms/linkedin/oauth2/oauth2.go | 18 +++---- internal/platforms/mastodon/mastodon.go | 4 +- internal/run.go | 12 ++++- 9 files changed, 142 insertions(+), 120 deletions(-) create mode 100644 internal/config/config.go delete mode 100644 internal/config/secrets.go diff --git a/README.md b/README.md index aa86e9a..ffcbbbb 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,9 @@ go-task install ## Configuration -Gos requires a configuration file to store API secrets and OAuth2 credentials for each supported social media platform. The configuration is managed using a Secrets structure, which is stored as a JSON file in `~/.config/gos/gosec.json`. +Gos requires a configuration file to store API secrets and OAuth2 credentials for each supported social media platform. The configuration is managed using a Secrets structure, which is stored as a JSON file in `~/.config/gos/gos.json`. -Example Configuration File (`~/.config/gos/gosec.json`): +Example Configuration File (`~/.config/gos/gos.json`): ```json { @@ -90,12 +90,13 @@ Flags are used to control the tool's behavior. Below are several common ways to * `-gosDir`: Specify the directory for Gos' queue and database files. Default is `~/.gosdir`. * `-cacheDir`: Specify the directory for Gos' cache. Default is based on the `gosDir` path. * `-browser`: Choose the browser for OAuth2 processes. Default is "firefox". -* `-secretsConfig`: Path to the secret configuration file. Default is `~/.config/gos/gosec.json`. +* `-configPath`: Path to the configuration file. Default is `~/.config/gos/gos.json`. * `-platforms`: Enabled platforms along with their post size limits. Default is "Mastodon:500,LinkedIn:1000". * `-target`: Target number of posts per week. Default is 2. * `-minQueued`: Minimum number of queued items before a warning message is printed. Default is 4. * `-maxDaysQueued`: Maximum number of days' worth of queued posts before target is increased and pauseDays decreased. Default is 365. * `-pauseDays`: Number of days until the next post can be submitted. Default is 3. +* `-runInterval`: Number of hours until the next post run. Default is 18. * `-lookback`: Number of days to look back in time to review posting history. Default is 30. * `-geminiSummaryFor`: Generate a summary in Gemini Gemtext format, specifying months as a comma-separated string. * `-geminiCapsules`: Comma-separated list of Gemini capsules. Used to detect Gemtext links. diff --git a/internal/config/args.go b/internal/config/args.go index e2b9dc1..d1a23b9 100644 --- a/internal/config/args.go +++ b/internal/config/args.go @@ -9,22 +9,23 @@ import ( ) type Args struct { - GosDir string - CacheDir string - DryRun bool - Platforms map[string]int // Platform and post size limits - Target int - MinQueued int - MaxDaysQueued int - PauseDays int - Lookback time.Duration - SecretsConfigPath string - Secrets Secrets - OAuth2Browser string - GeminiSummaryFor []string - GemtexterEnable bool - GeminiCapsules []string - ComposeMode bool + GosDir string + CacheDir string + DryRun bool + Platforms map[string]int // Platform and post size limits + Target int + MinQueued int + MaxDaysQueued int + PauseDays int + RunInterval time.Duration + Lookback time.Duration + ConfigPath string + Config Config + OAuth2Browser string + GeminiSummaryFor []string + GemtexterEnable bool + GeminiCapsules []string + ComposeMode bool } func (a *Args) ParsePlatforms(platformStrs string) error { diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..f865b67 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,79 @@ +package config + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "codeberg.org/snonux/gos/internal/colour" +) + +// The config file containing all the secrets and credentials plus maybe more. +type Config struct { + LastRunEpoch int64 `json:"LastRunEpoch,omitempty"` + MastodonURL string + MastodonAccessToken string + LinkedInClientID string + LinkedInSecret string + LinkedInRedirectURL string + // Will be updated by gos automatically, after successful oauth2 + LinkedInAccessToken string `json:"LinkedInAccessToken,omitempty"` + // Will be updated by gos automatically, after successful oauth2 + LinkedInPersonID string `json:"LinkedInPersonID,omitempty"` +} + +func New(configPath string, composeEntry bool) (Config, error) { + var conf Config + + _, err := os.Stat(configPath) + if os.IsNotExist(err) { + if !composeEntry { + return conf, fmt.Errorf("No config file %s", configPath) + } + // Create empty new config for compose mode. + return conf, conf.WriteToDisk(configPath) + } + + file, err := os.Open(configPath) + if err != nil { + return conf, fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + bytes, err := io.ReadAll(file) + if err != nil { + return conf, fmt.Errorf("failed to read file: %w", err) + } + + if err := json.Unmarshal(bytes, &conf); err != nil { + return conf, fmt.Errorf("failed to unmarshal JSON: %w", err) + } + + return conf, nil +} + +func (s Config) WriteToDisk(configPath string) error { + colour.Infoln("Writing", configPath) + if err := os.MkdirAll(filepath.Dir(configPath), os.ModePerm); err != nil { + return err + } + + bytes, err := json.MarshalIndent(s, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + tmpConfigPath := fmt.Sprintf("%s.tmp", configPath) + file, err := os.Create(tmpConfigPath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + + if _, err := file.Write(bytes); err != nil { + return fmt.Errorf("failed to write to file: %w", err) + } + + return os.Rename(tmpConfigPath, configPath) +} diff --git a/internal/config/secrets.go b/internal/config/secrets.go deleted file mode 100644 index b4e629f..0000000 --- a/internal/config/secrets.go +++ /dev/null @@ -1,69 +0,0 @@ -package config - -import ( - "encoding/json" - "fmt" - "io" - "os" - - "codeberg.org/snonux/gos/internal/colour" -) - -// The config file containing all the secrets and credentials. -type Secrets struct { - MastodonURL string - MastodonAccessToken string - LinkedInClientID string - LinkedInSecret string - LinkedInRedirectURL string - // Will be updated by gos automatically, after successful oauth2 - LinkedInAccessToken string `json:"LinkedInAccessToken,omitempty"` - // Will be updated by gos automatically, after successful oauth2 - LinkedInPersonID string `json:"LinkedInPersonID,omitempty"` -} - -func NewSecrets(configPath string, composeEntry bool) (Secrets, error) { - var sec Secrets - if composeEntry { - // In compose mode, no need to read the secrets. - return sec, nil - } - - file, err := os.Open(configPath) - if err != nil { - return sec, fmt.Errorf("failed to open file: %w", err) - } - defer file.Close() - - bytes, err := io.ReadAll(file) - if err != nil { - return sec, fmt.Errorf("failed to read file: %w", err) - } - - if err := json.Unmarshal(bytes, &sec); err != nil { - return sec, fmt.Errorf("failed to unmarshal JSON: %w", err) - } - - return sec, nil -} - -func (s Secrets) WriteToDisk(configPath string) error { - colour.Infoln("Writing", configPath) - - bytes, err := json.MarshalIndent(s, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - tmpConfigPath := fmt.Sprintf("%s.tmp", configPath) - file, err := os.Create(tmpConfigPath) - if err != nil { - return fmt.Errorf("failed to create file: %w", err) - } - defer file.Close() - - if _, err := file.Write(bytes); err != nil { - return fmt.Errorf("failed to write to file: %w", err) - } - - return os.Rename(tmpConfigPath, configPath) -} diff --git a/internal/main.go b/internal/main.go index 7ae8390..d047bb5 100644 --- a/internal/main.go +++ b/internal/main.go @@ -20,13 +20,14 @@ func Main(composeModeDefault bool) { gosDir := flag.String("gosDir", filepath.Join(os.Getenv("HOME"), ".gosdir"), "Gos' queue and DB directory") cacheDir := flag.String("cacheDir", filepath.Join(*gosDir, "cache"), "Go's cache dir") browser := flag.String("browser", "firefox", "OAuth2 browser") - secretsConfigPath := filepath.Join(os.Getenv("HOME"), ".config/gos/gosec.json") - secretsConfigPath = *flag.String("secretsConfig", secretsConfigPath, "Gos' secret config") + configPath := filepath.Join(os.Getenv("HOME"), ".config/gos/gos.json") + configPath = *flag.String("configPath", configPath, "Gos' config file path") platforms := flag.String("platforms", "Mastodon:500,LinkedIn:1000", "Platforms enabled plus their post size limits") target := flag.Int("target", 2, "How many posts per week are the target?") minQueued := flag.Int("minQueued", 4, "Minimum of queued items until printing a warn message!") maxDaysQueued := flag.Int("maxDaysQueued", 365, "Maximum days worth of queued posts until target++ and pauseDays--") pauseDays := flag.Int("pauseDays", 3, "How many days until next post can be posted?") + runIntervalHours := flag.Int("runInterval", 18, "How many hours to wait for the next run.") lookback := flag.Int("lookback", 30, "How many days look back in time for posting history") geminiSummaryFor := flag.String("geminiSummaryFor", "", "Generate a summary in Gemini Gemtext format, format is coma separated string of months, e.g. 202410,202411") geminiCapsules := flag.String("geminiCapsules", "foo.zone", "Comma sepaeated list Gemini capsules. Used by geminiEnable to detect Gemtext links") @@ -43,26 +44,27 @@ func Main(composeModeDefault bool) { os.Exit(0) } - secrets, err := config.NewSecrets(secretsConfigPath, *composeMode) + conf, err := config.New(configPath, *composeMode) if err != nil { log.Fatal(err) } args := config.Args{ - DryRun: *dry, - GosDir: *gosDir, - Target: *target, - MinQueued: *minQueued, - MaxDaysQueued: *maxDaysQueued, - PauseDays: *pauseDays, - Lookback: time.Duration(*lookback) * time.Hour * 24, - SecretsConfigPath: secretsConfigPath, - CacheDir: *cacheDir, - Secrets: secrets, - OAuth2Browser: *browser, - GemtexterEnable: *gemtexterEnable, - GeminiCapsules: strings.Split(*geminiCapsules, ","), - ComposeMode: *composeMode, + DryRun: *dry, + GosDir: *gosDir, + Target: *target, + MinQueued: *minQueued, + MaxDaysQueued: *maxDaysQueued, + PauseDays: *pauseDays, + RunInterval: time.Duration(*runIntervalHours) * time.Hour, // TODO: Document + Lookback: time.Duration(*lookback) * time.Hour * 24, + ConfigPath: configPath, + Config: conf, + CacheDir: *cacheDir, + OAuth2Browser: *browser, + GemtexterEnable: *gemtexterEnable, + GeminiCapsules: strings.Split(*geminiCapsules, ","), + ComposeMode: *composeMode, } if *geminiSummaryFor != "" { args.GeminiSummaryFor = strings.Split(*geminiSummaryFor, ",") diff --git a/internal/platforms/linkedin/linkedin.go b/internal/platforms/linkedin/linkedin.go index 2a3c7b2..4c63f9a 100644 --- a/internal/platforms/linkedin/linkedin.go +++ b/internal/platforms/linkedin/linkedin.go @@ -26,7 +26,7 @@ func Post(ctx context.Context, args config.Args, sizeLimit int, en entry.Entry) err := post(ctx, args, sizeLimit, en) if errors.Is(err, errUnauthorized) { colour.Infoln(err, "=> trying to refresh LinkedIn access token") - args.Secrets.LinkedInAccessToken = "" // Reset the token + args.Config.LinkedInAccessToken = "" // Reset the token return post(ctx, args, sizeLimit, en) } return err @@ -38,7 +38,7 @@ func post(ctx context.Context, args config.Args, sizeLimit int, en entry.Entry) } timeout := linkedInTimeout - if args.Secrets.LinkedInAccessToken == "" { + if args.Config.LinkedInAccessToken == "" { // Refreshing access token requires more time due to human interaction timeout = 1 * time.Minute } diff --git a/internal/platforms/linkedin/oauth2/oauth2.go b/internal/platforms/linkedin/oauth2/oauth2.go index dfaad19..8fe25ab 100644 --- a/internal/platforms/linkedin/oauth2/oauth2.go +++ b/internal/platforms/linkedin/oauth2/oauth2.go @@ -91,15 +91,15 @@ func oauthCallbackHandler(w http.ResponseWriter, r *http.Request) { } func LinkedInCreds(ctx context.Context, args config.Args) (string, string, error) { - secrets := args.Secrets - if secrets.LinkedInAccessToken != "" && secrets.LinkedInPersonID != "" { - return secrets.LinkedInPersonID, secrets.LinkedInAccessToken, nil + conf := args.Config + if conf.LinkedInAccessToken != "" && conf.LinkedInPersonID != "" { + return conf.LinkedInPersonID, conf.LinkedInAccessToken, nil } oauthConfig = &oauth2.Config{ - ClientID: secrets.LinkedInClientID, - ClientSecret: secrets.LinkedInSecret, - RedirectURL: secrets.LinkedInRedirectURL, + ClientID: conf.LinkedInClientID, + ClientSecret: conf.LinkedInSecret, + RedirectURL: conf.LinkedInRedirectURL, Scopes: []string{"openid", "profile", "w_member_social"}, Endpoint: linkedin.Endpoint, } @@ -133,9 +133,9 @@ func LinkedInCreds(ctx context.Context, args config.Args) (string, string, error return "", "", errs } - secrets.LinkedInAccessToken = oauthAccessToken - secrets.LinkedInPersonID = oauthPersonID - return oauthPersonID, oauthAccessToken, secrets.WriteToDisk(args.SecretsConfigPath) + conf.LinkedInAccessToken = oauthAccessToken + conf.LinkedInPersonID = oauthPersonID + return oauthPersonID, oauthAccessToken, conf.WriteToDisk(args.ConfigPath) } func openURLInFirefox(browser, url string) error { diff --git a/internal/platforms/mastodon/mastodon.go b/internal/platforms/mastodon/mastodon.go index f52f905..ad4733b 100644 --- a/internal/platforms/mastodon/mastodon.go +++ b/internal/platforms/mastodon/mastodon.go @@ -39,12 +39,12 @@ func Post(ctx context.Context, args config.Args, sizeLimit int, en entry.Entry) newCtx, cancel := context.WithTimeout(ctx, mastodonTimeout) defer cancel() - req, err := http.NewRequestWithContext(newCtx, "POST", args.Secrets.MastodonURL, bytes.NewBuffer(payloadBytes)) + req, err := http.NewRequestWithContext(newCtx, "POST", args.Config.MastodonURL, bytes.NewBuffer(payloadBytes)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("Authorization", "Bearer "+args.Secrets.MastodonAccessToken) + req.Header.Set("Authorization", "Bearer "+args.Config.MastodonAccessToken) req.Header.Set("Content-Type", "application/json") client := &http.Client{} diff --git a/internal/run.go b/internal/run.go index ac3f75e..27d6aeb 100644 --- a/internal/run.go +++ b/internal/run.go @@ -19,9 +19,10 @@ func run(ctx context.Context, args config.Args) error { if len(args.GeminiSummaryFor) > 0 { return summary.Run(ctx, args) } + now := time.Now().Unix() if args.ComposeMode { - entryPath := fmt.Sprintf("%s/%d.ask.txt", args.GosDir, time.Now().Unix()) + entryPath := fmt.Sprintf("%s/%d.ask.txt", args.GosDir, now) if err := prompt.EditFile(entryPath); err != nil { return err } @@ -34,6 +35,12 @@ func run(ctx context.Context, args config.Args) error { colour.Infoln(err) } + sinceLastRun := time.Duration(now-args.Config.LastRunEpoch) * time.Second + if sinceLastRun < args.RunInterval { + colour.Infoln("Run interval", args.RunInterval, "with", sinceLastRun, "not yet reached. Not posting anything!") + return nil + } + for platformStr, sizeLimit := range args.Platforms { platform, err := platforms.New(platformStr) if err != nil { @@ -48,7 +55,8 @@ func run(ctx context.Context, args config.Args) error { } } - return nil + args.Config.LastRunEpoch = now + return args.Config.WriteToDisk(args.ConfigPath) } func runPlatform(ctx context.Context, args config.Args, platform platforms.Platform, sizeLimit int) error { -- cgit v1.2.3