Add support for sending stats to Matrix, split out some code into more generic classes. #1

Open
rory.gay wants to merge 35 commits from rory.gay/freepbx-stats:main into main
17 changed files with 1368 additions and 116 deletions

579
.editorconfig Normal file
View file

@ -0,0 +1,579 @@
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = false
max_line_length = 120
tab_width = 4
ij_continuation_indent_size = 8
ij_formatter_off_tag = @formatter:off
ij_formatter_on_tag = @formatter:on
ij_formatter_tags_enabled = true
ij_smart_tabs = false
ij_visual_guides =
ij_wrap_on_typing = false
[*.css]
ij_css_align_closing_brace_with_properties = false
ij_css_blank_lines_around_nested_selector = 1
ij_css_blank_lines_between_blocks = 1
ij_css_block_comment_add_space = false
ij_css_brace_placement = end_of_line
ij_css_enforce_quotes_on_format = false
ij_css_hex_color_long_format = false
ij_css_hex_color_lower_case = false
ij_css_hex_color_short_format = false
ij_css_hex_color_upper_case = false
ij_css_keep_blank_lines_in_code = 2
ij_css_keep_indents_on_empty_lines = false
ij_css_keep_single_line_blocks = false
ij_css_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow
ij_css_space_after_colon = true
ij_css_space_before_opening_brace = true
ij_css_use_double_quotes = true
ij_css_value_alignment = do_not_align
[*.less]
indent_size = 2
ij_less_align_closing_brace_with_properties = false
ij_less_blank_lines_around_nested_selector = 1
ij_less_blank_lines_between_blocks = 1
ij_less_block_comment_add_space = false
ij_less_brace_placement = 0
ij_less_enforce_quotes_on_format = false
ij_less_hex_color_long_format = false
ij_less_hex_color_lower_case = false
ij_less_hex_color_short_format = false
ij_less_hex_color_upper_case = false
ij_less_keep_blank_lines_in_code = 2
ij_less_keep_indents_on_empty_lines = false
ij_less_keep_single_line_blocks = false
ij_less_line_comment_add_space = false
ij_less_line_comment_at_first_column = false
ij_less_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow
ij_less_space_after_colon = true
ij_less_space_before_opening_brace = true
ij_less_use_double_quotes = true
ij_less_value_alignment = 0
[*.sass]
indent_size = 2
ij_sass_align_closing_brace_with_properties = false
ij_sass_blank_lines_around_nested_selector = 1
ij_sass_blank_lines_between_blocks = 1
ij_sass_brace_placement = 0
ij_sass_enforce_quotes_on_format = false
ij_sass_hex_color_long_format = false
ij_sass_hex_color_lower_case = false
ij_sass_hex_color_short_format = false
ij_sass_hex_color_upper_case = false
ij_sass_keep_blank_lines_in_code = 2
ij_sass_keep_indents_on_empty_lines = false
ij_sass_keep_single_line_blocks = false
ij_sass_line_comment_add_space = false
ij_sass_line_comment_at_first_column = false
ij_sass_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow
ij_sass_space_after_colon = true
ij_sass_space_before_opening_brace = true
ij_sass_use_double_quotes = true
ij_sass_value_alignment = 0
[*.scss]
indent_size = 2
ij_scss_align_closing_brace_with_properties = false
ij_scss_blank_lines_around_nested_selector = 1
ij_scss_blank_lines_between_blocks = 1
ij_scss_block_comment_add_space = false
ij_scss_brace_placement = 0
ij_scss_enforce_quotes_on_format = false
ij_scss_hex_color_long_format = false
ij_scss_hex_color_lower_case = false
ij_scss_hex_color_short_format = false
ij_scss_hex_color_upper_case = false
ij_scss_keep_blank_lines_in_code = 2
ij_scss_keep_indents_on_empty_lines = false
ij_scss_keep_single_line_blocks = false
ij_scss_line_comment_add_space = false
ij_scss_line_comment_at_first_column = false
ij_scss_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow
ij_scss_space_after_colon = true
ij_scss_space_before_opening_brace = true
ij_scss_use_double_quotes = true
ij_scss_value_alignment = 0
[.editorconfig]
ij_editorconfig_align_group_field_declarations = false
ij_editorconfig_space_after_colon = false
ij_editorconfig_space_after_comma = true
ij_editorconfig_space_before_colon = false
ij_editorconfig_space_before_comma = false
ij_editorconfig_spaces_around_assignment_operators = true
[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul}]
ij_xml_align_attributes = true
ij_xml_align_text = false
ij_xml_attribute_wrap = normal
ij_xml_block_comment_add_space = false
ij_xml_block_comment_at_first_column = true
ij_xml_keep_blank_lines = 2
ij_xml_keep_indents_on_empty_lines = false
ij_xml_keep_line_breaks = true
ij_xml_keep_line_breaks_in_text = true
ij_xml_keep_whitespaces = false
ij_xml_keep_whitespaces_around_cdata = preserve
ij_xml_keep_whitespaces_inside_cdata = false
ij_xml_line_comment_at_first_column = true
ij_xml_space_after_tag_name = false
ij_xml_space_around_equals_in_attribute = false
ij_xml_space_inside_empty_tag = false
ij_xml_text_wrap = normal
[{*.ats,*.cts,*.mts,*.ts}]
ij_continuation_indent_size = 4
ij_typescript_align_imports = false
ij_typescript_align_multiline_array_initializer_expression = false
ij_typescript_align_multiline_binary_operation = false
ij_typescript_align_multiline_chained_methods = false
ij_typescript_align_multiline_extends_list = false
ij_typescript_align_multiline_for = true
ij_typescript_align_multiline_parameters = true
ij_typescript_align_multiline_parameters_in_calls = false
ij_typescript_align_multiline_ternary_operation = false
ij_typescript_align_object_properties = 0
ij_typescript_align_union_types = false
ij_typescript_align_var_statements = 0
ij_typescript_array_initializer_new_line_after_left_brace = false
ij_typescript_array_initializer_right_brace_on_new_line = false
ij_typescript_array_initializer_wrap = off
ij_typescript_assignment_wrap = off
ij_typescript_binary_operation_sign_on_next_line = false
ij_typescript_binary_operation_wrap = off
ij_typescript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/**
ij_typescript_blank_lines_after_imports = 1
ij_typescript_blank_lines_around_class = 1
ij_typescript_blank_lines_around_field = 0
ij_typescript_blank_lines_around_field_in_interface = 0
ij_typescript_blank_lines_around_function = 1
ij_typescript_blank_lines_around_method = 1
ij_typescript_blank_lines_around_method_in_interface = 1
ij_typescript_block_brace_style = end_of_line
ij_typescript_block_comment_add_space = false
ij_typescript_block_comment_at_first_column = true
ij_typescript_call_parameters_new_line_after_left_paren = false
ij_typescript_call_parameters_right_paren_on_new_line = false
ij_typescript_call_parameters_wrap = off
ij_typescript_catch_on_new_line = false
ij_typescript_chained_call_dot_on_new_line = true
ij_typescript_class_brace_style = end_of_line
ij_typescript_class_decorator_wrap = split_into_lines
ij_typescript_class_field_decorator_wrap = off
ij_typescript_class_method_decorator_wrap = off
ij_typescript_comma_on_new_line = false
ij_typescript_do_while_brace_force = never
ij_typescript_else_on_new_line = false
ij_typescript_enforce_trailing_comma = keep
ij_typescript_enum_constants_wrap = on_every_item
ij_typescript_extends_keyword_wrap = off
ij_typescript_extends_list_wrap = off
ij_typescript_field_prefix = _
ij_typescript_file_name_style = relaxed
ij_typescript_finally_on_new_line = false
ij_typescript_for_brace_force = never
ij_typescript_for_statement_new_line_after_left_paren = false
ij_typescript_for_statement_right_paren_on_new_line = false
ij_typescript_for_statement_wrap = off
ij_typescript_force_quote_style = false
ij_typescript_force_semicolon_style = false
ij_typescript_function_expression_brace_style = end_of_line
ij_typescript_function_parameter_decorator_wrap = off
ij_typescript_if_brace_force = never
ij_typescript_import_merge_members = global
ij_typescript_import_prefer_absolute_path = global
ij_typescript_import_sort_members = true
ij_typescript_import_sort_module_name = false
ij_typescript_import_use_node_resolution = true
ij_typescript_imports_wrap = on_every_item
ij_typescript_indent_case_from_switch = true
ij_typescript_indent_chained_calls = true
ij_typescript_indent_package_children = 0
ij_typescript_jsdoc_include_types = false
ij_typescript_jsx_attribute_value = braces
ij_typescript_keep_blank_lines_in_code = 2
ij_typescript_keep_first_column_comment = true
ij_typescript_keep_indents_on_empty_lines = false
ij_typescript_keep_line_breaks = true
ij_typescript_keep_simple_blocks_in_one_line = false
ij_typescript_keep_simple_methods_in_one_line = false
ij_typescript_line_comment_add_space = true
ij_typescript_line_comment_at_first_column = false
ij_typescript_method_brace_style = end_of_line
ij_typescript_method_call_chain_wrap = off
ij_typescript_method_parameters_new_line_after_left_paren = false
ij_typescript_method_parameters_right_paren_on_new_line = false
ij_typescript_method_parameters_wrap = off
ij_typescript_object_literal_wrap = on_every_item
ij_typescript_object_types_wrap = on_every_item
ij_typescript_parentheses_expression_new_line_after_left_paren = false
ij_typescript_parentheses_expression_right_paren_on_new_line = false
ij_typescript_place_assignment_sign_on_next_line = false
ij_typescript_prefer_as_type_cast = false
ij_typescript_prefer_explicit_types_function_expression_returns = false
ij_typescript_prefer_explicit_types_function_returns = false
ij_typescript_prefer_explicit_types_vars_fields = false
ij_typescript_prefer_parameters_wrap = false
ij_typescript_property_prefix =
ij_typescript_reformat_c_style_comments = false
ij_typescript_space_after_colon = true
ij_typescript_space_after_comma = true
ij_typescript_space_after_dots_in_rest_parameter = false
ij_typescript_space_after_generator_mult = true
ij_typescript_space_after_property_colon = true
ij_typescript_space_after_quest = true
ij_typescript_space_after_type_colon = true
ij_typescript_space_after_unary_not = false
ij_typescript_space_before_async_arrow_lparen = true
ij_typescript_space_before_catch_keyword = true
ij_typescript_space_before_catch_left_brace = true
ij_typescript_space_before_catch_parentheses = true
ij_typescript_space_before_class_lbrace = true
ij_typescript_space_before_class_left_brace = true
ij_typescript_space_before_colon = true
ij_typescript_space_before_comma = false
ij_typescript_space_before_do_left_brace = true
ij_typescript_space_before_else_keyword = true
ij_typescript_space_before_else_left_brace = true
ij_typescript_space_before_finally_keyword = true
ij_typescript_space_before_finally_left_brace = true
ij_typescript_space_before_for_left_brace = true
ij_typescript_space_before_for_parentheses = true
ij_typescript_space_before_for_semicolon = false
ij_typescript_space_before_function_left_parenth = true
ij_typescript_space_before_generator_mult = false
ij_typescript_space_before_if_left_brace = true
ij_typescript_space_before_if_parentheses = true
ij_typescript_space_before_method_call_parentheses = false
ij_typescript_space_before_method_left_brace = true
ij_typescript_space_before_method_parentheses = false
ij_typescript_space_before_property_colon = false
ij_typescript_space_before_quest = true
ij_typescript_space_before_switch_left_brace = true
ij_typescript_space_before_switch_parentheses = true
ij_typescript_space_before_try_left_brace = true
ij_typescript_space_before_type_colon = false
ij_typescript_space_before_unary_not = false
ij_typescript_space_before_while_keyword = true
ij_typescript_space_before_while_left_brace = true
ij_typescript_space_before_while_parentheses = true
ij_typescript_spaces_around_additive_operators = true
ij_typescript_spaces_around_arrow_function_operator = true
ij_typescript_spaces_around_assignment_operators = true
ij_typescript_spaces_around_bitwise_operators = true
ij_typescript_spaces_around_equality_operators = true
ij_typescript_spaces_around_logical_operators = true
ij_typescript_spaces_around_multiplicative_operators = true
ij_typescript_spaces_around_relational_operators = true
ij_typescript_spaces_around_shift_operators = true
ij_typescript_spaces_around_unary_operator = false
ij_typescript_spaces_within_array_initializer_brackets = false
ij_typescript_spaces_within_brackets = false
ij_typescript_spaces_within_catch_parentheses = false
ij_typescript_spaces_within_for_parentheses = false
ij_typescript_spaces_within_if_parentheses = false
ij_typescript_spaces_within_imports = false
ij_typescript_spaces_within_interpolation_expressions = false
ij_typescript_spaces_within_method_call_parentheses = false
ij_typescript_spaces_within_method_parentheses = false
ij_typescript_spaces_within_object_literal_braces = false
ij_typescript_spaces_within_object_type_braces = true
ij_typescript_spaces_within_parentheses = false
ij_typescript_spaces_within_switch_parentheses = false
ij_typescript_spaces_within_type_assertion = false
ij_typescript_spaces_within_union_types = true
ij_typescript_spaces_within_while_parentheses = false
ij_typescript_special_else_if_treatment = true
ij_typescript_ternary_operation_signs_on_next_line = false
ij_typescript_ternary_operation_wrap = off
ij_typescript_union_types_wrap = on_every_item
ij_typescript_use_chained_calls_group_indents = false
ij_typescript_use_double_quotes = true
ij_typescript_use_explicit_js_extension = auto
ij_typescript_use_import_type = auto
ij_typescript_use_path_mapping = always
ij_typescript_use_public_modifier = false
ij_typescript_use_semicolon_after_statement = true
ij_typescript_var_declaration_wrap = normal
ij_typescript_while_brace_force = never
ij_typescript_while_on_new_line = false
ij_typescript_wrap_comments = false
[{*.bash,*.sh,*.zsh}]
indent_size = 2
tab_width = 2
ij_shell_binary_ops_start_line = false
ij_shell_keep_column_alignment_padding = false
ij_shell_minify_program = false
ij_shell_redirect_followed_by_space = false
ij_shell_switch_cases_indented = false
ij_shell_use_unix_line_separator = true
[{*.cjs,*.es6,*.js,*.mjs}]
indent_style = tab
ij_continuation_indent_size = 4
ij_javascript_align_imports = false
ij_javascript_align_multiline_array_initializer_expression = false
ij_javascript_align_multiline_binary_operation = false
ij_javascript_align_multiline_chained_methods = false
ij_javascript_align_multiline_extends_list = false
ij_javascript_align_multiline_for = true
ij_javascript_align_multiline_parameters = true
ij_javascript_align_multiline_parameters_in_calls = false
ij_javascript_align_multiline_ternary_operation = false
ij_javascript_align_object_properties = 0
ij_javascript_align_union_types = false
ij_javascript_align_var_statements = 0
ij_javascript_array_initializer_new_line_after_left_brace = true
ij_javascript_array_initializer_right_brace_on_new_line = true
ij_javascript_array_initializer_wrap = on_every_item
ij_javascript_assignment_wrap = off
ij_javascript_binary_operation_sign_on_next_line = false
ij_javascript_binary_operation_wrap = off
ij_javascript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/**
ij_javascript_blank_lines_after_imports = 1
ij_javascript_blank_lines_around_class = 1
ij_javascript_blank_lines_around_field = 0
ij_javascript_blank_lines_around_function = 1
ij_javascript_blank_lines_around_method = 1
ij_javascript_block_brace_style = end_of_line
ij_javascript_block_comment_add_space = false
ij_javascript_block_comment_at_first_column = true
ij_javascript_call_parameters_new_line_after_left_paren = false
ij_javascript_call_parameters_right_paren_on_new_line = false
ij_javascript_call_parameters_wrap = off
ij_javascript_catch_on_new_line = false
ij_javascript_chained_call_dot_on_new_line = true
ij_javascript_class_brace_style = end_of_line
ij_javascript_class_decorator_wrap = split_into_lines
ij_javascript_class_field_decorator_wrap = off
ij_javascript_class_method_decorator_wrap = off
ij_javascript_comma_on_new_line = false
ij_javascript_do_while_brace_force = never
ij_javascript_else_on_new_line = false
ij_javascript_enforce_trailing_comma = remove
ij_javascript_extends_keyword_wrap = off
ij_javascript_extends_list_wrap = off
ij_javascript_field_prefix = _
ij_javascript_file_name_style = relaxed
ij_javascript_finally_on_new_line = false
ij_javascript_for_brace_force = never
ij_javascript_for_statement_new_line_after_left_paren = false
ij_javascript_for_statement_right_paren_on_new_line = false
ij_javascript_for_statement_wrap = off
ij_javascript_force_quote_style = true
ij_javascript_force_semicolon_style = true
ij_javascript_function_expression_brace_style = end_of_line
ij_javascript_function_parameter_decorator_wrap = off
ij_javascript_if_brace_force = never
ij_javascript_import_merge_members = global
ij_javascript_import_prefer_absolute_path = global
ij_javascript_import_sort_members = true
ij_javascript_import_sort_module_name = false
ij_javascript_import_use_node_resolution = true
ij_javascript_imports_wrap = on_every_item
ij_javascript_indent_case_from_switch = true
ij_javascript_indent_chained_calls = true
ij_javascript_indent_package_children = 0
ij_javascript_jsx_attribute_value = braces
ij_javascript_keep_blank_lines_in_code = 2
ij_javascript_keep_first_column_comment = true
ij_javascript_keep_indents_on_empty_lines = false
ij_javascript_keep_line_breaks = true
ij_javascript_keep_simple_blocks_in_one_line = false
ij_javascript_keep_simple_methods_in_one_line = false
ij_javascript_line_comment_add_space = true
ij_javascript_line_comment_at_first_column = false
ij_javascript_method_brace_style = end_of_line
ij_javascript_method_call_chain_wrap = off
ij_javascript_method_parameters_new_line_after_left_paren = false
ij_javascript_method_parameters_right_paren_on_new_line = false
ij_javascript_method_parameters_wrap = off
ij_javascript_object_literal_wrap = on_every_item
ij_javascript_object_types_wrap = on_every_item
ij_javascript_parentheses_expression_new_line_after_left_paren = false
ij_javascript_parentheses_expression_right_paren_on_new_line = false
ij_javascript_place_assignment_sign_on_next_line = false
ij_javascript_prefer_as_type_cast = false
ij_javascript_prefer_explicit_types_function_expression_returns = false
ij_javascript_prefer_explicit_types_function_returns = false
ij_javascript_prefer_explicit_types_vars_fields = false
ij_javascript_prefer_parameters_wrap = false
ij_javascript_property_prefix =
ij_javascript_reformat_c_style_comments = false
ij_javascript_space_after_colon = true
ij_javascript_space_after_comma = true
ij_javascript_space_after_dots_in_rest_parameter = false
ij_javascript_space_after_generator_mult = true
ij_javascript_space_after_property_colon = true
ij_javascript_space_after_quest = true
ij_javascript_space_after_type_colon = true
ij_javascript_space_after_unary_not = false
ij_javascript_space_before_async_arrow_lparen = true
ij_javascript_space_before_catch_keyword = true
ij_javascript_space_before_catch_left_brace = true
ij_javascript_space_before_catch_parentheses = true
ij_javascript_space_before_class_lbrace = true
ij_javascript_space_before_class_left_brace = true
ij_javascript_space_before_colon = true
ij_javascript_space_before_comma = false
ij_javascript_space_before_do_left_brace = true
ij_javascript_space_before_else_keyword = true
ij_javascript_space_before_else_left_brace = true
ij_javascript_space_before_finally_keyword = true
ij_javascript_space_before_finally_left_brace = true
ij_javascript_space_before_for_left_brace = true
ij_javascript_space_before_for_parentheses = true
ij_javascript_space_before_for_semicolon = false
ij_javascript_space_before_function_left_parenth = true
ij_javascript_space_before_generator_mult = false
ij_javascript_space_before_if_left_brace = true
ij_javascript_space_before_if_parentheses = true
ij_javascript_space_before_method_call_parentheses = false
ij_javascript_space_before_method_left_brace = true
ij_javascript_space_before_method_parentheses = false
ij_javascript_space_before_property_colon = false
ij_javascript_space_before_quest = true
ij_javascript_space_before_switch_left_brace = true
ij_javascript_space_before_switch_parentheses = true
ij_javascript_space_before_try_left_brace = true
ij_javascript_space_before_type_colon = false
ij_javascript_space_before_unary_not = false
ij_javascript_space_before_while_keyword = true
ij_javascript_space_before_while_left_brace = true
ij_javascript_space_before_while_parentheses = true
ij_javascript_spaces_around_additive_operators = true
ij_javascript_spaces_around_arrow_function_operator = true
ij_javascript_spaces_around_assignment_operators = true
ij_javascript_spaces_around_bitwise_operators = true
ij_javascript_spaces_around_equality_operators = true
ij_javascript_spaces_around_logical_operators = true
ij_javascript_spaces_around_multiplicative_operators = true
ij_javascript_spaces_around_relational_operators = true
ij_javascript_spaces_around_shift_operators = true
ij_javascript_spaces_around_unary_operator = false
ij_javascript_spaces_within_array_initializer_brackets = true
ij_javascript_spaces_within_brackets = false
ij_javascript_spaces_within_catch_parentheses = false
ij_javascript_spaces_within_for_parentheses = false
ij_javascript_spaces_within_if_parentheses = false
ij_javascript_spaces_within_imports = true
ij_javascript_spaces_within_interpolation_expressions = false
ij_javascript_spaces_within_method_call_parentheses = false
ij_javascript_spaces_within_method_parentheses = false
ij_javascript_spaces_within_object_literal_braces = true
ij_javascript_spaces_within_object_type_braces = true
ij_javascript_spaces_within_parentheses = false
ij_javascript_spaces_within_switch_parentheses = false
ij_javascript_spaces_within_type_assertion = false
ij_javascript_spaces_within_union_types = true
ij_javascript_spaces_within_while_parentheses = false
ij_javascript_special_else_if_treatment = true
ij_javascript_ternary_operation_signs_on_next_line = false
ij_javascript_ternary_operation_wrap = off
ij_javascript_union_types_wrap = on_every_item
ij_javascript_use_chained_calls_group_indents = false
ij_javascript_use_double_quotes = true
ij_javascript_use_explicit_js_extension = auto
ij_javascript_use_import_type = auto
ij_javascript_use_path_mapping = always
ij_javascript_use_public_modifier = false
ij_javascript_use_semicolon_after_statement = true
ij_javascript_var_declaration_wrap = normal
ij_javascript_while_brace_force = never
ij_javascript_while_on_new_line = false
ij_javascript_wrap_comments = false
[{*.har,*.jsb2,*.jsb3,*.json,*.jsonc,*.postman_collection,*.postman_collection.json,*.postman_environment,*.postman_environment.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,.ws-context,jest.config}]
indent_size = 2
ij_json_array_wrapping = split_into_lines
ij_json_keep_blank_lines_in_code = 0
ij_json_keep_indents_on_empty_lines = false
ij_json_keep_line_breaks = true
ij_json_keep_trailing_comma = false
ij_json_object_wrapping = split_into_lines
ij_json_property_alignment = do_not_align
ij_json_space_after_colon = true
ij_json_space_after_comma = true
ij_json_space_before_colon = false
ij_json_space_before_comma = false
ij_json_spaces_within_braces = false
ij_json_spaces_within_brackets = false
ij_json_wrap_long_lines = false
[{*.htm,*.html,*.sht,*.shtm,*.shtml}]
ij_html_add_new_line_before_tags = body,div,p,form,h1,h2,h3
ij_html_align_attributes = true
ij_html_align_text = false
ij_html_attribute_wrap = normal
ij_html_block_comment_add_space = false
ij_html_block_comment_at_first_column = true
ij_html_do_not_align_children_of_min_lines = 0
ij_html_do_not_break_if_inline_tags = title,h1,h2,h3,h4,h5,h6,p
ij_html_do_not_indent_children_of_tags = html,body,thead,tbody,tfoot
ij_html_enforce_quotes = false
ij_html_inline_tags = a,abbr,acronym,b,basefont,bdo,big,br,cite,cite,code,dfn,em,font,i,img,input,kbd,label,q,s,samp,select,small,span,strike,strong,sub,sup,textarea,tt,u,var
ij_html_keep_blank_lines = 2
ij_html_keep_indents_on_empty_lines = false
ij_html_keep_line_breaks = true
ij_html_keep_line_breaks_in_text = true
ij_html_keep_whitespaces = false
ij_html_keep_whitespaces_inside = span,pre,textarea
ij_html_line_comment_at_first_column = true
ij_html_new_line_after_last_attribute = never
ij_html_new_line_before_first_attribute = never
ij_html_quote_style = double
ij_html_remove_new_line_before_tags = br
ij_html_space_after_tag_name = false
ij_html_space_around_equality_in_attribute = false
ij_html_space_inside_empty_tag = false
ij_html_text_wrap = normal
[{*.http,*.rest}]
ij_continuation_indent_size = 4
ij_http-request_call_parameters_wrap = normal
ij_http-request_method_parameters_wrap = split_into_lines
ij_http-request_space_before_comma = true
ij_http-request_spaces_around_assignment_operators = true
[{*.markdown,*.md}]
ij_markdown_force_one_space_after_blockquote_symbol = true
ij_markdown_force_one_space_after_header_symbol = true
ij_markdown_force_one_space_after_list_bullet = true
ij_markdown_force_one_space_between_words = true
ij_markdown_format_tables = true
ij_markdown_insert_quote_arrows_on_wrap = true
ij_markdown_keep_indents_on_empty_lines = false
ij_markdown_keep_line_breaks_inside_text_blocks = true
ij_markdown_max_lines_around_block_elements = 1
ij_markdown_max_lines_around_header = 1
ij_markdown_max_lines_between_paragraphs = 1
ij_markdown_min_lines_around_block_elements = 1
ij_markdown_min_lines_around_header = 1
ij_markdown_min_lines_between_paragraphs = 1
ij_markdown_wrap_text_if_long = true
ij_markdown_wrap_text_inside_blockquotes = true
[{*.yaml,*.yml}]
indent_size = 2
ij_yaml_align_values_properties = do_not_align
ij_yaml_autoinsert_sequence_marker = true
ij_yaml_block_mapping_on_new_line = false
ij_yaml_indent_sequence_value = true
ij_yaml_keep_indents_on_empty_lines = false
ij_yaml_keep_line_breaks = true
ij_yaml_line_comment_add_space = false
ij_yaml_line_comment_add_space_on_reformat = false
ij_yaml_line_comment_at_first_column = true
ij_yaml_sequence_on_new_line = false
ij_yaml_space_before_colon = false
ij_yaml_spaces_within_braces = true
ij_yaml_spaces_within_brackets = true

View file

@ -1,5 +1,14 @@
MATRIX_BASE_URL =
MATRIX_ACCESS_TOKEN =
MATRIX_ROOM_ID =
DISCORD_WEBHOOK_URL =
DATABASE_USER = statsBot
DATABASE_PASSWORD = statsBot
DATABASE_HOST = 127.0.0.1
DATABASE_NAME = asteriskcdrdb
# Extra debugging options:
#NOOP=true
#RUN_ONCE=true
#DATABASE_LOG_TIMINGS=true
#LOG_MESSAGES=true
#NO_SAVE_RECORDS=true

8
.idea/.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View file

@ -0,0 +1,24 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="AUTODETECT_INDENTS" value="false" />
<JSCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_WITHIN_ARRAY_INITIALIZER_BRACKETS" value="true" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<editorconfig>
<option name="ENABLED" value="false" />
</editorconfig>
<codeStyleSettings language="JavaScript">
<option name="ARRAY_INITIALIZER_WRAP" value="5" />
<option name="ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE" value="true" />
<option name="ARRAY_INITIALIZER_RBRACE_ON_NEXT_LINE" value="true" />
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

View file

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

4
.idea/encodings.xml Normal file
View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

12
.idea/freepbx-stats.iml Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<includedPredefinedLibrary name="Node.js Core" />
</component>
</project>

8
.idea/modules.xml Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/freepbx-stats.iml" filepath="$PROJECT_DIR$/.idea/freepbx-stats.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

30
callStats.js Normal file
View file

@ -0,0 +1,30 @@
export class CallStats {
/**
* @param {CallStats} stats
*/
constructor(stats) {
for (const [ key, value ] of Object.entries(stats))
this[key] = value;
}
/**
* @type {number}
*/
totalCallsThisMonth;
/**
* @type {number}
*/
totalCallsEverPlaced;
/**
* @type {number}
*/
totalCallsMade;
/**
* @type {CallRecord}
*/
allTimeRecord;
/**
* @type {boolean}
*/
isNewRecord = false;
}

