Created
February 4, 2020 10:57
-
-
Save Wh1terat/fa6fd357094b34a8c15c4c2a74488f9a to your computer and use it in GitHub Desktop.
Incapsula JS Deobfuscator (obfuscator.io) - Python
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
import sys | |
import json | |
import string | |
import random | |
import visitor | |
import mytemplates | |
from pyjsparser import parse | |
from base64 import b64decode | |
def _rc4(message, key): | |
cipher_text = bytearray() | |
key = [ord(c) for c in key] | |
sbox = list(range(256)) | |
j = 0 | |
for i in range(256): | |
j = (j + sbox[i] + key[i % len(key)]) % 256 | |
sbox[i], sbox[j] = sbox[j], sbox[i] | |
i = j = 0 | |
for byte in message: | |
i = (i + 1) % 256 | |
j = (j + sbox[i]) % 256 | |
sbox[i], sbox[j] = sbox[j], sbox[i] | |
key_stream = sbox[(sbox[i] + sbox[j]) % 256] | |
cipher_text.append(key_stream ^ ord(byte)) | |
return cipher_text | |
def unwrap(program): | |
for node in program.traverse(): | |
if node.type == 'VariableDeclarator' and node.id.name == 'b': | |
return bytes.fromhex(node.init.value).decode('utf-8') | |
def rotate_sting_array(program): | |
for result in visitor.search(program, visitor.objectify(parse(mytemplates.STRING_ROTATE)).body[0]): | |
for node in program.traverse(): | |
if node.type == 'VariableDeclarator' and node.id.name == result.expression.arguments[0].name: | |
n = result.expression.arguments[1].value % len(node.init.elements) | |
node.init.elements = node.init.elements[n:] + node.init.elements[:n] | |
yield {'name': node.id.name, 'elements':node.init.elements} | |
node.remove() | |
result.remove() | |
def fix_calls(program): | |
string_arrays = list(rotate_sting_array(program)) | |
for result in visitor.search(program, visitor.objectify(parse(mytemplates.CALL_WRAPPER)).body[0]): | |
string_array = result.declarations[0].init.body.body[1].declarations[0].init.object.name | |
string_array = next(value['elements'] for value in string_arrays if value['name'] == string_array) | |
for node in program.traverse(): | |
try: | |
if node.callee.name == result.declarations[0].id.name: | |
i = int(node.arguments[0].value, 16) | |
key = node.arguments[1].value | |
raw = string_array[i].value | |
value = _rc4(b64decode(raw).decode('utf-8'),key).decode('utf-8') | |
node.replace(visitor.objectify({'type':'Literal', 'value': value})) | |
except AttributeError: | |
pass | |
result.remove() | |
def fix_control_flow(program): | |
def fix_switch_statements(): | |
for node in program.traverse(): | |
try: | |
if node.declarations[0].init.callee.property.value == 'split' and '|' in node.declarations[0].init.callee.object.value: | |
name = node.declarations[0].id.name | |
order = [int(n) for n in node.declarations[0].init.callee.object.value.split('|')] | |
for child in node.parent: | |
if child.type == 'WhileStatement' and child.body.body[0].discriminant.object.name == name: | |
node.parent.extend(child.body.body[0].cases[idx].consequent[0] for idx in order) | |
child.remove() | |
break; | |
node.remove() | |
except (AttributeError,IndexError) as e: | |
pass | |
def fix_indirect_expressions(): | |
# Ew. There's a level of indirection on the indirection, hacky workaround | |
for _ in range(2): | |
for node in program.traverse(): | |
try: | |
if all(prop.value.type == 'FunctionExpression' and prop.value.body.body[0].type == 'ReturnStatement' for prop in node.declarations[0].init.properties): | |
func = next((x for x in node.declarations[0].init.properties)) | |
for nodex in program.traverse(): | |
try: | |
if nodex.callee.object.name == node.declarations[0].id.name and nodex.callee.property.value == func.key.value: | |
new_node = visitor.objectify(func.value.body.body[0].argument.dict()) | |
if type(new_node) == visitor.BinaryExpression: | |
new_node.left = nodex.arguments[0] | |
new_node.right = nodex.arguments[1] | |
elif type(new_node) == visitor.CallExpression: | |
new_node.callee.name = nodex.arguments[0].name | |
new_node.arguments = nodex.arguments[1:] | |
nodex.replace(new_node) | |
except (AttributeError,IndexError): | |
pass | |
node.remove() | |
except (AttributeError,IndexError): | |
pass | |
fix_switch_statements() | |
fix_indirect_expressions() | |
def cleanup_debug(program): | |
for result in visitor.search(program, visitor.objectify(parse(mytemplates.DEBUG_INTERVAL)).body[0]): | |
for node in program.traverse(): | |
try: | |
if node.expression.callee.name == result.id.name: | |
node.remove() | |
except (AttributeError,IndexError): | |
pass | |
result.remove() | |
def cleanup(program): | |
templates = [mytemplates.SINGLE_NODE, mytemplates.UNICODE_PROTECTION, mytemplates.DEBUG_PROTECTION] | |
for idx,template in enumerate(templates): | |
for result in visitor.search(program, visitor.objectify(parse(template)).body[0]): | |
result.remove() | |
def cleanup_calls(program): | |
for node in program.traverse(): | |
if node.type == 'MemberExpression': | |
if (node.computed and | |
node.property.type == 'Literal' and | |
not isinstance(node.property.value, int)): | |
node.computed = False | |
node.property = visitor.objectify({'type':'Identifier','name':node.property.value}) | |
def cleanup_truthy(program): | |
for node in program.traverse(): | |
if node.type == 'UnaryExpression' and node.operator == '!': | |
if node.argument.type == 'UnaryExpression' and node.argument.operator == '!': | |
if node.argument.argument.type == 'ArrayExpression' and not node.argument.argument.elements: | |
node.replace(visitor.objectify({'type':'Literal', 'name':True})) | |
elif node.argument.type == 'ArrayExpression' and not node.argument.elements: | |
node.replace(visitor.objectify({'type':'Literal', 'name':False})) | |
def rename_idents(program): | |
# *WARNING* This is not scope aware **WARNING** | |
def generate_name(): | |
ecma_reserved = ['break','case','catch','class','const','continue','debugger','default','delete','do','else','export', | |
'extends','finally','for','function','if','import','in','instanceof','new','return','super','switch', | |
'this','throw','try','typeof','var','void','while','with','yield','enum','implements','interface','let', | |
'package','private','protected','await','abstract','boolean','byte','char','double','final','float', | |
'goto','int','long','native','short'] | |
while True: | |
#newname = ''.join(random.choices(string.ascii_letters, k=random.randint(1, 3))) | |
newname = ''.join(random.choices(string.ascii_letters, k=3)) | |
if newname not in ecma_reserved and newname not in idents.values(): | |
return newname | |
idents = {} | |
for node in program.traverse(): | |
if node.type == 'Identifier' and node.name.startswith('_0x'): | |
if node.name not in idents.keys(): | |
idents[node.name] = generate_name() | |
node.name = idents[node.name] | |
def main(): | |
script = parse(sys.stdin.read()) | |
program = visitor.objectify(script) | |
script = parse(unwrap(program)) | |
program = visitor.objectify(script) | |
fix_calls(program) | |
fix_control_flow(program) | |
cleanup_debug(program) | |
cleanup(program) | |
cleanup_calls(program) | |
cleanup_truthy(program) | |
rename_idents(program) | |
print(json.dumps(program.dict())) | |
if __name__ == '__main__': | |
main() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
CALL_WRAPPER = r''' | |
var stringArrayCallsWrapper = function (index, key) { | |
index = index - 0; | |
var value = stringArray[index]; | |
if (stringArrayCallsWrapper['initialized'] === undefined) { | |
(function () { | |
var getGlobal = Function('return (function() ' + '{}.constructor("return this")( )' + ');'); | |
var that = getGlobal(); | |
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; | |
that['atob'] || (that['atob'] = function (input) { | |
var str = String(input)['replace'](/=+$/, ''); | |
for (var bc = 0, bs, buffer, idx = 0, output = ''; | |
buffer = str['charAt'](idx++); | |
~buffer && (bs = bc % 4 ? bs * 64 + buffer : buffer, bc++ % 4) ? output += String['fromCharCode'](255 & bs >> (-2 * bc & 6)) : 0) { | |
buffer = chars['indexOf'](buffer); | |
} | |
return output; | |
}); | |
}()); | |
var rc4 = function (str, key) { | |
var s = [], j = 0, x, res = '', newStr = ''; | |
str = atob(str); | |
for (var k = 0, length = str['length']; k < length; k++) { | |
newStr += '%' + ('00' + str['charCodeAt'](k)['toString'](16))['slice'](-2); | |
} | |
str = decodeURIComponent(newStr); | |
for (var i = 0; i < 256; i++) { | |
s[i] = i; | |
} | |
for (i = 0; i < 256; i++) { | |
j = (j + s[i] + key['charCodeAt'](i % key['length'])) % 256; | |
x = s[i]; | |
s[i] = s[j]; | |
s[j] = x; | |
} | |
i = 0; | |
j = 0; | |
for (var y = 0; y < str['length']; y++) { | |
i = (i + 1) % 256; | |
j = (j + s[i]) % 256; | |
x = s[i]; | |
s[i] = s[j]; | |
s[j] = x; | |
res += String['fromCharCode'](str['charCodeAt'](y) ^ s[(s[i] + s[j]) % 256]); | |
} | |
return res; | |
}; | |
stringArrayCallsWrapper['rc4'] = rc4; | |
stringArrayCallsWrapper['data'] = {}; | |
stringArrayCallsWrapper['initialized'] = !![]; | |
} | |
var cachedValue = stringArrayCallsWrapper['data'][index]; | |
if (cachedValue === undefined) { | |
if (stringArrayCallsWrapper['once'] === undefined) { | |
var StatesClass = function (rc4Bytes) { | |
this['rc4Bytes'] = rc4Bytes; | |
this['states'] = [ | |
1, | |
0, | |
0 | |
]; | |
this['newState'] = function () { | |
return 'newState'; | |
}; | |
this['firstState'] = '\\w+ *\\(\\) *{\\w+ *'; | |
this['secondState'] = '[\'|"].+[\'|"];? *}'; | |
}; | |
StatesClass['prototype']['checkState'] = function () { | |
var regExp = new RegExp(this['firstState'] + this['secondState']); | |
return this['runState'](regExp['test'](this['newState']['toString']()) ? --this['states'][1] : --this['states'][0]); | |
}; | |
StatesClass['prototype']['runState'] = function (stateResult) { | |
if (!Boolean(~stateResult)) { | |
return stateResult; | |
} | |
return this['getState'](this['rc4Bytes']); | |
}; | |
StatesClass['prototype']['getState'] = function (rc4Bytes) { | |
for (var i = 0, len = this['states']['length']; i < len; i++) { | |
this['states']['push'](Math['round'](Math['random']())); | |
len = this['states']['length']; | |
} | |
return rc4Bytes(this['states'][0]); | |
}; | |
new StatesClass(stringArrayCallsWrapper)['checkState'](); | |
stringArrayCallsWrapper['once'] = !![]; | |
} | |
value = stringArrayCallsWrapper['rc4'](value, key); | |
stringArrayCallsWrapper['data'][index] = value; | |
} else { | |
value = cachedValue; | |
} | |
return value; | |
}; | |
''' | |
STRING_ROTATE = r''' | |
(function (array, times) { | |
var whileFunction = function (times) { | |
while (--times) { | |
array['push'](array['shift']()); | |
} | |
}; | |
var selfDefendingFunc = function () { | |
var object = { | |
'data': { | |
'key': 'cookie', | |
'value': 'timeout' | |
}, | |
'setCookie': function (options, name, value, document) { | |
document = document || {}; | |
var updatedCookie = name + '=' + value; | |
var i = 0; | |
for (var i = 0, len = options['length']; i < len; i++) { | |
var propName = options[i]; | |
updatedCookie += '; ' + propName; | |
var propValue = options[propName]; | |
options['push'](propValue); | |
len = options['length']; | |
if (propValue !== true) { | |
updatedCookie += '=' + propValue; | |
} | |
} | |
document['cookie'] = updatedCookie; | |
}, | |
'removeCookie': function () { | |
return 'dev'; | |
}, | |
'getCookie': function (document, name) { | |
document = document || function (value) { | |
return value; | |
}; | |
var matches = document(new RegExp('(?:^|; )' + name['replace'](/([\.$?*|{}\(\)\[\]\\\/+^])/g, '$1') + '=([^;\]*)')); | |
var func = function (param1, param2) { | |
param1(++param2); | |
}; | |
func(whileFunction, times); | |
return matches ? decodeURIComponent(matches[1]) : undefined; | |
} | |
}; | |
var test1 = function () { | |
var regExp = new RegExp('\\w+ *\\(\\) *{\\w+ *[\'|"].+[\'|"];? *}'); | |
return regExp['test'](object['removeCookie']['toString']()); | |
}; | |
object['updateCookie'] = test1; | |
var cookie = ''; | |
var result = object['updateCookie'](); | |
if (!result) { | |
object['setCookie'](['*'], 'counter', 1); | |
} else if (result) { | |
cookie = object['getCookie'](null, 'counter'); | |
} else { | |
object['removeCookie'](); | |
} | |
}; | |
selfDefendingFunc(); | |
}(stringArray, 123)); | |
''' | |
SINGLE_NODE= r''' | |
var singleNodeCallControllerFunction = function () { | |
var firstCall = !![]; | |
return function (context, fn) { | |
var rfn = firstCall ? function () { | |
if (fn) { | |
var res = fn['apply'](context, arguments); | |
fn = null; | |
return res; | |
} | |
} : function () { | |
}; | |
firstCall = ![]; | |
return rfn; | |
}; | |
}(); | |
''' | |
UNICODE_PROTECTION=r''' | |
var selfDefendingFunction = singleNodeCallControllerFunction(this, function () { | |
var func1 = function () { | |
return 'dev'; | |
}, func2 = function () { | |
return 'window'; | |
}; | |
var test1 = function () { | |
var regExp = new RegExp('\\w+ *\\(\\) *{\\w+ *[\'|"].+[\'|"];? *}'); | |
return !regExp['test'](func1['toString']()); | |
}; | |
var test2 = function () { | |
var regExp = new RegExp('(\\\\[x|u](\\w){2,4})+'); | |
return regExp['test'](func2['toString']()); | |
}; | |
var recursiveFunc1 = function (string) { | |
var i = ~-1 >> 1 + 255 % 0; | |
if (string['indexOf']('i' === i)) { | |
recursiveFunc2(string); | |
} | |
}; | |
var recursiveFunc2 = function (string) { | |
var i = ~-4 >> 1 + 255 % 0; | |
if (string['indexOf']((!![] + '')[3]) !== i) { | |
recursiveFunc1(string); | |
} | |
}; | |
if (!test1()) { | |
if (!test2()) { | |
recursiveFunc1('indеxOf'); | |
} else { | |
recursiveFunc1('indexOf'); | |
} | |
} else { | |
recursiveFunc1('indеxOf'); | |
} | |
}); | |
selfDefendingFunction(); | |
''' | |
DEBUG_PROTECTION=r''' | |
function debugProtectionFunction() { | |
function debuggerProtection(counter) { | |
if (('' + counter / counter)['length'] !== 1 || counter % 20 === 0) { | |
(function () { | |
}['constructor']('debugger')()); | |
} else { | |
(function () { | |
}['constructor']('debugger')()); | |
} | |
return debuggerProtection(++counter); | |
} | |
try { | |
return debuggerProtection(0); | |
} catch (y) { | |
} | |
} | |
''' | |
DEBUG_INTERVAL=r''' | |
function debugProtectionFunctionInterval() { | |
if (new windowfoo['Date']()['getTime']() - initTime > 500) { | |
debugProtectionFunction(); | |
} | |
} | |
''' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"""Trasnforms AST dictionary into a tree of Node objects. | |
Source: https://github.com/austinbyers/esprima-ast-visitor """ | |
import abc | |
from collections import OrderedDict | |
from typing import Any, Dict, Generator, List, Union | |
class UnknownNodeTypeError(Exception): | |
"""Raised if we encounter a node with an unknown type.""" | |
pass | |
class Node(abc.ABC): | |
"""Abstract Node class which defines node operations""" | |
@property | |
@abc.abstractmethod | |
def fields(self) -> List[str]: | |
"""List of field names associated with this node type, in canonical order.""" | |
def __init__(self, data: Dict[str, Any]) -> None: | |
"""Sets one attribute in the Node for each field (e.g. self.body).""" | |
#self.parent = None | |
for field in self.fields: | |
setattr(self, field, objectify(data.get(field))) | |
def __eq__(self, other): | |
"""Compare objects""" | |
return self is other | |
def replace(self,node): | |
"""Super hacky way to replace Nodes.""" | |
if isinstance(self.parent, list): | |
self.parent[self.parent.index(self)] = node | |
elif isinstance(self.parent, Node): | |
for field in self.grandparent.fields: | |
if self.parent == getattr(self.grandparent, field): | |
setattr(self.grandparent, field, node) | |
def remove(self): | |
"""Super hacky way to remove Nodes.""" | |
if isinstance(self.parent, list): | |
if len(self.parent) > 1: | |
self.parent.remove(self) | |
else: | |
self.grandparent.parent.remove(self.grandparent) | |
def dict(self) -> Dict[str, Any]: | |
"""Transform the Node back into an Esprima-compatible AST dictionary.""" | |
result = OrderedDict({'type': self.type}) # type: Dict[str, Any] | |
for field in self.fields: | |
val = getattr(self, field) | |
if isinstance(val, Node): | |
result[field] = val.dict() | |
elif isinstance(val, list): | |
result[field] = [x.dict() for x in val] | |
else: | |
result[field] = val | |
return result | |
def traverse(self) -> Generator['Node', None, None]: | |
"""Pre-order traversal of this node and all of its children.""" | |
yield self | |
for field in self.fields: | |
val = getattr(self, field) | |
if isinstance(val, Node): | |
setattr(val, 'parent', val) | |
setattr(val, 'grandparent', self) | |
yield from val.traverse() | |
elif isinstance(val, list): | |
for node in val: | |
setattr(node, 'parent', val) | |
setattr(node, 'grandparent', self) | |
yield from node.traverse() | |
@property | |
def type(self) -> str: | |
"""The name of the node type, e.g. 'Identifier'.""" | |
return self.__class__.__name__ | |
def search(program, pattern, strict=False): | |
"""Search tree for AST pattern""" | |
for node in program.traverse(): | |
if compare_nodes(node, pattern, strict): | |
yield node | |
def compare_nodes(node, pattern, strict): | |
"""Compare Node""" | |
if node == pattern: | |
return True | |
elif isinstance(node, list) and isinstance(pattern, list): | |
if len(pattern) != len(node): | |
return False | |
return all(compare_nodes(node[idx], child, strict) for idx,child in enumerate(pattern)) | |
elif isinstance(node, Node) and isinstance(pattern, Node): | |
if node.type != pattern.type: | |
return False | |
if not strict: | |
skipfields = ['name','value','raw']; | |
return all(compare_nodes(getattr(node, field),getattr(pattern, field), strict) for field in pattern.fields if field not in skipfields) | |
else: | |
return all(compare_nodes(getattr(node, field),getattr(pattern, field), strict) for field in pattern.fields if field) | |
def objectify(data: Union[None, Dict[str, Any], List[Dict[str, Any]]]) -> Union[ | |
None, Dict[str, Any], List[Any], Node]: | |
"""Recursively transform AST data into a Node object.""" | |
if not isinstance(data, (dict, list)): | |
# Data is a basic type (None, string, number) | |
return data | |
if isinstance(data, dict): | |
if 'type' not in data: | |
# Literal values can be empty dictionaries, for example. | |
return data | |
# Transform the type into the appropriate class. | |
node_class = globals().get(data['type']) | |
if not node_class: | |
raise UnknownNodeTypeError(data['type']) | |
return node_class(data) | |
else: | |
# Data is a list of nodes. | |
return [objectify(x) for x in data] | |
# --- AST spec: https://github.com/estree/estree/blob/master/es5.md --- | |
# pylint: disable=missing-docstring,multiple-statements | |
class Identifier(Node): | |
@property | |
def fields(self): return ['name'] | |
class Literal(Node): | |
@property | |
def fields(self): return ['value', 'regex'] | |
class Program(Node): | |
@property | |
def fields(self): return ['body'] | |
# ========== Statements ========== | |
class ExpressionStatement(Node): | |
@property | |
def fields(self): return ['expression'] | |
class BlockStatement(Node): | |
@property | |
def fields(self): return ['body'] | |
class EmptyStatement(Node): | |
@property | |
def fields(self): return [] | |
class DebuggerStatement(Node): | |
@property | |
def fields(self): return [] | |
class WithStatement(Node): | |
@property | |
def fields(self): return ['object', 'body'] | |
# ----- Control Flow ----- | |
class ReturnStatement(Node): | |
@property | |
def fields(self): return ['argument'] | |
class LabeledStatement(Node): | |
@property | |
def fields(self): return ['label', 'body'] | |
class BreakStatement(Node): | |
@property | |
def fields(self): return ['label'] | |
class ContinueStatement(Node): | |
@property | |
def fields(self): return ['label'] | |
# ----- Choice ----- | |
class IfStatement(Node): | |
@property | |
def fields(self): return ['test', 'consequent', 'alternate'] | |
class SwitchStatement(Node): | |
@property | |
def fields(self): return ['discriminant', 'cases'] | |
class SwitchCase(Node): | |
@property | |
def fields(self): return ['test', 'consequent'] | |
# ----- Exceptions ----- | |
class ThrowStatement(Node): | |
@property | |
def fields(self): return ['argument'] | |
class TryStatement(Node): | |
@property | |
def fields(self): return ['block', 'guardedHandlers', 'handlers', 'handler', 'finalizer'] | |
class CatchClause(Node): | |
@property | |
def fields(self): return ['param', 'body'] | |
# ----- Loops ----- | |
class WhileStatement(Node): | |
@property | |
def fields(self): return ['test', 'body'] | |
class DoWhileStatement(Node): | |
@property | |
def fields(self): return ['body', 'test'] | |
class ForStatement(Node): | |
@property | |
def fields(self): return ['init', 'test', 'update', 'body'] | |
class ForInStatement(Node): | |
@property | |
def fields(self): return ['left', 'right', 'body'] | |
# ========== Declarations ========== | |
class FunctionDeclaration(Node): | |
@property | |
def fields(self): return ['id', 'params', 'body'] | |
class VariableDeclaration(Node): | |
@property | |
def fields(self): return ['kind', 'declarations'] | |
class VariableDeclarator(Node): | |
@property | |
def fields(self): return ['id', 'init'] | |
# ========== Expressions ========== | |
class ThisExpression(Node): | |
@property | |
def fields(self): return [] | |
class ArrayExpression(Node): | |
@property | |
def fields(self): return ['elements'] | |
class ObjectExpression(Node): | |
@property | |
def fields(self): return ['properties'] | |
class Property(Node): | |
@property | |
def fields(self): return ['key', 'value', 'kind'] | |
class FunctionExpression(Node): | |
@property | |
def fields(self): return ['id', 'params', 'body'] | |
class UnaryExpression(Node): | |
@property | |
def fields(self): return ['operator', 'prefix', 'argument'] | |
class UpdateExpression(Node): | |
@property | |
def fields(self): return ['operator', 'argument', 'prefix'] | |
class BinaryExpression(Node): | |
@property | |
def fields(self): return ['operator', 'left', 'right'] | |
class AssignmentExpression(Node): | |
@property | |
def fields(self): return ['operator', 'left', 'right'] | |
class LogicalExpression(Node): | |
@property | |
def fields(self): return ['operator', 'left', 'right'] | |
class MemberExpression(Node): | |
@property | |
def fields(self): return ['object', 'property', 'computed'] | |
class ConditionalExpression(Node): | |
@property | |
def fields(self): return ['test', 'consequent', 'alternate'] | |
class CallExpression(Node): | |
@property | |
def fields(self): return ['callee', 'arguments'] | |
class NewExpression(Node): | |
@property | |
def fields(self): return ['callee', 'arguments'] | |
class SequenceExpression(Node): | |
@property | |
def fields(self): return ['expressions'] |
After reading about "mailbot", no. I don't want to assist with such a project - for money or not.
Ok, sure, you are a white rat, I'm a black one. I just had to try. Thank you anyway and appreciate your quick reply, sir!
After reading about "mailbot", no. I don't want to assist with such a project - for money or not.
maybe you read about another mailbot. Tavel is a good guy
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@Tavel
After reading about "mailbot", no. I don't want to assist with such a project - for money or not.