// Package bash provides utilities for executing bash commands and processing their output.
// It includes functionality for process management, system monitoring, and output formatting.
package bash
import (
"bytes"
"errors"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
)
const (
// ansi is a regular expression pattern for matching ANSI escape sequences.
// These sequences are used for terminal color formatting and control.
// The pattern matches:
// - CSI (Control Sequence Introducer) sequences starting with ESC[
// - OSC (Operating System Command) sequences
// - Other common terminal control sequences.
ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" //nolint:lll
// commandBash specifies the default command to use for bash shell execution.
commandBash = "bash"
// zeroValue is the default string value returned when numeric operations fail.
zeroValue = "0.0"
// uptimeZeroValue is the default string value returned when uptime cannot be determined.
uptimeZeroValue = "00:00:00"
)
// ansiRegexp is a precompiled regular expression created from the ansi pattern.
// This is used for efficient stripping of ANSI escape sequences from strings.
var ansiRegexp = regexp.MustCompile(ansi)
// Common error definitions used throughout the package.
var (
// ErrInvalidCommand indicates that command is invalid.
ErrInvalidCommand = errors.New("invalid command")
// ErrEmptyPID indicates that a process lookup returned an empty process ID.
ErrEmptyPID = errors.New("empty process id")
// ErrNotRunning indicates that the requested process is not currently running.
ErrNotRunning = errors.New("process is not running")
// ErrBashExecuteFailed indicates that a bash command execution failed.
ErrBashExecuteFailed = errors.New("bash execute failed")
)
// Strip removes all ANSI escape sequences and trailing newline characters from a string.
// This is useful for cleaning up colored terminal output or formatted text.
//
// Parameters:
// - str: The input string potentially containing ANSI codes
//
// Returns:
// - A cleaned string with all ANSI sequences and trailing newlines removed
//
// Example:
//
// cleaned := Strip("\033[32mHello\033[0m\n") // Returns "Hello".
func Strip(str string) string {
return strings.TrimRight(ansiRegexp.ReplaceAllString(str, ""), "\n")
}
// Execute runs a system command and captures its output streams.
// It provides a convenient wrapper around exec.Command with integrated error handling.
//
// Parameters:
// - name: The name/path of the command to execute (e.g. "ls", "/bin/bash")
// - args: Variadic arguments to pass to the command (e.g. "-l", "-a")
//
// Returns:
// - string: The combined stdout output of the command
// - error: Returns ErrBashExecuteFailed if stderr contains output,
// or the original exec error if the command failed to run.
// Returns nil if execution was successful with empty stderr.
//
// Behavior:
// - Captures both stdout and stderr streams separately
// - Considers any stderr output as an error condition
// - Preserves the command's exit status error if present
// - Trims no output - returned strings may contain trailing newlines
//
// Example:
//
// output, err := Execute("ls", "-l", "/tmp")
// if err != nil {
// // Handle error (either from stderr or command failure)
// }
// fmt.Println(output)
//
// Notes:
// - For bash commands, consider using commandBash constant as name
// - Command output is not stripped of ANSI codes (use Strip() separately)
// - Not suitable for interactive commands requiring stdin.
func Execute(name string, args ...string) (string, error) {
var stdout, stder bytes.Buffer
cmd := exec.Command(name, args...)
cmd.Stdout = &stdout
cmd.Stderr = &stder
err := cmd.Run()
// Return stderr if present, even if command technically succeeded.
if stder.String() != "" {
return stdout.String(), fmt.Errorf("%w: %s", ErrBashExecuteFailed, stder.String())
}
return stdout.String(), err
}
// GetLargeFileList finds large files with specific extension in given path
// Parameters:
// - path: directory to search
// - ext: file extension to match (e.g. ".log")
// - params: optional count parameter (default 20)
//
// Returns list of files or error if command fails.
func GetLargeFileList(path, mask string, params ...int) (string, error) {
count := "20"
if len(params) > 0 {
count = strconv.Itoa(params[0])
}
args := []string{
"-c", "ls " + path + " -hSRs | egrep '" + mask + "' | head -" + count,
}
return Execute(commandBash, args...)
}
// PidofByProcess retrieves the process ID (PID) of a running process by its name.
// It uses the system's 'pidof' command to find the PID of the specified process.
//
// Parameters:
// - process: Name of the process to look up (e.g., "nginx", "java").
// Should match the exact executable name.
//
// Returns:
// - string: The PID of the process as a string if found.
// - error: May return:
// - Original error from command execution if pidof fails
// - ErrEmptyPID if process is not running or pidof returns empty
// - Other system errors if command cannot be executed
//
// Behavior:
// - Executes 'pidof <process>' command internally
// - Automatically trims trailing newline from output
// - Returns first PID if multiple instances are running (pidof behavior)
// - Does not validate if the process is actually running beyond PID existence
//
// Example:
//
// pid, err := PidofByProcess("nginx")
// if err != nil {
// if errors.Is(err, ErrEmptyPID) {
// fmt.Println("Nginx is not running")
// } else {
// log.Fatalf("Error checking nginx: %v", err)
// }
// }
// fmt.Printf("Nginx PID: %s\n", pid)
//
// Notes:
// - Requires pidof command to be available in system PATH
// - For more advanced process lookups, see PidofByProcessAndParam
// - Returned PID string may need conversion to int for numeric operations
// - On systems with multiple process instances, consider using pgrep instead.
func PidofByProcess(process string) (string, error) {
out, err := Execute("pidof", process)
if err != nil {
return "", err
}
if l := strings.Split(out, "\n"); len(l) > 0 {
pid := l[0]
if pid == "" {
return "", ErrEmptyPID
}
return pid, nil
}
return "", ErrNotRunning
}
// PidofByProcessAndParam finds a process ID by process name and matching parameter.
// It executes a command pipeline: pgrep -af <process> | grep <param> to locate
// the specific process instance containing the given parameter.
//
// Parameters:
// - process: The name of the process to search for (e.g. "java", "nginx")
// - param: The parameter string to match in the process command line
// (e.g. "--config=myapp.conf", "servername")
//
// Returns:
// - string: The PID of the matching process
// - error: ErrEmptyPID if process is found but PID is empty,
// ErrNotRunning if no matching process is found,
// or other errors from command execution
//
// Example:
//
// pid, err := PidofByProcessAndParam("java", "-Dapp.name=myapp")
// if err != nil {
// // handle error
// }.
func PidofByProcessAndParam(process, param string) (string, error) {
if process == "" || param == "" || string(process[0]) == "-" {
return "", ErrInvalidCommand
}
if string(param[0]) == "-" {
param = "\\" + param
}
cmd := fmt.Sprintf("pgrep -af %q | grep -v %q | grep %q | grep -o -e %q", process, " bash ", param, `^[0-9]*`)
out, err := Execute(commandBash, "-c", cmd)
if err != nil {
if err.Error() == "exit status 1" {
return "", ErrNotRunning
}
return out, err
}
if l := strings.Split(out, "\n"); len(l) > 0 {
pid := l[0]
if pid == "" {
return "", ErrEmptyPID
}
return pid, nil
}
return "", ErrNotRunning
}
// GetUptimeByPID retrieves the elapsed time since a process started using its PID.
// It executes the 'ps' command to get the process's running duration in format [[DD-]HH:]MM:SS.
//
// Parameters:
// - pid: The process ID as a string (e.g., "12345"). Must be a valid running process ID.
//
// Returns:
// - string: The process uptime in format:
// - "MM:SS" for processes running <1 hour
// - "HH:MM:SS" for processes running <1 day
// - "DD-HH:MM:SS" for processes running multiple days
// - uptimeZeroValue ("00:00:00") if the process is not found
// - error: Returns:
// - Original error if 'ps' command execution fails
// - nil if successful (even if process not found)
//
// Behavior:
// - Uses 'ps -o etime= -p PID' command to get process duration
// - Automatically trims whitespace and newlines from output
// - Returns zero value (not error) if process doesn't exist
// - Output format matches system 'ps' command behavior
//
// Example:
//
// uptime, err := GetUptimeByPID("12345")
// if err != nil {
// log.Printf("Failed to check uptime: %v", err)
// }
// fmt.Printf("Process running for: %s", uptime) // e.g. "01:23:45"
//
// Notes:
// - Requires 'ps' command to be available in system PATH
// - Unlike other functions, returns zero value rather than error for missing process
// - For empty/zero uptime, check against uptimeZeroValue constant
// - Uptime resolution is seconds (no milliseconds).
func GetUptimeByPID(pid string) (string, error) {
uptime, err := Execute("ps", "-o", "etime=", "-p", pid)
if err != nil {
return uptimeZeroValue, err
}
return strings.Trim(uptime, " \n"), nil
}
// CPUPercentByPID retrieves the CPU usage percentage for a specific process.
// The percentage represents the process's total CPU utilization since its start.
//
// Parameters:
// - pid: The process ID as a string (e.g., "1234"). Must be a valid running process.
//
// Returns:
// - string: CPU usage percentage with "%" suffix (e.g., "25.5%")
// Returns zeroValue + "%" ("0.0%") if:
// - Process is not found
// - Process is using 0% CPU
// - Command fails
// - error: Error from command execution if ps command fails,
// nil if successful (even if process shows 0% usage)
//
// Behavior:
// - Uses 'ps S -p PID -o pcpu=' command to get CPU percentage
// - The 'S' option includes child processes in calculation
// - Automatically trims whitespace and appends "%" symbol
// - Returns string formatted to one decimal place
//
// Example:
//
// cpu, err := CPUPercentByPID("1234")
// if err != nil {
// log.Printf("CPU check failed: %v", err)
// }
// fmt.Printf("CPU Usage: %s", cpu) // e.g. "75.3%"
//
// Notes:
// - CPU percentage is relative to a single core (may exceed 100% on multicore systems)
// - Requires 'ps' command to be available in system PATH
// - For containerized processes, results may differ from host metrics
// - Values are snapshots, not averages over time
// - Consider using multiple samples for monitoring trending usage.
func CPUPercentByPID(pid string) (string, error) {
cpu, err := Execute("ps", "S", "-p", pid, "-o", "pcpu=")
if err != nil {
return zeroValue + "%", err
}
return strings.Trim(cpu, " \n") + "%", nil
}
// MemPercentByPID retrieves the memory usage percentage for a specific process.
// The percentage represents the process's resident memory relative to total system memory.
//
// Parameters:
// - pid: The process ID as a string (e.g., "5678"). Must be a valid running process.
//
// Returns:
// - string: Memory usage percentage with "%" suffix (e.g., "4.2%")
// Returns zeroValue + "%" ("0.0%") if:
// - Process is not found
// - Process uses 0% memory
// - Command execution fails
// - error: Error from command execution if ps command fails,
// nil if successful (even if process shows 0% usage)
//
// Behavior:
// - Uses 'ps S -p PID -o pmem=' command to get memory percentage
// - The 'S' option includes child processes in calculation
// - Automatically trims whitespace and appends "%" symbol
// - Returns string formatted to one decimal place
//
// Example:
//
// mem, err := MemPercentByPID("5678")
// if err != nil {
// log.Printf("Memory check failed: %v", err)
// }
// fmt.Printf("Memory Usage: %s", mem) // e.g. "2.8%"
//
// Notes:
// - Percentage is relative to total physical memory (RAM)
// - Does not include shared memory or swap usage
// - Requires 'ps' command to be available in system PATH
// - Values represent current snapshot, not averages over time.
func MemPercentByPID(pid string) (string, error) {
mem, err := Execute("ps", "S", "-p", pid, "-o", "pmem=")
if err != nil {
return zeroValue + "%", err
}
return strings.Trim(mem, " \n") + "%", nil
}
// MemUsedByPID calculates the total resident memory usage of a process and its children in megabytes.
// It sums the RSS (Resident Set Size) memory of all process threads and converts to MB.
//
// Parameters:
// - pid: The process ID as a string (e.g., "1234"). Must be a valid running process.
//
// Returns:
// - string: Memory usage formatted with " MB" suffix (e.g., "24.5 MB")
// Returns zeroValue + " MB" ("0.0 MB") if:
// - Process is not found
// - Process uses no resident memory
// - Command execution fails
// - error: Error from command execution if the bash command fails,
// nil if successful (even if memory usage is 0)
//
// Implementation Details:
// - Uses bash command pipeline:
// 1. `ps -ylp PID` lists all threads with memory info
// 2. `awk` sums the RSS (column 8) and converts to MB (/1024)
// - Automatically trims whitespace/newlines from output
// - Adds " MB" suffix to clarify units
//
// Example:
//
// memUsage, err := MemUsedByPID("1234")
// if err != nil {
// log.Printf("Memory check failed: %v", err)
// }
// fmt.Printf("Memory used: %s", memUsage) // e.g. "45.2 MB"
//
// Notes:
// - Measures physical RAM usage (RSS), not virtual memory
// - Includes memory used by all process threads
// - Values are in binary megabytes (MiB, 1024-based)
// - Requires GNU ps and awk utilities.
func MemUsedByPID(pid string) (string, error) {
args := []string{
"-c", "ps -ylp " + pid + " | awk '{x += $8} END {print \"\" x/1024;}'",
}
mem, err := Execute(commandBash, args...)
if err != nil {
return zeroValue + " MB", err
}
return strings.Trim(mem, " \n") + " MB", nil
}
// MemUsed retrieves the total used system memory in megabytes (MB).
// It calculates the actively used memory excluding buffers/cache.
//
// Returns:
// - string: Total used memory formatted with " MB" suffix (e.g., "2048 MB")
// Returns zeroValue + " MB" ("0.0 MB") if:
// - Command execution fails
// - Unable to parse memory information
// - error: Error from command execution if the bash command fails,
// nil if successful
//
// Implementation Details:
// - Uses bash command pipeline:
// 1. `free` command to get memory statistics
// 2. `awk` extracts the used memory value (column 3 from second line)
// 3. Converts from kilobytes to megabytes (/1024)
// 4. Formats as integer (%.0f) to remove decimal places
// - Automatically trims whitespace/newlines from output
// - Adds " MB" suffix to clarify units
//
// Example:
//
// usedMem, err := MemUsed()
// if err != nil {
// log.Printf("Failed to get system memory: %v", err)
// }
// fmt.Printf("System memory used: %s", usedMem) // e.g. "3752 MB"
//
// Notes:
// - Measures actual used memory excluding buffers/cache
// - Values are in binary megabytes (MiB, 1024-based)
// - Requires GNU free and awk utilities
// - Represents system-wide memory usage, not per-process
// - Consider using /proc/meminfo for more detailed breakdown.
func MemUsed() (string, error) {
args := []string{
"-c", "free | awk 'NR==2 { printf(\"%.0f\", $3/1024); }'",
}
mem, err := Execute(commandBash, args...)
if err != nil {
return zeroValue + " MB", err
}
return strings.Trim(mem, " \n") + " MB", nil
}
// MemAvail retrieves the total available system memory in megabytes (MB).
// This represents the physical RAM available for new processes, excluding buffers/cache.
//
// Returns:
// - string: Total available memory formatted with " MB" suffix (e.g., "8192 MB")
// Returns zeroValue + " MB" ("0.0 MB") if:
// - Command execution fails
// - Unable to parse memory information
// - error: Error from command execution if the bash command fails,
// nil if successful
//
// Implementation Details:
// - Uses bash command pipeline:
// 1. `free` command to get memory statistics
// 2. `awk` extracts the total memory value (column 2 from second line)
// 3. Converts from kilobytes to megabytes (/1024)
// 4. Formats as integer (%.0f) for clean output
// - Automatically trims whitespace/newlines from output
// - Adds " MB" suffix to clarify units
//
// Example:
//
// availMem, err := MemAvail()
// if err != nil {
// log.Printf("Failed to get available memory: %v", err)
// }
// fmt.Printf("Available system memory: %s", availMem) // e.g. "16384 MB"
//
// Notes:
// - Measures physical RAM, not including swap space
// - Values are in binary megabytes (MiB, 1024-based)
// - Requires GNU free and awk utilities
// - Represents system-wide available memory
// - For accurate container memory limits, check cgroup settings.
func MemAvail() (string, error) {
args := []string{
"-c", "free | awk 'NR==2 { printf(\"%.0f\", $2/1024); }'",
}
mem, err := Execute(commandBash, args...)
if err != nil {
return zeroValue + " MB", err
}
return strings.Trim(mem, " \n") + " MB", nil
}
package extract
// String safely dereferences a string pointer, returning the string value or empty string if nil.
// This provides a nil-safe way to access string pointers and ensures you always get a valid string:
// - If pointer is non-nil: returns the underlying string value
// - If pointer is nil: returns an empty string ("")
//
// Useful when working with optional string fields in structs or API responses.
func String(pointer *string) string {
if pointer != nil {
return *pointer
}
return ""
}
// IsEmptyString checks if a string pointer is nil or points to an empty string.
// Returns true if either:
// - The pointer is nil
// - The dereferenced string is empty ("")
//
// Useful for safely checking optional string fields that may be nil.
func IsEmptyString(pointer *string) bool {
return pointer == nil || *pointer == ""
}
// Int64 safely dereferences an int64 pointer, returning the value or 0 if nil.
//
// This helper function provides nil-safety when working with optional numeric fields:
// - If pointer is non-nil: returns the underlying int64 value
// - If pointer is nil: returns 0 (zero value for int64)
//
// Useful when working with optional int64 fields in structs or API responses.
func Int64(pointer *int64) int64 {
if pointer != nil {
return *pointer
}
return 0
}
package files
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"syscall"
"time"
)
// OwnerWritePerm provides 0755 permission.
const OwnerWritePerm = os.FileMode(0o755)
// FileExists checks if a file exists and is not a directory before we
// try using it to prevent further errors.
func FileExists(filename string) bool {
info, err := os.Stat(filename)
if os.IsNotExist(err) {
return false
}
return !info.IsDir()
}
// FileCopy copies src file to destination path.
func FileCopy(src string, destination string, perms ...os.FileMode) error {
perm := os.ModePerm
if len(perms) != 0 {
perm = perms[0]
}
input, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(destination, input, perm)
}
// ReadStringFile reads file as string.
func ReadStringFile(path string, name string) (string, error) {
b, err := os.ReadFile(path + "/" + name)
if err != nil {
return "", err
}
return string(b), nil
}
// ReadBinFile reads file as slice of bytes.
func ReadBinFile(path string, name string) ([]byte, error) {
b, err := os.ReadFile(path + "/" + name)
if err != nil {
return []byte{}, err
}
return b, nil
}
// WriteFileString writes string content to text file.
func WriteFileString(path string, name string, value string) error {
fo, err := os.Create(path + "/" + name)
if err != nil {
return err
}
if _, err := fo.WriteString(value); err != nil {
return err
}
return nil
}
// CreateAndOpenFile creates file and open it foe recording.
func CreateAndOpenFile(path string, fileName string, perms ...os.FileMode) (io.Writer, error) {
perm := OwnerWritePerm
if len(perms) != 0 {
perm = perms[0]
}
filePath := path
if filePath != "" {
// Not dependent on the transmitted "/".
filePath = strings.TrimRight(filePath, "/") + "/"
if err := MkdirAll(path); err != nil {
return nil, err
}
}
filePath += fileName
f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, perm)
if err != nil {
return nil, err
}
return f, nil
}
// StatTimes gets file stats info.
func StatTimes(name string) (atime, mtime, ctime time.Time, err error) {
info, err := os.Stat(name)
if err != nil {
return
}
mtime = info.ModTime()
stat := info.Sys().(*syscall.Stat_t) //nolint
atime = time.Unix(stat.Atim.Sec, stat.Atim.Nsec)
ctime = time.Unix(stat.Ctim.Sec, stat.Ctim.Nsec)
return
}
// MkdirAll creates a directory named path,
// along with any necessary parents, and returns nil,
// or else returns an error.
// The permission bits perm (before umask) are used for all
// directories that MkdirAll creates.
func MkdirAll(path string, perm ...os.FileMode) error {
_, err := os.Stat(path)
if os.IsNotExist(err) {
p := os.ModePerm
if len(perm) != 0 {
p = perm[0]
}
return os.MkdirAll(path, p)
}
return err
}
// GetDirNamesInFolder returns slice with directory names in path.
func GetDirNamesInFolder(path string) ([]string, error) {
items, err := os.ReadDir(path)
if err != nil {
return make([]string, 0), fmt.Errorf("scan dirrectory: %w", err)
}
names := make([]string, 0, len(items))
for _, item := range items {
if item.IsDir() {
names = append(names, item.Name())
}
}
return names, nil
}
// GetFileNamesInFolder returns slice with file names in path.
func GetFileNamesInFolder(path string) ([]string, error) {
items, err := os.ReadDir(path)
if err != nil {
return make([]string, 0), fmt.Errorf("scan dirrectory: %w", err)
}
names := make([]string, 0, len(items))
for _, item := range items {
if !item.IsDir() {
names = append(names, item.Name())
}
}
return names, nil
}
// GetAbsPath returns an absolute path based on the input path and a default path.
// It handles three cases for the input path:
// 1. If the path is already absolute (starts with "/"), home-relative (starts with "~/"),
// or relative to current directory (starts with "./"), it returns the path as-is.
// 2. If the path is empty, it uses the defaultPath instead.
// 3. For all other cases, it treats the path as relative to the executable's directory.
//
// Parameters:
// - path: The input path to process (can be empty, absolute, or relative)
// - defaultPath: The default path to use if input path is empty
//
// Returns:
// - string: The resulting absolute path
// - error: Any error that occurred while getting the executable's directory
//
// Example usage:
//
// absPath, err := GetAbsPath("config.json", "/etc/default/config.json")
// // Returns "/path/to/executable/config.json" if no error
func GetAbsPath(path, defaultPath string) (string, error) {
// Use default path if input path is empty
if path == "" {
path = defaultPath
}
// Check if path is already in absolute, home-relative, or current-directory-relative form
if path != "" && (string(path[0]) == "/" || strings.HasPrefix(path, "~/") || strings.HasPrefix(path, "./")) {
return path, nil
}
// Get the absolute path of the directory containing the executable
home, err := filepath.Abs(filepath.Dir(os.Args[0]))
if err != nil {
return "", err
}
// Combine executable directory with the relative path
path = home + "/" + path
return path, nil
}
// ClearDir removes all contents of the specified directory while preserving
// the directory itself. It traverses the directory recursively, deleting all
// files, subdirectories, and symbolic links.
//
// Parameters:
// - dir - string path to the directory to be cleared
//
// Returns:
// - error - nil on success, or any error encountered during the operation
//
// Behavior details:
// - Preserves the original directory (only removes its contents)
// - Handles nested directory structures recursively
// - Follows symbolic links when deleting (removes link targets)
// - Stops and returns on the first error encountered
// - Returns nil if directory doesn't exist (consistent with os.RemoveAll)
//
// Example usage:
//
// err := ClearDir("/tmp/workdir")
// if err != nil {
// log.Fatal("Failed to clear directory:", err)
// }
//
// Warning:
// - This is a destructive operation - deleted files cannot be recovered
// - The function will remove ALL contents without confirmation
// - Ensure proper permissions exist for all files/subdirectories.
func ClearDir(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, entry := range entries {
if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil {
return err
}
}
return nil
}
// Package httpclient provides a configurable HTTP client with sensible defaults
// and helper methods for making HTTP requests.
package httpclient
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
)
var (
// ErrWrongStatusCode is returned when the server responds with an unexpected HTTP status code.
ErrWrongStatusCode = errors.New("wrong status code")
// ErrEmptyResponse is returned when the server response is nil and error is nil.
ErrEmptyResponse = errors.New("empty response")
)
// Client wraps http.Client to provide additional functionality and configuration.
// It embeds the standard http.Client to expose all its methods while adding custom behavior.
type Client struct {
http.Client
}
// New creates and returns a new Client instance configured with the given settings.
// The configuration includes timeouts, transport settings, and dialer parameters.
func New(cfg *Config) *Client {
cfg.SetDefaults()
return &Client{
http.Client{
Timeout: cfg.Timeout,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: cfg.Dialer.Timeout,
Deadline: cfg.Dialer.Deadline,
FallbackDelay: cfg.Dialer.FallbackDelay,
KeepAlive: cfg.Dialer.KeepAlive,
}).DialContext,
TLSHandshakeTimeout: cfg.TLSHandshakeTimeout,
},
},
}
}
// SendRequest executes an HTTP request with the given method, URI, and optional body.
// It handles the full request lifecycle including context cancellation, error handling,
// and response processing.
//
// Parameters:
// - ctx: Context for cancellation and timeout control
// - method: HTTP method (GET, POST, etc.)
// - uri: Target URL for the request
// - body: Request body content (can be nil)
//
// Returns:
// - Response body as byte slice if successful
// - Error if request fails, response status is not 200 OK, or body read fails.
func (c *Client) SendRequest(ctx context.Context, method, uri string, body io.Reader) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, method, uri, body)
if err != nil {
return nil, err
}
res, err := c.Do(req)
if err != nil {
return nil, err
}
if res == nil {
return nil, ErrEmptyResponse
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: %d %s", ErrWrongStatusCode, res.StatusCode, res.Status)
}
resBody, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
return resBody, nil
}
package httpclient
import "time"
const (
// DefaultTimeout is the default timeout for general network operations.
DefaultTimeout = 10 * time.Second
// DefaultTLSHandshakeTimeout is the default timeout for TLS handshake operations.
DefaultTLSHandshakeTimeout = 5 * time.Second
// DefaultDialerTimeout is the default timeout for establishing new connections.
DefaultDialerTimeout = 5 * time.Second
)
// Config represents configuration settings for network connections.
// It includes timeouts and dialer-specific parameters that can be
// unmarshalled from either JSON or YAML formats.
type Config struct {
// Timeout specifies the maximum duration for the entire connection
// process. Zero means no timeout.
Timeout time.Duration `json:"timeout" yaml:"timeout"`
// TLSHandshakeTimeout specifies the maximum duration to wait for
// a TLS handshake to complete. Zero means no timeout.
TLSHandshakeTimeout time.Duration `json:"tls_handshake_timeout" yaml:"tls_handshake_timeout"`
// Dialer contains configuration specific to the connection dialer.
Dialer struct {
// Timeout is the maximum duration for dialing a connection.
// This includes name resolution if required.
Timeout time.Duration `json:"timeout" yaml:"timeout"`
// Deadline specifies an absolute time point after which
// dial operations will fail.
// Zero means no deadline.
Deadline time.Time `json:"deadline" yaml:"deadline"`
// FallbackDelay specifies the length of time to wait before
// spawning a fallback connection, when dual-stack IPv4/IPv6
// is enabled.
FallbackDelay time.Duration `json:"fallback_delay" yaml:"fallback_delay"`
// KeepAlive specifies the keep-alive period for network
// connections. If zero, keep-alives are not enabled.
KeepAlive time.Duration `json:"keep_alive" yaml:"keep_alive"`
} `json:"dialer" yaml:"dialer"`
}
// SetDefaults initializes the configuration with default values for any unset fields.
// If Timeout, TLSHandshakeTimeout, or Dialer.Timeout are zero (unset), they will be
// populated with their respective default values.
//
// This ensures the configuration is always valid and prevents zero-values from causing
// unexpected behavior in network operations.
func (cfg *Config) SetDefaults() {
// Set default general operation timeout if not specified.
if cfg.Timeout == 0 {
cfg.Timeout = DefaultTimeout
}
// Set default TLS handshake timeout if not specified.
if cfg.TLSHandshakeTimeout == 0 {
cfg.TLSHandshakeTimeout = DefaultTLSHandshakeTimeout
}
// Set default dialer connection timeout if not specified.
if cfg.Dialer.Timeout == 0 {
cfg.Dialer.Timeout = DefaultDialerTimeout
}
}
package problemdetails
import (
"encoding/json"
"errors"
"github.com/labstack/echo/v4"
"github.com/outdead/golibs/httpserver/validator"
)
type Binder struct {
logger Logger
}
// NewBinder creates and returns new Binder instance.
func NewBinder(l Logger) *Binder {
return &Binder{
logger: l,
}
}
// Bind parses echo Context to data structure. Returns en error if data is invalid.
func (b *Binder) Bind(c echo.Context, req interface{}) error {
if err := c.Bind(req); err != nil {
var t *json.UnmarshalTypeError
if ok := errors.As(err, &t); ok {
return validator.NewValidationError(t.Field, err.Error())
}
// TODO: There is no way to get field names of int, time.Time, uuid.CartUUID if got incorrect data.
return validator.NewValidationError("", err.Error())
}
return nil
}
// BindAndValidate parses echo Context to data structure and validate received
// data. Returns en error if data is invalid.
func (b *Binder) BindAndValidate(c echo.Context, req interface{}) error {
if err := b.Bind(c, req); err != nil {
return err
}
return c.Validate(req)
}
package problemdetails
import (
"errors"
"fmt"
"net/http"
"github.com/labstack/echo/v4"
"github.com/outdead/golibs/httpserver/validator"
)
// Error types for RFC-7807 Problem Details format.
// See: https://datatracker.ietf.org/doc/html/rfc7807
const (
TypeValidation = "validation-error"
TypeUnauthorized = "unauthorized"
TypeForbidden = "forbidden"
TypeNotFound = "data-not-found"
TypeInternalServerError = "internal-server-error"
TypeHTTP = "http-error"
)
// Titles for RFC-7807 Problem Details format.
const (
TitleValidation = "Your request parameters didn't validate."
TitleUnauthorized = "Your request has not been applied."
TitleForbidden = "Your request has been forbidden."
TitleNotFound = "Not Found"
TitleInternalServerError = "Internal Server Error"
)
// Statuses for RFC-7807 Problem Details format.
const (
StatusValidation = http.StatusBadRequest // 400
StatusUnauthorized = http.StatusUnauthorized // 401
StatusForbidden = http.StatusForbidden // 403
StatusNotFound = http.StatusNotFound // 404
StatusBusinessError = http.StatusUnprocessableEntity // 422
StatusInternalServerError = http.StatusInternalServerError // 500
)
// Error contains error in RFC-7807 Problem Details format.
type Error struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
// InvalidParams contains the explanation of errors in RFC-7807 format.
InvalidParams validator.ValidationErrors `json:"invalid-params,omitempty"` //nolint
}
// Error represents an error condition, with the nil value representing no error.
func (e *Error) Error() string {
msg := fmt.Sprintf("%d %s: %s", e.Status, e.Type, e.Title)
if len(e.InvalidParams) != 0 {
msg += " - " + e.InvalidParams.Error()
}
return msg
}
func NewError(err error) *Error {
body := NewInternalServerError()
if ok := errors.As(err, &body); !ok {
var (
validationError *validator.ValidationError
validationErrors validator.ValidationErrors
httpError *echo.HTTPError
)
switch {
case errors.As(err, &validationError):
body = NewValidationError(*validationError)
if validationError.Name == "" {
body.Detail = validationError.Reason
}
case errors.As(err, &validationErrors):
body = NewValidationError(validationErrors...)
case errors.As(err, &httpError):
body = NewHTTPError(httpError)
case errors.Is(err, validator.ErrNotFound):
body = NewNotFoundError(err)
}
}
return body
}
func NewValidationError(err ...validator.ValidationError) *Error {
return &Error{
Type: TypeValidation,
Title: TitleValidation,
Status: StatusValidation,
Detail: "",
InvalidParams: err,
}
}
func NewUnauthorizedError(err ...validator.ValidationError) *Error {
return &Error{
Type: TypeUnauthorized,
Title: TitleUnauthorized,
Status: StatusUnauthorized,
Detail: "",
InvalidParams: err,
}
}
func NewForbiddenError(err ...validator.ValidationError) *Error {
return &Error{
Type: TypeForbidden,
Title: TitleForbidden,
Status: StatusForbidden,
Detail: "",
InvalidParams: err,
}
}
func NewNotFoundError(err error) *Error {
return &Error{
Type: TypeNotFound,
Title: TitleNotFound,
Status: StatusNotFound,
Detail: err.Error(),
}
}
func NewInternalServerError() *Error {
return &Error{
Type: TypeInternalServerError,
Title: TitleInternalServerError,
Status: StatusInternalServerError,
Detail: TitleInternalServerError,
}
}
func NewHTTPError(err *echo.HTTPError) *Error {
detail, _ := err.Message.(string)
return &Error{
Type: TypeHTTP,
Title: http.StatusText(err.Code),
Status: err.Code,
Detail: detail,
}
}
package problemdetails
import (
"fmt"
"net/http"
"github.com/labstack/echo/v4"
)
// Response represents a standardized API response structure
// It's designed to be serialized as JSON and returned from API endpoints.
type Response struct {
Result interface{} `json:"result"` // The main data payload of the response, can be any type
Count int `json:"count,omitempty"` // Optional field indicating the number of items in result, omitted if zero
}
// Fields is a generic key-value mapping structure
// Commonly used for dynamic data storage where field names are not known at compile time.
type Fields map[string]interface{} // Maps string keys to values of any type, useful for flexible data structures
// Responder wraps on echo Context to be used on echo HTTP handlers to
// construct an HTTP response.
type Responder struct {
logger Logger
}
// NewResponder creates and returns pointer to Responder.
func NewResponder(l Logger) *Responder {
return &Responder{l}
}
// ServeResult sends a JSON response with the result data.
func (r *Responder) ServeResult(c echo.Context, i interface{}) error {
return c.JSON(http.StatusOK, i)
}
// ServeError sends a JSON error response with status code.
func (r *Responder) ServeError(c echo.Context, err error, logPrefix ...string) error {
if err == nil {
return nil
}
body := NewError(err)
if body.Status == StatusInternalServerError {
if len(logPrefix) != 0 {
err = fmt.Errorf("%s: %w", logPrefix[0], err)
}
r.logger.Error(err.Error())
}
return c.JSON(body.Status, body)
}
package httpserver
import (
"net/url"
"strconv"
"strings"
"github.com/labstack/echo/v4"
)
// ReplacementFields contains the fields that need to be replaced in the query string.
type ReplacementFields struct {
// Fields that apply to the entire row.
Keys []string
// Fields related to the filter key `q`, which are then included in the SQL query.
Query []string
}
type Param struct {
Q map[string]string `json:"q"`
Sort string `json:"sort"`
Offset int `json:"offset"`
Limit int `json:"limit"`
Mutator map[string]string `json:"mutator"`
}
func NewParam() *Param {
return &Param{
Q: make(map[string]string),
Mutator: make(map[string]string),
}
}
// ParseQueryString parses the query string, does unescaping.
func (s *Server) ParseQueryString(c echo.Context, mapData *ReplacementFields) (*Param, error) { //nolint: cyclop, lll // nothing to simplify
rawQuery := c.QueryString()
if mapData != nil && mapData.Keys != nil {
r := strings.NewReplacer(mapData.Keys...)
rawQuery = r.Replace(rawQuery)
}
param := NewParam()
for _, condition := range strings.Split(rawQuery, "&") {
couple := strings.Split(condition, "=")
if len(couple) <= 1 {
continue
}
key := couple[0]
value := couple[1]
var err error
switch key {
case "q":
param.Q, err = parseQuery(value, mapData)
case "m":
param.Mutator, err = parseMutators(value)
case "sort":
param.Sort, err = url.QueryUnescape(value)
case "offset":
param.Offset, err = parseInt(value)
case "limit":
param.Limit, err = parseInt(value)
}
if err != nil {
return nil, err
}
}
return param, nil
}
func parseQuery(value string, mapData *ReplacementFields) (map[string]string, error) {
result := make(map[string]string)
if mapData != nil && mapData.Query != nil {
r := strings.NewReplacer(mapData.Query...)
value = r.Replace(value)
}
queryString, err := url.QueryUnescape(value)
if err != nil {
return result, err
}
qsplit := strings.Split(queryString, ",")
for _, qq := range qsplit {
qpar := strings.Split(qq, ":")
if len(qpar) == 2 { //nolint:mnd // Not a magic, need to validate couples
result[qpar[0]] = qpar[1]
}
}
return result, nil
}
func parseMutators(value string) (map[string]string, error) {
result := make(map[string]string)
muts, err := url.QueryUnescape(value)
if err != nil {
return result, err
}
mutators := strings.Split(muts, ",")
for _, m := range mutators {
if m == "" {
continue
}
result[m] = m
}
return result, nil
}
func parseInt(value string) (int, error) {
offset, err2 := url.QueryUnescape(value)
if err2 != nil {
return 0, err2
}
return strconv.Atoi(offset)
}
package httpserver
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/outdead/golibs/httpserver/problemdetails"
"github.com/outdead/golibs/httpserver/validator"
)
// ShutdownTimeOut is time to terminate queries when quit signal given.
const ShutdownTimeOut = 10 * time.Second
// ErrLockedServer returned on repeated call Close() the HTTP server.
var ErrLockedServer = errors.New("http server is locked")
// Logger describes Error and Info functions.
type Logger interface {
Infof(format string, args ...interface{})
Fatalf(format string, args ...interface{})
Debug(args ...interface{})
Error(args ...interface{})
Writer() io.Writer
}
// Binder represents Bind and BindAndValidate functions.
type Binder interface {
Bind(c echo.Context, req interface{}) error
BindAndValidate(c echo.Context, req interface{}) error
}
// Responder represents ServeResult and ServeError functions.
type Responder interface {
ServeResult(c echo.Context, i interface{}) error
ServeError(c echo.Context, err error, logPrefix ...string) error
}
// Option infects params to Server.
type Option func(server *Server)
// WithBinder injects Binder to Server.
func WithBinder(binder Binder) Option {
return func(s *Server) {
s.Binder = binder
}
}
// WithResponder injects Responder to Server.
func WithResponder(responser Responder) Option {
return func(s *Server) {
s.Responder = responser
}
}
func WithRecover(rec bool) Option {
return func(s *Server) {
s.recover = rec
}
}
// A Server defines parameters for running an HTTP server.
type Server struct {
Binder
Responder
Echo *echo.Echo
logger Logger
errors chan error
recover bool
quit chan bool
wg sync.WaitGroup
}
// NewServer allocates and returns a new Server.
func NewServer(log Logger, errs chan error, options ...Option) *Server {
s := Server{
logger: log,
errors: errs,
quit: make(chan bool),
}
for _, option := range options {
option(&s)
}
if s.Binder == nil {
s.Binder = problemdetails.NewBinder(s.logger)
}
if s.Responder == nil {
s.Responder = problemdetails.NewResponder(s.logger)
}
s.Echo = s.newEcho()
return &s
}
// Serve initializes HTTP Server and runs it on received port.
func (s *Server) Serve(port string) {
go func() {
if err := s.Echo.Start(":" + port); err != nil && !errors.Is(err, http.ErrServerClosed) {
// Report error if server is not closed by Echo#Shutdown.
s.ReportError(fmt.Errorf("start http server: %w", err))
}
}()
s.logger.Infof("http server started on port %s", port)
s.quit = make(chan bool)
s.wg.Add(1)
go func() {
defer s.wg.Done()
<-s.quit
s.logger.Debug("stopping http server...")
ctx, cancel := context.WithTimeout(context.Background(), ShutdownTimeOut)
defer cancel()
if s.Echo != nil {
if err := s.Echo.Shutdown(ctx); err != nil {
s.ReportError(fmt.Errorf("shutdown http server: %w", err))
}
}
}()
}
// ReportError publishes error to the errors channel.
// if you do not read errors from the errors channel then after the channel
// buffer overflows the application exits with a fatal level and the
// os.Exit(1) exit code.
func (s *Server) ReportError(err error) {
if err != nil {
select {
case s.errors <- err:
default:
s.logger.Fatalf("http server error channel is locked: %s", err)
}
}
}
// Close stops HTTP Server.
func (s *Server) Close() error {
if s.quit == nil {
return ErrLockedServer
}
select {
case s.quit <- true:
s.wg.Wait()
s.logger.Debug("stop http server success")
return nil
default:
return ErrLockedServer
}
}
func (s *Server) newEcho() *echo.Echo {
e := echo.New()
e.Use(middleware.CORS())
if s.recover {
e.Use(middleware.Recover())
}
e.Validator = validator.New()
e.Logger.SetOutput(s.logger.Writer())
e.HideBanner = true
e.HidePort = true
e.HTTPErrorHandler = s.httpErrorHandler
return e
}
// httpErrorHandler customizes error response.
// @source: https://github.com/labstack/echo/issues/325
func (s *Server) httpErrorHandler(err error, c echo.Context) {
if err = s.ServeError(c, err); err != nil {
s.ReportError(fmt.Errorf("error handle: %w", err))
}
}
package validator
import "errors"
var (
// ErrNotFound is returned when data not found by identifier.
ErrNotFound = errors.New("not found")
// ErrDuplicateKey is returned when got duplicate key error from database.
ErrDuplicateKey = errors.New("duplicate key")
// ErrRequiredFieldMissed is returned when required param is empty in the request.
ErrRequiredFieldMissed = errors.New("is required")
)
// Delimiters for text representation of errors.
const (
ErrorSeparator = "; "
FieldSeparator = " : "
)
// ValidationError contains field name and error message.
type ValidationError struct {
Name string `json:"name"`
Reason string `json:"reason"`
}
// NewValidationError creates and returns pointer to ValidationError.
func NewValidationError(field, msg string) *ValidationError {
return &ValidationError{Name: field, Reason: msg}
}
// Error represents an error condition, with the nil value representing no error.
func (ve *ValidationError) Error() string {
return ve.Name + FieldSeparator + ve.Reason
}
// ValidationErrors is an array of ValidationError's for use in custom error
// messages post validation.
type ValidationErrors []ValidationError
// Error represents an error condition, with the nil value representing no error.
func (ves ValidationErrors) Error() string {
var message string
for i, ve := range ves {
message += ve.Error()
if i+1 != len(ves) {
message += ErrorSeparator
}
}
return message
}
package validator
import (
"errors"
"fmt"
"reflect"
"strings"
"github.com/go-playground/validator/v10"
)
// Validator is a wrapper for external validation package. Allows extending validation rules.
type Validator struct {
v *validator.Validate
}
// New returns a new instance of Validator with sane defaults.
func New() *Validator {
const couple = 2
validat := validator.New()
validat.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", couple)[0]
if name == "-" {
return ""
}
return name
})
return &Validator{
v: validat,
}
}
// Validate implement Validator interface.
func (v *Validator) Validate(i interface{}) error {
err := v.v.Struct(i)
if err == nil {
return nil
}
var vErrs validator.ValidationErrors
if !errors.As(err, &vErrs) {
return err
}
fields := make(ValidationErrors, 0, len(vErrs))
for _, vErr := range vErrs {
msg := fmt.Sprintf("invalid on '%s' rule", vErr.Tag())
valErr := NewValidationError(vErr.Field(), msg)
fields = append(fields, *valErr)
}
return fields
}
// Package jobticker provides a managed ticker for periodic job execution.
// It wraps time.Ticker with start/stop controls and error handling.
package jobticker
import (
"sync"
"time"
)
// Logger describes the minimal logging interface required by the Ticker.
// Implementations should provide Debug and Error logging capabilities.
type Logger interface {
Debug(args ...interface{})
Error(args ...interface{})
}
// HandlerFunc defines the signature for functions that will be executed
// on each tick interval. Returning an error will log the failure.
type HandlerFunc func() error
// Ticker manages a recurring background job with start/stop capabilities.
// It ensures safe concurrent operation and proper resource cleanup.
type Ticker struct {
name string
interval time.Duration
handler HandlerFunc
logger Logger
metrics Metrics
wg sync.WaitGroup
stopTimeout time.Duration
mu sync.Mutex
quit chan bool
started bool
}
// New creates a configured but unstarted Ticker instance.
// Useful when you need to delay starting the ticker.
//
// Parameters:
//
// name - identifier for logging.
// handler - function to execute on each interval.
// interval - time between executions.
// l - logger implementation.
//
// Returns:
//
// *Ticker - started ticker instance (call Close when done).
func New(name string, handler HandlerFunc, interval time.Duration, l Logger, options ...Option) *Ticker {
ticker := &Ticker{
name: name,
interval: interval,
handler: handler,
logger: l,
}
for _, option := range options {
option(ticker)
}
return ticker
}
// Start creates and immediately starts a new Ticker instance.
// This is the preferred entry point for most use cases.
//
// Parameters:
//
// name - identifier for logging.
// handler - function to execute on each interval.
// interval - time between executions.
// l - logger implementation.
//
// Returns:
//
// *Ticker - started ticker instance (call Close when done).
func Start(name string, handler HandlerFunc, interval time.Duration, l Logger, options ...Option) *Ticker {
ti := New(name, handler, interval, l, options...)
ti.Start()
return ti
}
// Start begins the ticker's execution loop in a new goroutine.
// Safe to call multiple times (will log and ignore subsequent calls).
func (t *Ticker) Start() {
t.mu.Lock()
defer t.mu.Unlock()
if t.started {
t.logger.Debug(t.name + ": already been started")
return
}
t.quit = make(chan bool, 1)
t.started = true
t.wg.Add(1)
go t.run()
}
// Stop initiates a graceful shutdown of the ticker. It:
// 1. Sends a quit signal to the running goroutine
// 2. Waits for the current handler to complete
// 3. Implements timeout protection for stuck handlers
//
// Safe to call multiple times - will return immediately if:
// - Ticker isn't running (!started)
// - Quit channel is already full (shutdown in progress)
//
// Logs debug messages for all edge cases (already stopped, etc.).
func (t *Ticker) Stop() {
t.mu.Lock()
defer t.mu.Unlock()
if t.quit == nil || !t.started {
t.logger.Debug(t.name + ": is not running")
return
}
select {
case t.quit <- true:
if t.stopTimeout == 0 {
t.wg.Wait() // waiting for goroutines
return
}
done := make(chan struct{})
go func() {
t.wg.Wait() // waiting for goroutines
close(done)
}()
select {
case <-done:
case <-time.After(t.stopTimeout):
t.logger.Error(t.name + ": forced shutdown due to timeout")
}
default:
t.logger.Debug(t.name + ": close already been called")
}
}
// IsRunning safely checks the ticker's current state.
// Returns:
//
// true - if ticker is actively running
// false - if stopped or never started
//
// Thread-safe atomic read - safe to call from any goroutine.
func (t *Ticker) IsRunning() bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.started
}
// run is the main ticker event loop running in a goroutine.
// Handles:
// - Interval tick execution
// - Graceful shutdown signals
// - Resource cleanup on exit
//
// Note: Started by Start(), stopped by Stop().
// Uses defer for guaranteed state cleanup.
func (t *Ticker) run() {
defer func() {
t.started = false
t.wg.Done()
}()
ticker := time.NewTicker(t.interval)
defer ticker.Stop()
for {
select {
case now := <-ticker.C:
t.executeHandler(now)
case <-t.quit:
t.logger.Debug(t.name + ": quit...")
return
}
}
}
// executeHandler safely runs the user-provided handler function.
// Provides:
// - Panic recovery
// - Error logging
// - Performance metrics collection
//
// Parameters:
//
// startedAt - timestamp when handler execution began.
func (t *Ticker) executeHandler(startedAt time.Time) {
defer func() {
if r := recover(); r != nil {
t.logger.Error(t.name+": handler panic:", r)
}
}()
err := t.handler()
if err != nil {
t.logger.Error(t.name+":", err)
}
if t.metrics != nil {
t.metrics.Observe(t.name, startedAt, time.Since(startedAt), err)
}
}
package jobticker
import "time"
type Option func(t *Ticker)
func WithStopTimeout(timeout time.Duration) Option {
return func(t *Ticker) {
t.stopTimeout = timeout
}
}
func WithMetrics(metrics Metrics) Option {
return func(t *Ticker) {
t.metrics = metrics
}
}
package logger
import (
"fmt"
"io"
"time"
"github.com/bwmarrin/discordgo"
"github.com/outdead/discordbotrus"
"github.com/outdead/golibs/files"
"github.com/sirupsen/logrus"
)
// Hook includes logrus.Hook interface and describes Close method.
type Hook interface {
logrus.Hook
Close() error
}
// Logger wraps logrus.Logger with additional configuration and methods.
type Logger struct {
*logrus.Logger
config Config
discordHook Hook
discordSession *discordgo.Session
}
// New creates and returns a new Logger instance with default JSON formatter.
// The returned logger has no output set by default (uses stderr).
func New() *Logger {
logger := &Logger{
Logger: logrus.New(),
}
logger.Formatter = new(logrus.JSONFormatter)
return logger
}
// AddOutput adds additional output writer to the logger.
// This allows writing logs to multiple destinations simultaneously.
// The new writer will be used in addition to any existing outputs.
func (log *Logger) AddOutput(w io.Writer) {
log.Out = io.MultiWriter(log.Out, w)
}
// SetConfig applies a new configuration to the Logger and configures all required outputs.
// It handles the following configuration aspects:
// - Core logger settings (log level)
// - File output configuration (if specified)
// - Discord hook setup (if configured)
//
// Parameters:
// - cfg: Pointer to Config struct containing all logger settings. Must not be nil.
// - options: Optional variadic list of Option functions to modify logger behavior.
//
// Returns:
// - error: Returns ErrInvalidConfig if cfg is nil or contains invalid settings.
// Returns file creation errors if file logging is configured.
// Returns Discord hook initialization errors if Discord logging is configured.
//
// Usage:
//
// err := logger.SetConfig(&Config{
// Level: "info",
// File: FileConfig{Path: "/var/log", Layout: "2006-01-02.log"},
// })
// if err != nil {
// // handle error
// }
//
// Notes:
// - This function is not concurrent-safe and should not be called while the logger is in use.
// - Replaces all previous configuration when called.
// - File outputs are created immediately if specified in config.
// - Discord hooks are initialized immediately if configured.
func (log *Logger) SetConfig(cfg *Config, options ...Option) error {
if cfg == nil {
return ErrInvalidConfig
}
log.config = *cfg
for _, option := range options {
option(log)
}
if cfg.Level != "" {
logrusLevel, err := logrus.ParseLevel(cfg.Level)
if err != nil {
return fmt.Errorf("%w: %w", ErrInvalidConfig, err)
}
log.Level = logrusLevel
}
if log.config.File.Layout != "" {
file, err := files.CreateAndOpenFile(log.config.File.Path, time.Now().Format(log.config.File.Layout))
if err != nil {
return fmt.Errorf("create logger file hook: %w", err)
}
log.AddOutput(file)
}
if cfg.Discord.ChannelID != "" {
var err error
if cfg.Discord.Token != "" {
log.discordHook, err = discordbotrus.New(&cfg.Discord)
} else {
log.discordHook, err = discordbotrus.New(&cfg.Discord, discordbotrus.WithSession(log.discordSession))
}
if err != nil {
return fmt.Errorf("create logrus discord hook error: %w", err)
}
log.AddHook(log.discordHook)
}
return nil
}
// Writer returns the current writer used by the logger.
// This can be used to redirect the logger's output or integrate with other systems.
func (log *Logger) Writer() io.Writer {
return log.Logger.Writer()
}
// Close implements the io.Closer interface for the Logger.
// Currently, it doesn't perform any cleanup but is provided for future compatibility.
func (log *Logger) Close() error {
if log.discordHook != nil {
return log.discordHook.Close()
}
return nil
}
package logger
import (
"github.com/bwmarrin/discordgo"
)
type Option func(log *Logger)
func WithDiscordSession(session *discordgo.Session) Option {
return func(log *Logger) {
log.discordSession = session
}
}
package random
import (
crand "crypto/rand"
"math/big"
mrand "math/rand"
)
func IntWeak(from, to int) int {
return from + mrand.Intn(to-from+1) //nolint:gosec // I'm ok with unsecure here.
}
func IntStrong(from, to int) int {
num, _ := crand.Int(crand.Reader, big.NewInt(int64(to-from+1)))
return from + int(num.Int64())
}
package times
import (
"fmt"
"time"
)
// FmtDuration formats a time.Duration into a human-readable string with optional seconds.
// The duration is rounded to the nearest minute (or second) before formatting.
//
// The function provides two formatting modes:
// - Default: "[-]XXhXXm" format (hours and minutes)
// - With seconds: "[-]XXhXXmXXs" format (when secs[0] is true)
//
// Parameters:
// - duration: The time duration to format (can be negative)
// - secs: Optional boolean flag to include seconds in output
//
// Returns:
// - Formatted duration string with leading zero padding and optional sign
func FmtDuration(duration time.Duration, withSeconds ...bool) string {
sign := ""
showSeconds := len(withSeconds) > 0 && withSeconds[0]
if showSeconds {
duration = duration.Round(time.Second)
} else {
duration = duration.Round(time.Minute)
}
if negative := duration < 0; negative {
duration = -duration
sign = "-"
}
hours := duration / time.Hour
duration -= hours * time.Hour //nolint:durationcheck // Intentional duration math - safe conversion
minutes := duration / time.Minute
if !showSeconds {
return fmt.Sprintf("%s%02dh%02dm", sign, hours, minutes)
}
duration -= minutes * time.Minute //nolint:durationcheck // Intentional duration math - safe conversion
seconds := duration / time.Second
return fmt.Sprintf("%s%02dh%02dm%02ds", sign, hours, minutes, seconds)
}
package times
import (
"errors"
"fmt"
"time"
)
var ErrInvalidTimeFormat = errors.New("invalid time format")
// ParseOnlyTime parses a time string into hours, minutes, and seconds components.
// It supports multiple time formats with flexible parsing:
// - "HH:MM:SS" (full time format)
// - "HH:MM" (hours and minutes, seconds default to 0)
// - "HH" (only hours, minutes and seconds default to 0)
//
// The function attempts each format in order and returns the first successful match.
// All returned time components are unsigned integers.
//
// Parameters:
//
// strtime - string containing the time to parse (e.g., "14:30:00", "09:45", "23")
//
// Returns:
//
// hour - parsed hour (0-23)
// minute - parsed minute (0-59)
// second - parsed second (0-59)
// err - error if parsing fails, wrapping ErrInvalidTimeFormat with the invalid input
//
// Example usage:
//
// h, m, s, err := ParseOnlyTime("08:30:15") // returns 8, 30, 15, nil
// h, m, s, err := ParseOnlyTime("12:45") // returns 12, 45, 0, nil
// h, m, s, err := ParseOnlyTime("invalid") // returns 0, 0, 0, error.
func ParseOnlyTime(strtime string) (hour, minute, second uint, err error) {
formats := []string{
"15:04:05", // Full format (HH:MM:SS)
"15:04", // Hours and minutes (HH:MM)
"15", // Only hours (HH)
}
for _, layout := range formats {
if t, err := time.Parse(layout, strtime); err == nil {
// #nosec G115 - Conversion is safe because time.Time methods
// always return valid ranges (Hour: 0-23, Minute/Second: 0-59)
return uint(t.Hour()), uint(t.Minute()), uint(t.Second()), nil
}
}
return 0, 0, 0, fmt.Errorf("%w: %q", ErrInvalidTimeFormat, strtime)
}
// ParseOnlyTimeSafe tries to parse time string, returns zero values (0,0,0) on failure.
// Silent version that never fails - ideal for non-critical time parsing.
func ParseOnlyTimeSafe(strtime string) (hour, minute, second uint) {
hour, minute, second, _ = ParseOnlyTime(strtime)
return
}
// Package times provides custom time handling utilities.
package times
import (
"errors"
"fmt"
"time"
)
// DateTimeLayout defines the standard format for date-time strings
// Uses Go's reference time format: Mon Jan 2 15:04:05 MST 2006.
const DateTimeLayout = "2006-01-02 15:04:05"
// ErrUndefinedDateTime is returned when an unsupported type is encountered.
var ErrUndefinedDateTime = errors.New("undefined datetime")
// SQLTime is a custom type that wraps time.Time to provide custom scanning behavior.
type SQLTime time.Time
// Scan implements the sql.Scanner interface for SQLTime
// It converts various input types into a SQLTime value.
func (t *SQLTime) Scan(v interface{}) error {
if v == nil {
return nil
}
switch value := v.(type) {
case time.Time:
// Direct assignment if input is time.Time
*t = SQLTime(value)
case string:
// Parse string using the defined layout
vt, err := time.Parse(DateTimeLayout, value)
if err != nil {
return err
}
*t = SQLTime(vt)
case []byte:
// Handle byte slices (common in database drivers)
vt, err := time.Parse(DateTimeLayout, string(value))
if err != nil {
return err
}
*t = SQLTime(vt)
default:
// Return error for unsupported types
return fmt.Errorf("%w: %v", ErrUndefinedDateTime, value)
}
return nil
}
// Package ttlcounter provides a thread-safe TTL counter.
// All methods are safe for concurrent use by multiple goroutines.
// Could be useful for scenarios like rate limiting, tracking recent
// activity, or any case where you need to count events but only care
// about recent ones.
package ttlcounter
import (
"sync"
"time"
)
const (
// DefaultTTL is the default time-to-live in seconds for counter items.
DefaultTTL = 1 * time.Second
// DefaultVacuumInterval is the default interval for automatic cleanup of expired items.
DefaultVacuumInterval = 1 * time.Second
)
// Item represents a counter item with a value and last access timestamp.
type Item struct {
value int // The current counter value
access int64 // Unix timestamp of last access
}
func (item *Item) Expired(ttl time.Duration) bool {
return item.access+ttl.Nanoseconds() <= time.Now().UnixNano()
}
// Counter is a TTL-based counter that automatically expires old entries.
type Counter struct {
mu sync.Mutex
items map[string]*Item
ttl time.Duration
stop chan struct{}
stopped sync.Once
}
// New creates a new Counter with the specified TTL (in seconds)
// and starts a background goroutine to periodically clean up expired items
// If ttl <= 0, DefaultTTL will be used.
func New(ttl time.Duration) *Counter {
if ttl <= 0 {
ttl = DefaultTTL
}
counter := &Counter{
items: make(map[string]*Item),
ttl: ttl,
stop: make(chan struct{}),
}
// Start a background goroutine to clean up expired items every second
go counter.vacuumLoop()
return counter
}
// Len returns the current number of items in the counter.
func (c *Counter) Len() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.items)
}
// Keys returns a slice of all existing keys in the counter
// The order of keys is not guaranteed.
func (c *Counter) Keys() []string {
c.mu.Lock()
defer c.mu.Unlock()
keys := make([]string, 0, len(c.items))
for key := range c.items {
keys = append(keys, key)
}
return keys
}
// Inc increments the counter for the specified key
// If the key doesn't exist, it creates a new counter starting at 1
// Updates the last access time to current time.
func (c *Counter) Inc(key string) {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
item, ok := c.items[key]
if !ok {
item = &Item{}
c.items[key] = item
}
item.value++
item.access = now.UnixNano()
}
// Get returns the current value for the specified key
// Returns 0 if the key doesn't exist
// Note: This doesn't update the last access time.
func (c *Counter) Get(key string) int {
c.mu.Lock()
defer c.mu.Unlock()
var value int
if item, ok := c.items[key]; ok {
if item.Expired(c.ttl) {
return 0
}
value = item.value
}
return value
}
// Touch returns the current value for the specified key and resets last access time.
// Returns 0 if the key doesn't exist.
func (c *Counter) Touch(key string) int {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
var value int
if item, ok := c.items[key]; ok {
if item.Expired(c.ttl) {
return 0
}
value = item.value
item.access = now.UnixNano()
}
return value
}
// Del removes the specified key from the counter.
// If the key doesn't exist, nothing happens.
func (c *Counter) Del(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.items, key)
}
// Expire returns how many seconds until the key expires
// Returns a negative number if the key is already expired
// Returns 0 if the key doesn't exist.
func (c *Counter) Expire(key string) time.Duration {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now().UnixNano()
if item, ok := c.items[key]; ok {
remaining := item.access + c.ttl.Nanoseconds() - now
if remaining > 0 {
return time.Duration(remaining)
}
return 0
}
return 0
}
// Vacuum cleans up expired items based on the provided current time
// Called automatically by the background goroutine.
func (c *Counter) Vacuum() {
c.mu.Lock()
defer c.mu.Unlock()
for key, item := range c.items {
if item.Expired(c.ttl) {
delete(c.items, key)
}
}
}
// TTL returns the configured time-to-live in seconds.
func (c *Counter) TTL() time.Duration {
return c.ttl
}
// SetTTL updates the time-to-live for counter items
// If ttl <= 0, DefaultTTL will be used.
func (c *Counter) SetTTL(ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
if ttl <= 0 {
ttl = DefaultTTL
}
c.ttl = ttl
}
// Close stops the background cleanup goroutine
// Safe to call multiple times.
func (c *Counter) Close() {
c.stopped.Do(func() {
close(c.stop)
})
}
// vacuumLoop runs in a goroutine and periodically calls Vacuum
// until the counter is closed.
func (c *Counter) vacuumLoop() {
ticker := time.NewTicker(DefaultVacuumInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.Vacuum()
case <-c.stop:
return
}
}
}