69
dateBuilder.js Normal file
View file

@ -0,0 +1,69 @@
export class DateBuilder {
// constructors
constructor(date = new Date()) {
if (!(date instanceof Date)) {
throw new Error("Invalid date object.");
}
this.date = new Date(date.getTime()); // Create a copy to avoid mutating the original date
}
// methods
addYears(years) {
this.date.setFullYear(this.date.getFullYear() + years);
return this;
}
addMonths(months) {
this.date.setMonth(this.date.getMonth() + months);
return this;
}
addDays(days) {
this.date.setDate(this.date.getDate() + days);
return this;
}
addHours(hours) {
this.date.setHours(this.date.getHours() + hours);
return this;
}
addMinutes(minutes) {
this.date.setMinutes(this.date.getMinutes() + minutes);
return this;
}
addSeconds(seconds) {
this.date.setSeconds(this.date.getSeconds() + seconds);
return this;
}
addMillis(millis) {
this.date.setTime(this.date.getTime() + millis);
return this;
}
withDate(year, month, day) {
this.date.setFullYear(year, month - 1, day); // month is 0-based
return this;
}
withTime(hour, minute = 0, second = 0, millisecond = 0) {
this.date.setHours(hour, minute, second, millisecond);
return this;
}
atStartOfDay() {
this.date.setHours(0, 0, 0, 0);
return this;
}
atEndOfDay() {
this.date.setHours(23, 59, 59, 999);
return this;
}
build() {
return new Date(this.date.getTime()); // Return a copy to avoid external mutation
}
}

