Hooks
- Hooks now verify the expected number of JSON lines emitted by hook scripts.
This commit is contained in:
@@ -27,7 +27,7 @@
|
|||||||
- TW-1531 'task export' should handle recurrence (thanks to Tomas Babej).
|
- TW-1531 'task export' should handle recurrence (thanks to Tomas Babej).
|
||||||
- TW-1532 Hooks does not execute any script on Cygwin (thanks to Taisuke
|
- TW-1532 Hooks does not execute any script on Cygwin (thanks to Taisuke
|
||||||
Hachimura).
|
Hachimura).
|
||||||
- TW-1534 Urgency coefficient for user project disables 'info' output(thanks to
|
- TW-1534 Urgency coefficient for user project disables 'info' output (thanks to
|
||||||
Martin).
|
Martin).
|
||||||
- Fixed assorted color theme problems.
|
- Fixed assorted color theme problems.
|
||||||
- Changed assorted reports so they do not use '.age' format for dates that are
|
- Changed assorted reports so they do not use '.age' format for dates that are
|
||||||
|
|||||||
332
src/Hooks.cpp
332
src/Hooks.cpp
@@ -130,29 +130,24 @@ void Hooks::onLaunch ()
|
|||||||
std::vector <std::string> output;
|
std::vector <std::string> output;
|
||||||
int status = callHookScript (*script, input, output);
|
int status = callHookScript (*script, input, output);
|
||||||
|
|
||||||
std::vector <std::string>::iterator line;
|
std::vector <std::string> outputJSON;
|
||||||
for (line = output.begin (); line != output.end (); ++line)
|
std::vector <std::string> outputFeedback;
|
||||||
{
|
separateOutput (output, outputJSON, outputFeedback);
|
||||||
if (isJSON (*line))
|
|
||||||
{
|
|
||||||
if (_debug >= 2)
|
|
||||||
context.error ("Line of JSON output ignored: " + (*line));
|
|
||||||
|
|
||||||
else if (_debug >= 1)
|
assertNTasks (outputJSON, 0);
|
||||||
context.error ("Line(s) of JSON output ignored.");
|
|
||||||
}
|
if (status == 0)
|
||||||
else
|
{
|
||||||
{
|
std::vector <std::string>::iterator message;
|
||||||
if (status == 0)
|
for (message = outputFeedback.begin (); message != outputFeedback.end (); ++message)
|
||||||
context.header (*line);
|
context.debug (*message);
|
||||||
else
|
|
||||||
context.error (*line);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
if (status)
|
|
||||||
{
|
{
|
||||||
// TODO Hooks debug info needed.
|
std::vector <std::string>::iterator message;
|
||||||
|
for (message = outputFeedback.begin (); message != outputFeedback.end (); ++message)
|
||||||
|
context.error (*message);
|
||||||
|
|
||||||
throw 0; // This is how hooks silently terminate processing.
|
throw 0; // This is how hooks silently terminate processing.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,29 +195,24 @@ void Hooks::onExit ()
|
|||||||
std::vector <std::string> output;
|
std::vector <std::string> output;
|
||||||
int status = callHookScript (*script, input, output);
|
int status = callHookScript (*script, input, output);
|
||||||
|
|
||||||
std::vector <std::string>::iterator line;
|
std::vector <std::string> outputJSON;
|
||||||
for (line = output.begin (); line != output.end (); ++line)
|
std::vector <std::string> outputFeedback;
|
||||||
{
|
separateOutput (output, outputJSON, outputFeedback);
|
||||||
if (isJSON (*line))
|
|
||||||
{
|
|
||||||
if (_debug >= 2)
|
|
||||||
context.error ("Line of JSON output ignored: " + (*line));
|
|
||||||
|
|
||||||
else if (_debug >= 1)
|
assertNTasks (outputJSON, 0);
|
||||||
context.error ("Line(s) of JSON output ignored.");
|
|
||||||
}
|
if (status == 0)
|
||||||
else
|
{
|
||||||
{
|
std::vector <std::string>::iterator message;
|
||||||
if (status == 0)
|
for (message = outputFeedback.begin (); message != outputFeedback.end (); ++message)
|
||||||
context.footnote (*line);
|
context.debug (*message);
|
||||||
else
|
|
||||||
context.error (*line);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
if (status)
|
|
||||||
{
|
{
|
||||||
// TODO Hooks debug info needed.
|
std::vector <std::string>::iterator message;
|
||||||
|
for (message = outputFeedback.begin (); message != outputFeedback.end (); ++message)
|
||||||
|
context.error (*message);
|
||||||
|
|
||||||
throw 0; // This is how hooks silently terminate processing.
|
throw 0; // This is how hooks silently terminate processing.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,7 +243,7 @@ void Hooks::onAdd (Task& task)
|
|||||||
std::vector <std::string> matchingScripts = scripts ("on-add");
|
std::vector <std::string> matchingScripts = scripts ("on-add");
|
||||||
if (matchingScripts.size ())
|
if (matchingScripts.size ())
|
||||||
{
|
{
|
||||||
// Convert vector of tasks to a vector of strings.
|
// Convert task to a vector of strings.
|
||||||
std::vector <std::string> input;
|
std::vector <std::string> input;
|
||||||
input.push_back (task.composeJSON ());
|
input.push_back (task.composeJSON ());
|
||||||
|
|
||||||
@@ -264,31 +254,29 @@ void Hooks::onAdd (Task& task)
|
|||||||
std::vector <std::string> output;
|
std::vector <std::string> output;
|
||||||
int status = callHookScript (*script, input, output);
|
int status = callHookScript (*script, input, output);
|
||||||
|
|
||||||
std::vector <std::string>::iterator line;
|
std::vector <std::string> outputJSON;
|
||||||
for (line = output.begin (); line != output.end (); ++line)
|
std::vector <std::string> outputFeedback;
|
||||||
{
|
separateOutput (output, outputJSON, outputFeedback);
|
||||||
if (isJSON (*line))
|
|
||||||
{
|
|
||||||
// TODO Verify the same task UUID.
|
|
||||||
// TODO Verify only one task.
|
|
||||||
|
|
||||||
if (status == 0)
|
assertNTasks (outputJSON, 1);
|
||||||
input[0] = *line;
|
assertValidJSON (outputJSON);
|
||||||
else
|
assertSameTask (outputJSON, task);
|
||||||
; // TODO Hooks debug info needed.
|
|
||||||
}
|
if (status == 0)
|
||||||
else
|
{
|
||||||
{
|
// Propagate forward to the next script.
|
||||||
if (status == 0)
|
input[0] = outputJSON[0];
|
||||||
context.footnote (*line);
|
|
||||||
else
|
std::vector <std::string>::iterator message;
|
||||||
context.error (*line);
|
for (message = outputFeedback.begin (); message != outputFeedback.end (); ++message)
|
||||||
}
|
context.debug (*message);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
if (status)
|
|
||||||
{
|
{
|
||||||
// TODO Hooks debug info needed.
|
std::vector <std::string>::iterator message;
|
||||||
|
for (message = outputFeedback.begin (); message != outputFeedback.end (); ++message)
|
||||||
|
context.error (*message);
|
||||||
|
|
||||||
throw 0; // This is how hooks silently terminate processing.
|
throw 0; // This is how hooks silently terminate processing.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,34 +323,29 @@ void Hooks::onModify (const Task& before, Task& after)
|
|||||||
std::vector <std::string> output;
|
std::vector <std::string> output;
|
||||||
int status = callHookScript (*script, input, output);
|
int status = callHookScript (*script, input, output);
|
||||||
|
|
||||||
std::vector <std::string>::iterator line;
|
std::vector <std::string> outputJSON;
|
||||||
for (line = output.begin (); line != output.end (); ++line)
|
std::vector <std::string> outputFeedback;
|
||||||
{
|
separateOutput (output, outputJSON, outputFeedback);
|
||||||
if (isJSON (*line))
|
|
||||||
{
|
|
||||||
// TODO Verify the same task UUID.
|
|
||||||
// TODO Verify only one task.
|
|
||||||
|
|
||||||
if (status == 0)
|
assertNTasks (outputJSON, 2);
|
||||||
{
|
assertValidJSON (outputJSON);
|
||||||
if (JSONContainsUUID ((*line), before.get ("uuid")))
|
assertSameTask (outputJSON, before);
|
||||||
input[1] = *line; // [1] original'
|
|
||||||
else
|
if (status == 0)
|
||||||
; // TODO Error/debug
|
{
|
||||||
}
|
// Propagate forward to the next script.
|
||||||
}
|
input[1] = outputJSON[0];
|
||||||
else
|
|
||||||
{
|
std::vector <std::string>::iterator message;
|
||||||
if (status == 0)
|
for (message = outputFeedback.begin (); message != outputFeedback.end (); ++message)
|
||||||
context.footnote (*line);
|
context.debug (*message);
|
||||||
else
|
|
||||||
context.error (*line);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
if (status)
|
|
||||||
{
|
{
|
||||||
// TODO Hooks debug info needed.
|
std::vector <std::string>::iterator message;
|
||||||
|
for (message = outputFeedback.begin (); message != outputFeedback.end (); ++message)
|
||||||
|
context.error (*message);
|
||||||
|
|
||||||
throw 0; // This is how hooks silently terminate processing.
|
throw 0; // This is how hooks silently terminate processing.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,91 +378,145 @@ std::vector <std::string> Hooks::scripts (const std::string& event)
|
|||||||
return matching;
|
return matching;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
void Hooks::separateOutput (
|
||||||
|
const std::vector <std::string>& output,
|
||||||
|
std::vector <std::string>& json,
|
||||||
|
std::vector <std::string>& feedback) const
|
||||||
|
{
|
||||||
|
std::vector <std::string>::const_iterator i;
|
||||||
|
for (i = output.begin (); i != output.end (); ++i)
|
||||||
|
{
|
||||||
|
if (isJSON (*i))
|
||||||
|
json.push_back (*i);
|
||||||
|
else
|
||||||
|
feedback.push_back (*i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
bool Hooks::isJSON (const std::string& input) const
|
bool Hooks::isJSON (const std::string& input) const
|
||||||
{
|
{
|
||||||
// Does it even look like JSON? {...}
|
if (input.length () < 3 ||
|
||||||
if (input.length () > 2 &&
|
input[0] != '{' ||
|
||||||
input[0] == '{' &&
|
input[input.length () - 1] != '}')
|
||||||
input[input.length () - 1] == '}')
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
|
json::value* root = json::parse (input);
|
||||||
|
if (root->type () != json::j_object)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (((json::object*)root)->_data.find ("description") == ((json::object*)root)->_data.end ())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (((json::object*)root)->_data.find ("uuid") == ((json::object*)root)->_data.end ())
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
void Hooks::assertValidJSON (const std::vector <std::string>& input) const
|
||||||
|
{
|
||||||
|
std::vector <std::string>::const_iterator i;
|
||||||
|
for (i = input.begin (); i != input.end (); i++)
|
||||||
|
{
|
||||||
|
if (i->length () < 3 ||
|
||||||
|
(*i)[0] != '{' ||
|
||||||
|
(*i)[i->length () - 1] != '}')
|
||||||
|
{
|
||||||
|
context.error ("Hook Error: JSON Object '{...}' expected.");
|
||||||
|
throw 0;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// The absolute minimum a task needs is:
|
json::value* root = json::parse (*i);
|
||||||
bool foundDescription = false;
|
if (root->type () != json::j_object)
|
||||||
|
|
||||||
// Parse the whole thing.
|
|
||||||
json::value* root = json::parse (input);
|
|
||||||
if (root->type () == json::j_object)
|
|
||||||
{
|
{
|
||||||
json::object* root_obj = (json::object*)root;
|
context.error ("Hook Error: JSON Object '{...}' expected.");
|
||||||
|
throw 0;
|
||||||
// For each object element...
|
|
||||||
json_object_iter i;
|
|
||||||
for (i = root_obj->_data.begin ();
|
|
||||||
i != root_obj->_data.end ();
|
|
||||||
++i)
|
|
||||||
{
|
|
||||||
// If the attribute is a recognized column.
|
|
||||||
std::string type = Task::attributes[i->first];
|
|
||||||
if (type == "string" && i->first == "description")
|
|
||||||
foundDescription = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
|
||||||
throw std::string ("Object expected.");
|
|
||||||
|
|
||||||
// It's JSON, but is it a task?
|
if (((json::object*)root)->_data.find ("description") == ((json::object*)root)->_data.end ())
|
||||||
if (! foundDescription)
|
{
|
||||||
throw std::string ("Missing 'description' attribute, of type 'string'.");
|
context.error ("Hook Error: JSON Object missing 'description' attribute: '{\"description\":\"...\",...}'.");
|
||||||
|
throw 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Yep, looks like a JSON task.
|
if (((json::object*)root)->_data.find ("uuid") == ((json::object*)root)->_data.end ())
|
||||||
return true;
|
{
|
||||||
|
context.error ("Hook Error: JSON Object missing 'uuid' attribute: '{\"uuid\":\"...\",...}'.");
|
||||||
|
throw 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
catch (const std::string& e)
|
catch (const std::string& e)
|
||||||
{
|
{
|
||||||
if (_debug >= 1)
|
if (_debug >= 1)
|
||||||
context.error ("Hook output looks like JSON, but is not a valid task.");
|
context.error ("Hook Error: JSON syntax error in: " + *i);
|
||||||
|
|
||||||
if (_debug >= 2)
|
if (_debug >= 2)
|
||||||
context.error ("JSON " + e);
|
context.error ("Hook Error: JSON " + e);
|
||||||
|
|
||||||
|
throw 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
catch (...)
|
catch (...)
|
||||||
{
|
{
|
||||||
if (_debug >= 1)
|
if (_debug >= 1)
|
||||||
context.error ("Hook output looks like JSON, but fails to parse.");
|
context.error ("Hook Error: JSON fails to parse: " + *i);
|
||||||
|
|
||||||
|
throw 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
// This method assumes that input has already been validated with isJSON method
|
void Hooks::assertNTasks (const std::vector <std::string>& json, int n) const
|
||||||
bool Hooks::JSONContainsUUID (const std::string& input, const std::string& uuid) const
|
|
||||||
{
|
{
|
||||||
// Parse the whole thing.
|
if (json.size () != n)
|
||||||
json::object* root_obj = (json::object*) (json::parse (input));
|
{
|
||||||
|
context.error (format ("Hook Error: Expected {1} JSON task(s), found {2}", n, (int) json.size ()));
|
||||||
|
throw 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// For each object element...
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
json_object_iter i;
|
void Hooks::assertSameTask (const std::vector <std::string>& input, const Task& task) const
|
||||||
for (i = root_obj->_data.begin ();
|
{
|
||||||
i != root_obj->_data.end ();
|
std::string uuid = task.get ("uuid");
|
||||||
++i)
|
|
||||||
{
|
|
||||||
// If the attribute is a recognized column.
|
|
||||||
std::string type = Task::attributes[i->first];
|
|
||||||
if (type == "string" &&
|
|
||||||
i->first == "uuid" &&
|
|
||||||
json::decode (unquoteText (i->second->dump ())) == uuid)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
std::vector <std::string>::const_iterator i;
|
||||||
|
for (i = input.begin (); i != input.end (); i++)
|
||||||
|
{
|
||||||
|
json::object* root_obj = (json::object*)json::parse (*i);
|
||||||
|
|
||||||
|
// If there is no UUID at all.
|
||||||
|
json_object_iter u = root_obj->_data.find ("uuid");
|
||||||
|
if (u == root_obj->_data.end () ||
|
||||||
|
u->second->type () != json::j_string)
|
||||||
|
{
|
||||||
|
context.error (format ("Hook Error: JSON must be for the same task: {1}", uuid));
|
||||||
|
throw 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
json::string* up = (json::string*) u->second;
|
||||||
|
std::string json_uuid = json::decode (unquoteText (up->dump ()));
|
||||||
|
if (json_uuid != uuid)
|
||||||
|
{
|
||||||
|
context.error (format ("Hook Error: JSON must be for the same task: {1} != {2}", uuid, json_uuid));
|
||||||
|
throw 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
@@ -489,11 +526,11 @@ int Hooks::callHookScript (
|
|||||||
std::vector <std::string>& output)
|
std::vector <std::string>& output)
|
||||||
{
|
{
|
||||||
if (_debug >= 1)
|
if (_debug >= 1)
|
||||||
context.debug ("Hooks: Calling " + script);
|
context.debug ("Hook: Calling " + script);
|
||||||
|
|
||||||
if (_debug >= 2)
|
if (_debug >= 2)
|
||||||
{
|
{
|
||||||
context.debug ("Hooks: input");
|
context.debug ("Hook: input");
|
||||||
std::vector <std::string>::const_iterator i;
|
std::vector <std::string>::const_iterator i;
|
||||||
for (i = input.begin (); i != input.end (); ++i)
|
for (i = input.begin (); i != input.end (); ++i)
|
||||||
context.debug (" " + *i);
|
context.debug (" " + *i);
|
||||||
@@ -519,18 +556,17 @@ int Hooks::callHookScript (
|
|||||||
else
|
else
|
||||||
status = execute (script, args, inputStr, outputStr);
|
status = execute (script, args, inputStr, outputStr);
|
||||||
|
|
||||||
|
|
||||||
split (output, outputStr, '\n');
|
split (output, outputStr, '\n');
|
||||||
|
|
||||||
if (_debug >= 2)
|
if (_debug >= 2)
|
||||||
{
|
{
|
||||||
context.debug ("Hooks: output");
|
context.debug ("Hook: output");
|
||||||
std::vector <std::string>::iterator i;
|
std::vector <std::string>::iterator i;
|
||||||
for (i = output.begin (); i != output.end (); ++i)
|
for (i = output.begin (); i != output.end (); ++i)
|
||||||
if (*i != "")
|
if (*i != "")
|
||||||
context.debug (" " + *i);
|
context.debug (" " + *i);
|
||||||
|
|
||||||
context.debug (format ("Hooks: Completed with status {1}", status));
|
context.debug (format ("Hook: Completed with status {1}", status));
|
||||||
context.debug (" "); // Blank line
|
context.debug (" "); // Blank line
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,8 +51,11 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
std::vector <std::string> scripts (const std::string&);
|
std::vector <std::string> scripts (const std::string&);
|
||||||
|
void separateOutput (const std::vector <std::string>&, std::vector <std::string>&, std::vector <std::string>&) const;
|
||||||
bool isJSON (const std::string&) const;
|
bool isJSON (const std::string&) const;
|
||||||
bool JSONContainsUUID (const std::string&, const std::string&) const;
|
void assertValidJSON (const std::vector <std::string>&) const;
|
||||||
|
void assertNTasks (const std::vector <std::string>&, int) const;
|
||||||
|
void assertSameTask (const std::vector <std::string>&, const Task&) const;
|
||||||
int callHookScript (const std::string&, const std::vector <std::string>&, std::vector <std::string>&);
|
int callHookScript (const std::string&, const std::vector <std::string>&, std::vector <std::string>&);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -83,13 +83,12 @@ class TestHooksOnAdd(TestCase):
|
|||||||
hookname = 'on-add-misbehave2'
|
hookname = 'on-add-misbehave2'
|
||||||
self.t.hooks.add_default(hookname, log=True)
|
self.t.hooks.add_default(hookname, log=True)
|
||||||
|
|
||||||
code, out, err = self.t(("add", "foo"))
|
code, out, err = self.t.runError(("add", "foo"))
|
||||||
self.assertIn("ERROR MISSING JSON", err)
|
self.assertIn("Hook Error: Expected 1 JSON task(s), found 0", err)
|
||||||
self.t.hooks[hookname].assertTriggered()
|
self.t.hooks[hookname].assertTriggered()
|
||||||
self.t.hooks[hookname].assertTriggeredCount(1)
|
self.t.hooks[hookname].assertTriggeredCount(1)
|
||||||
self.t.hooks[hookname].assertExitcode(0)
|
self.t.hooks[hookname].assertExitcode(0)
|
||||||
logs = self.t.hooks[hookname].get_logs()
|
logs = self.t.hooks[hookname].get_logs()
|
||||||
self.assertEqual(self.t.hooks[hookname].get_logs()["output"]["msgs"][0], "FEEDBACK")
|
|
||||||
|
|
||||||
def test_onadd_builtin_misbehave3(self):
|
def test_onadd_builtin_misbehave3(self):
|
||||||
"""on-add-misbehave3 - emits additional JSON."""
|
"""on-add-misbehave3 - emits additional JSON."""
|
||||||
@@ -97,12 +96,11 @@ class TestHooksOnAdd(TestCase):
|
|||||||
self.t.hooks.add_default(hookname, log=True)
|
self.t.hooks.add_default(hookname, log=True)
|
||||||
|
|
||||||
code, out, err = self.t(("add", "foo"))
|
code, out, err = self.t(("add", "foo"))
|
||||||
self.assertIn("ERROR EXTRA JSON", err)
|
self.assertIn("Hook Error: Expected 1 JSON task(s), found 2", err)
|
||||||
self.t.hooks[hookname].assertTriggered()
|
self.t.hooks[hookname].assertTriggered()
|
||||||
self.t.hooks[hookname].assertTriggeredCount(1)
|
self.t.hooks[hookname].assertTriggeredCount(1)
|
||||||
self.t.hooks[hookname].assertExitcode(0)
|
self.t.hooks[hookname].assertExitcode(0)
|
||||||
logs = self.t.hooks[hookname].get_logs()
|
logs = self.t.hooks[hookname].get_logs()
|
||||||
self.assertEqual(self.t.hooks[hookname].get_logs()["output"]["msgs"][0], "FEEDBACK")
|
|
||||||
|
|
||||||
def test_onadd_builtin_misbehave4(self):
|
def test_onadd_builtin_misbehave4(self):
|
||||||
"""on-add-misbehave4 - emits different task JSON."""
|
"""on-add-misbehave4 - emits different task JSON."""
|
||||||
|
|||||||
Reference in New Issue
Block a user