├── LICENSE.md ├── README.md ├── composer.json ├── extend.php ├── js ├── admin.js ├── dist │ ├── admin.js │ ├── admin.js.map │ ├── forum.js │ └── forum.js.map ├── forum.js ├── package.json ├── src │ ├── admin │ │ ├── components │ │ │ ├── ActionItem.js │ │ │ ├── AutoModeratorInstructions.js │ │ │ ├── AutoModeratorPage.js │ │ │ ├── CriterionPage.js │ │ │ ├── CriterionStatus.js │ │ │ ├── DriverSettings.js │ │ │ ├── GroupIdSelector.js │ │ │ ├── GroupSelector.js │ │ │ ├── MetricItem.js │ │ │ ├── MinMaxSelector.js │ │ │ ├── RequirementItem.js │ │ │ ├── SuspendSelector.js │ │ │ └── UndefinedDriverItem.js │ │ └── index.js │ ├── common │ │ ├── augmentEditUserModal.js │ │ ├── components │ │ │ └── ManagedGroups.js │ │ ├── index.js │ │ ├── models │ │ │ └── Criterion.js │ │ ├── registerModels.js │ │ └── utils │ │ │ └── managedGroups.js │ └── forum │ │ └── index.js ├── webpack.config.js └── yarn.lock ├── migrations ├── 2021_05_18_000000_add_criteria_table.php └── 2021_05_18_000001_add_criterion_user_table.php ├── phpstan.neon ├── resources ├── less │ ├── admin.less │ ├── admin │ │ ├── AutoModeratorPage.less │ │ ├── CriterionPage.less │ │ └── MinMaxSelector.less │ ├── forum.less │ └── forum │ │ └── EditUserModal.less └── locale │ └── en.yml └── src ├── Action ├── ActionDriverInterface.php ├── ActionManager.php └── Drivers │ ├── ActivateEmail.php │ ├── AddToGroup.php │ ├── RemoveFromGroup.php │ ├── Suspend.php │ └── Unsuspend.php ├── Api ├── Controller │ └── ShowAutomoderatorDriversController.php └── Serializer │ └── AutomoderatorDriversSerializer.php ├── DriverInterface.php ├── DriverManager.php ├── DriverWithSettingsInterface.php ├── Extend └── AutoModerator.php ├── Metric ├── Drivers │ ├── BestAnswers.php │ ├── DiscussionsEntered.php │ ├── DiscussionsParticipated.php │ ├── DiscussionsStarted.php │ ├── LikesGiven.php │ ├── LikesReceived.php │ ├── ModeratorStrikes.php │ └── PostsMade.php ├── MetricDriverInterface.php └── MetricManager.php ├── Provider └── AutoModeratorProvider.php ├── Requirement ├── Drivers │ ├── EmailConfirmed.php │ ├── EmailMatchesRegex.php │ ├── InGroup.php │ └── Suspended.php ├── RequirementDriverInterface.php └── RequirementManager.php ├── Rule.php └── Trigger ├── Drivers ├── CommentPostPosted.php ├── DiscussionStarted.php ├── PostLiked.php ├── PostUnliked.php ├── UserActivated.php ├── UserGroupsChanged.php ├── UserLoggedIn.php ├── UserRead.php ├── UserRegistered.php ├── UserSaved.php ├── UserSuspended.php └── UserUnsuspended .php ├── TriggerDriverInterface.php └── TriggerManager.php /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Auto Moderator 2 | 3 | ![License](https://img.shields.io/badge/license-MIT-blue.svg) [![Latest Stable Version](https://img.shields.io/packagist/v/askvortsov1/flarum-automod.svg)](https://packagist.org/packages/askvortsov/flarum-automod) 4 | 5 | A [Flarum](http://flarum.org) extension. 6 | 7 | ## Core Concept 8 | 9 | The idea is simple: **When X, if Y, do Z**. 10 | 11 | Let's define some key terms: 12 | 13 | - **Trigger:** A set of events (usually just one) that can trigger an automation. For example, "Posted", "LoggedIn", "Post liked". 14 | - **Metric:** A numerical quantity. For example, post count or number of likes received. 15 | - **Requirement:** An abstract boolean condition. For example, not being suspended, having an email that matches some regex, etc. 16 | - **Action:** Some side effect / mutation to perform. This could include anything from adding/removing a group to sending an email to suspending a user. 17 | 18 | Code-wise, these are represented by "Drivers", implementing one of `TriggerDriverInterface`, `MetricDriverInterface`, etc. 19 | 20 | _Requirement_ and _Action_ drivers take a list of "settings", which they specify validation rules for. This means you can build a `UserEmailMatchesRegex : RequirementDriverInterface`, or a `AddUserToGroup : ActionDriverInterface`, and then create multiple instances of the drivers with any regex or group ID. 21 | 22 | All these are tied together by **Rules**. Rules are stored as [A DATABASE TABLE OR A SETTING, IDK], and specify: 23 | 24 | - A trigger for when the rule should run 25 | - A list of metrics "instances". Each instance includes: 26 | - which metric driver is used 27 | - a numerical range (could also be a one-sided min or max range). If the value computed by the metric driver falls **in** this range, the metric is satisfied. E.g. "between 10 and 100" likes received 28 | - a "negation" Boolean. If true, the metric will be satisfied if the value computed by the metric driver falls **outside** of the range 29 | - A list of requirement "instances". Each instance includes: 30 | - which requirement driver is being used. 31 | - A value for the requirement driver's config. It will be plugged into the requirement driver to compute whether the requirement is satisfied. E.g. a users email needs to end with "flarum.org" 32 | - A "negation" Boolean. As with metrics, this allows inverting the requirement driver's output 33 | - a list of actions instances. Each includes: 34 | - which action driver is used 35 | - a "settings" value, that will be plugged into an action driver to run the action (e.g. which group to remove a user from) 36 | 37 | Trigger drivers specify a list of "subject models", e.g. the author and post in a post created event. These determine which metrics, requirements, and actions are available when defining a rule for some trigger, since "running" a metric, requirement, or action always requires some subject (e.g. which user are we calculating num likes received for, which post are we auto-flagging, etc) 38 | 39 | Whenever any event that has rules attached via triggers runs, we "evaluate" all valid rules, and if all the rule's metrics and requirements are satisfied, the rule's actions will run. 40 | 41 | A rule is invalid if (1) it has requirements or actions where settings don't pass validation, (2) any of it's components depend on an extension that isn't currently enabled, or (3) any of it's components reference drivers that don't currently exist. 42 | 43 | This makes for an **extremely** powerful extension. Since extensions can add their own metrics, requirements, and actions, this extension can automate away a lot of moderation. Beyond the examples listed below, some things that could be possible are: 44 | 45 | - Automating assignment of achievements / badges 46 | - Sending emails/notifications to users when they reach thresholds (or just when they register) 47 | - Establishing a system of "trust levels" like [Discourse](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/) 48 | - Onboard/offboard users to/from external systems when they receive/lose certain group membership 49 | - auto-flagging posts that fail some test 50 | 51 | ## Testability 52 | 53 | Because this system is so generic, we can separate testing the framework for (validating, evaluating, running) rules, from each of the drivers. 54 | 55 | Testing drivers is super easy, which makes it cheap and easy to add any drivers we want. See this extension's test suite for examples. 56 | 57 | ## TODO: 58 | 59 | - Implement the frontend for creating, viewing, and editing rules. Maybe there could be a feature to import a `Rule` as JSON, so that rules could be easily shared between forums? 60 | - We could allow registering form components / config for the settings of certain drivers, so that e.g. "AddUserToGroup" actions could be configured with a real group selector, not just a number field. 61 | - I've already implemented a metric range selector component. 62 | - Add a ton more drivers. 63 | - Actions e.g. send emails, flag posts, create warmings 64 | - metrics e.g. posts read, time spent, days visited, days since account creation 65 | - requirements e.g. "user has bio", "post matches regex", etc 66 | - more tests for rule evaluation and validation 67 | - add support for "dated" metrics, e.g. "num discussions created in the past X days" 68 | - cache / store calculated metric values for use by other extensions 69 | - making metric values available to action implementations 70 | 71 | ### Already Implemented 72 | 73 | - interfaces for the various drivers 74 | - a bunch of instances of each driver 75 | - tests for each instance 76 | - an extender for adding new drivers 77 | - A `Rule` class, including the core validation and evaluation logic for rules 78 | - And some tests for this, although more would be nice! 79 | 80 | ## Metrics vs Requirements 81 | 82 | Any metric driver could be implemented as a requirement driver, since requirements are more powerful. But if your requirements are about numerical conditions, metric drivers are better because: 83 | 84 | - it's easy to specify a range of numbers that is valid 85 | - the output of the metric driver contains the actual value, and so could be used for other features, e.g. calculating a "reputation" score per-user 86 | 87 | 88 | # EVERYTHING BELOW THIS LINE IS OUTDATED 89 | 90 | --- 91 | 92 | ## Examples 93 | 94 | ### Example 1: Group Management 95 | 96 | **Criteria:** Users that receive 50 or more likes and have started at least 10 discussions should placed in the "Active" group. 97 | 98 | Here, the metrics are "received 50 or more likes" and "have started at least 10 discussions". Unsurprisingly, they come with the triggers (`PostWasLiked`, `PostWasUnliked`) and (`Discussion\Started`) respectively. 99 | 100 | The actions are: 101 | 102 | - When the criteria is met, add the user to the "Active" group 103 | - When the criteria is lost, remove the user from the "Active" group 104 | 105 | ### Example 2: Suspension 106 | 107 | **Criteria:** If a user gets 15 warnings or more and is not an admin, suspend them. 108 | 109 | Here, the metrics are "gets 15 warnings or more" and the requirements are "is not an admin". The triggers would be a new warning for the metric. The requirement has no triggers. 110 | 111 | The actions are: 112 | 113 | - When the criteria is met, suspend them 114 | - When the criteria is lost, unsuspend them 115 | 116 | ### Example 3: Auto Activation 117 | 118 | **Criteria:** If a user's email matches a regex, activate their email. 119 | 120 | The requirement is "a user's email matches a regex". The triggers are saving a user. 121 | 122 | The actions are: 123 | 124 | - When the criteria is met, auto activate the user's email 125 | - When the criteria are not met, don't 126 | 127 | ### Example 4: Default Group 128 | 129 | **Criteria:** Add a user to a group 130 | 131 | There are no metrics or requirements, so this will be applied to all users on login. 132 | 133 | The actions are: 134 | 135 | - Add all users to a group on login 136 | ## Screenshots 137 | 138 | ![Admin](https://i.imgur.com/k9zfwd9.png) 139 | ![Criterion Edit](https://i.imgur.com/DIgcj48.png) 140 | ![Edit User](https://i.imgur.com/8kZZQmT.png) 141 | 142 | ## Extensibility 143 | 144 | This extension is extremely flexible. It can be considered a framework for automoderation actions. 145 | 146 | Extensions can use the `Askvortsov\AutoModerator\Extend\AutoModerator` extender to add: 147 | 148 | - Action drivers 149 | - Metric drivers 150 | - Requirement drivers 151 | 152 | You should look at the source code of the default drivers for examples. They're fairly exhaustive of what's offered. 153 | 154 | If your extension adds action or requirement drivers that [consume settings](#settings), you have 2 options: 155 | 156 | - Provide translation keys for the settings you need in the driver's `availableSettings` method. This is very easy, but also very restrictive. You can only use strings, and can't add any restrictions or UI. 157 | - You can declare a settings form component for your driver. See `js/src/admin/components/SuspendSelector` for an example. The component should take a settings stream as `this.attrs.settings`. The contents of the stream should be an object that maps setting keys to values. The component is responsible for updating the stream on input. You can register a form component by adding its class to `app.autoModeratorForms[DRIVER CATEGORY][TYPE]`, where `DRIVER CATEGORY` is `"action"` or `"requirement"`, and `TYPE` is the type string you registered your driver with in `extend.php`. See `js/src/admin/index.js` for the underlying data structure and examples. 158 | 159 | 160 | ## Contributions 161 | 162 | Contributions and PRs are welcome! Any PRs adding new drivers should come with unit tests (like with all existing drivers). 163 | 164 | ## Compatibility 165 | 166 | Compatible starting with Flarum 1.0. 167 | 168 | ## Installation 169 | 170 | ```sh 171 | composer require askvortsov/flarum-automod:* 172 | ``` 173 | 174 | ## Updating 175 | 176 | ```sh 177 | composer update askvortsov/flarum-automod 178 | ``` 179 | 180 | ## Links 181 | 182 | - [Packagist](https://packagist.org/packages/askvortsov/flarum-automod) 183 | - [Github](https://github.com/askvortsov1/flarum-automod) 184 | - [Discuss](https://discuss.flarum.org/d/27306-flarum-automoderator) 185 | 186 | 187 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "askvortsov/flarum-automod", 3 | "description": "Powerful automation engine.", 4 | "keywords": [ 5 | "flarum" 6 | ], 7 | "type": "flarum-extension", 8 | "license": "MIT", 9 | "support": { 10 | "issues": "https://github.com/askvortsov1/flarum-automod/issues", 11 | "source": "https://github.com/askvortsov1/flarum-automod", 12 | "forum": "https://discuss.flarum.org/d/27306-flarum-automoderator" 13 | }, 14 | "require": { 15 | "flarum/core": "^1.0.0" 16 | }, 17 | "require-dev": { 18 | "flarum/testing": "^1.0.0", 19 | "flarum/likes": "*@dev", 20 | "flarum/suspend": "*@dev", 21 | "fof/best-answer": "*@dev", 22 | "askvortsov/flarum-moderator-warnings": "*@dev", 23 | "flarum/phpstan": "^1.0" 24 | }, 25 | "authors": [ 26 | { 27 | "name": "Alexander Skvortsov", 28 | "email": "askvortsov@flarum.org", 29 | "role": "Developer" 30 | } 31 | ], 32 | "autoload": { 33 | "psr-4": { 34 | "Askvortsov\\AutoModerator\\": "src/" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "Askvortsov\\AutoModerator\\Tests\\": "tests/" 40 | } 41 | }, 42 | "extra": { 43 | "flarum-extension": { 44 | "title": "Auto Moderator", 45 | "category": "feature", 46 | "icon": { 47 | "name": "fas fa-robot", 48 | "backgroundColor": "#6932D1", 49 | "color": "#fff" 50 | } 51 | }, 52 | "flarum-cli": { 53 | "modules": { 54 | "admin": true, 55 | "forum": true, 56 | "js": true, 57 | "jsCommon": true, 58 | "css": false, 59 | "gitConf": true, 60 | "githubActions": true, 61 | "prettier": true, 62 | "typescript": false, 63 | "bundlewatch": false, 64 | "backendTesting": true, 65 | "editorConfig": true, 66 | "styleci": true 67 | } 68 | } 69 | }, 70 | "scripts": { 71 | "analyse:phpstan": "phpstan analyse", 72 | "clear-cache:phpstan": "phpstan clear-result-cache", 73 | "test": [ 74 | "@test:unit", 75 | "@test:integration" 76 | ], 77 | "test:integration": "phpunit -c tests/phpunit.integration.xml", 78 | "test:setup": "@php tests/integration/setup.php", 79 | "test:unit": "phpunit -c tests/phpunit.unit.xml" 80 | }, 81 | "scripts-descriptions": { 82 | "analyse:phpstan": "Run static analysis", 83 | "test": "Runs all tests.", 84 | "test:integration": "Runs all integration tests.", 85 | "test:setup": "Sets up a database for use with integration tests. Execute this only once.", 86 | "test:unit": "Runs all unit tests." 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /extend.php: -------------------------------------------------------------------------------- 1 | js(__DIR__.'/js/dist/forum.js') 22 | ->css(__DIR__.'/resources/less/forum.less'), 23 | 24 | (new Extend\Frontend('admin')) 25 | ->js(__DIR__.'/js/dist/admin.js') 26 | ->css(__DIR__.'/resources/less/admin.less'), 27 | 28 | (new Extend\Routes('api')) 29 | ->get('/automod_drivers', 'automod_drivers.index', Controller\ShowAutomoderatorDriversController::class), 30 | 31 | (new Extend\ServiceProvider) 32 | ->register(AutoModeratorProvider::class), 33 | 34 | new Extend\Locales(__DIR__.'/resources/locale'), 35 | 36 | (new AutoModerator()) 37 | ->actionDriver(Action\Drivers\ActivateEmail::class) 38 | ->actionDriver(Action\Drivers\AddToGroup::class) 39 | ->actionDriver(Action\Drivers\RemoveFromGroup::class) 40 | ->actionDriver(Action\Drivers\Suspend::class) 41 | ->actionDriver(Action\Drivers\Unsuspend::class) 42 | ->metricDriver(Metric\Drivers\DiscussionsEntered::class) 43 | ->metricDriver(Metric\Drivers\DiscussionsStarted::class) 44 | ->metricDriver(Metric\Drivers\DiscussionsParticipated::class) 45 | ->metricDriver(Metric\Drivers\PostsMade::class) 46 | ->metricDriver(Metric\Drivers\LikesGiven::class) 47 | ->metricDriver(Metric\Drivers\LikesReceived::class) 48 | ->metricDriver(Metric\Drivers\BestAnswers::class) 49 | ->metricDriver(Metric\Drivers\ModeratorStrikes::class) 50 | ->requirementDriver(Requirement\Drivers\EmailConfirmed::class) 51 | ->requirementDriver(Requirement\Drivers\EmailMatchesRegex::class) 52 | ->requirementDriver(Requirement\Drivers\InGroup::class) 53 | ->requirementDriver(Requirement\Drivers\Suspended::class), 54 | ]; 55 | -------------------------------------------------------------------------------- /js/admin.js: -------------------------------------------------------------------------------- 1 | export * from './src/common'; 2 | export * from './src/admin'; 3 | -------------------------------------------------------------------------------- /js/dist/admin.js: -------------------------------------------------------------------------------- 1 | (()=>{var t={n:n=>{var a=n&&n.__esModule?()=>n.default:()=>n;return t.d(a,{a}),a},d:(n,a)=>{for(var r in a)t.o(a,r)&&!t.o(n,r)&&Object.defineProperty(n,r,{enumerable:!0,get:a[r]})},o:(t,n)=>Object.prototype.hasOwnProperty.call(t,n),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},n={};(()=>{"use strict";t.r(n);const a=flarum.core.compat["common/app"];t.n(a)().initializers.add("askvortsov/flarum-automod",(function(){console.log("[askvortsov/flarum-automod] Hello, forum and admin!")}));const r=flarum.core.compat["common/extend"],e=flarum.core.compat["common/components/EditUserModal"];var i=t.n(e);const o=flarum.core.compat["common/components/LoadingIndicator"];var s=t.n(o);const c=flarum.core.compat["common/utils/ItemList"];var u=t.n(c);function l(t){var n=t.filter((function(t){return t.actions()})).reduce((function(t,n){var a=n.actions().filter((function(t){return"add_to_group"===t.type||"remove_from_group"===t.type})).map((function(t){return t.settings.group_id}));return t.push.apply(t,a),t}),[]);return Array.from(new Set(n).values()).map((function(t){return app.store.getById("groups",t)})).filter((function(t){return t}))}function p(t,n){return p=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,n){return t.__proto__=n,t},p(t,n)}function d(t,n){t.prototype=Object.create(n.prototype),t.prototype.constructor=t,p(t,n)}const v=flarum.core.compat["common/Component"];var h=t.n(v);const f=flarum.core.compat["common/components/GroupBadge"];var g=t.n(f),y=function(t){function n(){return t.apply(this,arguments)||this}return d(n,t),n.prototype.view=function(){var t=l(this.attrs.criteria),n=this.attrs.user;return m("div",{className:"ManagedGroups Form-group"},m("hr",null),m("h4",null,app.translator.trans("askvortsov-automod.lib.managed_groups.header")),m("ul",null,t.map((function(t){return m("label",{className:"checkbox"},n&&m("input",{type:"checkbox",checked:n.groups().includes(t),disabled:!0}),app.translator.trans("askvortsov-automod.lib.managed_groups.group_item",{badge:g().component({group:t,label:""}),groupName:t.nameSingular()}))}))),m("p",null,app.translator.trans("askvortsov-automod.lib.managed_groups.groups_not_editable")))},n}(h());const b=flarum.core.compat.Model;var _=t.n(b);const N=flarum.core.compat["models/User"];var k=t.n(N),x=function(t){function n(){return t.apply(this,arguments)||this}return d(n,t),n}(_());Object.assign(x.prototype,{name:_().attribute("name"),icon:_().attribute("icon"),description:_().attribute("description"),actions:_().attribute("actions"),metrics:_().attribute("metrics"),requirements:_().attribute("requirements"),isValid:_().attribute("isValid"),invalidActionSettings:_().attribute("invalidActionSettings"),invalidRequirementSettings:_().attribute("invalidRequirementSettings")});const w=flarum.core.compat["admin/components/ExtensionPage"];var D=t.n(w);const L=flarum.core.compat["common/components/Link"];var O=t.n(L);const E=flarum.core.compat["common/components/Tooltip"];var q=t.n(E);const S=flarum.core.compat["common/helpers/icon"];var B=t.n(S);const G=flarum.core.compat["common/utils/classList"];var F=t.n(G);const C=flarum.core.compat["common/utils/stringToColor"];var I=t.n(C);const M=flarum.core.compat.Component;var j=t.n(M),T=function(t){function n(){return t.apply(this,arguments)||this}return d(n,t),n.prototype.view=function(){return m("div",{className:"AutoModeratorInstructions"},m("h4",null,app.translator.trans("askvortsov-automod.admin.automoderator_instructions.header")),m("ul",null,app.translator.trans("askvortsov-automod.admin.automoderator_instructions.text")))},n}(j());function A(t){var n=t?t.name():app.translator.trans("askvortsov-automod.admin.automoderator_page.create_criterion_button"),a=t?t.icon()||"fas fa-bolt":"fas fa-plus",r=t?{"background-color":"#"+I()(t.name())}:"";return m(O(),{className:"ExtensionListItem",href:app.route.criterion(t)},m("span",{className:"ExtensionListItem-icon ExtensionIcon",style:r},B()(a)),m("span",{className:F()({"ExtensionListItem-title":!0,"ExtensionListItem--invalid":t&&!t.isValid()})},t&&!t.isValid()&&m(q(),{text:app.translator.trans("askvortsov-automod.admin.automoderator_page.criterion_invalid")},B()("fas fa-exclamation-triangle")),n))}var P=function(t){function n(){return t.apply(this,arguments)||this}d(n,t);var a=n.prototype;return a.oninit=function(n){var a=this;t.prototype.oninit.call(this,n),this.loading=!0,app.store.find("criteria").then((function(){a.loading=!1,m.redraw()}))},a.content=function(){return this.loading?m("div",{className:"Criteria"},m("div",{className:"container"},m(s(),null))):m("div",{className:"Criteria"},m("div",{className:"container"},m("div",{className:"ExtensionsWidget-list Criteria-list"},m("p",{className:"Criteria-list-heading"},app.translator.trans("askvortsov-automod.admin.automoderator_page.list_heading")),m("div",{className:"ExtensionList"},[].concat(app.store.all("criteria").map(A),[A()]))),m(y,{criteria:app.store.all("criteria")}),m("hr",null),m(T,null)))},n}(D());function R(){return R=Object.assign?Object.assign.bind():function(t){for(var n=1;n{var t={n:r=>{var o=r&&r.__esModule?()=>r.default:()=>r;return t.d(o,{a:o}),o},d:(r,o)=>{for(var e in o)t.o(o,e)&&!t.o(r,e)&&Object.defineProperty(r,e,{enumerable:!0,get:o[e]})},o:(t,r)=>Object.prototype.hasOwnProperty.call(t,r),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},r={};(()=>{"use strict";t.r(r);const o=flarum.core.compat["common/app"];t.n(o)().initializers.add("askvortsov/flarum-automod",(function(){console.log("[askvortsov/flarum-automod] Hello, forum and admin!")}));const e=flarum.core.compat["common/extend"],n=flarum.core.compat["common/components/EditUserModal"];var a=t.n(n);const i=flarum.core.compat["common/components/LoadingIndicator"];var u=t.n(i);const s=flarum.core.compat["common/utils/ItemList"];var c=t.n(s);function p(t){var r=t.filter((function(t){return t.actions()})).reduce((function(t,r){var o=r.actions().filter((function(t){return"add_to_group"===t.type||"remove_from_group"===t.type})).map((function(t){return t.settings.group_id}));return t.push.apply(t,o),t}),[]);return Array.from(new Set(r).values()).map((function(t){return app.store.getById("groups",t)})).filter((function(t){return t}))}function l(t,r){return l=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,r){return t.__proto__=r,t},l(t,r)}function d(t,r){t.prototype=Object.create(r.prototype),t.prototype.constructor=t,l(t,r)}const f=flarum.core.compat["common/Component"];var g=t.n(f);const v=flarum.core.compat["common/components/GroupBadge"];var b=t.n(v),y=function(t){function r(){return t.apply(this,arguments)||this}return d(r,t),r.prototype.view=function(){var t=p(this.attrs.criteria),r=this.attrs.user;return m("div",{className:"ManagedGroups Form-group"},m("hr",null),m("h4",null,app.translator.trans("askvortsov-automod.lib.managed_groups.header")),m("ul",null,t.map((function(t){return m("label",{className:"checkbox"},r&&m("input",{type:"checkbox",checked:r.groups().includes(t),disabled:!0}),app.translator.trans("askvortsov-automod.lib.managed_groups.group_item",{badge:b().component({group:t,label:""}),groupName:t.nameSingular()}))}))),m("p",null,app.translator.trans("askvortsov-automod.lib.managed_groups.groups_not_editable")))},r}(g());const h=flarum.core.compat.Model;var _=t.n(h);const O=flarum.core.compat["models/User"];var S=t.n(O),k=function(t){function r(){return t.apply(this,arguments)||this}return d(r,t),r}(_());Object.assign(k.prototype,{name:_().attribute("name"),icon:_().attribute("icon"),description:_().attribute("description"),actions:_().attribute("actions"),metrics:_().attribute("metrics"),requirements:_().attribute("requirements"),isValid:_().attribute("isValid"),invalidActionSettings:_().attribute("invalidActionSettings"),invalidRequirementSettings:_().attribute("invalidRequirementSettings")}),app.initializers.add("askvortsov/flarum-automod",(function(){app.store.models.criteria=k,S().prototype.criteria=_().hasMany("criteria"),(0,e.extend)(a().prototype,"oninit",(function(){var t=this;this.loading=!0,app.store.find("criteria").then((function(r){p(r).forEach((function(r){return delete t.groups[r.id()]})),t.loading=!1,m.redraw()}))})),(0,e.override)(a().prototype,"fields",(function(t){if(this.loading){var r=new(c());return r.add("loading",m(u(),null)),r}var o=t();return o.add("Criteria",m(y,{criteria:app.store.all("criteria"),user:this.attrs.user}),10),o}))}))})(),module.exports=r})(); 2 | //# sourceMappingURL=forum.js.map -------------------------------------------------------------------------------- /js/dist/forum.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"forum.js","mappings":"MACA,IAAIA,EAAsB,CCA1BA,EAAyBC,IACxB,IAAIC,EAASD,GAAUA,EAAOE,WAC7B,IAAOF,EAAiB,QACxB,IAAM,EAEP,OADAD,EAAoBI,EAAEF,EAAQ,CAAEG,EAAGH,IAC5BA,CAAM,ECLdF,EAAwB,CAACM,EAASC,KACjC,IAAI,IAAIC,KAAOD,EACXP,EAAoBS,EAAEF,EAAYC,KAASR,EAAoBS,EAAEH,EAASE,IAC5EE,OAAOC,eAAeL,EAASE,EAAK,CAAEI,YAAY,EAAMC,IAAKN,EAAWC,IAE1E,ECNDR,EAAwB,CAACc,EAAKC,IAAUL,OAAOM,UAAUC,eAAeC,KAAKJ,EAAKC,GCClFf,EAAyBM,IACH,oBAAXa,QAA0BA,OAAOC,aAC1CV,OAAOC,eAAeL,EAASa,OAAOC,YAAa,CAAEC,MAAO,WAE7DX,OAAOC,eAAeL,EAAS,aAAc,CAAEe,OAAO,GAAO,G,+BCL9D,MAAM,EAA+BC,OAAOC,KAAKC,OAAO,c,MCExDC,GAAAA,aAAiBC,IAAI,6BAA6B,WAChDC,QAAQC,IAAI,sDACd,ICJA,MAAM,EAA+BN,OAAOC,KAAKC,OAAO,iBCAlD,EAA+BF,OAAOC,KAAKC,OAAO,mC,aCAxD,MAAM,EAA+BF,OAAOC,KAAKC,OAAO,sC,aCAxD,MAAM,EAA+BF,OAAOC,KAAKC,OAAO,yB,aCAzC,SAASK,EAAcC,GACpC,IAAMC,EAAMD,EACTE,QAAO,SAACC,GACP,OAAOA,EAAUC,SACnB,IACCC,QAAO,SAACC,EAAKH,GACZ,IAAMF,EAAME,EACTC,UACAF,QACC,SAAC3B,GAAC,MAAgB,iBAAXA,EAAEgC,MAAsC,sBAAXhC,EAAEgC,IAA4B,IAEnEC,KAAI,SAACjC,GAAC,OAAKA,EAAEkC,SAAmB,QAAC,IAGpC,OAFAH,EAAII,KAAIC,MAARL,EAAYL,GAELK,CACT,GAAG,IAEL,OAAOM,MAAMC,KAAK,IAAIC,IAAIb,GAAKc,UAC5BP,KAAI,SAACQ,GAAO,OAAKrB,IAAIsB,MAAMC,QAAQ,SAAUF,EAAQ,IACrDd,QAAO,SAACiB,GAAC,OAAKA,CAAC,GACpB,CCpBe,SAASC,EAAgBzC,EAAG0C,GAKzC,OAJAD,EAAkBxC,OAAO0C,eAAiB1C,OAAO0C,eAAeC,OAAS,SAAyB5C,EAAG0C,GAEnG,OADA1C,EAAE6C,UAAYH,EACP1C,CACT,EACOyC,EAAgBzC,EAAG0C,EAC5B,CCLe,SAASI,EAAeC,EAAUC,GAC/CD,EAASxC,UAAYN,OAAOgD,OAAOD,EAAWzC,WAC9CwC,EAASxC,UAAU2C,YAAcH,EACjCJ,EAAeI,EAAUC,EAC3B,CCLA,MAAM,EAA+BnC,OAAOC,KAAKC,OAAO,oB,aCAxD,MAAM,EAA+BF,OAAOC,KAAKC,OAAO,gC,aCInCoC,EAAa,SAAAC,GAAA,SAAAD,IAAA,OAAAC,EAAApB,MAAA,KAAAqB,YAAA,KA2C/B,OA3C+BP,EAAAK,EAAAC,GAAAD,EAAA5C,UAChC+C,KAAA,WACE,IAAMC,EAASnC,EAAcoC,KAAKC,MAAMpC,UAClCqC,EAAOF,KAAKC,MAAMC,KAExB,OACEC,EAAA,OAAKC,UAAU,4BACbD,EAAA,WACAA,EAAA,UACG3C,IAAI6C,WAAWC,MACd,iDAGJH,EAAA,UACGJ,EAAO1B,KAAI,SAACkC,GAAK,OAChBJ,EAAA,SAAOC,UAAU,YACdF,GACCC,EAAA,SACE/B,KAAK,WACLoC,QAASN,EAAKH,SAASU,SAASF,GAChCG,UAAU,IAGblD,IAAI6C,WAAWC,MACd,mDACA,CACEK,MAAOC,IAAAA,UAAqB,CAC1BL,MAAAA,EACAM,MAAO,KAETC,UAAWP,EAAMQ,iBAGf,KAGZZ,EAAA,SACG3C,IAAI6C,WAAWC,MACd,8DAKV,EAACX,CAAA,CA3C+B,CAASqB,KCJ3C,MAAM,EAA+B3D,OAAOC,KAAKC,OAAc,M,aCA/D,MAAM,EAA+BF,OAAOC,KAAKC,OAAO,e,aCEnC0D,EAAS,SAAAC,GAAA,SAAAD,IAAA,OAAAC,EAAA1C,MAAA,KAAAqB,YAAA,YAAAP,EAAA2B,EAAAC,GAAAD,CAAA,EAASE,KAEvC1E,OAAO2E,OAAOH,EAAUlE,UAAW,CACjCsE,KAAMF,IAAAA,UAAgB,QACtBG,KAAMH,IAAAA,UAAgB,QACtBI,YAAaJ,IAAAA,UAAgB,eAC7BlD,QAASkD,IAAAA,UAAgB,WACzBK,QAASL,IAAAA,UAAgB,WACzBM,aAAcN,IAAAA,UAAgB,gBAC9BO,QAASP,IAAAA,UAAgB,WACzBQ,sBAAuBR,IAAAA,UAAgB,yBACvCS,2BAA4BT,IAAAA,UAAgB,gCCV9C3D,IAAIqE,aAAapE,IAAI,6BAA6B,WCEhDD,IAAIsB,MAAMgD,OAAOjE,SAAWoD,EAE5Bc,IAAAA,UAAelE,SAAWsD,IAAAA,QAAc,aCCxCa,EAAAA,EAAAA,QAAOC,IAAAA,UAAyB,UAAU,WAAY,IAAAC,EAAA,KACpDlC,KAAKmC,SAAU,EACf3E,IAAIsB,MAAMsD,KAAK,YAAYC,MAAK,SAACxE,GAC/BD,EAAcC,GAAUyE,SACtB,SAAC/B,GAAK,cAAY2B,EAAKnC,OAAOQ,EAAMgC,KAAK,IAG3CL,EAAKC,SAAU,EACfhC,EAAEqC,QACJ,GACF,KAEAC,EAAAA,EAAAA,UAASR,IAAAA,UAAyB,UAAU,SAAUS,GACpD,GAAI1C,KAAKmC,QAAS,CAChB,IAAMQ,EAAQ,IAAIC,KAElB,OADAD,EAAMlF,IAAI,UAAW0C,EAAC0C,IAAgB,OAC/BF,CACT,CAEA,IAAMA,EAAQD,IAWd,OATAC,EAAMlF,IACJ,WACA0C,EAACR,EAAa,CACZ9B,SAAUL,IAAIsB,MAAMgE,IAAI,YACxB5C,KAAMF,KAAKC,MAAMC,OAEnB,IAGKyC,CACT,GFjCF,G","sources":["webpack://@askvortsov/flarum-automod/webpack/bootstrap","webpack://@askvortsov/flarum-automod/webpack/runtime/compat get default export","webpack://@askvortsov/flarum-automod/webpack/runtime/define property getters","webpack://@askvortsov/flarum-automod/webpack/runtime/hasOwnProperty shorthand","webpack://@askvortsov/flarum-automod/webpack/runtime/make namespace object","webpack://@askvortsov/flarum-automod/external root \"flarum.core.compat['common/app']\"","webpack://@askvortsov/flarum-automod/./src/common/index.js","webpack://@askvortsov/flarum-automod/external root \"flarum.core.compat['common/extend']\"","webpack://@askvortsov/flarum-automod/external root \"flarum.core.compat['common/components/EditUserModal']\"","webpack://@askvortsov/flarum-automod/external root \"flarum.core.compat['common/components/LoadingIndicator']\"","webpack://@askvortsov/flarum-automod/external root \"flarum.core.compat['common/utils/ItemList']\"","webpack://@askvortsov/flarum-automod/./src/common/utils/managedGroups.js","webpack://@askvortsov/flarum-automod/./node_modules/@babel/runtime/helpers/esm/setPrototypeOf.js","webpack://@askvortsov/flarum-automod/./node_modules/@babel/runtime/helpers/esm/inheritsLoose.js","webpack://@askvortsov/flarum-automod/external root \"flarum.core.compat['common/Component']\"","webpack://@askvortsov/flarum-automod/external root \"flarum.core.compat['common/components/GroupBadge']\"","webpack://@askvortsov/flarum-automod/./src/common/components/ManagedGroups.js","webpack://@askvortsov/flarum-automod/external root \"flarum.core.compat['Model']\"","webpack://@askvortsov/flarum-automod/external root \"flarum.core.compat['models/User']\"","webpack://@askvortsov/flarum-automod/./src/common/models/Criterion.js","webpack://@askvortsov/flarum-automod/./src/forum/index.js","webpack://@askvortsov/flarum-automod/./src/common/registerModels.js","webpack://@askvortsov/flarum-automod/./src/common/augmentEditUserModal.js"],"sourcesContent":["// The require scope\nvar __webpack_require__ = {};\n\n","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tvar getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tfor(var key in definition) {\n\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t}\n\t}\n};","__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))","// define __esModule on exports\n__webpack_require__.r = (exports) => {\n\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n\t}\n\tObject.defineProperty(exports, '__esModule', { value: true });\n};","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/app'];","import app from 'flarum/common/app';\n\napp.initializers.add('askvortsov/flarum-automod', () => {\n console.log('[askvortsov/flarum-automod] Hello, forum and admin!');\n});\n","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/extend'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/components/EditUserModal'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/components/LoadingIndicator'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/utils/ItemList'];","export default function managedGroups(criteria) {\n const ids = criteria\n .filter((criterion) => {\n return criterion.actions();\n })\n .reduce((acc, criterion) => {\n const ids = criterion\n .actions()\n .filter(\n (a) => a.type === \"add_to_group\" || a.type === \"remove_from_group\"\n )\n .map((a) => a.settings[\"group_id\"]);\n acc.push(...ids);\n\n return acc;\n }, []);\n\n return Array.from(new Set(ids).values())\n .map((groupId) => app.store.getById(\"groups\", groupId))\n .filter((g) => g);\n}\n","export default function _setPrototypeOf(o, p) {\n _setPrototypeOf = Object.setPrototypeOf ? Object.setPrototypeOf.bind() : function _setPrototypeOf(o, p) {\n o.__proto__ = p;\n return o;\n };\n return _setPrototypeOf(o, p);\n}","import setPrototypeOf from \"./setPrototypeOf.js\";\nexport default function _inheritsLoose(subClass, superClass) {\n subClass.prototype = Object.create(superClass.prototype);\n subClass.prototype.constructor = subClass;\n setPrototypeOf(subClass, superClass);\n}","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/Component'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/components/GroupBadge'];","import Component from \"flarum/common/Component\";\nimport GroupBadge from \"flarum/common/components/GroupBadge\";\nimport managedGroups from \"../utils/managedGroups\";\n\nexport default class ManagedGroups extends Component {\n view() {\n const groups = managedGroups(this.attrs.criteria);\n const user = this.attrs.user;\n\n return (\n
\n
\n

\n {app.translator.trans(\n \"askvortsov-automod.lib.managed_groups.header\"\n )}\n

\n
    \n {groups.map((group) => (\n \n ))}\n
\n

\n {app.translator.trans(\n \"askvortsov-automod.lib.managed_groups.groups_not_editable\"\n )}\n

\n
\n );\n }\n}\n","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['Model'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['models/User'];","import Model from \"flarum/Model\";\n\nexport default class Criterion extends Model {}\n\nObject.assign(Criterion.prototype, {\n name: Model.attribute(\"name\"),\n icon: Model.attribute(\"icon\"),\n description: Model.attribute(\"description\"),\n actions: Model.attribute(\"actions\"),\n metrics: Model.attribute(\"metrics\"),\n requirements: Model.attribute(\"requirements\"),\n isValid: Model.attribute(\"isValid\"),\n invalidActionSettings: Model.attribute(\"invalidActionSettings\"),\n invalidRequirementSettings: Model.attribute(\"invalidRequirementSettings\"),\n});\n","import augmentEditUserModal from \"../common/augmentEditUserModal\";\nimport registerModels from \"../common/registerModels\";\n\napp.initializers.add(\"askvortsov/flarum-automod\", () => {\n registerModels();\n augmentEditUserModal();\n});\n","import Model from \"flarum/Model\";\nimport User from \"flarum/models/User\";\nimport Criterion from \"./models/Criterion\";\n\nexport default function registerModels() {\n app.store.models.criteria = Criterion;\n\n User.prototype.criteria = Model.hasMany(\"criteria\");\n}\n","import { extend, override } from \"flarum/common/extend\";\nimport EditUserModal from \"flarum/common/components/EditUserModal\";\nimport LoadingIndicator from \"flarum/common/components/LoadingIndicator\";\nimport ItemList from \"flarum/common/utils/ItemList\";\nimport managedGroups from \"./utils/managedGroups\";\nimport ManagedGroups from \"./components/ManagedGroups\";\n\nexport default function augmentEditUserModal() {\n extend(EditUserModal.prototype, \"oninit\", function () {\n this.loading = true;\n app.store.find(\"criteria\").then((criteria) => {\n managedGroups(criteria).forEach(\n (group) => delete this.groups[group.id()]\n );\n\n this.loading = false;\n m.redraw();\n });\n });\n\n override(EditUserModal.prototype, \"fields\", function (original) {\n if (this.loading) {\n const items = new ItemList();\n items.add(\"loading\", );\n return items;\n }\n\n const items = original();\n\n items.add(\n \"Criteria\",\n ,\n 10\n );\n\n return items;\n });\n}\n"],"names":["__webpack_require__","module","getter","__esModule","d","a","exports","definition","key","o","Object","defineProperty","enumerable","get","obj","prop","prototype","hasOwnProperty","call","Symbol","toStringTag","value","flarum","core","compat","app","add","console","log","managedGroups","criteria","ids","filter","criterion","actions","reduce","acc","type","map","settings","push","apply","Array","from","Set","values","groupId","store","getById","g","_setPrototypeOf","p","setPrototypeOf","bind","__proto__","_inheritsLoose","subClass","superClass","create","constructor","ManagedGroups","_Component","arguments","view","groups","this","attrs","user","m","className","translator","trans","group","checked","includes","disabled","badge","GroupBadge","label","groupName","nameSingular","Component","Criterion","_Model","Model","assign","name","icon","description","metrics","requirements","isValid","invalidActionSettings","invalidRequirementSettings","initializers","models","User","extend","EditUserModal","_this","loading","find","then","forEach","id","redraw","override","original","items","ItemList","LoadingIndicator","all"],"sourceRoot":""} -------------------------------------------------------------------------------- /js/forum.js: -------------------------------------------------------------------------------- 1 | export * from './src/common'; 2 | export * from './src/forum'; 3 | -------------------------------------------------------------------------------- /js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@askvortsov/flarum-automod", 3 | "version": "0.0.0", 4 | "private": true, 5 | "devDependencies": { 6 | "prettier": "^2.5.1", 7 | "flarum-webpack-config": "^2.0.0", 8 | "webpack": "^5.65.0", 9 | "webpack-cli": "^4.9.1", 10 | "@flarum/prettier-config": "^1.0.0" 11 | }, 12 | "scripts": { 13 | "dev": "webpack --mode development --watch", 14 | "build": "webpack --mode production", 15 | "format": "prettier --write src", 16 | "format-check": "prettier --check src", 17 | "analyze": "cross-env ANALYZER=true yarn run build" 18 | }, 19 | "prettier": "@flarum/prettier-config" 20 | } 21 | -------------------------------------------------------------------------------- /js/src/admin/components/ActionItem.js: -------------------------------------------------------------------------------- 1 | import Component from "flarum/Component"; 2 | import Button from "flarum/common/components/Button"; 3 | import Tooltip from "flarum/common/components/Tooltip"; 4 | import icon from "flarum/common/helpers/icon"; 5 | import classList from "flarum/common/utils/classList"; 6 | 7 | import UndefinedDriverItem from "./UndefinedDriverItem"; 8 | import DriverSettings from "./DriverSettings"; 9 | 10 | export default class ActionItem extends Component { 11 | view() { 12 | const action = this.attrs.action; 13 | const actionDef = this.attrs.actionDef; 14 | const selected = this.attrs.selected; 15 | 16 | if (!actionDef) 17 | return ; 18 | 19 | return ( 20 |
  • 21 |
    27 | {actionDef.missingExt && ( 28 | 33 | {icon("fas fa-exclamation-triangle")} 34 | 35 | )} 36 | 37 | {app.translator.trans(actionDef.translationKey)} 38 | 39 | {Button.component({ 40 | className: "Button Button--link", 41 | icon: "fas fa-trash-alt", 42 | onclick: () => selected(selected().filter((val) => val !== action)), 43 | })} 44 | 50 |
    51 |
    52 |
  • 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /js/src/admin/components/AutoModeratorInstructions.js: -------------------------------------------------------------------------------- 1 | import Component from "flarum/Component"; 2 | 3 | export default class AutoModeratorInstructions extends Component { 4 | view() { 5 | return ( 6 |
    7 |

    8 | {app.translator.trans( 9 | "askvortsov-automod.admin.automoderator_instructions.header" 10 | )} 11 |

    12 |
      13 | {app.translator.trans( 14 | "askvortsov-automod.admin.automoderator_instructions.text" 15 | )} 16 |
    17 |
    18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /js/src/admin/components/AutoModeratorPage.js: -------------------------------------------------------------------------------- 1 | import ExtensionPage from "flarum/admin/components/ExtensionPage"; 2 | import Link from "flarum/common/components/Link"; 3 | import LoadingIndicator from "flarum/common/components/LoadingIndicator"; 4 | import Tooltip from "flarum/common/components/Tooltip"; 5 | import icon from "flarum/common/helpers/icon"; 6 | import classList from "flarum/common/utils/classList"; 7 | import stringToColor from "flarum/common/utils/stringToColor"; 8 | import ManagedGroups from "../../common/components/ManagedGroups"; 9 | import AutoModeratorInstructions from "./AutoModeratorInstructions"; 10 | 11 | function criterionItem(criterion) { 12 | const name = criterion 13 | ? criterion.name() 14 | : app.translator.trans( 15 | "askvortsov-automod.admin.automoderator_page.create_criterion_button" 16 | ); 17 | const iconName = criterion 18 | ? criterion.icon() || "fas fa-bolt" 19 | : "fas fa-plus"; 20 | const style = criterion 21 | ? { "background-color": `#${stringToColor(criterion.name())}` } 22 | : ""; 23 | 24 | return ( 25 | 26 | 27 | {icon(iconName)} 28 | 29 | 35 | {criterion && !criterion.isValid() && ( 36 | 41 | {icon("fas fa-exclamation-triangle")} 42 | 43 | )} 44 | {name} 45 | 46 | 47 | ); 48 | } 49 | 50 | export default class AutoModeratorPage extends ExtensionPage { 51 | oninit(vnode) { 52 | super.oninit(vnode); 53 | 54 | this.loading = true; 55 | 56 | app.store.find("criteria").then(() => { 57 | this.loading = false; 58 | m.redraw(); 59 | }); 60 | } 61 | content() { 62 | if (this.loading) { 63 | return ( 64 |
    65 |
    66 | 67 |
    68 |
    69 | ); 70 | } 71 | 72 | return ( 73 |
    74 |
    75 |
    76 |

    77 | {app.translator.trans( 78 | "askvortsov-automod.admin.automoderator_page.list_heading" 79 | )} 80 |

    81 |
    82 | {[ 83 | ...app.store.all("criteria").map(criterionItem), 84 | criterionItem(), 85 | ]} 86 |
    87 |
    88 | 89 |
    90 | 91 |
    92 |
    93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /js/src/admin/components/CriterionPage.js: -------------------------------------------------------------------------------- 1 | import AdminPage from "flarum/admin/components/AdminPage"; 2 | import Button from "flarum/common/components/Button"; 3 | import LinkButton from "flarum/common/components/LinkButton"; 4 | import Select from "flarum/common/components/Select"; 5 | import LoadingIndicator from "flarum/common/components/LoadingIndicator"; 6 | import Stream from "flarum/common/utils/Stream"; 7 | import ActionItem from "./ActionItem"; 8 | import MetricItem from "./MetricItem"; 9 | import RequirementItem from "./RequirementItem"; 10 | import CriterionStatus from "./CriterionStatus"; 11 | import AutoModeratorInstructions from "./AutoModeratorInstructions"; 12 | 13 | let actionDefs; 14 | let metricDefs; 15 | let requirementDefs; 16 | 17 | export default class CriterionPage extends AdminPage { 18 | oninit(vnode) { 19 | super.oninit(vnode); 20 | 21 | this.id = m.route.param("id"); 22 | 23 | this.name = Stream(""); 24 | this.icon = Stream("fas fa-bolt"); 25 | this.description = Stream(""); 26 | this.actionsOnGain = Stream([]); 27 | this.actionsOnLoss = Stream([]); 28 | this.metrics = Stream([]); 29 | this.requirements = Stream([]); 30 | 31 | this.newActionOnGain = Stream(""); 32 | this.newActionOnLoss = Stream(""); 33 | this.newMetric = Stream(""); 34 | this.newRequirement = Stream(""); 35 | 36 | this.loadingDrivers = true; 37 | 38 | app 39 | .request({ 40 | method: "GET", 41 | url: app.forum.attribute("apiUrl") + "/automod_drivers", 42 | }) 43 | .then((response) => { 44 | actionDefs = response["data"]["attributes"]["action"]; 45 | metricDefs = response["data"]["attributes"]["metric"]; 46 | requirementDefs = response["data"]["attributes"]["requirement"]; 47 | 48 | this.loadingDrivers = false; 49 | m.redraw(); 50 | }); 51 | 52 | if (this.id === "new") return; 53 | 54 | this.loadingCriterion = true; 55 | 56 | app.store.find("criteria", this.id).then((criterion) => { 57 | this.loadCriterion(criterion); 58 | this.loadingCriterion = false; 59 | m.redraw(); 60 | }); 61 | } 62 | 63 | loadCriterion(criterion) { 64 | this.criterion = criterion; 65 | this.name(criterion.name()); 66 | this.icon(criterion.icon() || "fas fa-bolt"); 67 | this.description(criterion.description()); 68 | this.metrics( 69 | criterion.metrics().map((m) => { 70 | return { type: m.type, min: Stream(m.min), max: Stream(m.max) }; 71 | }) 72 | ); 73 | this.actionsOnGain( 74 | criterion 75 | .actions() 76 | .filter((a) => a.gain) 77 | .map((a) => { 78 | return { type: a.type, settings: Stream(a.settings) }; 79 | }) 80 | ); 81 | this.actionsOnLoss( 82 | criterion 83 | .actions() 84 | .filter((a) => !a.gain) 85 | .map((a) => { 86 | return { type: a.type, settings: Stream(a.settings) }; 87 | }) 88 | ); 89 | this.requirements( 90 | criterion.requirements().map((r) => { 91 | return { 92 | type: r.type, 93 | negated: Stream(r.negated), 94 | settings: Stream(r.settings), 95 | }; 96 | }) 97 | ); 98 | } 99 | 100 | headerInfo() { 101 | let title; 102 | let description = ""; 103 | 104 | if (this.loadingCriterion) { 105 | description = app.translator.trans( 106 | "askvortsov-automod.admin.criterion_page.loading" 107 | ); 108 | title = app.translator.trans( 109 | "askvortsov-automod.admin.criterion_page.loading" 110 | ); 111 | } else if (this.criterion) { 112 | title = this.criterion.name(); 113 | } else { 114 | title = app.translator.trans( 115 | "askvortsov-automod.admin.criterion_page.new_criterion" 116 | ); 117 | } 118 | 119 | return { 120 | className: "CriterionPage", 121 | icon: this.icon(), 122 | title, 123 | description, 124 | }; 125 | } 126 | 127 | content() { 128 | if (this.loadingCriterion || this.loadingDrivers) { 129 | return ( 130 |
    131 |
    132 | 133 |
    134 |
    135 | ); 136 | } 137 | 138 | return ( 139 |
    140 |
    141 |
    142 |
    143 | 150 | {app.translator.trans( 151 | "askvortsov-automod.admin.criterion_page.back" 152 | )} 153 | 154 |
    155 | 156 |
    157 | 162 | 163 |
    164 |
    165 | 170 | 171 |
    172 |
    173 | 178 | 179 |
    180 | {this.metricsAndRequirementsForm()} 181 | {this.actionsForm()} 182 |
    183 | 191 |
    192 | {this.criterion && ( 193 | 204 | )} 205 | 206 |
    207 | 208 |
    209 |
    210 | ); 211 | } 212 | 213 | metricsAndRequirementsForm() { 214 | return ( 215 |
    216 | 221 |
    222 | {app.translator.trans( 223 | "askvortsov-automod.admin.criterion_page.metrics_and_requirements_help" 224 | )} 225 |
    226 |
    227 |
    228 | 233 |
      234 | {this.metrics().map((metric) => ( 235 | 240 | ))} 241 |
    242 | 243 | {Select.component({ 244 | options: Object.keys(metricDefs).reduce((acc, key) => { 245 | acc[key] = app.translator.trans( 246 | metricDefs[key].translationKey 247 | ); 248 | return acc; 249 | }, {}), 250 | value: this.newMetric(), 251 | onchange: this.newMetric, 252 | })} 253 | {Button.component({ 254 | className: "Button DriverList-button", 255 | icon: "fas fa-plus", 256 | disabled: !this.newMetric(), 257 | onclick: () => { 258 | this.metrics([ 259 | ...this.metrics(), 260 | { type: this.newMetric(), min: Stream(), max: Stream() }, 261 | ]); 262 | }, 263 | })} 264 | 265 |
    266 | 267 |
    268 | 273 |
      274 | {this.requirements().map((r) => ( 275 | 280 | ))} 281 |
    282 | 283 | {Select.component({ 284 | options: Object.keys(requirementDefs).reduce((acc, key) => { 285 | acc[key] = app.translator.trans( 286 | requirementDefs[key].translationKey 287 | ); 288 | return acc; 289 | }, {}), 290 | value: this.newRequirement(), 291 | onchange: this.newRequirement, 292 | })} 293 | {Button.component({ 294 | className: "Button DriverList-button", 295 | icon: "fas fa-plus", 296 | disabled: !this.newRequirement(), 297 | onclick: () => { 298 | this.requirements([ 299 | ...this.requirements(), 300 | { 301 | type: this.newRequirement(), 302 | negated: Stream(false), 303 | settings: Stream({}), 304 | }, 305 | ]); 306 | }, 307 | })} 308 | 309 |
    310 |
    311 |
    312 | ); 313 | } 314 | 315 | actionsForm() { 316 | return ( 317 |
    318 | 323 |
    324 | {app.translator.trans( 325 | "askvortsov-automod.admin.criterion_page.actions_help" 326 | )} 327 |
    328 |
    329 |
    330 | 335 |
      336 | {this.actionsOnGain().map((a) => ( 337 | 342 | ))} 343 |
    344 | 345 | {Select.component({ 346 | options: Object.keys(actionDefs).reduce((acc, key) => { 347 | acc[key] = app.translator.trans( 348 | actionDefs[key].translationKey 349 | ); 350 | return acc; 351 | }, {}), 352 | value: this.newActionOnGain(), 353 | onchange: this.newActionOnGain, 354 | })} 355 | {Button.component({ 356 | className: "Button DriverList-button", 357 | icon: "fas fa-plus", 358 | disabled: !this.newActionOnGain(), 359 | onclick: () => { 360 | this.actionsOnGain([ 361 | ...this.actionsOnGain(), 362 | { type: this.newActionOnGain(), settings: Stream({}) }, 363 | ]); 364 | }, 365 | })} 366 | 367 |
    368 | 369 |
    370 | 375 |
      376 | {this.actionsOnLoss().map((a) => ( 377 | 382 | ))} 383 |
    384 | 385 | {Select.component({ 386 | options: Object.keys(actionDefs).reduce((acc, key) => { 387 | acc[key] = app.translator.trans( 388 | actionDefs[key].translationKey 389 | ); 390 | return acc; 391 | }, {}), 392 | value: this.newActionOnLoss(), 393 | onchange: this.newActionOnLoss, 394 | })} 395 | {Button.component({ 396 | className: "Button DriverList-button", 397 | icon: "fas fa-plus", 398 | disabled: !this.newActionOnLoss(), 399 | onclick: () => { 400 | this.actionsOnLoss([ 401 | ...this.actionsOnLoss(), 402 | { type: this.newActionOnLoss(), settings: Stream({}) }, 403 | ]); 404 | }, 405 | })} 406 | 407 |
    408 |
    409 |
    410 | ); 411 | } 412 | 413 | data() { 414 | return { 415 | name: this.name(), 416 | icon: this.icon(), 417 | description: this.description(), 418 | actions: [ 419 | ...this.actionsOnGain().map((a) => { 420 | return { ...a, gain: true, settings: a.settings() }; 421 | }), 422 | ...this.actionsOnLoss().map((a) => { 423 | return { ...a, gain: false, settings: a.settings() }; 424 | }), 425 | ], 426 | metrics: this.metrics().map((m) => { 427 | return { type: m.type, min: m.min(), max: m.max() }; 428 | }), 429 | requirements: this.requirements().map((r) => { 430 | return { type: r.type, negated: r.negated(), settings: r.settings() }; 431 | }), 432 | }; 433 | } 434 | 435 | delete(e) { 436 | e.preventDefault(); 437 | 438 | this.deleting = true; 439 | m.redraw(); 440 | 441 | this.criterion.delete().then(() => { 442 | m.route.set(app.route("extension", { id: "askvortsov-automod" })); 443 | }); 444 | } 445 | 446 | onsubmit(e) { 447 | e.preventDefault(); 448 | 449 | this.saving = true; 450 | m.redraw(); 451 | 452 | const criterion = this.criterion || app.store.createRecord("criteria"); 453 | 454 | criterion.save(this.data()).then((newCriterion) => { 455 | if (this.id === "new") { 456 | m.route.set(app.route.criterion(newCriterion)); 457 | } else { 458 | this.loadCriterion(criterion); 459 | this.saving = false; 460 | m.redraw(); 461 | } 462 | }); 463 | } 464 | } 465 | -------------------------------------------------------------------------------- /js/src/admin/components/CriterionStatus.js: -------------------------------------------------------------------------------- 1 | import Component from "flarum/Component"; 2 | import Alert from "flarum/common/components/Alert"; 3 | 4 | export default class CriterionStatus extends Component { 5 | view() { 6 | const criterion = this.attrs.criterion; 7 | 8 | if (!criterion) return; 9 | const messages = []; 10 | 11 | if (criterion.isValid()) { 12 | messages.push( 13 | 14 | {app.translator.trans( 15 | "askvortsov-automod.admin.criterion_status.valid" 16 | )} 17 | 18 | ); 19 | } else { 20 | messages.push( 21 | 22 | {app.translator.trans( 23 | "askvortsov-automod.admin.criterion_status.invalid" 24 | )} 25 | 26 | ); 27 | } 28 | 29 | const actionValidation = criterion.invalidActionSettings(); 30 | if (actionValidation && Object.keys(actionValidation).length) { 31 | messages.push( 32 | 33 | {app.translator.trans( 34 | "askvortsov-automod.admin.criterion_status.action_validation_errors" 35 | )} 36 |
    37 |
      38 | {Object.keys(actionValidation).map((key) => ( 39 |
    1. 40 | {key}: {actionValidation[key].join("")} 41 |
    2. 42 | ))} 43 |
    44 |
    45 |
    46 | ); 47 | } 48 | 49 | const requirementValidation = criterion.invalidRequirementSettings(); 50 | if (requirementValidation && Object.keys(requirementValidation).length) { 51 | messages.push( 52 | 53 | {app.translator.trans( 54 | "askvortsov-automod.admin.criterion_status.requirement_validation_errors" 55 | )} 56 |
    57 |
      58 | {Object.keys(requirementValidation).map((key) => ( 59 |
    1. 60 | {key}: {requirementValidation[key].join("")} 61 |
    2. 62 | ))} 63 |
    64 |
    65 |
    66 | ); 67 | } 68 | 69 | if ( 70 | criterion.metrics().length === 0 && 71 | criterion.requirements().length === 0 72 | ) { 73 | messages.push( 74 | 75 | {app.translator.trans( 76 | "askvortsov-automod.admin.criterion_status.no_metrics_or_reqs" 77 | )} 78 | 79 | ); 80 | } 81 | 82 | if (criterion.actions().length === 0) { 83 | messages.push( 84 | 85 | {app.translator.trans( 86 | "askvortsov-automod.admin.criterion_status.no_actions" 87 | )} 88 | 89 | ); 90 | } 91 | 92 | return ( 93 |
    94 | 99 | {messages} 100 |
    101 | ); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /js/src/admin/components/DriverSettings.js: -------------------------------------------------------------------------------- 1 | import Component from "flarum/Component"; 2 | 3 | export default class DriverSettings extends Component { 4 | view() { 5 | const driverType = this.attrs.driverType; 6 | const type = this.attrs.type; 7 | const settings = this.attrs.settings; 8 | const availableSettings = this.attrs.availableSettings; 9 | 10 | const forms = app.autoModeratorForms[driverType]; 11 | 12 | let form; 13 | if (type in forms) { 14 | form = forms[type].component({ settings, availableSettings }); 15 | } else { 16 | form = Object.keys(availableSettings).map((s) => ( 17 |
    18 | { 22 | const newSettings = { ...settings() }; 23 | newSettings[s] = e.target.value; 24 | settings(newSettings); 25 | }} 26 | placeholder={app.translator.trans(availableSettings[s])} 27 | /> 28 |
    29 | )); 30 | } 31 | 32 | return
    {form}
    ; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /js/src/admin/components/GroupIdSelector.js: -------------------------------------------------------------------------------- 1 | import Component from "flarum/Component"; 2 | import GroupSelector from "./GroupSelector"; 3 | 4 | export default class GroupActionDriverSettings extends Component { 5 | view() { 6 | const settings = this.attrs.settings; 7 | 8 | return ( 9 | settings({ group_id: val })} 12 | > 13 | ); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /js/src/admin/components/GroupSelector.js: -------------------------------------------------------------------------------- 1 | import Component from "flarum/Component"; 2 | import Button from "flarum/components/Button"; 3 | import Dropdown from "flarum/components/Dropdown"; 4 | import icon from "flarum/helpers/icon"; 5 | import Group from "flarum/models/Group"; 6 | 7 | export default class GroupSelector extends Component { 8 | view() { 9 | const group = app.store.getById("groups", this.attrs.value); 10 | const label = group 11 | ? [icon(group.icon()), "\t", group.namePlural()] 12 | : app.translator.trans( 13 | "askvortsov-automod.admin.group_selector.placeholder" 14 | ); 15 | return ( 16 |
    17 | 18 | 19 | {this.attrs.disabled ? ( 20 |
    {label}
    21 | ) : ( 22 | 27 | {app.store 28 | .all("groups") 29 | .filter( 30 | (g) => ![Group.MEMBER_ID, Group.GUEST_ID].includes(g.id()) 31 | ) 32 | .map((g) => 33 | Button.component( 34 | { 35 | active: group && group.id() === g.id(), 36 | disabled: group && group.id() === g.id(), 37 | icon: g.icon(), 38 | onclick: () => { 39 | this.attrs.onchange(g.id()); 40 | }, 41 | }, 42 | g.namePlural() 43 | ) 44 | )} 45 | 46 | )} 47 |
    48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /js/src/admin/components/MetricItem.js: -------------------------------------------------------------------------------- 1 | import Component from "flarum/Component"; 2 | import Button from "flarum/common/components/Button"; 3 | import Tooltip from "flarum/common/components/Tooltip"; 4 | import icon from "flarum/common/helpers/icon"; 5 | import classList from "flarum/common/utils/classList"; 6 | import MinMaxSelector from "./MinMaxSelector"; 7 | 8 | import UndefinedDriverItem from "./UndefinedDriverItem"; 9 | 10 | export default class MetricItem extends Component { 11 | view() { 12 | const metric = this.attrs.metric; 13 | const metricDef = this.attrs.metricDef; 14 | const selected = this.attrs.selected; 15 | 16 | if (!metricDef) 17 | return ; 18 | 19 | return ( 20 |
  • 21 |
    27 | {metricDef.missingExt && ( 28 | 33 | {icon("fas fa-exclamation-triangle")} 34 | 35 | )} 36 | 37 | {app.translator.trans(metricDef.translationKey)} 38 | 39 | {Button.component({ 40 | className: "Button Button--link", 41 | icon: "fas fa-trash-alt", 42 | onclick: () => selected(selected().filter((val) => val !== metric)), 43 | })} 44 |
    45 | 46 |
    47 |
  • 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /js/src/admin/components/MinMaxSelector.js: -------------------------------------------------------------------------------- 1 | import Component from "flarum/Component"; 2 | import Button from "flarum/components/Button"; 3 | import Stream from "flarum/utils/Stream"; 4 | 5 | class MinMaxSelector extends Component { 6 | oninit(vnode) { 7 | super.oninit(vnode); 8 | 9 | this.state = -1; 10 | if (this.attrs.min() !== -1) this.state += 1; 11 | if (this.attrs.max() !== -1) this.state += 2; 12 | 13 | this.min = Stream(this.attrs.min()); 14 | this.max = Stream(this.attrs.max()); 15 | } 16 | 17 | view() { 18 | return ( 19 |
    20 | 21 |
    {this.controls()}
    22 |
    23 | ); 24 | } 25 | 26 | controls() { 27 | const minInput = () => ( 28 | 42 | ); 43 | 44 | const maxInput = () => ( 45 | 62 | ); 63 | 64 | const placeholder = () => ( 65 | 70 | ); 71 | 72 | const button = (icon) => ( 73 | 78 | ); 79 | 80 | switch (this.state) { 81 | case MinMaxSelector.State.LTE: 82 | return [placeholder(), button("fas fa-less-than-equal"), maxInput()]; 83 | case MinMaxSelector.State.GTE: 84 | return [placeholder(), button("fas fa-greater-than-equal"), minInput()]; 85 | case MinMaxSelector.State.BETWEEN: 86 | return [ 87 | minInput(), 88 | button("fas fa-less-than-equal"), 89 | placeholder(), 90 | button("fas fa-less-than-equal"), 91 | maxInput(), 92 | ]; 93 | } 94 | } 95 | 96 | cycle() { 97 | this.state++; 98 | this.state %= 3; 99 | 100 | if (this.attrs.min() !== -1) this.min(this.attrs.min()); 101 | if (this.attrs.max() !== -1) this.max(this.attrs.max()); 102 | 103 | switch (this.state) { 104 | case MinMaxSelector.State.GTE: 105 | this.attrs.min(this.min()); 106 | this.attrs.max(-1); 107 | break; 108 | case MinMaxSelector.State.LTE: 109 | this.attrs.min(-1); 110 | this.attrs.max(this.max()); 111 | break; 112 | case MinMaxSelector.State.BETWEEN: 113 | this.attrs.min(this.min()); 114 | this.attrs.max(this.max()); 115 | break; 116 | } 117 | } 118 | } 119 | 120 | MinMaxSelector.State = { 121 | GTE: 0, 122 | LTE: 1, 123 | BETWEEN: 2, 124 | }; 125 | 126 | export default MinMaxSelector; 127 | -------------------------------------------------------------------------------- /js/src/admin/components/RequirementItem.js: -------------------------------------------------------------------------------- 1 | import Component from "flarum/Component"; 2 | import Button from "flarum/common/components/Button"; 3 | import Switch from "flarum/common/components/Switch"; 4 | import Tooltip from "flarum/common/components/Tooltip"; 5 | import icon from "flarum/common/helpers/icon"; 6 | import classList from "flarum/common/utils/classList"; 7 | 8 | import UndefinedDriverItem from "./UndefinedDriverItem"; 9 | import DriverSettings from "./DriverSettings"; 10 | 11 | export default class RequirementItem extends Component { 12 | view() { 13 | const requirement = this.attrs.requirement; 14 | const requirementDef = this.attrs.requirementDef; 15 | const selected = this.attrs.selected; 16 | 17 | if (!requirementDef) 18 | return ; 19 | 20 | return ( 21 |
  • 22 |
    28 | {requirementDef.missingExt && ( 29 | 34 | {icon("fas fa-exclamation-triangle")} 35 | 36 | )} 37 | 38 | {app.translator.trans(requirementDef.translationKey)} 39 | 40 | {Button.component({ 41 | className: "Button Button--link", 42 | icon: "fas fa-trash-alt", 43 | onclick: () => 44 | selected(selected().filter((val) => val !== requirement)), 45 | })} 46 |
    47 | 48 | {app.translator.trans( 49 | "askvortsov-automod.admin.criterion_page.negated" 50 | )} 51 | 52 | 58 |
    59 |
  • 60 | ); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /js/src/admin/components/SuspendSelector.js: -------------------------------------------------------------------------------- 1 | import Component from "flarum/common/Component"; 2 | import Switch from "flarum/common/components/Switch"; 3 | 4 | export default class SuspendSelector extends Component { 5 | view() { 6 | const settings = this.attrs.settings; 7 | 8 | return ( 9 |
    10 |
    11 | settings({ ...settings(), indefinitely: val })} 14 | > 15 | {app.translator.trans( 16 | "askvortsov-automod.admin.suspend_selector.indefinitely" 17 | )} 18 | 19 |
    20 | {!settings().indefinitely && ( 21 |
    22 | 28 | settings({ ...settings(), days: e.target.value }) 29 | } 30 | placeholder={app.translator.trans( 31 | "askvortsov-automod.admin.suspend_selector.days" 32 | )} 33 | /> 34 |
    35 | )} 36 |
    37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /js/src/admin/components/UndefinedDriverItem.js: -------------------------------------------------------------------------------- 1 | import Component from "flarum/Component"; 2 | import Button from "flarum/common/components/Button"; 3 | 4 | export default class UndefinedDriverItem extends Component { 5 | view() { 6 | const item = this.attrs.item; 7 | const selected = this.attrs.selected; 8 | 9 | return ( 10 |
  • 11 |
    12 | 13 | {app.translator.trans( 14 | "askvortsov-automod.admin.undefined_driver_item.text", 15 | { driverName: item.type } 16 | )} 17 | 18 | {Button.component({ 19 | className: "Button Button--link", 20 | icon: "fas fa-trash-alt", 21 | onclick: () => selected(selected().filter((val) => val !== item)), 22 | })} 23 |
    24 |
    25 |
  • 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /js/src/admin/index.js: -------------------------------------------------------------------------------- 1 | import augmentEditUserModal from "../common/augmentEditUserModal"; 2 | import registerModels from "../common/registerModels"; 3 | import AutoModeratorPage from "./components/AutoModeratorPage"; 4 | import CriterionPage from "./components/CriterionPage"; 5 | import GroupIdSelector from "./components/GroupIdSelector"; 6 | import SuspendSelector from "./components/SuspendSelector"; 7 | 8 | app.initializers.add("askvortsov/flarum-automod", () => { 9 | app.routes.criterion = { 10 | path: "/askvortsov-automod/criterion/:id", 11 | component: CriterionPage, 12 | }; 13 | 14 | app.autoModeratorForms = { 15 | action: { 16 | add_to_group: GroupIdSelector, 17 | remove_from_group: GroupIdSelector, 18 | suspend: SuspendSelector, 19 | }, 20 | requirement: { 21 | in_group: GroupIdSelector, 22 | }, 23 | }; 24 | 25 | app.extensionData 26 | .for("askvortsov-automod") 27 | .registerPage(AutoModeratorPage); 28 | 29 | app.route.criterion = (criterion) => { 30 | return app.route("criterion", { id: criterion?.id() || "new" }); 31 | }; 32 | 33 | augmentEditUserModal(); 34 | registerModels(); 35 | }); 36 | -------------------------------------------------------------------------------- /js/src/common/augmentEditUserModal.js: -------------------------------------------------------------------------------- 1 | import { extend, override } from "flarum/common/extend"; 2 | import EditUserModal from "flarum/common/components/EditUserModal"; 3 | import LoadingIndicator from "flarum/common/components/LoadingIndicator"; 4 | import ItemList from "flarum/common/utils/ItemList"; 5 | import managedGroups from "./utils/managedGroups"; 6 | import ManagedGroups from "./components/ManagedGroups"; 7 | 8 | export default function augmentEditUserModal() { 9 | extend(EditUserModal.prototype, "oninit", function () { 10 | this.loading = true; 11 | app.store.find("criteria").then((criteria) => { 12 | managedGroups(criteria).forEach( 13 | (group) => delete this.groups[group.id()] 14 | ); 15 | 16 | this.loading = false; 17 | m.redraw(); 18 | }); 19 | }); 20 | 21 | override(EditUserModal.prototype, "fields", function (original) { 22 | if (this.loading) { 23 | const items = new ItemList(); 24 | items.add("loading", ); 25 | return items; 26 | } 27 | 28 | const items = original(); 29 | 30 | items.add( 31 | "Criteria", 32 | , 36 | 10 37 | ); 38 | 39 | return items; 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /js/src/common/components/ManagedGroups.js: -------------------------------------------------------------------------------- 1 | import Component from "flarum/common/Component"; 2 | import GroupBadge from "flarum/common/components/GroupBadge"; 3 | import managedGroups from "../utils/managedGroups"; 4 | 5 | export default class ManagedGroups extends Component { 6 | view() { 7 | const groups = managedGroups(this.attrs.criteria); 8 | const user = this.attrs.user; 9 | 10 | return ( 11 |
    12 |
    13 |

    14 | {app.translator.trans( 15 | "askvortsov-automod.lib.managed_groups.header" 16 | )} 17 |

    18 |
      19 | {groups.map((group) => ( 20 | 39 | ))} 40 |
    41 |

    42 | {app.translator.trans( 43 | "askvortsov-automod.lib.managed_groups.groups_not_editable" 44 | )} 45 |

    46 |
    47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /js/src/common/index.js: -------------------------------------------------------------------------------- 1 | import app from 'flarum/common/app'; 2 | 3 | app.initializers.add('askvortsov/flarum-automod', () => { 4 | console.log('[askvortsov/flarum-automod] Hello, forum and admin!'); 5 | }); 6 | -------------------------------------------------------------------------------- /js/src/common/models/Criterion.js: -------------------------------------------------------------------------------- 1 | import Model from "flarum/Model"; 2 | 3 | export default class Criterion extends Model {} 4 | 5 | Object.assign(Criterion.prototype, { 6 | name: Model.attribute("name"), 7 | icon: Model.attribute("icon"), 8 | description: Model.attribute("description"), 9 | actions: Model.attribute("actions"), 10 | metrics: Model.attribute("metrics"), 11 | requirements: Model.attribute("requirements"), 12 | isValid: Model.attribute("isValid"), 13 | invalidActionSettings: Model.attribute("invalidActionSettings"), 14 | invalidRequirementSettings: Model.attribute("invalidRequirementSettings"), 15 | }); 16 | -------------------------------------------------------------------------------- /js/src/common/registerModels.js: -------------------------------------------------------------------------------- 1 | import Model from "flarum/Model"; 2 | import User from "flarum/models/User"; 3 | import Criterion from "./models/Criterion"; 4 | 5 | export default function registerModels() { 6 | app.store.models.criteria = Criterion; 7 | 8 | User.prototype.criteria = Model.hasMany("criteria"); 9 | } 10 | -------------------------------------------------------------------------------- /js/src/common/utils/managedGroups.js: -------------------------------------------------------------------------------- 1 | export default function managedGroups(criteria) { 2 | const ids = criteria 3 | .filter((criterion) => { 4 | return criterion.actions(); 5 | }) 6 | .reduce((acc, criterion) => { 7 | const ids = criterion 8 | .actions() 9 | .filter( 10 | (a) => a.type === "add_to_group" || a.type === "remove_from_group" 11 | ) 12 | .map((a) => a.settings["group_id"]); 13 | acc.push(...ids); 14 | 15 | return acc; 16 | }, []); 17 | 18 | return Array.from(new Set(ids).values()) 19 | .map((groupId) => app.store.getById("groups", groupId)) 20 | .filter((g) => g); 21 | } 22 | -------------------------------------------------------------------------------- /js/src/forum/index.js: -------------------------------------------------------------------------------- 1 | import augmentEditUserModal from "../common/augmentEditUserModal"; 2 | import registerModels from "../common/registerModels"; 3 | 4 | app.initializers.add("askvortsov/flarum-automod", () => { 5 | registerModels(); 6 | augmentEditUserModal(); 7 | }); 8 | -------------------------------------------------------------------------------- /js/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('flarum-webpack-config')(); 2 | -------------------------------------------------------------------------------- /migrations/2021_05_18_000000_add_criteria_table.php: -------------------------------------------------------------------------------- 1 | function (Builder $schema) { 17 | $schema->create('criteria', function (Blueprint $table) { 18 | $table->increments('id'); 19 | 20 | $table->integer('last_edited_by_id')->unsigned(); 21 | 22 | $table->string('name', 200); 23 | $table->string('icon', 50); 24 | $table->text('description'); 25 | 26 | $table->text('metrics'); 27 | $table->text('requirements'); 28 | $table->text('actions'); 29 | 30 | $table->foreign('last_edited_by_id')->references('id')->on('users')->onDelete('cascade'); 31 | }); 32 | }, 33 | 'down' => function (Builder $schema) { 34 | $schema->dropIfExists('criteria'); 35 | }, 36 | ]; 37 | -------------------------------------------------------------------------------- /migrations/2021_05_18_000001_add_criterion_user_table.php: -------------------------------------------------------------------------------- 1 | function (Builder $schema) { 17 | $schema->create('criterion_user', function (Blueprint $table) { 18 | $table->integer('criterion_id')->unsigned(); 19 | $table->integer('user_id')->unsigned(); 20 | 21 | $table->primary(['criterion_id', 'user_id']); 22 | 23 | $table->foreign('criterion_id')->references('id')->on('criteria')->onDelete('cascade'); 24 | $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); 25 | }); 26 | }, 27 | 'down' => function (Builder $schema) { 28 | $schema->dropIfExists('criterion_user'); 29 | }, 30 | ]; 31 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/flarum/phpstan/extension.neon 3 | 4 | parameters: 5 | # The level will be increased in Flarum 2.0 6 | level: 5 7 | paths: 8 | - src 9 | - extend.php 10 | excludePaths: 11 | - *.blade.php 12 | checkMissingIterableValueType: false 13 | databaseMigrationsPath: ['migrations'] 14 | -------------------------------------------------------------------------------- /resources/less/admin.less: -------------------------------------------------------------------------------- 1 | @import './admin/MinMaxSelector'; 2 | @import './admin/AutoModeratorPage'; 3 | @import './admin/CriterionPage'; -------------------------------------------------------------------------------- /resources/less/admin/AutoModeratorPage.less: -------------------------------------------------------------------------------- 1 | .Criteria-list { 2 | margin: 20px 0 20px 0; 3 | padding: 10px 20px; 4 | 5 | border: 1px solid @control-bg; 6 | border-radius: 4px; 7 | 8 | .ExtensionListItem { 9 | text-decoration: none !important; 10 | } 11 | 12 | .Criteria-list-heading { 13 | font-weight: bold; 14 | color: @muted-color; 15 | margin-bottom: 15px; 16 | } 17 | } 18 | 19 | .ExtensionListItem--invalid { 20 | text-decoration: line-through; 21 | } 22 | .AutoModeratorInstructions, .ManagedGroups { 23 | color: @control-color; 24 | 25 | h4 { 26 | margin-bottom: 5px; 27 | font-weight: bold; 28 | } 29 | 30 | ul { 31 | margin-block-start: .3em; 32 | padding-inline-start: 28px; 33 | } 34 | } -------------------------------------------------------------------------------- /resources/less/admin/CriterionPage.less: -------------------------------------------------------------------------------- 1 | .DriverListItem-info { 2 | border-radius: @border-radius; 3 | padding: 5px; 4 | 5 | .Button { 6 | float: right; 7 | margin: -8px -16px -8px 16px; 8 | } 9 | } 10 | 11 | .DriverListItem-form { 12 | padding-top: 5px; 13 | } 14 | 15 | .DriverListItem--missingExt { 16 | text-decoration: line-through; 17 | } 18 | 19 | .StatusCheck .Alert { 20 | margin: 10px 0; 21 | } 22 | 23 | ul.DriverList { 24 | list-style-type: none; 25 | padding: 0; 26 | margin: 0; 27 | } 28 | 29 | .DriverGroup label { 30 | font-size: 15px; 31 | text-decoration: underline; 32 | } 33 | 34 | 35 | .SettingsGroups { 36 | display: flex; 37 | column-count: 3; 38 | column-gap: 30px; 39 | flex-wrap: wrap; 40 | 41 | @media (@tablet-up) { 42 | .DriverGroup--secondary { 43 | max-width: 250px !important; 44 | } 45 | } 46 | 47 | .DriverGroup-controls { 48 | align-self: end; 49 | } 50 | 51 | .Form { 52 | min-width: 300px; 53 | max-height: 500px; 54 | 55 | >label { 56 | margin-bottom: 10px; 57 | } 58 | 59 | .TagSettings-rangeInput { 60 | input { 61 | width: 80px; 62 | display: inline; 63 | margin: 0 5px; 64 | 65 | &:first-child { 66 | margin-left: 0; 67 | } 68 | } 69 | } 70 | } 71 | 72 | .DriverGroup, 73 | .Form { 74 | display: inline-grid; 75 | padding: 10px 20px; 76 | min-height: 20vh; 77 | max-width: 400px; 78 | grid-template-rows: min-content; 79 | border: 1px solid @control-bg; 80 | border-radius: @border-radius; 81 | flex: 1 1 160px; 82 | 83 | @media (max-width: 1209px) { 84 | margin-bottom: 20px; 85 | } 86 | 87 | .DriverList-button { 88 | background: none; 89 | border: 1px dashed @control-bg; 90 | height: 40px; 91 | margin: auto auto 0 0; 92 | } 93 | 94 | >label { 95 | float: left; 96 | font-weight: bold; 97 | color: @muted-color; 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /resources/less/admin/MinMaxSelector.less: -------------------------------------------------------------------------------- 1 | .MinMaxSelector--inputs { 2 | display: flex; 3 | 4 | .Button { 5 | .Button--color(@control-color, darken(@body-bg, 15%)); 6 | 7 | .icon { 8 | margin-right: 0; 9 | } 10 | } 11 | 12 | .MinMaxSelector--placeholder { 13 | text-align: center; 14 | } 15 | 16 | >:not(:first-child):not(:last-child) { 17 | border-radius: 0; 18 | } 19 | 20 | >:first-child:not(:last-child) { 21 | margin-left: 0; 22 | border-top-right-radius: 0; 23 | border-bottom-right-radius: 0; 24 | } 25 | 26 | >:last-child:not(:first-child) { 27 | border-top-left-radius: 0; 28 | border-bottom-left-radius: 0; 29 | } 30 | } -------------------------------------------------------------------------------- /resources/less/forum.less: -------------------------------------------------------------------------------- 1 | @import './forum/EditUserModal'; -------------------------------------------------------------------------------- /resources/less/forum/EditUserModal.less: -------------------------------------------------------------------------------- 1 | .CriterionList { 2 | margin-block-start: 0; 3 | padding-inline-start: 20px; 4 | } -------------------------------------------------------------------------------- /resources/locale/en.yml: -------------------------------------------------------------------------------- 1 | askvortsov-automod: 2 | admin: 3 | trigger_drivers: 4 | comment_post_posted: New Comment Post 5 | discussion_started: Discussion Started 6 | post_liked: Post Liked 7 | post_unliked: Post Unliked 8 | user_activated: User Activated 9 | user_groups_changed: User Groups Changed 10 | user_logged_in: User Logged In 11 | user_read: User Read 12 | user_registered: User Registered 13 | user_saving: User Saving 14 | user_suspended: User Suspended 15 | user_unsuspended: User Unsuspended 16 | action_drivers: 17 | activate_email: Activate Email 18 | add_to_group: Add to Group 19 | remove_from_group: Remove from Group 20 | suspend: Suspend 21 | unsuspend: Unsuspend 22 | metric_drivers: 23 | discussions_entered: Discussions Entered 24 | discussions_participated: Discussions Participated 25 | discussions_started: Discussions Started 26 | posts_made: Posts Made 27 | likes_given: Likes Given 28 | likes_received: Likes Received 29 | best_answers: Best Answers 30 | moderator_strikes: Moderator Warnings Strikes 31 | requirement_drivers: 32 | email_confirmed: Email Confirmed 33 | in_group: In Group 34 | suspended: Is Suspended 35 | email_matches_regex: Email Matches Regex 36 | in_group_settings: 37 | regex: Regex for Valid Emails (no slashes) 38 | group_selector: 39 | placeholder: Select a group 40 | suspend_selector: 41 | days: Days to Suspend For 42 | indefinitely: Suspend Indefinitely? 43 | criterion_status: 44 | heading: Status Check 45 | invalid: The criterion is currently invalid. Please check the drivers below for errors. 46 | no_actions: No actions are defined for this criterion. Nothing will happen when users gain or lose this criterion. 47 | no_metrics_or_reqs: No metrics or requirements are defined for this criterion. This means that ALL users will qualify for this criterion. 48 | valid: This criterion is valid! 49 | action_validation_errors: "The following action driver validation errors were encountered:" 50 | requirement_validation_errors: "The following requirement driver validation errors were encountered:" 51 | criterion_page: 52 | driver_missing_ext: This driver depends on extensions that aren't enabled. 53 | back: Back to Criteria List 54 | name_label: Name 55 | icon_label: Icon 56 | description_label: Description 57 | new_criterion: Create Criterion 58 | negated: Negated? 59 | loading: => core.ref.loading 60 | delete_button: => core.ref.delete 61 | metrics_and_requirements_label: Metrics and Requirements 62 | metrics_and_requirements_help: These define whether users qualify for this criterion. Metrics represent ranges of values, requirements are boolean conditions. 63 | metrics_heading: Metrics 64 | requirements_heading: Requirements 65 | add_metric: Add Metric 66 | actions_label: Actions 67 | actions_help: Actions on gain execute when a user qualifies for a new criterion. Actions on loss execute when a user stops qualifying for a criterion. 68 | actions_on_gain_heading: Actions on Gain 69 | actions_on_loss_heading: Actions on Loss 70 | add_action: Add Action 71 | undefined_driver_item: 72 | text: "ERROR! The driver {driverName} is not defined. Most likely, it was added by an extension that isn't currently installed. You'll need to reenable the extension or remove this driver for the criterion to be valid again." 73 | 74 | automoderator_page: 75 | criterion_invalid: The criterion is currently invalid. Please check its settings for more information 76 | list_heading: Criteria 77 | create_criterion_button: Create New Criterion 78 | automoderator_instructions: 79 | header: Extension Instructions 80 | text: | 81 |
  • Tl;dr: When a user meets criteria X, do Y. When a user no longer meets criteria X, do Z.
  • 82 |
  • Criteria: Criteria are the core of this extension. They are arbitrary sets of metrics and requirements. When a user meets a criterion, any "on gain" actions for the criterion will be executed. When a user loses a criterion, any "on loss" actions for the criterion will be executed.
  • 83 |
      84 |
    • Metric: A numerical condition. For example, post count or number of likes received. A criterion could require a range/minimum/maximum of metrics.
    • 85 |
    • Requirement: An abstract boolean condition. For example, not being suspended, or having an email that matches a certain regex.
    • 86 |
    87 |
  • Actions: Something that happens automatically when a criteria is met or lost. This could include anything from adding/removing a group to sending an email to suspending a user.
  • 88 |
  • Trigger: A set of events that would cause a user's criteria groups to be reevaluated. These are part of the definitions of metrics and requirements. All criteria will be evaluated on login: that is the "universal trigger".
  • 89 |
  • "On loss" actions will always run before "on gain" actions. That way, actions will be taken as long as a user meets at least one of the criteria that do that action on gain.
  • 90 |
  • Keep in mind that actions only run "on loss" and "on gain". Manual edits while a user qualifies for a criterion will not trigger the criterion's "on gain" actions to be reapplied.
  • 91 |
  • If you delete a criterion, "on loss" actions will NOT be run for the users who currently qualify for the criterion.
  • 92 |
  • If you are using criteria to manage groups, we recommend maintaining a separate set of groups to be managed by criteria. You will not be able to add/remove users to/from these groups manually.
  • 93 | 94 | forum: 95 | edit_user: 96 | managed_groups_heading: 97 | 98 | lib: 99 | group_id: Group ID 100 | managed_groups: 101 | header: "Managed Groups:" 102 | group_item: " {groupName}" 103 | groups_not_editable: Groups managed by automoderator criteria cannot be added/removed to/from users manually to prevent inconsistencies. 104 | 105 | -------------------------------------------------------------------------------- /src/Action/ActionDriverInterface.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | public function subject(): string; 21 | 22 | /** 23 | * Apply the action to the user. 24 | * Don't forget to dispatch any events that should be emitted! 25 | * 26 | * @param T $subject 27 | */ 28 | public function execute(AbstractModel $subject, array $settings, User $lastEditedBy); 29 | } 30 | -------------------------------------------------------------------------------- /src/Action/ActionManager.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class ActionManager extends DriverManager 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /src/Action/Drivers/ActivateEmail.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class ActivateEmail implements ActionDriverInterface 16 | { 17 | public function subject(): string 18 | { 19 | return User::class; 20 | } 21 | 22 | public function id(): string 23 | { 24 | return 'activate_email'; 25 | } 26 | 27 | public function translationKey(): string 28 | { 29 | return 'askvortsov-automod.admin.action_drivers.activate_email'; 30 | } 31 | 32 | public function extensionDependencies(): array 33 | { 34 | return []; 35 | } 36 | 37 | public function availableSettings(): array { 38 | return []; 39 | } 40 | 41 | public function validateSettings(array $settings, Factory $validator): MessageBag { 42 | return $validator->make($settings, [])->errors(); 43 | } 44 | 45 | public function execute(AbstractModel $user, array $settings, User $lastEditedBy ) { 46 | $user->is_email_confirmed = true; 47 | $user->save(); 48 | 49 | resolve('events')->dispatch(new Activated($user)); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Action/Drivers/AddToGroup.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class AddToGroup implements ActionDriverInterface 16 | { 17 | public function subject(): string 18 | { 19 | return User::class; 20 | } 21 | 22 | public function id(): string 23 | { 24 | return 'add_to_group'; 25 | } 26 | 27 | public function translationKey(): string 28 | { 29 | return 'askvortsov-automod.admin.action_drivers.add_to_group'; 30 | } 31 | 32 | public function extensionDependencies(): array 33 | { 34 | return []; 35 | } 36 | 37 | public function availableSettings(): array { 38 | return [ 39 | 'group_id' => 'askvortsov-automod.lib.group_id' 40 | ]; 41 | } 42 | 43 | public function validateSettings(array $settings, Factory $validator): MessageBag { 44 | return $validator->make($settings, [ 45 | 'group_id' => 'required|integer', 46 | ])->errors(); 47 | } 48 | 49 | public function execute(AbstractModel $user, array $settings, User $lastEditedBy ) { 50 | $groups = $user->groups->toArray(); 51 | $user->groups()->syncWithoutDetaching([$settings['group_id']]); 52 | 53 | resolve('events')->dispatch(new GroupsChanged($user, $groups)); 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/Action/Drivers/RemoveFromGroup.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class RemoveFromGroup implements ActionDriverInterface 16 | { 17 | public function subject(): string 18 | { 19 | return User::class; 20 | } 21 | 22 | public function id(): string 23 | { 24 | return 'remove_from_group'; 25 | } 26 | 27 | public function translationKey(): string 28 | { 29 | return 'askvortsov-automod.admin.action_drivers.remove_from_group'; 30 | } 31 | 32 | public function extensionDependencies(): array 33 | { 34 | return []; 35 | } 36 | 37 | public function availableSettings(): array 38 | { 39 | return [ 40 | 'group_id' => 'askvortsov-automod.lib.group_id' 41 | ]; 42 | } 43 | 44 | public function validateSettings(array $settings, Factory $validator): MessageBag 45 | { 46 | return $validator->make($settings, [ 47 | 'group_id' => 'required|integer', 48 | ])->errors(); 49 | } 50 | 51 | public function execute(AbstractModel $user, array $settings , User $lastEditedBy ) 52 | { 53 | $groups = $user->groups->toArray(); 54 | $user->groups()->detach($settings['group_id']); 55 | 56 | resolve('events')->dispatch(new GroupsChanged($user, $groups)); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Action/Drivers/Suspend.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class Suspend implements ActionDriverInterface 18 | { 19 | public function subject(): string 20 | { 21 | return User::class; 22 | } 23 | 24 | public function id(): string 25 | { 26 | return 'suspend'; 27 | } 28 | 29 | public function extensionDependencies(): array 30 | { 31 | return ['flarum-suspend']; 32 | } 33 | 34 | public function translationKey(): string 35 | { 36 | return 'askvortsov-automod.admin.action_drivers.suspend'; 37 | } 38 | 39 | public function availableSettings(): array 40 | { 41 | return [ 42 | 'days' => '', 43 | 'indefinitely' => '' 44 | ]; 45 | } 46 | 47 | public function validateSettings(array $settings, Factory $validator): MessageBag 48 | { 49 | return $validator->make($settings, [ 50 | 'days' => 'required_without:indefinitely|integer', 51 | 'indefinitely' => 'required_without:days|boolean', 52 | ])->errors(); 53 | } 54 | 55 | public function execute(AbstractModel $user, array $settings , User $lastEditedBy ) 56 | { 57 | $days = Arr::get($settings, 'indefinitely', false) ? 365 * 100 : $settings['days']; 58 | 59 | /** @phpstan-ignore-next-line */ 60 | $user->suspended_until = max(Carbon::now()->addDays($days), $user->suspended_until); 61 | $user->save(); 62 | 63 | resolve('events')->dispatch(new Suspended($user, $lastEditedBy)); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Action/Drivers/Unsuspend.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class Unsuspend implements ActionDriverInterface 16 | { 17 | public function subject(): string 18 | { 19 | return User::class; 20 | } 21 | 22 | public function id(): string 23 | { 24 | return 'suspend'; 25 | } 26 | 27 | public function extensionDependencies(): array 28 | { 29 | return ['flarum-suspend']; 30 | } 31 | 32 | public function translationKey(): string 33 | { 34 | return 'askvortsov-automod.admin.action_drivers.unsuspend'; 35 | } 36 | 37 | public function availableSettings(): array 38 | { 39 | return []; 40 | } 41 | 42 | public function validateSettings(array $settings, Factory $validator): MessageBag 43 | { 44 | return $validator->make($settings, [])->errors(); 45 | } 46 | public function execute(AbstractModel $user, array $settings , User $lastEditedBy ) 47 | { 48 | /** @phpstan-ignore-next-line */ 49 | $user->suspended_until = null; 50 | $user->save(); 51 | 52 | resolve('events')->dispatch(new Unsuspended($user, $lastEditedBy)); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Api/Controller/ShowAutomoderatorDriversController.php: -------------------------------------------------------------------------------- 1 | actions = $actions; 48 | $this->metrics = $metrics; 49 | $this->requirements = $requirements; 50 | } 51 | 52 | /** 53 | * {@inheritdoc} 54 | */ 55 | protected function data(ServerRequestInterface $request, Document $document) 56 | { 57 | RequestUtil::getActor($request)->assertAdmin(); 58 | 59 | return [ 60 | 'actionDrivers' => $this->actions->getDrivers(), 61 | 'actionDriversMissingExt' => $this->actions->getDrivers(true), 62 | 'metricDrivers' => $this->metrics->getDrivers(), 63 | 'metricDriversMissingExt' => $this->metrics->getDrivers(true), 64 | 'requirementDrivers' => $this->requirements->getDrivers(), 65 | 'requirementDriversMissingExt' => $this->requirements->getDrivers(true), 66 | ]; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Api/Serializer/AutomoderatorDriversSerializer.php: -------------------------------------------------------------------------------- 1 | serializeActionDrivers($drivers['actionDrivers']), 27 | $this->serializeActionDrivers($drivers['actionDriversMissingExt'], true) 28 | ); 29 | 30 | $metric = array_merge( 31 | $this->serializeMetricDrivers($drivers['metricDrivers']), 32 | $this->serializeMetricDrivers($drivers['metricDriversMissingExt'], true) 33 | ); 34 | 35 | $requirement = array_merge( 36 | $this->serializeRequirementDrivers($drivers['requirementDrivers']), 37 | $this->serializeRequirementDrivers($drivers['requirementDriversMissingExt'], true) 38 | ); 39 | 40 | return [ 41 | 'action' => $action, 42 | 'metric' => $metric, 43 | 'requirement' => $requirement 44 | ]; 45 | } 46 | 47 | protected function serializeActionDrivers($drivers, $missingExt = false) 48 | { 49 | return collect($drivers) 50 | ->map(function (ActionDriverInterface $driver) use ($missingExt) { 51 | return [ 52 | 'availableSettings' => $driver->availableSettings(), 53 | 'translationKey' => $driver->translationKey(), 54 | 'missingExt' => $missingExt 55 | ]; 56 | }) 57 | ->toArray(); 58 | } 59 | 60 | protected function serializeMetricDrivers($drivers, $missingExt = false) 61 | { 62 | return collect($drivers) 63 | ->map(function (MetricDriverInterface $driver) use ($missingExt) { 64 | return [ 65 | 'translationKey' => $driver->translationKey(), 66 | 'missingExt' => $missingExt 67 | ]; 68 | }) 69 | ->toArray(); 70 | } 71 | 72 | protected function serializeRequirementDrivers($drivers, $missingExt = false) 73 | { 74 | return collect($drivers) 75 | ->map(function (RequirementDriverInterface $driver) use ($missingExt) { 76 | return [ 77 | 'availableSettings' => $driver->availableSettings(), 78 | 'translationKey' => $driver->translationKey(), 79 | 'missingExt' => $missingExt 80 | ]; 81 | }) 82 | ->toArray(); 83 | } 84 | 85 | public function getId($model) 86 | { 87 | return 'global'; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/DriverInterface.php: -------------------------------------------------------------------------------- 1 | extensions = $extensions; 30 | } 31 | 32 | /** 33 | * @var array 34 | */ 35 | protected $drivers = []; 36 | 37 | /** 38 | * @param I $driver 39 | * @return void 40 | */ 41 | public function addDriver(DriverInterface $driver) 42 | { 43 | $name = $driver->id(); 44 | $this->drivers[$name] = $driver; 45 | } 46 | 47 | /** 48 | * @param string $id 49 | * @return I|null 50 | */ 51 | public function getDriver(string $id) 52 | { 53 | $filtered = $this->getDrivers(); 54 | 55 | return Arr::get($filtered, $id); 56 | } 57 | 58 | /** 59 | * @param bool $inverse 60 | * @return array 61 | */ 62 | public function getDrivers(bool $inverse = false) 63 | { 64 | $filtered = array_filter($this->drivers, function ($driver) { 65 | foreach ($driver->extensionDependencies() as $extensionId) { 66 | if (!$this->extensions->isEnabled($extensionId)) { 67 | return false; 68 | } 69 | } 70 | 71 | return true; 72 | }); 73 | 74 | if ($inverse) { 75 | return array_diff_key($this->drivers, $filtered); 76 | } 77 | 78 | return $filtered; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/DriverWithSettingsInterface.php: -------------------------------------------------------------------------------- 1 | $driver 37 | */ 38 | public function triggerDriver(string $driver) 39 | { 40 | $this->triggerDrivers[] = $driver; 41 | 42 | return $this; 43 | } 44 | 45 | /** 46 | * Define a new action driver. 47 | * 48 | * @param class-string $driver 49 | */ 50 | public function actionDriver(string $driver) 51 | { 52 | $this->actionDrivers[] = $driver; 53 | 54 | return $this; 55 | } 56 | 57 | /** 58 | * Define a new metric driver. 59 | * 60 | * @param class-string $driver 61 | */ 62 | public function metricDriver(string $driver) 63 | { 64 | $this->metricDrivers[] = $driver; 65 | 66 | return $this; 67 | } 68 | 69 | /** 70 | * Define a new requirement driver. 71 | * 72 | * @param class-string $driver 73 | */ 74 | public function requirementDriver(string $driver) 75 | { 76 | $this->requirementDrivers[] = $driver; 77 | 78 | return $this; 79 | } 80 | 81 | public function extend(Container $container, Extension $extension = null) 82 | { 83 | $container->resolving(TriggerManager::class, function ($triggers) use ($container) { 84 | foreach ($this->triggerDrivers as $driver) { 85 | $triggers->addDriver($container->make($driver)); 86 | } 87 | 88 | return $triggers; 89 | }); 90 | 91 | $container->resolving(ActionManager::class, function ($actions) use ($container) { 92 | foreach ($this->actionDrivers as $driver) { 93 | $actions->addDriver($container->make($driver)); 94 | } 95 | 96 | return $actions; 97 | }); 98 | 99 | $container->resolving(MetricManager::class, function ($metrics) use ($container) { 100 | foreach ($this->metricDrivers as $driver) { 101 | $metrics->addDriver($container->make($driver)); 102 | } 103 | 104 | return $metrics; 105 | }); 106 | 107 | $container->resolving(RequirementManager::class, function ($requirements) use ($container) { 108 | foreach ($this->requirementDrivers as $driver) { 109 | $requirements->addDriver($container->make($driver)); 110 | } 111 | 112 | return $requirements; 113 | }); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Metric/Drivers/BestAnswers.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class BestAnswers implements MetricDriverInterface 23 | { 24 | public function subject(): string { 25 | return User::class; 26 | } 27 | 28 | public function id(): string { 29 | return 'best_answers'; 30 | } 31 | 32 | public function extensionDependencies(): array 33 | { 34 | return ['fof-best-answer']; 35 | } 36 | 37 | public function translationKey(): string 38 | { 39 | return 'askvortsov-automod.admin.metric_drivers.best_answers'; 40 | } 41 | 42 | public function getValue(AbstractModel $user): int 43 | { 44 | return $user->posts()->join('discussions', 'discussions.best_answer_post_id', '=', 'posts.id') 45 | ->count(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Metric/Drivers/DiscussionsEntered.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class DiscussionsEntered implements MetricDriverInterface 23 | { 24 | public function subject(): string { 25 | return User::class; 26 | } 27 | 28 | public function id(): string { 29 | return 'discussions_entered'; 30 | } 31 | 32 | public function translationKey(): string 33 | { 34 | return 'askvortsov-automod.admin.metric_drivers.discussions_entered'; 35 | } 36 | 37 | public function extensionDependencies(): array 38 | { 39 | return []; 40 | } 41 | 42 | public function getValue(AbstractModel $user): int 43 | { 44 | return $user->read()->count(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Metric/Drivers/DiscussionsParticipated.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class DiscussionsParticipated implements MetricDriverInterface 23 | { 24 | public function subject(): string { 25 | return User::class; 26 | } 27 | 28 | public function id(): string { 29 | return 'discussions_participated'; 30 | } 31 | public function translationKey(): string 32 | { 33 | return 'askvortsov-automod.admin.metric_drivers.discussions_participated'; 34 | } 35 | 36 | public function extensionDependencies(): array 37 | { 38 | return []; 39 | } 40 | 41 | public function getValue(AbstractModel $user): int 42 | { 43 | return $user->posts() 44 | ->where('type', 'comment') 45 | ->where('is_private', false) 46 | ->distinct()->count('discussion_id'); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Metric/Drivers/DiscussionsStarted.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class DiscussionsStarted implements MetricDriverInterface 23 | { 24 | public function subject(): string 25 | { 26 | return User::class; 27 | } 28 | 29 | public function id(): string 30 | { 31 | return 'discussions_started'; 32 | } 33 | 34 | public function translationKey(): string 35 | { 36 | return 'askvortsov-automod.admin.metric_drivers.discussions_started'; 37 | } 38 | 39 | public function extensionDependencies(): array 40 | { 41 | return []; 42 | } 43 | 44 | public function getValue(AbstractModel $user): int 45 | { 46 | return intval($user->discussion_count); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Metric/Drivers/LikesGiven.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class LikesGiven implements MetricDriverInterface 24 | { 25 | public function subject(): string { 26 | return User::class; 27 | } 28 | 29 | public function id(): string { 30 | return 'likes_given'; 31 | } 32 | 33 | public function translationKey(): string 34 | { 35 | return 'askvortsov-automod.admin.metric_drivers.likes_given'; 36 | } 37 | 38 | public function extensionDependencies(): array 39 | { 40 | return ['flarum-likes']; 41 | } 42 | 43 | public function getValue(AbstractModel $user): int 44 | { 45 | return $user->join('post_likes', 'users.id', '=', 'post_likes.user_id')->where('users.id', $user->id)->count(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Metric/Drivers/LikesReceived.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | class LikesReceived implements MetricDriverInterface 25 | { 26 | public function subject(): string { 27 | return User::class; 28 | } 29 | 30 | public function id(): string { 31 | return 'likes_received'; 32 | } 33 | 34 | public function translationKey(): string 35 | { 36 | return 'askvortsov-automod.admin.metric_drivers.likes_received'; 37 | } 38 | 39 | public function extensionDependencies(): array 40 | { 41 | return ['flarum-likes']; 42 | } 43 | 44 | public function getValue(AbstractModel $user): int 45 | { 46 | if (property_exists($user, 'clarkwinkelmann_likes_received_count')) { 47 | return intval($user->clarkwinkelmann_likes_received_count); 48 | } 49 | 50 | return Post::where('user_id', $user->id)->select('id')->withCount('likes')->get()->sum('likes_count'); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Metric/Drivers/ModeratorStrikes.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class ModeratorStrikes implements MetricDriverInterface 23 | { 24 | public function subject(): string { 25 | return User::class; 26 | } 27 | 28 | public function id(): string { 29 | return 'moderator_strikes'; 30 | } 31 | 32 | public function translationKey(): string 33 | { 34 | return 'askvortsov-automod.admin.metric_drivers.moderator_strikes'; 35 | } 36 | 37 | public function extensionDependencies(): array 38 | { 39 | return ['askvortsov-moderator-warnings']; 40 | } 41 | 42 | public function getValue(AbstractModel $user): int 43 | { 44 | return Warning::where('user_id', $user->id)->sum('strikes'); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Metric/Drivers/PostsMade.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class PostsMade implements MetricDriverInterface 23 | { 24 | public function subject(): string { 25 | return User::class; 26 | } 27 | 28 | public function id(): string { 29 | return 'posts_made'; 30 | } 31 | 32 | public function extensionDependencies(): array 33 | { 34 | return []; 35 | } 36 | 37 | 38 | public function translationKey(): string 39 | { 40 | return 'askvortsov-automod.admin.metric_drivers.posts_made'; 41 | } 42 | 43 | public function getValue(AbstractModel $user): int 44 | { 45 | return intval($user->comment_count); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Metric/MetricDriverInterface.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | public function subject(): string; 28 | 29 | /** 30 | * Get the current value of this metric for a given subject. 31 | * 32 | * @param T $subject 33 | */ 34 | public function getValue(AbstractModel $subject): int; 35 | } 36 | -------------------------------------------------------------------------------- /src/Metric/MetricManager.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class MetricManager extends DriverManager 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /src/Provider/AutoModeratorProvider.php: -------------------------------------------------------------------------------- 1 | get('automod-rules', '[]'); 23 | $json = json_decode($raw, true); 24 | 25 | $rules = collect($json)->map(fn (array $r) => new Rule($r))->toArray(); 26 | $rulesByTriggers = collect($rules)->groupBy(fn (Rule $r) => $r->triggerId); 27 | 28 | foreach ($triggerManager->getDrivers() as $trigger) { 29 | /** 30 | * @var array $rules 31 | */ 32 | $rules = $rulesByTriggers->get($trigger->id(), []); 33 | 34 | $events->listen($trigger->eventClass(), function ($e) use ($rules) { 35 | foreach ($rules as $rule) { 36 | $rule->execute($e); 37 | } 38 | }); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Requirement/Drivers/EmailConfirmed.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class EmailConfirmed implements RequirementDriverInterface 16 | { 17 | public function subject(): string { 18 | return User::class; 19 | } 20 | 21 | public function id(): string { 22 | return 'email_confirmed'; 23 | } 24 | 25 | public function translationKey(): string { 26 | return 'askvortsov-automod.admin.requirement_drivers.email_confirmed'; 27 | } 28 | 29 | public function extensionDependencies(): array { 30 | return []; 31 | } 32 | 33 | public function availableSettings(): array 34 | { 35 | return []; 36 | } 37 | 38 | public function validateSettings(array $settings, Factory $validator): MessageBag 39 | { 40 | return $validator->make($settings, [])->errors(); 41 | } 42 | 43 | public function subjectSatisfies(AbstractModel $user, array $settings ): bool { 44 | return boolval($user->is_email_confirmed); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Requirement/Drivers/EmailMatchesRegex.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class EmailMatchesRegex implements RequirementDriverInterface 16 | { 17 | public function subject(): string { 18 | return User::class; 19 | } 20 | 21 | public function id(): string { 22 | return 'email_matches_regex'; 23 | } 24 | 25 | public function translationKey(): string { 26 | return 'askvortsov-automod.admin.requirement_drivers.email_matches_regex'; 27 | } 28 | 29 | public function extensionDependencies(): array { 30 | return []; 31 | } 32 | 33 | public function availableSettings(): array 34 | { 35 | return [ 36 | 'regex' => 'askvortsov-automod.admin.in_group_settings.regex' 37 | ]; 38 | } 39 | 40 | public function validateSettings(array $settings, Factory $validator): MessageBag 41 | { 42 | return $validator->make($settings, [ 43 | 'regex' => 'required|string', 44 | ])->errors(); 45 | } 46 | 47 | public function subjectSatisfies(AbstractModel $user, array $settings ): bool { 48 | $regex = $settings['regex']; 49 | return boolval(preg_match("/$regex/", $user->email)); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Requirement/Drivers/InGroup.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class InGroup implements RequirementDriverInterface 15 | { 16 | public function subject(): string { 17 | return User::class; 18 | } 19 | 20 | public function id(): string { 21 | return 'in_group'; 22 | } 23 | 24 | 25 | public function translationKey(): string { 26 | return 'askvortsov-automod.admin.requirement_drivers.in_group'; 27 | } 28 | 29 | public function extensionDependencies(): array { 30 | return []; 31 | } 32 | 33 | public function availableSettings(): array 34 | { 35 | return [ 36 | 'group_id' => 'askvortsov-automod.lib.group_id' 37 | ]; 38 | } 39 | 40 | public function validateSettings(array $settings, Factory $validator): MessageBag 41 | { 42 | return $validator->make($settings, [ 43 | 'group_id' => 'required|integer', 44 | ])->errors(); 45 | } 46 | 47 | public function subjectSatisfies(AbstractModel $user, array $settings ): bool { 48 | return $user->groups()->where('groups.id', $settings['group_id'])->exists(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Requirement/Drivers/Suspended.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class Suspended implements RequirementDriverInterface 15 | { 16 | public function subject(): string { 17 | return User::class; 18 | } 19 | 20 | public function id(): string { 21 | return 'suspended'; 22 | } 23 | 24 | public function translationKey(): string { 25 | return 'askvortsov-automod.admin.requirement_drivers.suspended'; 26 | } 27 | 28 | public function extensionDependencies(): array { 29 | return ['flarum-suspend']; 30 | } 31 | 32 | public function availableSettings(): array 33 | { 34 | return []; 35 | } 36 | 37 | public function validateSettings(array $settings, Factory $validator): MessageBag 38 | { 39 | return $validator->make($settings, [])->errors(); 40 | } 41 | 42 | public function subjectSatisfies(AbstractModel $user, array $settings): bool { 43 | /** @phpstan-ignore-next-line */ 44 | return $user->suspended_until !== null; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Requirement/RequirementDriverInterface.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | public function subject(): string; 29 | 30 | /** 31 | * Does the given subject satisfy the requirement? 32 | * 33 | * @param T $subject 34 | */ 35 | public function subjectSatisfies(AbstractModel $subject, array $settings ): bool; 36 | } 37 | -------------------------------------------------------------------------------- /src/Requirement/RequirementManager.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | class RequirementManager extends DriverManager {} 22 | -------------------------------------------------------------------------------- /src/Rule.php: -------------------------------------------------------------------------------- 1 | > 40 | */ 41 | public $triggerId; 42 | 43 | /** 44 | * @var list, settings: array, negate: bool}> 45 | */ 46 | public $actions; 47 | 48 | /** 49 | * @var list, min: int, max: int, negate: bool}> 50 | */ 51 | public $metrics; 52 | 53 | /** 54 | * @var list, settings: array}> 55 | */ 56 | public $requirements; 57 | 58 | public function __construct(array $json) 59 | { 60 | $this->triggerId = $json['triggerId']; 61 | $this->actions = $json['actions']; 62 | $this->metrics = $json['metrics'] ?? []; 63 | $this->requirements = $json['requirements'] ?? []; 64 | } 65 | 66 | public function execute(mixed $event) 67 | { 68 | /** @var TriggerManager $triggerManager */ 69 | $triggerManager = resolve(TriggerManager::class); 70 | /** @var ActionManager $actionManager */ 71 | $actionManager = resolve(ActionManager::class); 72 | /** @var MetricManager $metricManager */ 73 | $metricManager = resolve(MetricManager::class); 74 | /** @var RequirementManager $requirementManager */ 75 | $requirementManager = resolve(RequirementManager::class); 76 | 77 | $triggerDriver = $triggerManager->getDriver($this->triggerId); 78 | if ($triggerDriver === null) { 79 | return; 80 | } 81 | 82 | if (!$this->isValid($triggerManager, $actionManager, $metricManager, $requirementManager)) { 83 | return; 84 | } 85 | 86 | foreach ($this->metrics as $metricConfig) { 87 | $metricDriver = $metricManager->getDriver($metricConfig['id']); 88 | 89 | if ($metricDriver === null) { 90 | // Shouldn't get here due to `isValid` checks. 91 | return; 92 | } 93 | 94 | $subject = $triggerDriver->getSubject($metricDriver->subject(), $event); 95 | 96 | $metricVal = $metricDriver->getValue($subject); 97 | 98 | $min = Arr::get($metricConfig, "min", -1); 99 | $max = Arr::get($metricConfig, "max", -1); 100 | $withinRange = ($min === -1 || $metricVal >= $min) && ($max === -1 || $metricVal <= $max); 101 | 102 | $meets = boolval(Arr::get($metricConfig, 'negate', false)) ^ $withinRange; 103 | 104 | if (!$meets) return; 105 | } 106 | 107 | foreach ($this->requirements as $reqConfig) { 108 | $reqDriver = $requirementManager->getDriver($reqConfig['id']); 109 | if ($reqDriver === null) { 110 | // Shouldn't get here due to `isValid` checks. 111 | return; 112 | } 113 | 114 | $subject = $triggerDriver->getSubject($reqDriver->subject(), $event); 115 | 116 | $settings = Arr::get($reqConfig, 'settings', []); 117 | 118 | $meets = Arr::get($reqConfig, 'negate', false) ^ $reqDriver->subjectSatisfies($subject, $settings); 119 | if (!$meets) return; 120 | } 121 | 122 | $lastEditedBy = new Guest(); 123 | // $lastEditedBy = User::find($this->lastEditedById); 124 | 125 | foreach ($this->actions as $actionConfig) { 126 | $actionDriver = $actionManager->getDriver($actionConfig['id']); 127 | if ($actionDriver === null) { 128 | // Shouldn't get here due to `isValid` checks. 129 | return; 130 | } 131 | 132 | $subject = $triggerDriver->getSubject($actionDriver->subject(), $event); 133 | 134 | $settings = Arr::get($actionConfig, 'settings', []); 135 | $actionDriver->execute($subject, $settings, $lastEditedBy); 136 | } 137 | } 138 | 139 | public function isValid(TriggerManager $triggers, ActionManager $actions, MetricManager $metrics, RequirementManager $requirements) 140 | { 141 | return $this->validateDrivers($triggers, [['id' => $this->triggerId]]) && 142 | $this->validateDrivers($actions, $this->actions) && 143 | $this->validateDrivers($metrics, $this->metrics) && 144 | $this->validateDrivers($requirements, $this->requirements) && 145 | $this->invalidDriverSettings($actions, $this->actions)->isEmpty() && 146 | $this->invalidDriverSettings($requirements, $this->requirements)->isEmpty(); 147 | } 148 | 149 | /** 150 | * @param DriverManager $drivers 151 | * @param list $driverConfigs 152 | * @return bool 153 | */ 154 | protected function validateDrivers(DriverManager $drivers, array $driverConfigs): bool 155 | { 156 | $driversWithMissingExts = $drivers->getDrivers(true); 157 | $hasDriversWithMissingExts = collect($driverConfigs) 158 | ->some(function ($driverConfig) use ($driversWithMissingExts) { 159 | return array_key_exists($driverConfig['id'], $driversWithMissingExts); 160 | }); 161 | 162 | $allDrivers = $drivers->getDrivers(); 163 | $hasDriversMissing = collect($driverConfigs) 164 | ->some(function ($driver) use ($allDrivers) { 165 | return !array_key_exists($driver['id'], $allDrivers); 166 | }); 167 | 168 | return !$hasDriversWithMissingExts && !$hasDriversMissing; 169 | } 170 | 171 | /** 172 | * @template T of DriverWithSettingsInterface 173 | * @param DriverManager $drivers 174 | * @param list, settings: array}> $driverConfigs 175 | * @return MessageBag 176 | */ 177 | public function invalidDriverSettings(DriverManager $drivers, array $driverConfigs): MessageBag 178 | { 179 | $factory = resolve(Factory::class); 180 | $allDrivers = $drivers->getDrivers(); 181 | 182 | 183 | return collect($driverConfigs) 184 | ->reduce(function (MessageBag $acc, $driverConfig) use ($allDrivers, $factory) { 185 | if (($driver = Arr::get($allDrivers, $driverConfig['id']))) { 186 | /** @var MessageBagConcrete */ 187 | $errors = $driver->validateSettings($driverConfig['settings'] ?? [], $factory); 188 | 189 | return $acc->merge($errors->getMessages()); 190 | } 191 | 192 | return $acc; 193 | }, new MessageBagConcrete()); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/Trigger/Drivers/CommentPostPosted.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class CommentPostPosted implements TriggerDriverInterface 14 | { 15 | public function eventClass(): string 16 | { 17 | return Posted::class; 18 | } 19 | 20 | public function subjectClasses(): array 21 | { 22 | return [Post::class, User::class]; 23 | } 24 | 25 | public function getSubject(string $subjectClass, mixed $event): AbstractModel 26 | { 27 | switch ($subjectClass) { 28 | case Post::class: 29 | return $event->post; 30 | case User::class: 31 | return $event->user; 32 | } 33 | } 34 | 35 | public function id(): string 36 | { 37 | return "comment_post_posted"; 38 | } 39 | 40 | public function translationKey(): string 41 | { 42 | return "askvortsov-automod.admin.trigger_drivers.comment_post_posted"; 43 | } 44 | 45 | public function extensionDependencies(): array 46 | { 47 | return []; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Trigger/Drivers/DiscussionStarted.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class DiscussionStarted implements TriggerDriverInterface 15 | { 16 | public function eventClass(): string 17 | { 18 | return Started::class; 19 | } 20 | 21 | public function subjectClasses(): array 22 | { 23 | return [Discussion::class, Post::class, User::class]; 24 | } 25 | 26 | public function getSubject(string $subjectClass, mixed $event): AbstractModel 27 | { 28 | switch ($subjectClass) { 29 | case Discussion::class: 30 | return $event->discussion; 31 | case Post::class: 32 | return $event->discussion->firstPost; 33 | case User::class: 34 | return $event->user; 35 | } 36 | } 37 | 38 | public function id(): string 39 | { 40 | return "discussion_started"; 41 | } 42 | 43 | public function translationKey(): string 44 | { 45 | return "askvortsov-automod.admin.trigger_drivers.discussion_started"; 46 | } 47 | 48 | public function extensionDependencies(): array 49 | { 50 | return []; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Trigger/Drivers/PostLiked.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class PostLiked implements TriggerDriverInterface 14 | { 15 | public function eventClass(): string 16 | { 17 | return PostWasLiked::class; 18 | } 19 | 20 | public function subjectClasses(): array 21 | { 22 | return [Post::class, User::class]; 23 | } 24 | 25 | public function getSubject(string $subjectClass, mixed $event): AbstractModel 26 | { 27 | switch ($subjectClass) { 28 | case Post::class: 29 | return $event->post; 30 | case User::class: 31 | return $event->user; 32 | } 33 | } 34 | 35 | public function id(): string 36 | { 37 | return "post_liked"; 38 | } 39 | 40 | public function translationKey(): string 41 | { 42 | return "askvortsov-automod.admin.trigger_drivers.post_liked"; 43 | } 44 | 45 | public function extensionDependencies(): array 46 | { 47 | return ['flarum-likes']; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Trigger/Drivers/PostUnliked.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class PostUnliked implements TriggerDriverInterface 14 | { 15 | public function eventClass(): string 16 | { 17 | return PostWasUnliked::class; 18 | } 19 | 20 | public function subjectClasses(): array 21 | { 22 | return [Post::class, User::class]; 23 | } 24 | 25 | public function getSubject(string $subjectClass, mixed $event): AbstractModel 26 | { 27 | switch ($subjectClass) { 28 | case Post::class: 29 | return $event->post; 30 | case User::class: 31 | return $event->user; 32 | } 33 | } 34 | 35 | public function id(): string 36 | { 37 | return "post_unliked"; 38 | } 39 | 40 | public function translationKey(): string 41 | { 42 | return "askvortsov-automod.admin.trigger_drivers.post_unliked"; 43 | } 44 | 45 | public function extensionDependencies(): array 46 | { 47 | return ['flarum-likes']; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Trigger/Drivers/UserActivated.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class UserActivated implements TriggerDriverInterface 13 | { 14 | public function eventClass(): string 15 | { 16 | return Activated::class; 17 | } 18 | 19 | public function subjectClasses(): array 20 | { 21 | return [User::class]; 22 | } 23 | 24 | public function getSubject(string $subjectClass, mixed $event): AbstractModel 25 | { 26 | return $event->user; 27 | } 28 | 29 | public function id(): string 30 | { 31 | return "user_activated"; 32 | } 33 | 34 | public function translationKey(): string 35 | { 36 | return "askvortsov-automod.admin.trigger_drivers.user_activated"; 37 | } 38 | 39 | public function extensionDependencies(): array 40 | { 41 | return []; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Trigger/Drivers/UserGroupsChanged.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class UserGroupsChanged implements TriggerDriverInterface 13 | { 14 | public function eventClass(): string 15 | { 16 | return GroupsChanged::class; 17 | } 18 | 19 | public function subjectClasses(): array 20 | { 21 | return [User::class]; 22 | } 23 | 24 | public function getSubject(string $subjectClass, mixed $event): AbstractModel 25 | { 26 | return $event->user; 27 | } 28 | 29 | public function id(): string 30 | { 31 | return "user_groups_changed"; 32 | } 33 | 34 | public function translationKey(): string 35 | { 36 | return "askvortsov-automod.admin.trigger_drivers.user_groups_changed"; 37 | } 38 | 39 | public function extensionDependencies(): array 40 | { 41 | return []; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Trigger/Drivers/UserLoggedIn.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class UserLoggedIn implements TriggerDriverInterface 12 | { 13 | public function eventClass(): string 14 | { 15 | return LoggedIn::class; 16 | } 17 | 18 | public function subjectClasses(): array 19 | { 20 | return [User::class]; 21 | } 22 | 23 | public function getSubject(string $subjectClass, mixed $event): AbstractModel 24 | { 25 | return $event->user; 26 | } 27 | 28 | public function id(): string 29 | { 30 | return "user_logged_in"; 31 | } 32 | 33 | public function translationKey(): string 34 | { 35 | return "askvortsov-automod.admin.trigger_drivers.user_logged_in"; 36 | } 37 | 38 | public function extensionDependencies(): array 39 | { 40 | return []; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Trigger/Drivers/UserRead.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class UserRead implements TriggerDriverInterface 12 | { 13 | public function eventClass(): string 14 | { 15 | return Read::class; 16 | } 17 | 18 | public function subjectClasses(): array 19 | { 20 | return [User::class]; 21 | } 22 | 23 | public function getSubject(string $subjectClass, mixed $event): AbstractModel 24 | { 25 | return $event->user; 26 | } 27 | 28 | public function id(): string 29 | { 30 | return "user_read"; 31 | } 32 | 33 | public function translationKey(): string 34 | { 35 | return "askvortsov-automod.admin.trigger_drivers.user_read"; 36 | } 37 | 38 | public function extensionDependencies(): array 39 | { 40 | return []; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Trigger/Drivers/UserRegistered.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class UserRegistered implements TriggerDriverInterface 12 | { 13 | public function eventClass(): string 14 | { 15 | return Registered::class; 16 | } 17 | 18 | public function subjectClasses(): array 19 | { 20 | return [User::class]; 21 | } 22 | 23 | public function getSubject(string $subjectClass, mixed $event): AbstractModel 24 | { 25 | return $event->user; 26 | } 27 | 28 | public function id(): string 29 | { 30 | return "user_registered"; 31 | } 32 | 33 | public function translationKey(): string 34 | { 35 | return "askvortsov-automod.admin.trigger_drivers.user_registered"; 36 | } 37 | 38 | public function extensionDependencies(): array 39 | { 40 | return []; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Trigger/Drivers/UserSaved.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class UserSaving implements TriggerDriverInterface 12 | { 13 | public function eventClass(): string 14 | { 15 | return Saving::class; 16 | } 17 | 18 | public function subjectClasses(): array 19 | { 20 | return [User::class]; 21 | } 22 | 23 | public function getSubject(string $subjectClass, mixed $event): AbstractModel 24 | { 25 | return $event->user; 26 | } 27 | 28 | public function id(): string 29 | { 30 | return "user_saving"; 31 | } 32 | 33 | public function translationKey(): string 34 | { 35 | return "askvortsov-automod.admin.trigger_drivers.user_saving"; 36 | } 37 | 38 | public function extensionDependencies(): array 39 | { 40 | return []; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Trigger/Drivers/UserSuspended.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class UserSuspended implements TriggerDriverInterface 12 | { 13 | public function eventClass(): string 14 | { 15 | return Suspended::class; 16 | } 17 | 18 | public function subjectClasses(): array 19 | { 20 | return [User::class]; 21 | } 22 | 23 | public function getSubject(string $subjectClass, mixed $event): AbstractModel 24 | { 25 | return $event->user; 26 | } 27 | 28 | public function id(): string 29 | { 30 | return "user_suspended"; 31 | } 32 | 33 | public function translationKey(): string 34 | { 35 | return "askvortsov-automod.admin.trigger_drivers.user_suspended"; 36 | } 37 | 38 | public function extensionDependencies(): array 39 | { 40 | return ["flarum-suspend"]; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Trigger/Drivers/UserUnsuspended .php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class UserUnsuspended implements TriggerDriverInterface 12 | { 13 | public function eventClass(): string 14 | { 15 | return Unsuspended::class; 16 | } 17 | 18 | public function subjectClasses(): array 19 | { 20 | return [User::class]; 21 | } 22 | 23 | public function getSubject(string $subjectClass, mixed $event): AbstractModel 24 | { 25 | return $event->user; 26 | } 27 | 28 | public function id(): string 29 | { 30 | return "user_unsuspended"; 31 | } 32 | 33 | public function translationKey(): string 34 | { 35 | return "askvortsov-automod.admin.trigger_drivers.user_unsuspended"; 36 | } 37 | 38 | public function extensionDependencies(): array 39 | { 40 | return ["flarum-suspend"]; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Trigger/TriggerDriverInterface.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | public function eventClass(): string; 19 | 20 | /** 21 | * Which subjects are part of this trigger? 22 | * 23 | * @return list> 24 | */ 25 | public function subjectClasses(): array; 26 | 27 | /** 28 | * @template T of AbstractModel 29 | * @param class-string $subjectClass 30 | * @param mixed $event 31 | * @return T 32 | */ 33 | public function getSubject(string $subjectClass, mixed $event): AbstractModel; 34 | } 35 | -------------------------------------------------------------------------------- /src/Trigger/TriggerManager.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class TriggerManager extends DriverManager 20 | {} 21 | --------------------------------------------------------------------------------