137
dateBuilder.test.js Normal file
View file

@ -0,0 +1,137 @@
// noinspection ES6ConvertRequireIntoImport - this is CJS, not MJS
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { DateBuilder } = require("./dateBuilder");
test("DateBuilder should be able to be initialised", () => {
const db = new DateBuilder();
assert.equal(db instanceof DateBuilder, true);
});
test("DateBuilder should be able to build current date", () => {
const now = new Date();
const db = new DateBuilder();
const built = db.build();
assert.equal(built.getFullYear(), now.getFullYear());
assert.equal(built.getMonth(), now.getMonth());
assert.equal(built.getDate(), now.getDate());
assert.equal(built.getHours(), now.getHours());
assert.equal(built.getMinutes(), now.getMinutes());
assert.equal(built.getSeconds(), now.getSeconds());
});
test("DateBuilder should be able to add days", () => {
const db = new DateBuilder(new Date(2024, 0, 1)); // Jan 1, 2024
db.addDays(30);
const built = db.build();
assert.equal(built.getFullYear(), 2024);
assert.equal(built.getMonth(), 0); // January
assert.equal(built.getDate(), 31); // January has 31 days
});
test("DateBuilder should be able to add months", () => {
const db = new DateBuilder(new Date(2024, 0, 1)); // Jan 31, 2024
db.addMonths(1);
const built = db.build();
assert.equal(built.getFullYear(), 2024);
assert.equal(built.getMonth(), 1); // February
});
test("DateBuilder should be able to add years", () => {
const db = new DateBuilder(new Date(2020, 1, 1));
db.addYears(1);
const built = db.build();
assert.equal(built.getFullYear(), 2021);
assert.equal(built.getMonth(), 1); // February
assert.equal(built.getDate(), 1);
});
test("DateBuilder should be able to set date", () => {
const db = new DateBuilder();
db.withDate(2022, 12, 25); // Dec 25, 2022
const built = db.build();
assert.equal(built.getFullYear(), 2022);
assert.equal(built.getMonth(), 11); // December
assert.equal(built.getDate(), 25);
});
test("DateBuilder should be able to set time", () => {
const db = new DateBuilder();
db.withTime(15, 30, 45, 123); // 15:30:45.123
const built = db.build();
assert.equal(built.getHours(), 15);
assert.equal(built.getMinutes(), 30);
assert.equal(built.getSeconds(), 45);
assert.equal(built.getMilliseconds(), 123);
});
test("DateBuilder should be able to set start of day", () => {
const db = new DateBuilder(new Date(2024, 5, 15, 10, 20, 30, 456)); // June 15, 2024, 10:20:30.456
db.atStartOfDay();
const built = db.build();
assert.equal(built.getFullYear(), 2024);
assert.equal(built.getMonth(), 5); // June
assert.equal(built.getDate(), 15);
assert.equal(built.getHours(), 0);
assert.equal(built.getMinutes(), 0);
assert.equal(built.getSeconds(), 0);
assert.equal(built.getMilliseconds(), 0);
});
test("DateBuilder should be able to set end of day", () => {
const db = new DateBuilder(new Date(2024, 5, 15, 10, 20, 30, 456)); // June 15, 2024, 10:20:30.456
db.atEndOfDay();
const built = db.build();
assert.equal(built.getFullYear(), 2024);
assert.equal(built.getMonth(), 5); // June
assert.equal(built.getDate(), 15);
assert.equal(built.getHours(), 23);
assert.equal(built.getMinutes(), 59);
assert.equal(built.getSeconds(), 59);
assert.equal(built.getMilliseconds(), 999);
});
test("DateBuilder should be able to chain methods", () => {
const db = new DateBuilder(new Date(2024, 0, 1)); // Jan 1, 2024
db.addDays(1).addMonths(1).addYears(1).withTime(12, 0, 0).atEndOfDay();
const built = db.build();
assert.equal(built.getFullYear(), 2025);
assert.equal(built.getMonth(), 1); // March
assert.equal(built.getDate(), 2);
assert.equal(built.getHours(), 23);
assert.equal(built.getMinutes(), 59);
assert.equal(built.getSeconds(), 59);
assert.equal(built.getMilliseconds(), 999);
});
test("DateBuilder should not mutate original date", () => {
const original = new Date(2024, 0, 1); // Jan 1, 2024
const db = new DateBuilder(original);
db.addDays(10);
const built = db.build();
assert.equal(original.getFullYear(), 2024);
assert.equal(original.getMonth(), 0); // January
assert.equal(original.getDate(), 1); // Original date should remain unchanged
assert.equal(built.getFullYear(), 2024);
assert.equal(built.getMonth(), 0); // January
assert.equal(built.getDate(), 11); // New date should be Jan 11, 2024
});
test("DateBuilder should handle leap years correctly", () => {
const db = new DateBuilder(new Date(2020, 1, 29)); // Feb 29, 2020 (leap year)
db.addYears(1);
const built = db.build();
assert.equal(built.getFullYear(), 2021);
assert.equal(built.getMonth(), 2); // March
assert.equal(built.getDate(), 1); // March 1, 2021 (not a leap year)
});
test("DateBuilder should handle month overflow correctly", () => {
const db = new DateBuilder(new Date(2024, 0, 31)); // Jan 31, 2024
db.addDays(1);
const built = db.build();
assert.equal(built.getFullYear(), 2024);
assert.equal(built.getMonth(), 1); // February
assert.equal(built.getDate(), 1); // Feb 29, 2024 (leap year)
});

