diff --git a/.gitignore b/.gitignore index c237551..7bcef76 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea -./config \ No newline at end of file +config +main* \ No newline at end of file diff --git a/README.md b/README.md index 1e1ba62..99650d3 100644 --- a/README.md +++ b/README.md @@ -27,36 +27,11 @@ Plugins of aw-sync-suite Agent are used to extend the functionality of the agent | Plugin | Description | Has Config | Config File | Documentation | |-----------|-----------------------------------|------------|--------------------------|---------------------------------------------------------------------| | `filters` | Filters the data of ActivityWatch | ✅ | `aw-plugin-filters.yaml` | [📄](https://github.com/phrp720/aw-sync-suite-plugins/wiki/Filters) | +| `scripts` | Runs third party Scripts | ✅ | `aw-plugin-scripts.yaml` | [📄](https://github.com/phrp720/aw-sync-suite-plugins/wiki/Scripts) | ## ⚙️ How It Works -When the `aw-sync-agent` starts, it initializes the chosen plugins specified in the configuration. The workflow is as follows: - -1. **Initialization**: - - Upon startup, the agent reads the `aw-sync-settings.yaml` file to identify which plugins are enabled. - - Each plugin is initialized according to its specific requirements, preparing it for data processing. - -2. **Data Synchronization**: - - At every sync cycle, the agent collects data from the local ActivityWatch instance. - - The collected data is then passed through the initialized plugins sequentially. - -3. **Data Processing**: - - Each plugin processes the data according to its defined functionality (e.g., filtering, transformation). - - After processing, the plugins return the final dataset that has been modified or filtered as per the plugin logic. - -4. **Data Push to Prometheus**: - - The final processed data is then securely pushed to the Prometheus database using the remote-write feature. - -This modular approach allows for flexible and customizable data processing, ensuring that only the desired data is sent to Prometheus while maintaining the integrity and confidentiality of sensitive information. - -#### flow-diagram: - -
- - ![flow](plugins-flow-diagram.png) - -
- +For documentation about the plugin workflow in `aw-sync-agent`, please refer [here](https://github.com/phrp720/aw-sync-suite-plugins/wiki/%E2%9A%99%EF%B8%8F-Plugin-Workflow). ## 🛠️ How to create a plugin diff --git a/config-examples/aw-plugin-filters.yaml b/config-examples/aw-plugin-filters.yaml new file mode 100644 index 0000000..7d2c785 --- /dev/null +++ b/config-examples/aw-plugin-filters.yaml @@ -0,0 +1,205 @@ +Filters: + - Filter: + filter-name: Email Category + enable: true + watchers: [] + target: + - key: app + value: Google.* + - key: title + value: Gmail|Thunderbird|mutt|alpine|Yahoo|Hotmail + category: Email + + - Filter: + filter-name: Programming Category + enable: true + watchers: [] + target: + - key: app + value: Google.* + - key: title + value: GitHub|Stack Overflow|BitBucket|Gitlab|vim|Spyderbeans|eclipse|visual studio|visual studio code|emacs|vim|nano|sublime|notepad + enable: true + watchers: [] + target: + - key: app + value: Google.* + - key: title + value: GIMP|Inkscape|Photoshop|Krita|Paint|Paint3D|Paint.NET|PaintShop|CorelDRAW|Illustrator|Lightroom|Acorn|Affinity|Pixelmator|Sketch|Figma|Gravit|Vectr|Canva|Photopea|SumoPaint|Pinta|Seashore|MyPaint|TuxPaint|KolourPaint|Karbon|CinePaint + category: Image + + - Filter: + filter-name: 3D Category + enable: true + watchers: [] + target: + - key: app + value: Google.* + - key: title + value: Blender|Maya|3ds Max|Cinema 4D|ZBrush|Houdini|Modo|LightWave|Rhino|SketchUp|AutoCAD|SolidWorks|Fusion 360|Tinkercad|OpenSCAD|FreeCAD|Sculptris|SculptGL|Clara.io|Vectary|Onshape|Shapr3D|SelfCAD|BlocksCAD|Morphi|TinkerCAD|Tinkercad|TinkerCAD + category: "3D" + + - Filter: + filter-name: Audio Category + enable: true + watchers: [] + target: + - key: app + value: Google.* + - key: title + value: Audacity|Ardour|LMMS|Reaper|FL Studio|Ableton Live|Pro Tools|GarageBand|Logic Pro|Cubase|Reason|Bitwig Studio|Studio One|Cakewalk|Waveform|Tracktion|Mixcraft|Audition|Sound Forge|Acid Pro|FL Studio|Ableton Live|Pro Tools|GarageBand|Logic Pro|Cubase|Reason|Bitwig Studio|Studio One|Cakewalk|Waveform|Tracktion|Mixcraft|Audition|Sound Forge|Acid Pro|FL Studio|Ableton Live|Pro Tools|GarageBand|Logic Pro|Cubase|Reason|Bitwig Studio|Studio One|Cakewalk|Waveform|Tracktion|Mixcraft|Audition|Sound Forge|Acid Pro|FL Studio|Ableton Live|Pro Tools|GarageBand|Logic Pro|Cubase|Reason|Bitwig Studio|Studio One|Cakewalk|Waveform|Tracktion|Mixcraft|Audition|Sound Forge|Acid Pro|FL Studio|Ableton Live|Pro Tools|GarageBand|Logic Pro|Cubase|Reason|Bitwig Studio|Studio One|Cakewalk|Waveform|Tracktion|Mixcraft|Audition|Sound Forge|Acid Pro|FL Studio|Ableton Live|Pro Tools|GarageBand|Logic Pro|Cubase|Reason|Bitwig Studio|Studio One|Cakewalk|Waveform|Tracktion|Mixcraft|Audition|Sound Forge|Acid Pro|FL Studio|Ableton Live|Pro Tools|GarageBand|Logic Pro|Cubase|Reason|Bitwig Studio|Studio One|Cakewalk|Waveform|Tracktion|Mixcraft|Audition|Sound Forge|Acid Pro|FL Studio|Ableton Live|Pro Tools|GarageBand|Logic Pro|Cubase|Reason|Bitwig Studio|Studio One|Cakewalk|Waveform|Tracktion|Mixcraft|Audition|Sound Forge|Acid Pro|FL Studio|Ableton Live|Pro Tools|GarageBand|Logic Pro|Cubase|Reason|Bitwig Studio|Studio One|Cakewalk|Waveform|Tracktion|Mixcraft|Audition|Sound + category: Audio + + - Filter: + filter-name: Video Edit Category + enable: true + watchers: [] + target: + - key: app + value: Google.* + - key: title + value: Kdenlive|Shotcut|OpenShot|DaVinci Resolve|Lightworks|HitFilm Express|Avidemux|Blender + category: Video Edit + + - Filter: + filter-name: Video Category + enable: true + watchers: [] + target: + - key: app + value: Google.* + - key: title + value: YouTube|Plex|VLC + category: Video + + - Filter: + filter-name: Social Media Category + enable: true + watchers: [] + target: + - key: app + value: Google.* | Reddit | Facebook | Twitter | Instagram | devRant | LinkedIn | Pinterest | Snapchat | Tumblr | TikTok + - key: title + value: reddit|Facebook|Twitter|Instagram|devRant|LinkedIn|Pinterest|Snapchat|Tumblr|TikTok + category: Social Media + + - Filter: + filter-name: Music Category + enable: true + watchers: [] + target: + - key: app + value: Google.* | Spotify | Deezer | Apple Music | Amazon Music | Tidal | Pandora | SoundCloud | YouTube Music + - key: title + value: Spotify|Deezer|Apple Music|Amazon Music|Tidal|Pandora|SoundCloud|YouTube Music + category: Email + + - Filter: + filter-name: Communication Category + enable: true + watchers: [] + target: + - key: app + value: Google.* | Slack | Discord | Zoom | Microsoft Teams | Jitsi Meet | Cisco Webex | BlueJeans | GoToMeeting | Zoho Meeting + - key: title + value: Messenger|Telegram|Signal|WhatsApp|Rambox|Slack|Riot|Element|Discord|Nheko|NeoChat|Mattermost|Zulip|Rocket.Chat|Jitsi|Jami|Wire|Tox|Threema|Viber|Skype|Zoom|Microsoft Teams|Jitsi Meet|Cisco Webex|BlueJeans|GoToMeeting|Zoho Meeting + category: Communication + + + + + - Filter: + filter-name: "Youtube Plain Filtering" ## Name of the filter (optional) + enable: true + watchers: ## watchers where the filter will be applied (optional) + - "aw-watcher-window" + + target: ## Data Records that if match , do the filtering (mandatory) + + - key: "app" ## key to filter on + value: "Google.*" ## value to filter on REGEX + + - key: "title" ## key to filter on + value: "YouTube" ## value to filter on REGEX + + plain-replace: ## key value pairs to replace e.g. on the key `title` replace its value with `Email` + + - key: "title" ## key of record + value: "YouTube" ## value to replace + + - Filter: + filter-name: "Slack Plain Filtering" ## Name of the filter (optional) + enable: true + watchers: ## watchers where the filter will be applied (optional) + - "aw-watcher-window" + + target: ## Data Records that if match , do the filtering (mandatory) + + - key: "app" ## key to filter on + value: "Slack.*" ## value to filter on REGEX + + - key: "title" ## key to filter on + value: "Slack" ## value to filter on REGEX + + plain-replace: ## key value pairs to replace e.g. on the key `title` replace its value with `Email` + + - key: "title" ## key of record + value: "Slack" ## value to replace + + + - Filter: + filter-name: "Linkedin Plain Filtering" ## Name of the filter (optional) + enable: true + watchers: ## watchers where the filter will be applied (optional) + - "aw-watcher-window" + + target: ## Data Records that if match , do the filtering (mandatory) + + - key: "app" ## key to filter on + value: "Google.*" ## value to filter on REGEX + + - key: "title" ## key to filter on + value: "LinkedIn" ## value to filter on REGEX + + plain-replace: ## key value pairs to replace e.g. on the key `title` replace its value with `Email` + + - key: "title" ## key of record + value: "LinkedIn" ## value to replace + + - Filter: + filter-name: "VsCode Plain Filtering" ## Name of the filter (optional) + enable: true + watchers: ## watchers where the filter will be applied (optional) + - "aw-watcher-window" + + target: ## Data Records that if match , do the filtering (mandatory) + + - key: "app" ## key to filter on + value: "Google.*" ## value to filter on REGEX + + - key: "title" ## key to filter on + value: "Visual Studio Code" ## value to filter on REGEX + + plain-replace: ## key value pairs to replace e.g. on the key `title` replace its value with `Email` + + - key: "title" ## key of record + value: "VsCode" ## value to replace + + - Filter: + filter-name: "Grafana Plain Filtering" ## Name of the filter (optional) + enable: true + watchers: ## watchers where the filter will be applied (optional) + - "aw-watcher-window" + + target: ## Data Records that if match , do the filtering (mandatory) + + - key: "app" ## key to filter on + value: "Google.*" ## value to filter on REGEX + + - key: "title" ## key to filter on + value: "Grafana" ## value to filter on REGEX + + plain-replace: ## key value pairs to replace e.g. on the key `title` replace its value with `Email` + + - key: "title" ## key of record + value: "Grafana" ## value to replace diff --git a/config-examples/aw-plugin-scripts.yaml b/config-examples/aw-plugin-scripts.yaml new file mode 100644 index 0000000..d857a13 --- /dev/null +++ b/config-examples/aw-plugin-scripts.yaml @@ -0,0 +1,8 @@ +Scripts: + - Script: + name: "test" + path: "/home/tester/Desktop/scripts/test-script.sh" + - Script: + name: "dummy" + path: "/home/tester/Desktop/scripts/dummy-script.sh" + timeout: 10 diff --git a/models/constants.go b/models/constants.go new file mode 100644 index 0000000..7ba95b6 --- /dev/null +++ b/models/constants.go @@ -0,0 +1,7 @@ +package models + +// Constants for the plugins RAW NAMES +const ( + FILTER = "filters" + SCRIPT = "scripts" +) diff --git a/models/plugin.go b/models/plugin-core.go similarity index 100% rename from models/plugin.go rename to models/plugin-core.go diff --git a/models/script.go b/models/script.go new file mode 100644 index 0000000..684a986 --- /dev/null +++ b/models/script.go @@ -0,0 +1,11 @@ +package models + +type ScriptConfig struct { + Scripts []Script `yaml:"Scripts"` +} + +type Script struct { + Name string `yaml:"name"` // ScriptName is the name of the script + Path string `yaml:"path"` // Path is the path of the script + Timeout int `yaml:"timeout"` // Timeout is the timeout of the script in seconds +} diff --git a/plugins/filter/operations.go b/plugins/filter/operations.go index 7ca9f2a..f00b117 100644 --- a/plugins/filter/operations.go +++ b/plugins/filter/operations.go @@ -3,6 +3,7 @@ package filter import ( "fmt" "github.com/phrp720/aw-sync-agent-plugins/models" + "github.com/phrp720/aw-sync-agent-plugins/util" "log" "strconv" "strings" @@ -43,7 +44,7 @@ func ValidateFilters(filters []models.Filter) ([]models.Filter, int, int, int) { func GetCategories(filters []models.Filter) []string { var categories []string for _, filter := range filters { - if filter.Category != "" && !contains(categories, filter.Category) { + if filter.Category != "" && !util.Contains(categories, filter.Category) { categories = append(categories, filter.Category) } } @@ -115,7 +116,7 @@ func Replace(data map[string]interface{}, plain []models.PlainReplace, regex []m func GetMatchingFilters(filters []models.Filter, watcher string) []models.Filter { var matchingFilters []models.Filter for _, filter := range filters { - if len(filter.Watchers) == 0 || contains(filter.Watchers, watcher) { + if len(filter.Watchers) == 0 || util.Contains(filter.Watchers, watcher) { matchingFilters = append(matchingFilters, filter) } } @@ -214,13 +215,3 @@ func PrintCategories(categories []string) { } fmt.Println(border) } - -// contains checks if a slice contains a given string -func contains(slice []string, item string) bool { - for _, s := range slice { - if s == item { - return true - } - } - return false -} diff --git a/plugins/filter/plugin.go b/plugins/filter/plugin.go index dfada5b..80e06af 100644 --- a/plugins/filter/plugin.go +++ b/plugins/filter/plugin.go @@ -30,6 +30,11 @@ func (f *Plugin) Initialize() { } func (f *Plugin) Execute(events models.Events, watcher string, userID string, includeHostName bool) models.Events { + + if config.Filters == nil { + return events + } + // Implementation //Apply the filters var modifiedEvents models.Events @@ -74,5 +79,5 @@ func (f *Plugin) Name() string { } func (f *Plugin) RawName() string { - return "filters" + return models.FILTER } diff --git a/plugins/manager.go b/plugins/manager.go index e6c6fc3..e3b8377 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -3,11 +3,15 @@ package plugins import ( "github.com/phrp720/aw-sync-agent-plugins/models" "github.com/phrp720/aw-sync-agent-plugins/plugins/filter" + "github.com/phrp720/aw-sync-agent-plugins/plugins/script" ) func Initialize() []models.Plugin { var plugins []models.Plugin + + // Add all the plugins here plugins = append(plugins, &filter.Plugin{}) + plugins = append(plugins, &script.Plugin{}) return plugins } diff --git a/plugins/script/config.go b/plugins/script/config.go new file mode 100644 index 0000000..06edebf --- /dev/null +++ b/plugins/script/config.go @@ -0,0 +1,44 @@ +package script + +import ( + "github.com/phrp720/aw-sync-agent-plugins/models" + "gopkg.in/yaml.v3" + "io" + "log" + "os" + "path/filepath" +) + +// LoadYAMLConfig Load the YAML config file +func LoadYAMLConfig(filename string) models.ScriptConfig { + file, err := os.Open(filename) + + if err != nil { + log.Printf("No %s file found.", filename) + } else { + log.Printf("Loading scripts from %s file.", filename) + defer file.Close() + decoder := yaml.NewDecoder(file) + if err = decoder.Decode(&config); err != nil && err != io.EOF { + log.Fatalf("Error: Failed to decode filters file: %v", err) + } + + } + + return config +} + +// CreateConfigFile creates a config file to a given path based on the settings +func CreateConfigFile(path string, name string) error { + + fullPath := path + name + content, err := yaml.Marshal(&config) + if err != nil { + return err + } + dir := filepath.Dir(fullPath) + if err = os.MkdirAll(dir, 0755); err != nil { + return err + } + return os.WriteFile(fullPath, content, 0644) +} diff --git a/plugins/script/operations.go b/plugins/script/operations.go new file mode 100644 index 0000000..e30be5f --- /dev/null +++ b/plugins/script/operations.go @@ -0,0 +1,50 @@ +package script + +import ( + "fmt" + "github.com/phrp720/aw-sync-agent-plugins/models" + "github.com/phrp720/aw-sync-agent-plugins/util" + "log" + "strconv" + "strings" +) + +// PrintScripts prints the array of the registered scripts with borders +func PrintScripts(scripts []string) { + log.Print("Scripts Registered:") + + scriptsMap := map[string]string{ + "Scripts found": strconv.Itoa(len(scripts)), + "Scripts": strings.Join(scripts, ", "), + } + + maxKeyLength := 0 + maxValueLength := 0 + for key, value := range scriptsMap { + if len(key) > maxKeyLength { + maxKeyLength = len(key) + } + if len(value) > maxValueLength { + maxValueLength = len(value) + } + } + + borderLength := maxKeyLength + maxValueLength + 7 + border := strings.Repeat("-", borderLength) + fmt.Println(border) + for key, value := range scriptsMap { + fmt.Printf("| %-*s | %-*s |\n", maxKeyLength, key, maxValueLength, value) + } + fmt.Println(border) +} + +// GetScriptNames returns the names of the registered scripts +func GetScriptNames(scripts []models.Script) []string { + var scriptNames []string + for _, script := range scripts { + if script.Name != "" && !util.Contains(scriptNames, script.Name) { + scriptNames = append(scriptNames, script.Name) + } + } + return scriptNames +} diff --git a/plugins/script/plugin.go b/plugins/script/plugin.go new file mode 100644 index 0000000..af286a7 --- /dev/null +++ b/plugins/script/plugin.go @@ -0,0 +1,107 @@ +package script + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "github.com/phrp720/aw-sync-agent-plugins/models" + "github.com/phrp720/aw-sync-agent-plugins/util" + "log" + "os/exec" + "time" +) + +var config models.ScriptConfig + +type Plugin struct{} + +func (f *Plugin) Initialize() { + config = LoadYAMLConfig("./config/" + f.Name()) + if config.Scripts != nil { + PrintScripts(GetScriptNames(config.Scripts)) + } +} + +func (f *Plugin) Execute(events models.Events, watcher string, userID string, includeHostName bool) models.Events { + + if config.Scripts == nil { + return events + } else { + log.Printf("Scripts") + } + // Convert events to JSON + eventsToJSON, err := json.Marshal(events) + if err != nil { + log.Printf("Error marshalling events: %v", err) + return events + } + + for _, script := range config.Scripts { + + if !util.FileExists(script.Path) { + log.Printf("Error running %s : %s", script.Name, "No such file or directory.") + continue + } else { + log.Printf("Running %s", script.Name) + + } + + // Set a default timeout of 30 seconds + if script.Timeout == 0 { + script.Timeout = 30 + } + + // Create a context with a timeout + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(script.Timeout)*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, script.Path) + // Set up stdin and stdout + cmd.Stdin = bytes.NewReader(eventsToJSON) + var stdout bytes.Buffer + cmd.Stdout = &stdout + + // Run the command + err = cmd.Run() + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + log.Printf("Error running %s : %s", script.Name, "Timeout exceeded") + return events + + } + if err != nil { + log.Printf("Error running %s : %v", script.Name, err) + return events + } + // Read the output and convert it back to events + var EventsBuffer models.Events + err = json.Unmarshal(stdout.Bytes(), &EventsBuffer) + if err != nil { + log.Printf("Error unmarshalling script output: %v", err) + return events + } + if EventsBuffer != nil { + events = EventsBuffer + log.Printf("Finished %s ", script.Name) + + } + + } + + return events +} + +func (f *Plugin) ReplicateConfig(path string) { + err := CreateConfigFile(path, f.Name()) + if err != nil { + log.Print(err) + } +} + +func (f *Plugin) Name() string { + return "aw-plugin-" + f.RawName() + ".yaml" +} + +func (f *Plugin) RawName() string { + return models.SCRIPT +} diff --git a/tests/script_test.go b/tests/script_test.go new file mode 100644 index 0000000..937801e --- /dev/null +++ b/tests/script_test.go @@ -0,0 +1,6 @@ +package tests + +import "testing" + +func TestScript(t *testing.T) { +} diff --git a/util/util.go b/util/util.go new file mode 100644 index 0000000..747a690 --- /dev/null +++ b/util/util.go @@ -0,0 +1,21 @@ +package util + +import "os" + +// Contains checks if a slice contains a given string +func Contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +func FileExists(filename string) bool { + info, err := os.Stat(filename) + if os.IsNotExist(err) { + return false + } + return !info.IsDir() +}