292
index.js
View file

@ -1,150 +1,228 @@
require("dotenv").config()
require("dotenv").config();
const cron = require("node-cron");
const os = require("os")
const Discord = require('discord.js');
const { connect } = require("http2");
const mysql = require('mysql');
const fs = require('fs').promises;
const os = require("os");
const Discord = require("discord.js");
const mysql = require("mysql");
const { TimeSpan } = require("./timeSpan");
const { DateBuilder } = require("./dateBuilder");
const { CallStats } = require("./callStats");
const { CallRecord, Records } = require("./records");
const fsSync = require("fs");
const hook = new Discord.WebhookClient({ url: process.env.DISCORD_WEBHOOK_URL })
const hook = !!process.env.DISCORD_WEBHOOK_URL ? new Discord.WebhookClient({ url: process.env.DISCORD_WEBHOOK_URL }) : null;
const JSON_FILE = process.env.JSON_FILE || "records.json";
const records = fsSync.existsSync(JSON_FILE) ? Records.fromJSONFile(JSON_FILE) : new Records();
function getStartOfYesterdayTimestamp() {
const today = new Date();
// Set the date to yesterday
today.setDate(today.getDate() - 1);
// Create a new Date object for the start of yesterday
const startOfYesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate());
return startOfYesterday.getTime(); // Returns the timestamp in milliseconds
function getYesterday() {
return new TimeSpan(new DateBuilder().addDays(-1).atStartOfDay().build().getTime(), new DateBuilder().addDays(-1).atEndOfDay().build().getTime());
}
async function loadRecords() {
try {
const data = await fs.readFile(JSON_FILE, 'utf-8');
return JSON.parse(data);
} catch (error) {
if (error.code === 'ENOENT') {
return { records: {} }; // Return empty records if file doesn't exist
}
throw error;
}
}
async function saveRecords(root) {
const json = JSON.stringify(root, null, 2);
await fs.writeFile(JSON_FILE, json);
}
async function getPreviousDayData() {
return new Promise(async (resolve, reject) => {
const previousDay = new Date(getStartOfYesterdayTimestamp()) // 24 hours ago
const startTime = new Date(previousDay.setHours(0, 0, 0, 0));
const endTime = new Date(previousDay.setHours(23, 59, 59, 999));
/**
* @param {string} query
* @param {any} params
* @returns {Promise<int>}
*/
async function queryScalarAsync(query, ...params) {
const start = Date.now();
const connection = await mysql.createConnection({
host: process.env.DATABASE_HOST,
user: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
multipleStatements: true
database: process.env.DATABASE_NAME
});
await connection.connect();
let callsMade;
let recordForToday;
let monthlyTotal;
let totalCalls;
await connection.query(`
SELECT COUNT(DISTINCT uniqueid) AS call_count FROM cdr WHERE calldate BETWEEN ? AND ?;
SELECT COUNT(DISTINCT uniqueid) AS call_count FROM cdr WHERE MONTH(calldate) = MONTH(?) AND YEAR(calldate) = YEAR(?);
SELECT COUNT(DISTINCT uniqueid) AS call_count FROM cdr;
`, [startTime, endTime, previousDay, previousDay], (err, res) => {
return new Promise((resolve, reject) => {
connection.query(query, params, (err, results) => {
if (err) {
reject(err)
reject(err);
} else {
if (process.env.DATABASE_LOG_TIMINGS) console.log(`Query took ${Date.now() - start}ms:`, query, params, "=>", results);
resolve(results[0][Object.keys(results[0])[0]]);
}
connection.end();
let output = {
"Calls Made": res[0][0].call_count,
"Monthly Total": res[1][0].call_count,
"Total Calls Ever Placed": res[2][0].call_count,
"System Uptime": getSystemUptime(),
"All Time Record": null, // Placeholder
}
console.log(output)
resolve(output);
});
})
});
}
/**
* Fetch call statistics
* @returns {Promise<CallStats>}
*/
async function getPreviousDayData() {
const [ callsYesterday, callsThisMonth, callsTotal ] = await Promise.all([
queryScalarAsync(`
SELECT COUNT(DISTINCT uniqueid) AS call_count
FROM cdr
WHERE calldate BETWEEN ? AND ?;
`, getYesterday().startDate, getYesterday().endDate), queryScalarAsync(`
SELECT COUNT(DISTINCT uniqueid) AS call_count
FROM cdr
WHERE MONTH (calldate) = ? AND YEAR (calldate) = ?;
`, getYesterday().startDate.getMonth(), getYesterday().startDate.getFullYear()), queryScalarAsync(`
SELECT COUNT(DISTINCT uniqueid) AS call_count
FROM cdr;
`)
]);
const stats = new CallStats({
totalCallsMade: callsYesterday, totalCallsThisMonth: callsThisMonth, totalCallsEverPlaced: callsTotal
});
console.log("Got stats:", stats, "built from query results:", { callsYesterday, callsThisMonth, callsTotal });
return stats;
}
function getSystemUptime() {
const uptime = os.uptime();
const days = Math.floor(uptime / 86400);
const hours = Math.floor((uptime % 86400) / 3600);
const minutes = Math.floor((uptime % 3600) / 60);
const seconds = Math.floor(uptime % 60);
return `${days} days, ${hours} hours, ${minutes} minutes, ${seconds} seconds`;
const now = new Date();
return new TimeSpan(now - (uptime * 1000), now.getTime());
}
function updateRecords(data, root) {
const currentDate = new Date(getStartOfYesterdayTimestamp()).toISOString().split('T')[0];
const month = currentDate.slice(0, 7);
let isNewRecord = false;
/**
* Update records with new data
* @param {CallStats} callStats
* @returns {CallStats}
*/
function updateRecords(callStats) {
const yesterday = getYesterday().startDate;
const yesterdayDateString = yesterday.toISOString().split("T")[0];
// Update all-time record
const allTimeRecord = root.records.record_calls || { date: currentDate, count: 0 };
if (!root.records.record_calls) {
root.records.record_calls = { date: currentDate, count: data["Calls Made"] };
isNewRecord = true;
} else if (parseInt(allTimeRecord.count) < data["Calls Made"]) {
allTimeRecord.count = data["Calls Made"];
isNewRecord = true;
const previousRecord = records.callRecord || new CallRecord({ date: yesterdayDateString, count: 0 });
callStats.isNewRecord = false;
if (!records.callRecord) {
records.callRecord = new CallRecord({ date: yesterdayDateString, count: callStats.totalCallsMade });
callStats.isNewRecord = true;
console.warn("No previous call record found, initializing new record.");
} else if (callStats.totalCallsMade > previousRecord.count) {
records.callRecord.count = callStats.totalCallsMade;
records.callRecord.date = yesterdayDateString;
callStats.isNewRecord = true;
console.log(`New all-time record: ${previousRecord.count} calls on ${yesterdayDateString}, previous record was ${records.callRecord.count} calls on ${records.callRecord.date}`);
} else {
console.log(`No new record. Yesterday: ${callStats.totalCallsMade}, Record: ${previousRecord.count} on ${previousRecord.date}`);
}
data["All Time Record"] = `${allTimeRecord.count} calls on ${allTimeRecord.date}`;
// pass record to call stats for reporting
callStats.allTimeRecord = records.callRecord;
// Update total calls ever placed
root.records.total_calls_ever_placed = data["Total Calls Ever Placed"];
records.totalCallsEverPlaced = callStats.totalCallsEverPlaced;
// Update monthly total
root.records[`monthly_total_${month}`] = data["Monthly Total"];
// Update monthly totals
if (!records.monthlyTotals) records.monthlyTotals = {};
if (!records.monthlyTotals[yesterday.getFullYear().toString()]) records.monthlyTotals[yesterday.getFullYear().toString()] = {};
records.monthlyTotals[yesterday.getFullYear().toString()][yesterday.getMonth().toString()] = callStats.totalCallsThisMonth;
if (isNewRecord) {
data["NEW RECORD"] = true;
}
return data;
return callStats;
}
async function sendSummary() {
console.log("Preparing summary.")
console.log("Preparing summary.");
const data = await getPreviousDayData();
console.log("Loading records.")
const root = await loadRecords();
console.log("Updating records...")
const updatedData = await updateRecords(data, root);
console.log("Saving.")
await saveRecords(root);
console.log("Updating records...");
const stats = await updateRecords(data);
if (!process.env.NO_SAVE_RECORDS) {
console.log("Saving.");
await records.toJSONFile(JSON_FILE);
}
const previousDayStart = new Date(getStartOfYesterdayTimestamp());
const previousDayEnd = new Date(previousDayStart);
previousDayEnd.setHours(23, 59, 59, 999);
const yesterday = getYesterday();
await sendSummaryDiscord(yesterday, stats);
await sendSummaryMatrix(yesterday, stats);
}
async function sendSummaryMatrix(yesterday, stats) {
const message = {
"msgtype": "m.text",
"format": "org.matrix.custom.html",
"body": `Summary from ${new Date(yesterday.start).toDateString()} to ${new Date(yesterday.end).toDateString()}\n
Calls Made: ${stats.totalCallsMade}
Monthly Total: ${stats.totalCallsThisMonth}
Total Calls Ever Placed: ${stats.totalCallsEverPlaced}
System Uptime: ${getSystemUptime().toString(false, false)}
All Time Record: ${stats.allTimeRecord.count} calls on ${stats.allTimeRecord.date}
${stats.isNewRecord ? `🎉 NEW RECORD! 🎉 A new record has been set, at ${stats.totalCallsMade} calls in a day!` : ""}`.trim(),
"formatted_body": `
<table>
<thead>
<tr><td colspan="2"><strong>Summary from ${yesterday.startDate.toString()} to ${yesterday.endDate.toString()}</strong></td></tr>
</thead>
<tbody>
<tr><td>Calls made</td><td>${stats.totalCallsMade}</td></tr>
<tr><td>Monthly total</td><td>${stats.totalCallsThisMonth}</td></tr>
<tr><td>Total calls ever placed</td><td>${stats.totalCallsEverPlaced}</td></tr>
<tr><td>System uptime</td><td>${getSystemUptime().toString(false, false)}</td></tr>
<tr><td>All-time record</td><td>${stats.allTimeRecord.count} calls on ${stats.allTimeRecord.date}</td></tr>
${stats.isNewRecord ? `<tr><td colspan="2"><span data-mx-color="#FFD700"><font color="#FFD700">🎉 NEW RECORD! 🎉</font></span> A new record has been set, at ${stats.totalCallsMade} calls in a day!</td></tr>` : ""}
</tbody>
</table>
`.split("\n").map(s => s.trim()).join(""),
"tel.litenet.call_stats_summary": {
...stats, date: yesterday, allTimeRecordData: records.callRecord
}
};
if (process.env.LOG_MESSAGES) {
console.log("Sending Matrix message:", JSON.stringify(message, null, 2));
console.log("Plaintext:\n", message.body);
console.log("HTML:\n", message.formatted_body);
}
if (!process.env.NOOP) {
if (!process.env.MATRIX_BASE_URL || !process.env.MATRIX_ROOM_ID || !process.env.MATRIX_ACCESS_TOKEN) {
console.warn("MATRIX_BASE_URL, MATRIX_ROOM_ID or MATRIX_ACCESS_TOKEN not set, skipping Matrix message.");
return;
}
const resp = await fetch(`${process.env.MATRIX_BASE_URL}/_matrix/client/v3/rooms/${encodeURIComponent(process.env.MATRIX_ROOM_ID)}/send/m.room.message/${Math.random()}`, {
method: "PUT", body: JSON.stringify(message, null, 2), headers: {
"Authorization": `Bearer ${process.env.MATRIX_ACCESS_TOKEN}`, "Content-Type": "application/json"
}
});
if (!resp.ok) {
console.error("Failed to send Matrix message:", resp.status, await resp.text());
} else {
console.log("Matrix message sent successfully.");
}
}
}
async function sendSummaryDiscord(yesterday, stats) {
const makeField = (name, value) => ({
name, value: value === undefined ? "***ERR: UNDEFINED***" : value.toString(), inline: false
});
let embed = {
title: `Summary from <t:${Math.floor(previousDayStart / 1000)}:f> to <t:${Math.floor(previousDayEnd / 1000)}:f>`,
title: `Summary from <t:${Math.floor(yesterday.start / 1000)}:f> to <t:${Math.floor(yesterday.end / 1000)}:f>`,
color: 0x1E90FF,
fields: [],
fields: [
makeField("Calls Made", stats.totalCallsMade),
makeField("Monthly Total", stats.totalCallsThisMonth),
makeField("Total Calls Ever Placed", stats.totalCallsEverPlaced),
makeField("System Uptime", getSystemUptime().toString(false, false)),
makeField("All Time Record", `${stats.allTimeRecord.count} calls on ${stats.allTimeRecord.date}`)
],
timestamp: new Date(),
footer: {
footer: {}
};
if (stats.isNewRecord) {
embed.color = 0xFFD700; // Gold color for new record
embed.fields.push(makeField("🎉 NEW RECORD! 🎉", `A new record has been set, at ${stats.totalCallsMade} calls in a day!`));
}
}
for (const [key, value] of Object.entries(updatedData)) {
if (key === "NEW RECORD") {
embed.fields.push({ name: "NEW RECORD!", value: "A new record has been set!", inline: false });
} else {
embed.fields.push({ name: key, value: value, inline: false });
}
}
console.log("Sending message.")
await hook.send({ embeds: [embed] });
const payload = { embeds: [ embed ] };
if (process.env.LOG_MESSAGES) console.log("Sending Discord message:", JSON.stringify(payload, null, 2));
if (hook && !process.env.NOOP) await hook.send(payload);
}
console.log("Scheduling...")
const schedule = cron.schedule("0 1 * * *", sendSummary)
if (process.env.NOOP || process.env.RUN_ONCE) {
sendSummary();
return;
}
console.log("Scheduling...");
const schedule = cron.schedule("0 1 * * *", sendSummary);

67
records.js Normal file
View file

@ -0,0 +1,67 @@
import fs from "fs";
export class Records {
constructor(records = {}) {
if (typeof records.records === "object")
for (const [ key, value ] of Object.entries(records.records)) {
const oldTableMatches = key.match(/^monthly_total_(\d{4})-(\d{2})$/);
if (oldTableMatches) {
const year = oldTableMatches[1];
const month = oldTableMatches[2];
if (!this.monthlyTotals) this.monthlyTotals = {};
if (!this.monthlyTotals[year]) this.monthlyTotals[year] = {};
this.monthlyTotals[year][month] = value;
} else if (key === "record_calls" && typeof value === "object" && value !== null) this.callRecord = new CallRecord(value.date, value.count);
else if (key === "total_calls_ever_placed" && typeof value === "number") this.totalCallsEverPlaced = value;
else throw new Error(`Unknown legacy record key: ${key}`);
} else for (const [ key, value ] of Object.entries(records)) this[key] = value;
}
static fromJSONFile(path) {
const data = fs.readFileSync(path, "utf-8");
const obj = JSON.parse(data);
return new Records(obj);
}
toJSONFile(path) {
const data = JSON.stringify(this, null, 2);
fs.writeFileSync(path, data, "utf-8");
}
/**
* @type {CallRecord}
*/
callRecord;
/**
* @type {number}
*/
totalCallsEverPlaced;
/**
* @type {{[year: string]: {[month: string]: number}}}
*/
monthlyTotals;
}
export class CallRecord {
constructor(date, count) {
this.date = date;
this.count = count;
}
/**
* @type {string} YYYY-MM-DD
*/
date;
/**
* @type {number}
*/
count;
}
// test (mjs)
if (import.meta.url === `file://${process.argv[1]}`) {
const records = Records.fromJSONFile("records.json");
console.log(records);
console.log(JSON.stringify(records, null, 2));
}

129
timeSpan.js Normal file
View file

@ -0,0 +1,129 @@
/**
* Represents a timespan with a start and end time.
*/
export class TimeSpan {
// constructors
constructor(start = Date.now(), end = Date.now()) {
if (start > end) {
throw new Error("Start time must be less than or equal to end time.");
}
this.start = start;
this.end = end;
}
static fromDates(startDate, endDate) {
return new TimeSpan(startDate, endDate);
}
static fromMillis(millis) {
return new TimeSpan(0, millis);
}
static fromSeconds(seconds) {
return TimeSpan.fromMillis(seconds * 1000);
}
// methods
get totalMillis() {
return this.end - this.start;
}
get millis() {
return Math.floor((this.totalMillis) % 1000);
}
get totalSeconds() {
return Math.floor(this.totalMillis / 1000);
}
get seconds() {
return Math.floor((this.totalMillis) / 1000 % 60);
}
get totalMinutes() {
return Math.floor(this.totalMillis / 1000 / 60);
}
get minutes() {
return Math.floor(this.totalMillis / 1000 / 60 % 60);
}
get totalHours() {
return Math.floor(this.totalMillis / 1000 / 60 / 60);
}
get hours() {
return Math.floor(this.totalMillis / 1000 / 60 / 60 % 24);
}
get totalDays() {
return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24);
}
get days() {
return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 % 30.44); // Average days in a month
}
get weekDays() {
return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 % 7);
}
get totalWeeks() {
return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 / 7);
}
get weeks() {
return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 / 7 % 4.345); // Average weeks in a month
}
get totalMonths() {
return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 / 30.44); // Average days in a month
}
get months() {
return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 / 30.44 % 12); // Average days in a month
}
get totalYears() {
return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 / 365.25); // Average days in a year
}
get years() {
return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 / 365.25 % 1); // Average days in a year
}
toString(includeWeeks = true, includeMillis = true) {
const parts = [];
if (this.totalYears >= 1) parts.push(`${this.totalYears} years`);
if (this.totalMonths >= 1) parts.push(`${this.months} months`);
if (includeWeeks && this.totalWeeks >= 1) parts.push(`${this.weeks} weeks`);
if (this.totalDays >= 1) parts.push(`${includeWeeks ? this.weekDays : this.days} days`);
if (this.totalHours >= 1) parts.push(`${this.hours} hours`);
if (this.totalMinutes >= 1) parts.push(`${this.minutes} minutes`);
if (this.totalSeconds >= 1) parts.push(`${this.seconds} seconds`);
if (includeMillis) parts.push(`${this.millis} milliseconds`);
return parts.join(", ");
}
toShortString(includeWeeks = true, includeMillis = true, withSpaces = false) {
const parts = [];
if (this.totalYears >= 1) parts.push(`${this.totalYears}y`);
if (this.totalMonths >= 1) parts.push(`${this.months}mo`);
if (includeWeeks && this.totalWeeks >= 1) parts.push(`${this.weeks}w`);
if (this.totalDays >= 1) parts.push(`${includeWeeks ? this.weekDays : this.days}d`);
if (this.totalHours >= 1) parts.push(`${this.hours}h`);
if (this.totalMinutes >= 1) parts.push(`${this.minutes}m`);
if (this.totalSeconds >= 1) parts.push(`${this.seconds}s`);
if (includeMillis) parts.push(`${this.millis}ms`);
return parts.join(withSpaces ? " " : "");
}
get startDate() {
return new Date(this.start);
}
get endDate() {
return new Date(this.end);
}
}

81
timeSpan.test.js Normal file
View file

@ -0,0 +1,81 @@
// noinspection ES6ConvertRequireIntoImport - this is CJS, not MJS
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { TimeSpan } = require("./timeSpan");
test("TimeSpan should be able to return zero", () => {
const ts = new TimeSpan();
assert.equal(ts.totalMillis, 0);
});
test("TimeSpan should be able to return timespan from milliseconds", () => {
const ts = TimeSpan.fromMillis(1000);
assert.equal(ts.totalMillis, 1000);
assert.equal(ts.totalSeconds, 1);
});
test("TimeSpan should be able to return timespan from seconds", () => {
const ts = TimeSpan.fromSeconds(60);
assert.equal(ts.totalMillis, 60000);
assert.equal(ts.totalSeconds, 60);
assert.equal(ts.totalMinutes, 1);
assert.equal(ts.minutes, 1);
assert.equal(ts.hours, 0);
assert.equal(ts.days, 0);
});
test("TimeSpan should be pure", () => {
const count = 10;
const timestamps = [];
for (let i = 0; i < count; i++) {
timestamps.push(TimeSpan.fromMillis(8972347984));
for (const ts2 of timestamps) {
assert.equal(ts2.totalMillis, 8972347984);
assert.equal(ts2.totalSeconds, 8972347);
assert.equal(ts2.totalMinutes, 149539);
assert.equal(ts2.totalHours, 2492);
assert.equal(ts2.totalDays, 103);
assert.equal(ts2.totalWeeks, 14);
assert.equal(ts2.totalMonths, 3);
assert.equal(ts2.totalYears, 0);
assert.equal(ts2.millis, 984);
assert.equal(ts2.seconds, 7);
assert.equal(ts2.minutes, 19);
assert.equal(ts2.hours, 20);
assert.equal(ts2.days, 12);
assert.equal(ts2.weekDays, 5);
assert.equal(ts2.weeks, 1);
assert.equal(ts2.months, 3);
assert.equal(ts2.years, 0);
}
}
});
test("TimeSpan should be able to stringify", () => {
const ts = TimeSpan.fromMillis(8972347984);
assert.equal(ts.toString(), "3 months, 1 weeks, 5 days, 20 hours, 19 minutes, 7 seconds, 984 milliseconds");
assert.equal(ts.toString(true), "3 months, 1 weeks, 5 days, 20 hours, 19 minutes, 7 seconds, 984 milliseconds");
assert.equal(ts.toString(true, false), "3 months, 1 weeks, 5 days, 20 hours, 19 minutes, 7 seconds");
assert.equal(ts.toString(false), "3 months, 12 days, 20 hours, 19 minutes, 7 seconds, 984 milliseconds");
assert.equal(ts.toString(false, false), "3 months, 12 days, 20 hours, 19 minutes, 7 seconds");
});
test("TimeSpan should be able to shortStringify", () => {
const ts = TimeSpan.fromMillis(8972347984);
assert.equal(ts.toShortString(), "3mo1w5d20h19m7s984ms");
assert.equal(ts.toShortString(true), "3mo1w5d20h19m7s984ms");
assert.equal(ts.toShortString(true, false), "3mo1w5d20h19m7s");
assert.equal(ts.toShortString(false), "3mo12d20h19m7s984ms");
assert.equal(ts.toShortString(false, false), "3mo12d20h19m7s");
});
test("TimeSpan should be able to shortStringify with spaces", () => {
const ts = TimeSpan.fromMillis(8972347984);
assert.equal(ts.toShortString(undefined, undefined, true), "3mo 1w 5d 20h 19m 7s 984ms");
assert.equal(ts.toShortString(true, undefined, true), "3mo 1w 5d 20h 19m 7s 984ms");
assert.equal(ts.toShortString(true, false, true), "3mo 1w 5d 20h 19m 7s");
assert.equal(ts.toShortString(false, undefined, true), "3mo 12d 20h 19m 7s 984ms");
assert.equal(ts.toShortString(false, false, true), "3mo 12d 20h 19m 7s");
});