From 2d83df8b32bff7f0028923bb5b64dc0b55f20d03 Mon Sep 17 00:00:00 2001 From: Jordan Bracco Date: Tue, 20 Dec 2022 00:21:54 +0000 Subject: Nola rename: The Big Move, Refs T77 --- lib/irc/account.ex | 451 ------- lib/irc/connection.ex | 521 --------- lib/irc/conns.ex | 3 - lib/irc/irc.ex | 79 -- lib/irc/membership.ex | 129 -- lib/irc/plugin/temp_ref.ex | 95 -- lib/irc/plugin_supervisor.ex | 99 -- lib/irc/puppet_connection.ex | 238 ---- lib/irc/user_track.ex | 329 ------ lib/lsg/application.ex | 56 - lib/lsg/auth_token.ex | 59 - lib/lsg/icecast.ex | 117 -- lib/lsg/icecast_agent.ex | 17 - lib/lsg/lsg.ex | 30 - lib/lsg/subnet.ex | 84 -- lib/lsg/token.ex | 38 - lib/lsg_irc/admin_handler.ex | 41 - lib/lsg_irc/alcolog_plugin.ex | 1229 -------------------- lib/lsg_irc/alcoolog_announcer_plugin.ex | 272 ----- lib/lsg_irc/base_plugin.ex | 131 --- lib/lsg_irc/bourosama_plugin.ex | 58 - lib/lsg_irc/buffer_plugin.ex | 44 - lib/lsg_irc/calc_plugin.ex | 37 - lib/lsg_irc/coronavirus_plugin.ex | 172 --- lib/lsg_irc/correction_plugin.ex | 59 - lib/lsg_irc/dice_plugin.ex | 66 -- lib/lsg_irc/finance_plugin.ex | 190 --- lib/lsg_irc/gpt_plugin.ex | 259 ----- lib/lsg_irc/kick_roulette_plugin.ex | 32 - lib/lsg_irc/last_fm_plugin.ex | 187 --- lib/lsg_irc/link_plugin.ex | 271 ----- lib/lsg_irc/link_plugin/github.ex | 49 - lib/lsg_irc/link_plugin/html.ex | 106 -- lib/lsg_irc/link_plugin/imgur.ex | 96 -- lib/lsg_irc/link_plugin/pdf.ex | 39 - lib/lsg_irc/link_plugin/redacted.ex | 18 - lib/lsg_irc/link_plugin/reddit.ex | 119 -- lib/lsg_irc/link_plugin/twitter.ex | 158 --- lib/lsg_irc/link_plugin/youtube.ex | 72 -- lib/lsg_irc/logger_plugin.ex | 70 -- lib/lsg_irc/lsg_irc.ex | 34 - lib/lsg_irc/outline_plugin.ex | 108 -- lib/lsg_irc/preums_plugin.ex | 276 ----- lib/lsg_irc/quatre_cent_vingt_plugin.ex | 149 --- lib/lsg_irc/radio_france_plugin.ex | 133 --- lib/lsg_irc/say_plugin.ex | 73 -- lib/lsg_irc/script_plugin.ex | 42 - lib/lsg_irc/seen_plugin.ex | 59 - lib/lsg_irc/sms_plugin.ex | 165 --- lib/lsg_irc/tell_plugin.ex | 106 -- lib/lsg_irc/txt_plugin.ex | 556 --------- lib/lsg_irc/txt_plugin/markov.ex | 9 - lib/lsg_irc/txt_plugin/markov_native.ex | 33 - lib/lsg_irc/txt_plugin/markov_py_markovify.ex | 39 - lib/lsg_irc/untappd_plugin.ex | 66 -- lib/lsg_irc/user_mention_plugin.ex | 52 - lib/lsg_irc/wikipedia_plugin.ex | 90 -- lib/lsg_irc/wolfram_alpha_plugin.ex | 47 - lib/lsg_irc/youtube_plugin.ex | 104 -- lib/lsg_matrix/matrix.ex | 169 --- lib/lsg_matrix/plug.ex | 25 - lib/lsg_matrix/room.ex | 196 ---- lib/lsg_telegram/room.ex | 188 --- lib/lsg_telegram/telegram.ex | 233 ---- lib/lsg_web/channels/user_socket.ex | 37 - lib/lsg_web/components/component.ex | 44 - lib/lsg_web/components/event_component.ex | 43 - lib/lsg_web/components/message_component.ex | 12 - lib/lsg_web/context_plug.ex | 92 -- lib/lsg_web/controllers/alcoolog_controller.ex | 323 ----- lib/lsg_web/controllers/gpt_controller.ex | 33 - lib/lsg_web/controllers/icecast_see_controller.ex | 41 - lib/lsg_web/controllers/irc_auth_sse_controller.ex | 66 -- lib/lsg_web/controllers/irc_controller.ex | 101 -- lib/lsg_web/controllers/network_controller.ex | 11 - lib/lsg_web/controllers/open_id_controller.ex | 64 - lib/lsg_web/controllers/page_controller.ex | 53 - lib/lsg_web/controllers/sms_controller.ex | 10 - lib/lsg_web/controllers/untappd_controller.ex | 18 - lib/lsg_web/endpoint.ex | 62 - lib/lsg_web/gettext.ex | 24 - lib/lsg_web/live/chat_live.ex | 120 -- lib/lsg_web/live/chat_live.html.heex | 91 -- lib/lsg_web/lsg_web.ex | 99 -- lib/lsg_web/router.ex | 85 -- lib/lsg_web/templates/alcoolog/auth.html.eex | 43 - lib/lsg_web/templates/alcoolog/index.html.eex | 205 ---- lib/lsg_web/templates/alcoolog/user.html.eex | 170 --- lib/lsg_web/templates/irc/index.html.eex | 44 - lib/lsg_web/templates/irc/txt.html.eex | 27 - lib/lsg_web/templates/irc/txts.html.eex | 49 - lib/lsg_web/templates/layout/app.html.eex | 126 -- lib/lsg_web/templates/layout/root.html.leex | 18 - lib/lsg_web/templates/network/index.html.eex | 1 - lib/lsg_web/templates/open_id/error.html.eex | 3 - lib/lsg_web/templates/page/api.html.eex | 35 - lib/lsg_web/templates/page/index.html.eex | 1 - lib/lsg_web/templates/page/irc.html.eex | 19 - lib/lsg_web/templates/page/user.html.eex | 43 - lib/lsg_web/templates/page/widget.html.eex | 20 - lib/lsg_web/views/alcoolog_view.ex | 6 - lib/lsg_web/views/error_helpers.ex | 40 - lib/lsg_web/views/error_view.ex | 17 - lib/lsg_web/views/irc_view.ex | 3 - lib/lsg_web/views/layout_view.ex | 81 -- lib/lsg_web/views/network_view.ex | 4 - lib/lsg_web/views/open_id_view.ex | 4 - lib/lsg_web/views/page_view.ex | 3 - lib/nola/application.ex | 56 + lib/nola/auth_token.ex | 59 + lib/nola/icecast.ex | 117 ++ lib/nola/icecast_agent.ex | 17 + lib/nola/nola.ex | 30 + lib/nola/subnet.ex | 84 ++ lib/nola/token.ex | 38 + lib/nola_irc/account.ex | 451 +++++++ lib/nola_irc/connection.ex | 521 +++++++++ lib/nola_irc/conns.ex | 3 + lib/nola_irc/irc.ex | 79 ++ lib/nola_irc/membership.ex | 129 ++ lib/nola_irc/nola_irc.ex | 34 + lib/nola_irc/plugin/temp_ref.ex | 95 ++ lib/nola_irc/plugin_supervisor.ex | 99 ++ lib/nola_irc/puppet_connection.ex | 238 ++++ lib/nola_irc/user_track.ex | 329 ++++++ lib/nola_matrix/matrix.ex | 169 +++ lib/nola_matrix/plug.ex | 25 + lib/nola_matrix/room.ex | 196 ++++ lib/nola_plugins/admin_handler.ex | 41 + lib/nola_plugins/alcolog_plugin.ex | 1229 ++++++++++++++++++++ lib/nola_plugins/alcoolog_announcer_plugin.ex | 272 +++++ lib/nola_plugins/base_plugin.ex | 131 +++ lib/nola_plugins/bourosama_plugin.ex | 58 + lib/nola_plugins/buffer_plugin.ex | 44 + lib/nola_plugins/calc_plugin.ex | 37 + lib/nola_plugins/coronavirus_plugin.ex | 172 +++ lib/nola_plugins/correction_plugin.ex | 59 + lib/nola_plugins/dice_plugin.ex | 66 ++ lib/nola_plugins/finance_plugin.ex | 190 +++ lib/nola_plugins/gpt_plugin.ex | 259 +++++ lib/nola_plugins/kick_roulette_plugin.ex | 32 + lib/nola_plugins/last_fm_plugin.ex | 187 +++ lib/nola_plugins/link_plugin.ex | 271 +++++ lib/nola_plugins/link_plugin/github.ex | 49 + lib/nola_plugins/link_plugin/html.ex | 106 ++ lib/nola_plugins/link_plugin/imgur.ex | 96 ++ lib/nola_plugins/link_plugin/pdf.ex | 39 + lib/nola_plugins/link_plugin/redacted.ex | 18 + lib/nola_plugins/link_plugin/reddit.ex | 119 ++ lib/nola_plugins/link_plugin/twitter.ex | 158 +++ lib/nola_plugins/link_plugin/youtube.ex | 72 ++ lib/nola_plugins/logger_plugin.ex | 70 ++ lib/nola_plugins/outline_plugin.ex | 108 ++ lib/nola_plugins/preums_plugin.ex | 276 +++++ lib/nola_plugins/quatre_cent_vingt_plugin.ex | 149 +++ lib/nola_plugins/radio_france_plugin.ex | 133 +++ lib/nola_plugins/say_plugin.ex | 73 ++ lib/nola_plugins/script_plugin.ex | 42 + lib/nola_plugins/seen_plugin.ex | 59 + lib/nola_plugins/sms_plugin.ex | 165 +++ lib/nola_plugins/tell_plugin.ex | 106 ++ lib/nola_plugins/txt_plugin.ex | 556 +++++++++ lib/nola_plugins/txt_plugin/markov.ex | 9 + lib/nola_plugins/txt_plugin/markov_native.ex | 33 + lib/nola_plugins/txt_plugin/markov_py_markovify.ex | 39 + lib/nola_plugins/untappd_plugin.ex | 66 ++ lib/nola_plugins/user_mention_plugin.ex | 52 + lib/nola_plugins/wikipedia_plugin.ex | 90 ++ lib/nola_plugins/wolfram_alpha_plugin.ex | 47 + lib/nola_plugins/youtube_plugin.ex | 104 ++ lib/nola_telegram/room.ex | 188 +++ lib/nola_telegram/telegram.ex | 233 ++++ lib/nola_web/channels/user_socket.ex | 37 + lib/nola_web/components/component.ex | 44 + lib/nola_web/components/event_component.ex | 43 + lib/nola_web/components/message_component.ex | 12 + lib/nola_web/context_plug.ex | 92 ++ lib/nola_web/controllers/alcoolog_controller.ex | 323 +++++ lib/nola_web/controllers/gpt_controller.ex | 33 + lib/nola_web/controllers/icecast_see_controller.ex | 41 + .../controllers/irc_auth_sse_controller.ex | 66 ++ lib/nola_web/controllers/irc_controller.ex | 101 ++ lib/nola_web/controllers/network_controller.ex | 11 + lib/nola_web/controllers/open_id_controller.ex | 64 + lib/nola_web/controllers/page_controller.ex | 53 + lib/nola_web/controllers/sms_controller.ex | 10 + lib/nola_web/controllers/untappd_controller.ex | 18 + lib/nola_web/endpoint.ex | 62 + lib/nola_web/gettext.ex | 24 + lib/nola_web/live/chat_live.ex | 120 ++ lib/nola_web/live/chat_live.html.heex | 91 ++ lib/nola_web/router.ex | 85 ++ lib/nola_web/templates/alcoolog/auth.html.eex | 43 + lib/nola_web/templates/alcoolog/index.html.eex | 205 ++++ lib/nola_web/templates/alcoolog/user.html.eex | 170 +++ lib/nola_web/templates/irc/index.html.eex | 44 + lib/nola_web/templates/irc/txt.html.eex | 27 + lib/nola_web/templates/irc/txts.html.eex | 49 + lib/nola_web/templates/layout/app.html.eex | 126 ++ lib/nola_web/templates/layout/root.html.leex | 18 + lib/nola_web/templates/network/index.html.eex | 1 + lib/nola_web/templates/open_id/error.html.eex | 3 + lib/nola_web/templates/page/api.html.eex | 35 + lib/nola_web/templates/page/index.html.eex | 1 + lib/nola_web/templates/page/irc.html.eex | 19 + lib/nola_web/templates/page/user.html.eex | 43 + lib/nola_web/templates/page/widget.html.eex | 20 + lib/nola_web/views/alcoolog_view.ex | 6 + lib/nola_web/views/error_helpers.ex | 40 + lib/nola_web/views/error_view.ex | 17 + lib/nola_web/views/irc_view.ex | 3 + lib/nola_web/views/layout_view.ex | 81 ++ lib/nola_web/views/network_view.ex | 4 + lib/nola_web/views/open_id_view.ex | 4 + lib/nola_web/views/page_view.ex | 3 + lib/nola_web/web.ex | 99 ++ test/lsg_web/controllers/page_controller_test.exs | 8 - test/lsg_web/views/error_view_test.exs | 21 - test/lsg_web/views/layout_view_test.exs | 3 - test/lsg_web/views/page_view_test.exs | 3 - test/nola_web/controllers/page_controller_test.exs | 8 + test/nola_web/views/error_view_test.exs | 21 + test/nola_web/views/layout_view_test.exs | 3 + test/nola_web/views/page_view_test.exs | 3 + 224 files changed, 11498 insertions(+), 11498 deletions(-) delete mode 100644 lib/irc/account.ex delete mode 100644 lib/irc/connection.ex delete mode 100644 lib/irc/conns.ex delete mode 100644 lib/irc/irc.ex delete mode 100644 lib/irc/membership.ex delete mode 100644 lib/irc/plugin/temp_ref.ex delete mode 100644 lib/irc/plugin_supervisor.ex delete mode 100644 lib/irc/puppet_connection.ex delete mode 100644 lib/irc/user_track.ex delete mode 100644 lib/lsg/application.ex delete mode 100644 lib/lsg/auth_token.ex delete mode 100644 lib/lsg/icecast.ex delete mode 100644 lib/lsg/icecast_agent.ex delete mode 100644 lib/lsg/lsg.ex delete mode 100644 lib/lsg/subnet.ex delete mode 100644 lib/lsg/token.ex delete mode 100644 lib/lsg_irc/admin_handler.ex delete mode 100644 lib/lsg_irc/alcolog_plugin.ex delete mode 100644 lib/lsg_irc/alcoolog_announcer_plugin.ex delete mode 100644 lib/lsg_irc/base_plugin.ex delete mode 100644 lib/lsg_irc/bourosama_plugin.ex delete mode 100644 lib/lsg_irc/buffer_plugin.ex delete mode 100644 lib/lsg_irc/calc_plugin.ex delete mode 100644 lib/lsg_irc/coronavirus_plugin.ex delete mode 100644 lib/lsg_irc/correction_plugin.ex delete mode 100644 lib/lsg_irc/dice_plugin.ex delete mode 100644 lib/lsg_irc/finance_plugin.ex delete mode 100644 lib/lsg_irc/gpt_plugin.ex delete mode 100644 lib/lsg_irc/kick_roulette_plugin.ex delete mode 100644 lib/lsg_irc/last_fm_plugin.ex delete mode 100644 lib/lsg_irc/link_plugin.ex delete mode 100644 lib/lsg_irc/link_plugin/github.ex delete mode 100644 lib/lsg_irc/link_plugin/html.ex delete mode 100644 lib/lsg_irc/link_plugin/imgur.ex delete mode 100644 lib/lsg_irc/link_plugin/pdf.ex delete mode 100644 lib/lsg_irc/link_plugin/redacted.ex delete mode 100644 lib/lsg_irc/link_plugin/reddit.ex delete mode 100644 lib/lsg_irc/link_plugin/twitter.ex delete mode 100644 lib/lsg_irc/link_plugin/youtube.ex delete mode 100644 lib/lsg_irc/logger_plugin.ex delete mode 100644 lib/lsg_irc/lsg_irc.ex delete mode 100644 lib/lsg_irc/outline_plugin.ex delete mode 100644 lib/lsg_irc/preums_plugin.ex delete mode 100644 lib/lsg_irc/quatre_cent_vingt_plugin.ex delete mode 100644 lib/lsg_irc/radio_france_plugin.ex delete mode 100644 lib/lsg_irc/say_plugin.ex delete mode 100644 lib/lsg_irc/script_plugin.ex delete mode 100644 lib/lsg_irc/seen_plugin.ex delete mode 100644 lib/lsg_irc/sms_plugin.ex delete mode 100644 lib/lsg_irc/tell_plugin.ex delete mode 100644 lib/lsg_irc/txt_plugin.ex delete mode 100644 lib/lsg_irc/txt_plugin/markov.ex delete mode 100644 lib/lsg_irc/txt_plugin/markov_native.ex delete mode 100644 lib/lsg_irc/txt_plugin/markov_py_markovify.ex delete mode 100644 lib/lsg_irc/untappd_plugin.ex delete mode 100644 lib/lsg_irc/user_mention_plugin.ex delete mode 100644 lib/lsg_irc/wikipedia_plugin.ex delete mode 100644 lib/lsg_irc/wolfram_alpha_plugin.ex delete mode 100644 lib/lsg_irc/youtube_plugin.ex delete mode 100644 lib/lsg_matrix/matrix.ex delete mode 100644 lib/lsg_matrix/plug.ex delete mode 100644 lib/lsg_matrix/room.ex delete mode 100644 lib/lsg_telegram/room.ex delete mode 100644 lib/lsg_telegram/telegram.ex delete mode 100644 lib/lsg_web/channels/user_socket.ex delete mode 100644 lib/lsg_web/components/component.ex delete mode 100644 lib/lsg_web/components/event_component.ex delete mode 100644 lib/lsg_web/components/message_component.ex delete mode 100644 lib/lsg_web/context_plug.ex delete mode 100644 lib/lsg_web/controllers/alcoolog_controller.ex delete mode 100644 lib/lsg_web/controllers/gpt_controller.ex delete mode 100644 lib/lsg_web/controllers/icecast_see_controller.ex delete mode 100644 lib/lsg_web/controllers/irc_auth_sse_controller.ex delete mode 100644 lib/lsg_web/controllers/irc_controller.ex delete mode 100644 lib/lsg_web/controllers/network_controller.ex delete mode 100644 lib/lsg_web/controllers/open_id_controller.ex delete mode 100644 lib/lsg_web/controllers/page_controller.ex delete mode 100644 lib/lsg_web/controllers/sms_controller.ex delete mode 100644 lib/lsg_web/controllers/untappd_controller.ex delete mode 100644 lib/lsg_web/endpoint.ex delete mode 100644 lib/lsg_web/gettext.ex delete mode 100644 lib/lsg_web/live/chat_live.ex delete mode 100644 lib/lsg_web/live/chat_live.html.heex delete mode 100644 lib/lsg_web/lsg_web.ex delete mode 100644 lib/lsg_web/router.ex delete mode 100644 lib/lsg_web/templates/alcoolog/auth.html.eex delete mode 100644 lib/lsg_web/templates/alcoolog/index.html.eex delete mode 100644 lib/lsg_web/templates/alcoolog/user.html.eex delete mode 100644 lib/lsg_web/templates/irc/index.html.eex delete mode 100644 lib/lsg_web/templates/irc/txt.html.eex delete mode 100644 lib/lsg_web/templates/irc/txts.html.eex delete mode 100644 lib/lsg_web/templates/layout/app.html.eex delete mode 100644 lib/lsg_web/templates/layout/root.html.leex delete mode 100644 lib/lsg_web/templates/network/index.html.eex delete mode 100644 lib/lsg_web/templates/open_id/error.html.eex delete mode 100644 lib/lsg_web/templates/page/api.html.eex delete mode 100644 lib/lsg_web/templates/page/index.html.eex delete mode 100644 lib/lsg_web/templates/page/irc.html.eex delete mode 100644 lib/lsg_web/templates/page/user.html.eex delete mode 100644 lib/lsg_web/templates/page/widget.html.eex delete mode 100644 lib/lsg_web/views/alcoolog_view.ex delete mode 100644 lib/lsg_web/views/error_helpers.ex delete mode 100644 lib/lsg_web/views/error_view.ex delete mode 100644 lib/lsg_web/views/irc_view.ex delete mode 100644 lib/lsg_web/views/layout_view.ex delete mode 100644 lib/lsg_web/views/network_view.ex delete mode 100644 lib/lsg_web/views/open_id_view.ex delete mode 100644 lib/lsg_web/views/page_view.ex create mode 100644 lib/nola/application.ex create mode 100644 lib/nola/auth_token.ex create mode 100644 lib/nola/icecast.ex create mode 100644 lib/nola/icecast_agent.ex create mode 100644 lib/nola/nola.ex create mode 100644 lib/nola/subnet.ex create mode 100644 lib/nola/token.ex create mode 100644 lib/nola_irc/account.ex create mode 100644 lib/nola_irc/connection.ex create mode 100644 lib/nola_irc/conns.ex create mode 100644 lib/nola_irc/irc.ex create mode 100644 lib/nola_irc/membership.ex create mode 100644 lib/nola_irc/nola_irc.ex create mode 100644 lib/nola_irc/plugin/temp_ref.ex create mode 100644 lib/nola_irc/plugin_supervisor.ex create mode 100644 lib/nola_irc/puppet_connection.ex create mode 100644 lib/nola_irc/user_track.ex create mode 100644 lib/nola_matrix/matrix.ex create mode 100644 lib/nola_matrix/plug.ex create mode 100644 lib/nola_matrix/room.ex create mode 100644 lib/nola_plugins/admin_handler.ex create mode 100644 lib/nola_plugins/alcolog_plugin.ex create mode 100644 lib/nola_plugins/alcoolog_announcer_plugin.ex create mode 100644 lib/nola_plugins/base_plugin.ex create mode 100644 lib/nola_plugins/bourosama_plugin.ex create mode 100644 lib/nola_plugins/buffer_plugin.ex create mode 100644 lib/nola_plugins/calc_plugin.ex create mode 100644 lib/nola_plugins/coronavirus_plugin.ex create mode 100644 lib/nola_plugins/correction_plugin.ex create mode 100644 lib/nola_plugins/dice_plugin.ex create mode 100644 lib/nola_plugins/finance_plugin.ex create mode 100644 lib/nola_plugins/gpt_plugin.ex create mode 100644 lib/nola_plugins/kick_roulette_plugin.ex create mode 100644 lib/nola_plugins/last_fm_plugin.ex create mode 100644 lib/nola_plugins/link_plugin.ex create mode 100644 lib/nola_plugins/link_plugin/github.ex create mode 100644 lib/nola_plugins/link_plugin/html.ex create mode 100644 lib/nola_plugins/link_plugin/imgur.ex create mode 100644 lib/nola_plugins/link_plugin/pdf.ex create mode 100644 lib/nola_plugins/link_plugin/redacted.ex create mode 100644 lib/nola_plugins/link_plugin/reddit.ex create mode 100644 lib/nola_plugins/link_plugin/twitter.ex create mode 100644 lib/nola_plugins/link_plugin/youtube.ex create mode 100644 lib/nola_plugins/logger_plugin.ex create mode 100644 lib/nola_plugins/outline_plugin.ex create mode 100644 lib/nola_plugins/preums_plugin.ex create mode 100644 lib/nola_plugins/quatre_cent_vingt_plugin.ex create mode 100644 lib/nola_plugins/radio_france_plugin.ex create mode 100644 lib/nola_plugins/say_plugin.ex create mode 100644 lib/nola_plugins/script_plugin.ex create mode 100644 lib/nola_plugins/seen_plugin.ex create mode 100644 lib/nola_plugins/sms_plugin.ex create mode 100644 lib/nola_plugins/tell_plugin.ex create mode 100644 lib/nola_plugins/txt_plugin.ex create mode 100644 lib/nola_plugins/txt_plugin/markov.ex create mode 100644 lib/nola_plugins/txt_plugin/markov_native.ex create mode 100644 lib/nola_plugins/txt_plugin/markov_py_markovify.ex create mode 100644 lib/nola_plugins/untappd_plugin.ex create mode 100644 lib/nola_plugins/user_mention_plugin.ex create mode 100644 lib/nola_plugins/wikipedia_plugin.ex create mode 100644 lib/nola_plugins/wolfram_alpha_plugin.ex create mode 100644 lib/nola_plugins/youtube_plugin.ex create mode 100644 lib/nola_telegram/room.ex create mode 100644 lib/nola_telegram/telegram.ex create mode 100644 lib/nola_web/channels/user_socket.ex create mode 100644 lib/nola_web/components/component.ex create mode 100644 lib/nola_web/components/event_component.ex create mode 100644 lib/nola_web/components/message_component.ex create mode 100644 lib/nola_web/context_plug.ex create mode 100644 lib/nola_web/controllers/alcoolog_controller.ex create mode 100644 lib/nola_web/controllers/gpt_controller.ex create mode 100644 lib/nola_web/controllers/icecast_see_controller.ex create mode 100644 lib/nola_web/controllers/irc_auth_sse_controller.ex create mode 100644 lib/nola_web/controllers/irc_controller.ex create mode 100644 lib/nola_web/controllers/network_controller.ex create mode 100644 lib/nola_web/controllers/open_id_controller.ex create mode 100644 lib/nola_web/controllers/page_controller.ex create mode 100644 lib/nola_web/controllers/sms_controller.ex create mode 100644 lib/nola_web/controllers/untappd_controller.ex create mode 100644 lib/nola_web/endpoint.ex create mode 100644 lib/nola_web/gettext.ex create mode 100644 lib/nola_web/live/chat_live.ex create mode 100644 lib/nola_web/live/chat_live.html.heex create mode 100644 lib/nola_web/router.ex create mode 100644 lib/nola_web/templates/alcoolog/auth.html.eex create mode 100644 lib/nola_web/templates/alcoolog/index.html.eex create mode 100644 lib/nola_web/templates/alcoolog/user.html.eex create mode 100644 lib/nola_web/templates/irc/index.html.eex create mode 100644 lib/nola_web/templates/irc/txt.html.eex create mode 100644 lib/nola_web/templates/irc/txts.html.eex create mode 100644 lib/nola_web/templates/layout/app.html.eex create mode 100644 lib/nola_web/templates/layout/root.html.leex create mode 100644 lib/nola_web/templates/network/index.html.eex create mode 100644 lib/nola_web/templates/open_id/error.html.eex create mode 100644 lib/nola_web/templates/page/api.html.eex create mode 100644 lib/nola_web/templates/page/index.html.eex create mode 100644 lib/nola_web/templates/page/irc.html.eex create mode 100644 lib/nola_web/templates/page/user.html.eex create mode 100644 lib/nola_web/templates/page/widget.html.eex create mode 100644 lib/nola_web/views/alcoolog_view.ex create mode 100644 lib/nola_web/views/error_helpers.ex create mode 100644 lib/nola_web/views/error_view.ex create mode 100644 lib/nola_web/views/irc_view.ex create mode 100644 lib/nola_web/views/layout_view.ex create mode 100644 lib/nola_web/views/network_view.ex create mode 100644 lib/nola_web/views/open_id_view.ex create mode 100644 lib/nola_web/views/page_view.ex create mode 100644 lib/nola_web/web.ex delete mode 100644 test/lsg_web/controllers/page_controller_test.exs delete mode 100644 test/lsg_web/views/error_view_test.exs delete mode 100644 test/lsg_web/views/layout_view_test.exs delete mode 100644 test/lsg_web/views/page_view_test.exs create mode 100644 test/nola_web/controllers/page_controller_test.exs create mode 100644 test/nola_web/views/error_view_test.exs create mode 100644 test/nola_web/views/layout_view_test.exs create mode 100644 test/nola_web/views/page_view_test.exs diff --git a/lib/irc/account.ex b/lib/irc/account.ex deleted file mode 100644 index 45680f8..0000000 --- a/lib/irc/account.ex +++ /dev/null @@ -1,451 +0,0 @@ -defmodule IRC.Account do - alias IRC.UserTrack.User - - @moduledoc """ - Account registry.... - - Maps a network predicate: - * `{net, {:nick, nickname}}` - * `{net, {:account, account}}` - * `{net, {:mask, user@host}}` - to an unique identifier, that can be shared over multiple networks. - - If a predicate cannot be found for an existing account, a new account will be made in the database. - - To link two existing accounts from different network onto a different one, a merge operation is provided. - - """ - - # FIXME: Ensure uniqueness of name? - - @derive {Poison.Encoder, except: [:token]} - defstruct [:id, :name, :token] - @type t :: %__MODULE__{id: id(), name: String.t()} - @type id :: String.t() - - defimpl Inspect, for: __MODULE__ do - import Inspect.Algebra - - def inspect(%{id: id, name: name}, opts) do - concat(["#IRC.Account[", id, " ", name, "]"]) - end - end - - def file(base) do - to_charlist(Nola.data_path() <> "/account_#{base}.dets") - end - - defp from_struct(%__MODULE__{id: id, name: name, token: token}) do - {id, name, token} - end - - defp from_tuple({id, name, token}) do - %__MODULE__{id: id, name: name, token: token} - end - - def start_link() do - GenServer.start_link(__MODULE__, [], [name: __MODULE__]) - end - - def init(_) do - {:ok, accounts} = :dets.open_file(file("db"), []) - {:ok, meta} = :dets.open_file(file("meta"), []) - {:ok, predicates} = :dets.open_file(file("predicates"), [{:type, :set}]) - {:ok, %{accounts: accounts, meta: meta, predicates: predicates}} - end - - def get(id) do - case :dets.lookup(file("db"), id) do - [account] -> from_tuple(account) - _ -> nil - end - end - - def get_by_name(name) do - spec = [{{:_, :"$1", :_}, [{:==, :"$1", {:const, name}}], [:"$_"]}] - case :dets.select(file("db"), spec) do - [account] -> from_tuple(account) - _ -> nil - end - end - - def get_meta(%__MODULE__{id: id}, key, default \\ nil) do - case :dets.lookup(file("meta"), {id, key}) do - [{_, value}] -> (value || default) - _ -> default - end - end - - @spec find_meta_accounts(String.t()) :: [{account :: t(), value :: String.t()}, ...] - @doc "Find all accounts that have a meta of `key`." - def find_meta_accounts(key) do - spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$2", {:const, key}}], [{{:"$1", :"$3"}}]}] - for {id, val} <- :dets.select(file("meta"), spec), do: {get(id), val} - end - - @doc "Find an account given a specific meta `key` and `value`." - @spec find_meta_account(String.t(), String.t()) :: t() | nil - def find_meta_account(key, value) do - #spec = [{{{:"$1", :"$2"}, :"$3"}, [:andalso, {:==, :"$2", {:const, key}}, {:==, :"$3", {:const, value}}], [:"$1"]}] - spec = [{{{:"$1", :"$2"}, :"$3"}, [{:andalso, {:==, :"$2", {:const, key}}, {:==, {:const, value}, :"$3"}}], [:"$1"]}] - case :dets.select(file("meta"), spec) do - [id] -> get(id) - _ -> nil - end - end - - def get_all_meta(%__MODULE__{id: id}) do - spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$1", {:const, id}}], [{{:"$2", :"$3"}}]}] - :dets.select(file("meta"), spec) - end - - def put_user_meta(account = %__MODULE__{}, key, value) do - put_meta(account, "u:"<>key, value) - end - - def put_meta(%__MODULE__{id: id}, key, value) do - :dets.insert(file("meta"), {{id, key}, value}) - end - - def delete_meta(%__MODULE__{id: id}, key) do - :dets.delete(file("meta"), {id, key}) - end - - def all_accounts() do - :dets.traverse(file("db"), fn(obj) -> {:continue, from_tuple(obj)} end) - end - - def all_predicates() do - :dets.traverse(file("predicates"), fn(obj) -> {:continue, obj} end) - end - - def all_meta() do - :dets.traverse(file("meta"), fn(obj) -> {:continue, obj} end) - end - - def merge_account(old_id, new_id) do - if old_id != new_id do - spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, old_id}}], [:"$1"]}] - predicates = :dets.select(file("predicates"), spec) - for pred <- predicates, do: :ok = :dets.insert(file("predicates"), {pred, new_id}) - spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$1", {:const, old_id}}], [{{:"$2", :"$3"}}]}] - metas = :dets.select(file("meta"), spec) - for {k,v} <- metas do - :dets.delete(file("meta"), {{old_id, k}}) - :ok = :dets.insert(file("meta"), {{new_id, k}, v}) - end - :dets.delete(file("db"), old_id) - IRC.Membership.merge_account(old_id, new_id) - IRC.UserTrack.merge_account(old_id, new_id) - IRC.Connection.dispatch("account", {:account_change, old_id, new_id}) - IRC.Connection.dispatch("conn", {:account_change, old_id, new_id}) - end - :ok - end - - @doc "Find an account by a logged in user" - def find_by_nick(network, nick) do - do_lookup(%ExIRC.SenderInfo{nick: nick, network: network}, false) - end - - @doc "Always find an account by nickname, even if offline. Uses predicates and then account name." - def find_always_by_nick(network, chan, nick) do - with \ - nil <- find_by_nick(network, nick), - nil <- do_lookup(%User{network: network, nick: nick}, false), - nil <- get_by_name(nick) - do - nil - else - %__MODULE__{} = account -> - memberships = IRC.Membership.of_account(account) - if Enum.any?(memberships, fn({net, ch}) -> (net == network) or (chan && chan == ch) end) do - account - else - nil - end - end - end - - def find(something) do - do_lookup(something, false) - end - - def lookup(something, make_default \\ true) do - account = do_lookup(something, make_default) - if account && Map.get(something, :nick) do - IRC.Connection.dispatch("account", {:account_auth, Map.get(something, :nick), account.id}) - end - account - end - - def handle_info(_, state) do - {:noreply, state} - end - - def handle_cast(_, state) do - {:noreply, state} - end - - def handle_call(_, _, state) do - {:noreply, state} - end - - def terminate(_, state) do - for {_, dets} <- state do - :dets.sync(dets) - :dets.close(dets) - end - end - - defp do_lookup(message = %IRC.Message{account: account_id}, make_default) when is_binary(account_id) do - get(account_id) - end - - defp do_lookup(sender = %ExIRC.Who{}, make_default) do - if user = IRC.UserTrack.find_by_nick(sender) do - lookup(user, make_default) - else - #FIXME this will never work with continued lookup by other methods as Who isn't compatible - lookup_by_nick(sender, :dets.lookup(file("predicates"), {sender.network,{:nick, sender.nick}}), make_default) - end - end - - defp do_lookup(sender = %ExIRC.SenderInfo{}, make_default) do - lookup(IRC.UserTrack.find_by_nick(sender), make_default) - end - - defp do_lookup(user = %User{account: id}, make_default) when is_binary(id) do - get(id) - end - - defp do_lookup(user = %User{network: server, nick: nick}, make_default) do - lookup_by_nick(user, :dets.lookup(file("predicates"), {server,{:nick, nick}}), make_default) - end - - defp do_lookup(nil, _) do - nil - end - - defp lookup_by_nick(_, [{_, id}], _make_default) do - get(id) - end - - defp lookup_by_nick(user, _, make_default) do - #authenticate_by_host(user) - if make_default, do: new_account(user), else: nil - end - - def new_account(nick) do - id = EntropyString.large_id() - :dets.insert(file("db"), {id, nick, EntropyString.token()}) - get(id) - end - - def new_account(%{nick: nick, network: server}) do - id = EntropyString.large_id() - :dets.insert(file("db"), {id, nick, EntropyString.token()}) - :dets.insert(file("predicates"), {{server, {:nick, nick}}, id}) - get(id) - end - - def update_account_name(account = %__MODULE__{id: id}, name) do - account = %__MODULE__{account | name: name} - :dets.insert(file("db"), from_struct(account)) - get(id) - end - - def get_predicates(%__MODULE__{} = account) do - spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}] - :dets.select(file("predicates"), spec) - end - - defmodule AccountPlugin do - @moduledoc """ - # Account - - * **account** Get current account id and token - * **auth `` ``** Authenticate and link the current nickname to an account - * **auth** list authentications methods - * **whoami** list currently authenticated users - * **web** get a one-time login link to web - * **enable-telegram** Link a Telegram account - * **enable-sms** Link a SMS number - * **enable-untappd** Link a Untappd account - * **set-name** set account name - * **setusermeta puppet-nick ``** Set puppet IRC nickname - """ - - def irc_doc, do: @moduledoc - def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) - def init(_) do - {:ok, _} = Registry.register(IRC.PubSub, "messages:private", []) - {:ok, nil} - end - - def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "help"}}, state) do - text = [ - "account: show current account and auth token", - "auth: show authentications methods", - "whoami: list authenticated users", - "set-name : set account name", - "web: login to web", - "enable-sms | disable-sms: enable/change or disable sms", - "enable-telegram: link/change telegram", - "enable-untappd: link untappd account", - "getmeta: show meta datas", - "setusermeta: set user meta", - ] - m.replyfun.(text) - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "auth"}}, state) do - spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}] - predicates = :dets.select(IRC.Account.file("predicates"), spec) - text = for {net, {key, value}} <- predicates, do: "#{net}: #{to_string(key)}: #{value}" - m.replyfun.(text) - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "whoami"}}, state) do - users = for user <- IRC.UserTrack.find_by_account(m.account) do - chans = Enum.map(user.privileges, fn({chan, _}) -> chan end) - |> Enum.join(" ") - "#{user.network} - #{user.nick}!#{user.username}@#{user.host} - #{chans}" - end - m.replyfun.(users) - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "account"}}, state) do - account = IRC.Account.lookup(m.sender) - text = ["Account Id: #{account.id}", - "Authenticate to this account from another network: \"auth #{account.id} #{account.token}\" to the other bot!"] - m.replyfun.(text) - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{sender: sender, text: "auth"<>_}}, state) do - #account = IRC.Account.lookup(m.sender) - case String.split(m.text, " ") do - ["auth", id, token] -> - join_account(m, id, token) - _ -> - m.replyfun.("Invalid parameters") - end - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "set-name "<>name}}, state) do - IRC.Account.update_account_name(account, name) - m.replyfun.("Name changed: #{name}") - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{text: "disable-sms"}}, state) do - if IRC.Account.get_meta(m.account, "sms-number") do - IRC.Account.delete_meta(m.account, "sms-number") - m.replfyun.("SMS disabled.") - else - m.replyfun.("SMS already disabled.") - end - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{text: "web"}}, state) do - auth_url = Untappd.auth_url() - login_url = Nola.AuthToken.new_url(m.account.id, nil) - m.replyfun.("-> " <> login_url) - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{text: "enable-sms"}}, state) do - code = String.downcase(EntropyString.small_id()) - IRC.Account.put_meta(m.account, "sms-validation-code", code) - IRC.Account.put_meta(m.account, "sms-validation-target", m.network) - number = Nola.IRC.SmsPlugin.my_number() - text = "To enable or change your number for SMS messaging, please send:" - <> " \"enable #{code}\" to #{number}" - m.replyfun.(text) - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{text: "enable-telegram"}}, state) do - code = String.downcase(EntropyString.small_id()) - IRC.Account.delete_meta(m.account, "telegram-id") - IRC.Account.put_meta(m.account, "telegram-validation-code", code) - IRC.Account.put_meta(m.account, "telegram-validation-target", m.network) - text = "To enable or change your number for telegram messaging, please open #{Nola.Telegram.my_path()} and send:" - <> " \"/enable #{code}\"" - m.replyfun.(text) - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{text: "enable-untappd"}}, state) do - auth_url = Untappd.auth_url() - login_url = Nola.AuthToken.new_url(m.account.id, {:external_redirect, auth_url}) - m.replyfun.(["To link your Untappd account, open this URL:", login_url]) - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{text: "getmeta"<>_}}, state) do - result = case String.split(m.text, " ") do - ["getmeta"] -> - for {k, v} <- IRC.Account.get_all_meta(m.account) do - case k do - "u:"<>key -> "(user) #{key}: #{v}" - key -> "#{key}: #{v}" - end - end - ["getmeta", key] -> - value = IRC.Account.get_meta(m.account, key) - text = if value do - "#{key}: #{value}" - else - "#{key} is not defined" - end - _ -> - "usage: getmeta [key]" - end - m.replyfun.(result) - {:noreply, state} - end - - def handle_info({:irc, :text, m = %IRC.Message{text: "setusermet"<>_}}, state) do - result = case String.split(m.text, " ") do - ["setusermeta", key, value] -> - IRC.Account.put_user_meta(m.account, key, value) - "ok" - _ -> - "usage: setusermeta " - end - m.replyfun.(result) - {:noreply, state} - end - - def handle_info(_, state) do - {:noreply, state} - end - - defp join_account(m, id, token) do - old_account = IRC.Account.lookup(m.sender) - new_account = IRC.Account.get(id) - if new_account && token == new_account.token do - case IRC.Account.merge_account(old_account.id, new_account.id) do - :ok -> - if old_account.id == new_account.id do - m.replyfun.("Already authenticated, but hello") - else - m.replyfun.("Accounts merged!") - end - _ -> m.replyfun.("Something failed :(") - end - else - m.replyfun.("Invalid token") - end - end - - end - -end diff --git a/lib/irc/connection.ex b/lib/irc/connection.ex deleted file mode 100644 index 86d8279..0000000 --- a/lib/irc/connection.ex +++ /dev/null @@ -1,521 +0,0 @@ -defmodule IRC.Connection do - require Logger - use Ecto.Schema - - @moduledoc """ - # IRC Connection - - Provides a nicer abstraction over ExIRC's handlers. - - ## Start connections - - ``` - IRC.Connection.start_link(host: "irc.random.sh", port: 6697, nick: "pouetbot", channels: ["#dev"]) - - ## PubSub topics - - * `account` -- accounts change - * {:account_change, old_account_id, new_account_id} # Sent when account merged - * {:accounts, [{:account, network, channel, nick, account_id}] # Sent on bot join - * {:account, network, nick, account_id} # Sent on user join - * `message` -- aill messages (without triggers) - * `message:private` -- all messages without a channel - * `message:#CHANNEL` -- all messages within `#CHANNEL` - * `triggers` -- all triggers - * `trigger:TRIGGER` -- any message with a trigger `TRIGGER` - - ## Replying to %IRC.Message{} - - Each `IRC.Message` comes with a dedicated `replyfun`, to which you only have to pass either: - - """ - def irc_doc, do: nil - - @min_backoff :timer.seconds(5) - @max_backoff :timer.seconds(2*60) - - embedded_schema do - field :network, :string - field :host, :string - field :port, :integer - field :nick, :string - field :user, :string - field :name, :string - field :pass, :string - field :tls, :boolean, default: false - field :channels, {:array, :string}, default: [] - end - - defmodule Supervisor do - use DynamicSupervisor - - def start_link() do - DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) - end - - def start_child(%IRC.Connection{} = conn) do - spec = %{id: conn.id, start: {IRC.Connection, :start_link, [conn]}, restart: :transient} - DynamicSupervisor.start_child(__MODULE__, spec) - end - - @impl true - def init(_init_arg) do - DynamicSupervisor.init( - strategy: :one_for_one, - max_restarts: 10, - max_seconds: 1 - ) - end - end - - - def changeset(params) do - import Ecto.Changeset - - %__MODULE__{id: EntropyString.large_id()} - |> cast(params, [:network, :host, :port, :nick, :user, :name, :pass, :channels, :tls]) - |> validate_required([:host, :port, :nick, :user, :name]) - |> apply_action(:insert) - end - - def to_tuple(%__MODULE__{} = conn) do - {conn.id, conn.network, conn.host, conn.port, conn.nick, conn.user, conn.name, conn.pass, conn.tls, conn.channels, nil} - end - - def from_tuple({id, network, host, port, nick, user, name, pass, tls, channels, _}) do - %__MODULE__{id: id, network: network, host: host, port: port, nick: nick, user: user, name: name, pass: pass, tls: tls, channels: channels} - end - - ## -- MANAGER API - - def setup() do - :dets.open_file(dets(), []) - end - - def dets(), do: to_charlist(Nola.data_path("/connections.dets")) - - def lookup(id) do - case :dets.lookup(dets(), id) do - [object | _] -> from_tuple(object) - _ -> nil - end - end - - def connections() do - :dets.foldl(fn(object, acc) -> [from_tuple(object) | acc] end, [], dets()) - end - - def start_all() do - for conn <- connections(), do: {conn, IRC.Connection.Supervisor.start_child(conn)} - end - - def get_network(network, channel \\ nil) do - spec = [{{:_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_, :_}, - [{:==, :"$1", {:const, network}}], [:"$_"]}] - results = Enum.map(:dets.select(dets(), spec), fn(object) -> from_tuple(object) end) - if channel do - Enum.find(results, fn(conn) -> Enum.member?(conn.channels, channel) end) - else - List.first(results) - end - end - - def get_host_nick(host, port, nick) do - spec = [{{:_, :_, :"$1", :"$2", :"$3", :_, :_, :_, :_, :_, :_}, - [{:andalso, - {:andalso, {:==, :"$1", {:const, host}}, {:==, :"$2", {:const, port}}}, - {:==, :"$3", {:const, nick}}}], - [:"$_"]} - ] - case :dets.select(dets(), spec) do - [object] -> from_tuple(object) - [] -> nil - end - end - - def delete_connection(%__MODULE__{id: id} = conn) do - :dets.delete(dets(), id) - stop_connection(conn) - :ok - end - - def start_connection(%__MODULE__{} = conn) do - IRC.Connection.Supervisor.start_child(conn) - end - - def stop_connection(%__MODULE__{id: id}) do - case :global.whereis_name(id) do - pid when is_pid(pid) -> - GenServer.stop(pid, :normal) - _ -> :error - end - end - - def add_connection(opts) do - case changeset(opts) do - {:ok, conn} -> - if existing = get_host_nick(conn.host, conn.port, conn.nick) do - {:error, {:existing, conn}} - else - :dets.insert(dets(), to_tuple(conn)) - IRC.Connection.Supervisor.start_child(conn) - end - error -> error - end - end - - def update_connection(connection) do - :dets.insert(dets(), to_tuple(connection)) - end - - def start_link(conn) do - GenServer.start_link(__MODULE__, [conn], name: {:global, conn.id}) - end - - def broadcast_message(net, chan, message) do - dispatch("conn", {:broadcast, net, chan, message}, IRC.ConnectionPubSub) - end - def broadcast_message(list, message) when is_list(list) do - for {net, chan} <- list do - broadcast_message(net, chan, message) - end - end - - def privmsg(channel, line) do - GenServer.cast(__MODULE__, {:privmsg, channel, line}) - end - - def init([conn]) do - Logger.metadata(conn: conn.id) - backoff = :backoff.init(@min_backoff, @max_backoff) - |> :backoff.type(:jitter) - {:ok, %{client: nil, backoff: backoff, conn: conn, connected_server: nil, connected_port: nil, network: conn.network}, {:continue, :connect}} - end - - @triggers %{ - "!" => :bang, - "+" => :plus, - "-" => :minus, - "?" => :query, - "." => :dot, - "~" => :tilde, - "@" => :at, - "++" => :plus_plus, - "--" => :minus_minus, - "!!" => :bang_bang, - "??" => :query_query, - ".." => :dot_dot, - "~~" => :tilde_tilde, - "@@" => :at_at - } - - def handle_continue(:connect, state) do - client_opts = [] - |> Keyword.put(:network, state.conn.network) - {:ok, _} = Registry.register(IRC.ConnectionPubSub, "conn", []) - client = if state.client && Process.alive?(state.client) do - Logger.info("Reconnecting client") - state.client - else - Logger.info("Connecting") - {:ok, client} = ExIRC.Client.start_link(debug: false) - ExIRC.Client.add_handler(client, self()) - client - end - - opts = [{:nodelay, true}] - conn_fun = if state.conn.tls, do: :connect_ssl!, else: :connect! - apply(ExIRC.Client, conn_fun, [client, to_charlist(state.conn.host), state.conn.port, opts]) - - {:noreply, %{state | client: client}} - end - - def handle_info(:disconnected, state) do - {delay, backoff} = :backoff.fail(state.backoff) - Logger.info("#{inspect(self())} Disconnected -- reconnecting in #{inspect delay}ms") - Process.send_after(self(), :connect, delay) - {:noreply, %{state | backoff: backoff}} - end - - def handle_info(:connect, state) do - {:noreply, state, {:continue, :connect}} - end - - def handle_cast({:privmsg, channel, line}, state) do - irc_reply(state, {channel, nil}, line) - {:noreply, state} - end - - # Connection successful - def handle_info({:connected, server, port}, state) do - Logger.info("#{inspect(self())} Connected to #{inspect(server)}:#{port} #{inspect state}") - {_, backoff} = :backoff.succeed(state.backoff) - ExIRC.Client.logon(state.client, state.conn.pass || "", state.conn.nick, state.conn.user, state.conn.name) - {:noreply, %{state | backoff: backoff, connected_server: server, connected_port: port}} - end - - # Logon successful - def handle_info(:logged_in, state) do - Logger.info("#{inspect(self())} Logged in") - {_, backoff} = :backoff.succeed(state.backoff) - Enum.map(state.conn.channels, &ExIRC.Client.join(state.client, &1)) - {:noreply, %{state | backoff: backoff}} - end - - # ISUP - def handle_info({:isup, network}, state) when is_binary(network) do - IRC.UserTrack.clear_network(state.network) - if network != state.network do - Logger.warn("Possibly misconfigured network: #{network} != #{state.network}") - end - {:noreply, state} - end - - # Been kicked - def handle_info({:kicked, _sender, chan, _reason}, state) do - ExIRC.Client.join(state.client, chan) - {:noreply, state} - end - - # Received something in a channel - def handle_info({:received, text, sender, chan}, state) do - user = if user = IRC.UserTrack.find_by_nick(state.network, sender.nick) do - user - else - Logger.error("Could not lookup user for message: #{inspect {state.network, chan, sender.nick}}") - user = IRC.UserTrack.joined(chan, sender, []) - ExIRC.Client.who(state.client, chan) # Rewho everything in case of need ? We shouldn't not know that user.. - user - end - if !user do - ExIRC.Client.who(state.client, chan) # Rewho everything in case of need ? We shouldn't not know that user.. - Logger.error("Could not lookup user nor create it for message: #{inspect {state.network, chan, sender.nick}}") - else - if !Map.get(user.options, :puppet) do - reply_fun = fn(text) -> irc_reply(state, {chan, sender}, text) end - account = IRC.Account.lookup(sender) - message = %IRC.Message{id: FlakeId.get(), transport: :irc, at: NaiveDateTime.utc_now(), text: text, network: state.network, - account: account, sender: sender, channel: chan, replyfun: reply_fun, - trigger: extract_trigger(text)} - message = case IRC.UserTrack.messaged(message) do - :ok -> message - {:ok, message} -> message - end - publish(message, ["#{message.network}/#{chan}:messages"]) - end - end - {:noreply, state} - end - - # Received a private message - def handle_info({:received, text, sender}, state) do - reply_fun = fn(text) -> irc_reply(state, {sender.nick, sender}, text) end - account = IRC.Account.lookup(sender) - message = %IRC.Message{id: FlakeId.get(), transport: :irc, text: text, network: state.network, at: NaiveDateTime.utc_now(), - account: account, sender: sender, replyfun: reply_fun, trigger: extract_trigger(text)} - message = case IRC.UserTrack.messaged(message) do - :ok -> message - {:ok, message} -> message - end - publish(message, ["messages:private", "#{message.network}/#{account.id}:messages"]) - {:noreply, state} - end - - ## -- Broadcast - def handle_info({:broadcast, net, account = %IRC.Account{}, message}, state) do - if net == state.conn.network do - user = IRC.UserTrack.find_by_account(net, account) - if user do - irc_reply(state, {user.nick, nil}, message) - end - end - {:noreply, state} - end - def handle_info({:broadcast, net, chan, message}, state) do - if net == state.conn.network && Enum.member?(state.conn.channels, chan) do - irc_reply(state, {chan, nil}, message) - end - {:noreply, state} - end - - ## -- UserTrack - - def handle_info({:joined, channel}, state) do - ExIRC.Client.who(state.client, channel) - {:noreply, state} - end - - def handle_info({:who, channel, whos}, state) do - accounts = Enum.map(whos, fn(who = %ExIRC.Who{nick: nick, operator?: operator}) -> - priv = if operator, do: [:operator], else: [] - # Don't touch -- on WHO the bot joined, not the users. - IRC.UserTrack.joined(channel, who, priv, false) - account = IRC.Account.lookup(who) - if account do - {:account, who.network, channel, who.nick, account.id} - end - end) - |> Enum.filter(fn(x) -> x end) - dispatch("account", {:accounts, accounts}) - {:noreply, state} - end - - def handle_info({:quit, reason, sender}, state) do - IRC.UserTrack.quitted(sender, reason) - {:noreply, state} - end - - def handle_info({:joined, channel, sender}, state) do - IRC.UserTrack.joined(channel, sender, []) - account = IRC.Account.lookup(sender) - if account do - dispatch("account", {:account, sender.network, channel, sender.nick, account.id}) - end - {:noreply, state} - end - - def handle_info({:kicked, nick, _by, channel, _reason}, state) do - IRC.UserTrack.parted(state.network, channel, nick) - {:noreply, state} - end - - def handle_info({:parted, channel, %ExIRC.SenderInfo{nick: nick}}, state) do - IRC.UserTrack.parted(state.network, channel, nick) - {:noreply, state} - end - - def handle_info({:mode, [channel, mode, nick]}, state) do - track_mode(state.network, channel, nick, mode) - {:noreply, state} - end - - def handle_info({:nick_changed, old_nick, new_nick}, state) do - IRC.UserTrack.renamed(state.network, old_nick, new_nick) - {:noreply, state} - end - - def handle_info(unhandled, client) do - Logger.debug("unhandled: #{inspect unhandled}") - {:noreply, client} - end - - def publish(pub), do: publish(pub, []) - - def publish(m = %IRC.Message{trigger: nil}, keys) do - dispatch(["messages"] ++ keys, {:irc, :text, m}) - end - - def publish(m = %IRC.Message{trigger: t = %IRC.Trigger{trigger: trigger}}, keys) do - dispatch(["triggers", "#{m.network}/#{m.channel}:triggers", "trigger:"<>trigger], {:irc, :trigger, trigger, m}) - end - - def publish_event(net, event = %{type: _}) when is_binary(net) do - event = event - |> Map.put(:at, NaiveDateTime.utc_now()) - |> Map.put(:network, net) - dispatch("#{net}:events", {:irc, :event, event}) - end - def publish_event({net, chan}, event = %{type: type}) do - event = event - |> Map.put(:at, NaiveDateTime.utc_now()) - |> Map.put(:network, net) - |> Map.put(:channel, chan) - dispatch("#{net}/#{chan}:events", {:irc, :event, event}) - end - - def dispatch(keys, content, sub \\ IRC.PubSub) - - def dispatch(key, content, sub) when is_binary(key), do: dispatch([key], content, sub) - def dispatch(keys, content, sub) when is_list(keys) do - Logger.debug("dispatch #{inspect keys} = #{inspect content}") - for key <- keys do - spawn(fn() -> Registry.dispatch(sub, key, fn h -> - for {pid, _} <- h, do: send(pid, content) - end) end) - end - end - - # - # Triggers - # - - def triggers, do: @triggers - - for {trigger, name} <- @triggers do - def extract_trigger(unquote(trigger)<>text) do - text = String.strip(text) - [trigger | args] = String.split(text, " ") - %IRC.Trigger{type: unquote(name), trigger: String.downcase(trigger), args: args} - end - end - - def extract_trigger(_), do: nil - - # - # IRC Replies - # - - # irc_reply(ExIRC.Client pid, {channel or nick, ExIRC.Sender}, binary | replies - # replies :: {:kick, reason} | {:kick, nick, reason} | {:mode, mode, nick} - defp irc_reply(state = %{client: client, network: network}, {target, _}, text) when is_binary(text) or is_list(text) do - lines = IRC.splitlong(text) - |> Enum.map(fn(x) -> if(is_list(x), do: x, else: String.split(x, "\n")) end) - |> List.flatten() - outputs = for line <- lines do - ExIRC.Client.msg(client, :privmsg, target, line) - {:irc, :out, %IRC.Message{id: FlakeId.get(), transport: :irc, network: network, - channel: target, text: line, sender: %ExIRC.SenderInfo{nick: state.conn.nick}, at: NaiveDateTime.utc_now(), meta: %{self: true}}} - end - for f <- outputs, do: dispatch(["irc:outputs", "#{network}/#{target}:outputs"], f) - end - - defp irc_reply(%{client: client}, {target, %{nick: nick}}, {:kick, reason}) do - ExIRC.Client.kick(client, target, nick, reason) - end - - defp irc_reply(%{client: client}, {target, _}, {:kick, nick, reason}) do - ExIRC.Client.kick(client, target, nick, reason) - end - - defp irc_reply(%{client: client}, {target, %{nick: nick}}, {:mode, mode}) do - ExIRC.Client.mode(%{client: client}, target, mode, nick) - end - - defp irc_reply(%{client: client}, target, {:mode, mode, nick}) do - ExIRC.Client.mode(client, target, mode, nick) - end - - defp irc_reply(%{client: client}, target, {:channel_mode, mode}) do - ExIRC.Client.mode(client, target, mode) - end - - defp track_mode(network, channel, nick, "+o") do - IRC.UserTrack.change_privileges(network, channel, nick, {[:operator], []}) - :ok - end - - defp track_mode(network, channel, nick, "-o") do - IRC.UserTrack.change_privileges(network, channel, nick, {[], [:operator]}) - :ok - end - - defp track_mode(network, channel, nick, "+v") do - IRC.UserTrack.change_privileges(network, channel, nick, {[:voice], []}) - :ok - end - - defp track_mode(network, channel, nick, "-v") do - IRC.UserTrack.change_privileges(network, channel, nick, {[], [:voice]}) - :ok - end - - defp track_mode(network, channel, nick, mode) do - Logger.warn("Unhandled track_mode: #{inspect {nick, mode}}") - :ok - end - - defp server(%{conn: %{host: host, port: port}}) do - host <> ":" <> to_string(port) - end - -end diff --git a/lib/irc/conns.ex b/lib/irc/conns.ex deleted file mode 100644 index b0e5d3c..0000000 --- a/lib/irc/conns.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule IRC.Conns do - -end diff --git a/lib/irc/irc.ex b/lib/irc/irc.ex deleted file mode 100644 index 71d6d93..0000000 --- a/lib/irc/irc.ex +++ /dev/null @@ -1,79 +0,0 @@ -defmodule IRC do - - defmodule Message do - @derive {Poison.Encoder, except: [:replyfun]} - defstruct [:id, - :text, - {:transport, :irc}, - :network, - :account, - :sender, - :channel, - :trigger, - :replyfun, - :at, - {:meta, %{}} - ] - end - defmodule Trigger do - @derive Poison.Encoder - defstruct [:type, :trigger, :args] - end - - def send_message_as(account, network, channel, text, force_puppet \\ false) do - connection = IRC.Connection.get_network(network) - if connection && (force_puppet || IRC.PuppetConnection.whereis(account, connection)) do - IRC.PuppetConnection.start_and_send_message(account, connection, channel, text) - else - user = IRC.UserTrack.find_by_account(network, account) - nick = if(user, do: user.nick, else: account.name) - IRC.Connection.broadcast_message(network, channel, "<#{nick}> #{text}") - end - end - - def register(key) do - case Registry.register(IRC.PubSub, key, []) do - {:ok, _} -> :ok - error -> error - end - end - - def admin?(%Message{sender: sender}), do: admin?(sender) - - def admin?(%{nick: nick, user: user, host: host}) do - for {n, u, h} <- Nola.IRC.env(:admins, []) do - admin_part_match?(n, nick) && admin_part_match?(u, user) && admin_part_match?(h, host) - end - |> Enum.any? - end - - defp admin_part_match?(:_, _), do: true - defp admin_part_match?(a, a), do: true - defp admin_part_match?(_, _), do: false - - @max_chars 440 - - def splitlong(string, max_chars \\ 440) - - def splitlong(string, max_chars) when is_list(string) do - Enum.map(string, fn(s) -> splitlong(s, max_chars) end) - |> List.flatten() - end - - def splitlong(string, max_chars) do - string - |> String.codepoints - |> Enum.chunk_every(max_chars) - |> Enum.map(&Enum.join/1) - end - - def splitlong_with_prefix(string, prefix, max_chars \\ 440) do - prefix = "#{prefix} " - max_chars = max_chars - (length(String.codepoints(prefix))) - string - |> String.codepoints - |> Enum.chunk_every(max_chars) - |> Enum.map(fn(line) -> prefix <> Enum.join(line) end) - end - -end diff --git a/lib/irc/membership.ex b/lib/irc/membership.ex deleted file mode 100644 index b727dfd..0000000 --- a/lib/irc/membership.ex +++ /dev/null @@ -1,129 +0,0 @@ -defmodule IRC.Membership do - @moduledoc """ - Memberships (users in channels) - """ - - # Key: {account, net, channel} - # Format: {key, last_seen} - - defp dets() do - to_charlist(Nola.data_path <> "/memberships.dets") - end - - def start_link() do - GenServer.start_link(__MODULE__, [], [name: __MODULE__]) - end - - def init(_) do - dets = :dets.open_file(dets(), []) - {:ok, dets} - end - - def of_account(%IRC.Account{id: id}) do - spec = [{{{:"$1", :"$2", :"$3"}, :_}, [{:==, :"$1", {:const, id}}], [{{:"$2", :"$3"}}]}] - :dets.select(dets(), spec) - end - - def merge_account(old_id, new_id) do - #iex(37)> :ets.fun2ms(fn({{old_id, _, _}, _}=obj) when old_id == "42" -> obj end) - spec = [{{{:"$1", :_, :_}, :_}, [{:==, :"$1", {:const, old_id}}], [:"$_"]}] - Util.ets_mutate_select_each(:dets, dets(), spec, fn(table, obj = {{_old, net, chan}, ts}) -> - :dets.delete_object(table, obj) - :dets.insert(table, {{new_id, net, chan}, ts}) - end) - end - - def touch(%IRC.Account{id: id}, network, channel) do - :dets.insert(dets(), {{id, network, channel}, NaiveDateTime.utc_now()}) - end - def touch(account_id, network, channel) do - if account = IRC.Account.get(account_id) do - touch(account, network, channel) - end - end - - def notify_channels(account, minutes \\ 30, last_active \\ true) do - not_before = NaiveDateTime.add(NaiveDateTime.utc_now(), (minutes*-60), :second) - spec = [{{{:"$1", :_, :_}, :_}, [{:==, :"$1", {:const, account.id}}], [:"$_"]}] - memberships = :dets.select(dets(), spec) - |> Enum.sort_by(fn({_, ts}) -> ts end, {:desc, NaiveDateTime}) - active_memberships = Enum.filter(memberships, fn({_, ts}) -> NaiveDateTime.compare(ts, not_before) == :gt end) - cond do - active_memberships == [] && last_active -> - case memberships do - [{{_, net, chan}, _}|_] -> [{net, chan}] - _ -> [] - end - active_memberships == [] -> - [] - true -> - Enum.map(active_memberships, fn({{_, net, chan}, _}) -> {net,chan} end) - end - end - - def members_or_friends(account, _network, nil) do - friends(account) - end - - def members_or_friends(_, network, channel) do - members(network, channel) - end - - def expanded_members_or_friends(account, network, channel) do - expand(network, members_or_friends(account, network, channel)) - end - - def expanded_members(network, channel) do - expand(network, members(network, channel)) - end - - def members(network, channel) do - #iex(19)> :ets.fun2ms(fn({{id, net, chan}, ts}) when net == network and chan == channel and ts > min_seen -> id end) - limit = 0 # NaiveDateTime.add(NaiveDateTime.utc_now, 30*((24*-60)*60), :second) - spec = [ - {{{:"$1", :"$2", :"$3"}, :"$4"}, - [ - {:andalso, - {:andalso, {:==, :"$2", {:const, network}}, {:==, :"$3", {:const, channel}}}, - {:>, :"$4", {:const, limit}}} - ], [:"$1"]} - ] - :dets.select(dets(), spec) - end - - def friends(account = %IRC.Account{id: id}) do - for({net, chan} <- of_account(account), do: members(net, chan)) - |> List.flatten() - |> Enum.uniq() - end - - def handle_info(_, dets) do - {:noreply, dets} - end - - def handle_cast(_, dets) do - {:noreply, dets} - end - - def handle_call(_, _, dets) do - {:noreply, dets} - end - - def terminate(_, dets) do - :dets.sync(dets) - :dets.close(dets) - end - - defp expand(network, list) do - for id <- list do - if account = IRC.Account.get(id) do - user = IRC.UserTrack.find_by_account(network, account) - nick = if(user, do: user.nick, else: account.name) - {account, user, nick} - end - end - |> Enum.filter(fn(x) -> x end) - end - - -end diff --git a/lib/irc/plugin/temp_ref.ex b/lib/irc/plugin/temp_ref.ex deleted file mode 100644 index 923fa1a..0000000 --- a/lib/irc/plugin/temp_ref.ex +++ /dev/null @@ -1,95 +0,0 @@ -defmodule Irc.Plugin.TempRef do - @moduledoc """ - This module allows to easily implement local temporary simple references for easy access from IRC. - - For example, your plugin output could be acted on, and instead of giving the burden for the user to - write or copy that uuid, you could give them a small alphanumeric reference to use instead. - - You can configure how many and for how long the references are kept. - - ## Usage - - `import Irc.Plugin.TempRef` - - ```elixir - defmodule Irc.MyPlugin do - defstruct [:temprefs] - - def init(_) do - # … - {:ok, %__MODULE__{temprefs: new_temp_refs()} - end - end - ``` - """ - - defstruct [:refs, :max, :expire, :build_fun, :build_increase_fun, :build_options] - - defmodule SimpleAlphaNumericBuilder do - def build(options) do - length = Keyword.get(options, :length, 3) - for _ <- 1..length, into: "", do: <> - end - - def increase(options) do - Keyword.put(options, :length, Keyword.get(options, :length, 3) + 1) - end - end - - def new_temp_refs(options \\ []) do - %__MODULE__{ - refs: Keyword.get(options, :init_refs, []), - max: Keyword.get(options, :max, []), - expire: Keyword.get(options, :expire, :infinity), - build_fun: Keyword.get(options, :build_fun, &__MODULE__.SimpleAlphaNumericBuilder.build/1), - build_increase_fun: Keyword.get(options, :build_increase_fun, &__MODULE__.SimpleAlphaNumericBuilder.increase/1), - build_options: Keyword.get(options, :build_options, [length: 3]) - } - end - - def janitor_refs(state = %__MODULE__{}) do - if length(state.refs) > state.max do - %__MODULE__{refs: state.refs |> Enum.reverse() |> tl() |> Enum.reverse()} - else - state - end - end - - def put_temp_ref(data, state = %__MODULE__{}) do - state = janitor_refs(state) - key = new_nonexisting_key(state) - if key do - ref = {key, DateTime.utc_now(), data} - {key, %__MODULE__{state | refs: [ref | state.refs]}} - else - {nil, state} - end - end - - def lookup_temp_ref(key, state, default \\ nil) do - case List.keyfind(state.refs, key, 0) do - {_, _, data} -> data - _ -> default - end - end - - defp new_nonexisting_key(state, i) when i > 50 do - nil - end - - defp new_nonexisting_key(state = %__MODULE__{refs: refs}, i \\ 1) do - build_options = if rem(i, 5) == 0 do - state.build_increase_fun.(state.build_options) - else - state.build_options - end - - key = state.build_fun.(state.build_options) - if !List.keymember?(refs, key, 0) do - key - else - new_nonexisting_key(state, i + 1) - end - end - -end diff --git a/lib/irc/plugin_supervisor.ex b/lib/irc/plugin_supervisor.ex deleted file mode 100644 index a65ad09..0000000 --- a/lib/irc/plugin_supervisor.ex +++ /dev/null @@ -1,99 +0,0 @@ -defmodule IRC.Plugin do - require Logger - - defmodule Supervisor do - use DynamicSupervisor - require Logger - - def start_link() do - DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) - end - - def start_child(module, opts \\ []) do - Logger.info("Starting #{module}") - spec = %{id: {IRC.Plugin,module}, start: {IRC.Plugin, :start_link, [module, opts]}, name: module, restart: :transient} - case DynamicSupervisor.start_child(__MODULE__, spec) do - {:ok, _} = res -> res - :ignore -> - Logger.warn("Ignored #{module}") - :ignore - {:error,_} = res -> - Logger.error("Could not start #{module}: #{inspect(res, pretty: true)}") - res - end - end - - @impl true - def init(_init_arg) do - DynamicSupervisor.init( - strategy: :one_for_one, - max_restarts: 10, - max_seconds: 1 - ) - end - end - - def dets(), do: to_charlist(Nola.data_path("/plugins.dets")) - - def setup() do - :dets.open_file(dets(), []) - end - - def enabled() do - :dets.foldl(fn - {name, true, _}, acc -> [name | acc] - _, acc -> acc - end, [], dets()) - end - - def start_all() do - for mod <- enabled(), do: {mod, IRC.Plugin.Supervisor.start_child(mod)} - end - - def declare(module) do - case get(module) do - :disabled -> :dets.insert(dets(), {module, true, nil}) - _ -> nil - end - end - - def start(module, opts \\ []) do - IRC.Plugin.Supervisor.start_child(module) - end - - @doc "Enables a plugin" - def enable(name), do: switch(name, true) - - @doc "Disables a plugin" - def disable(name), do: switch(name, false) - - @doc "Enables or disables a plugin" - def switch(name, value) when is_boolean(value) do - last = case get(name) do - {:ok, last} -> last - _ -> nil - end - :dets.insert(dets(), {name, value, last}) - end - - @spec get(module()) :: {:ok, last_start :: nil | non_neg_integer()} | :disabled - def get(name) do - case :dets.lookup(dets(), name) do - [{name, enabled, last_start}] -> {:ok, enabled, last_start} - _ -> :disabled - end - end - - def start_link(module, options \\ []) do - with {:disabled, {_, true, last}} <- {:disabled, get(module)}, - {:throttled, false} <- {:throttled, false} - do - module.start_link() - else - {error, _} -> - Logger.info("Plugin: #{to_string(module)} ignored start: #{to_string(error)}") - :ignore - end - end - -end diff --git a/lib/irc/puppet_connection.ex b/lib/irc/puppet_connection.ex deleted file mode 100644 index 91a26b3..0000000 --- a/lib/irc/puppet_connection.ex +++ /dev/null @@ -1,238 +0,0 @@ -defmodule IRC.PuppetConnection do - require Logger - @min_backoff :timer.seconds(5) - @max_backoff :timer.seconds(2*60) - @max_idle :timer.hours(12) - @env Mix.env - - defmodule Supervisor do - use DynamicSupervisor - - def start_link() do - DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) - end - - def start_child(%IRC.Account{id: account_id}, %IRC.Connection{id: connection_id}) do - spec = %{id: {account_id, connection_id}, start: {IRC.PuppetConnection, :start_link, [account_id, connection_id]}, restart: :transient} - DynamicSupervisor.start_child(__MODULE__, spec) - end - - @impl true - def init(_init_arg) do - DynamicSupervisor.init( - strategy: :one_for_one, - max_restarts: 10, - max_seconds: 1 - ) - end - end - - def whereis(account = %IRC.Account{id: account_id}, connection = %IRC.Connection{id: connection_id}) do - {:global, name} = name(account_id, connection_id) - case :global.whereis_name(name) do - :undefined -> nil - pid -> pid - end - end - - def send_message(account = %IRC.Account{id: account_id}, connection = %IRC.Connection{id: connection_id}, channel, text) do - GenServer.cast(name(account_id, connection_id), {:send_message, self(), channel, text}) - end - - def start_and_send_message(account = %IRC.Account{id: account_id}, connection = %IRC.Connection{id: connection_id}, channel, text) do - {:global, name} = name(account_id, connection_id) - pid = whereis(account, connection) - pid = if !pid do - case IRC.PuppetConnection.Supervisor.start_child(account, connection) do - {:ok, pid} -> pid - {:error, {:already_started, pid}} -> pid - end - else - pid - end - GenServer.cast(pid, {:send_message, self(), channel, text}) - end - - def start(account = %IRC.Account{}, connection = %IRC.Connection{}) do - IRC.PuppetConnection.Supervisor.start_child(account, connection) - end - - def start_link(account_id, connection_id) do - GenServer.start_link(__MODULE__, [account_id, connection_id], name: name(account_id, connection_id)) - end - - def name(account_id, connection_id) do - {:global, {PuppetConnection, account_id, connection_id}} - end - - def init([account_id, connection_id]) do - account = %IRC.Account{} = IRC.Account.get(account_id) - connection = %IRC.Connection{} = IRC.Connection.lookup(connection_id) - Logger.metadata(puppet_conn: account.id <> "@" <> connection.id) - backoff = :backoff.init(@min_backoff, @max_backoff) - |> :backoff.type(:jitter) - idle = :erlang.send_after(@max_idle, self, :idle) - {:ok, %{client: nil, backoff: backoff, idle: idle, connected: false, buffer: [], channels: [], connection_id: connection_id, account_id: account_id, connected_server: nil, connected_port: nil, network: connection.network}, {:continue, :connect}} - end - - def handle_continue(:connect, state) do - #ipv6 = if @env == :prod do - # subnet = Nola.Subnet.assign(state.account_id) - # IRC.Account.put_meta(IRC.Account.get(state.account_id), "subnet", subnet) - # ip = Pfx.host(subnet, 1) - # {:ok, ipv6} = :inet_parse.ipv6_address(to_charlist(ip)) - # System.cmd("add-ip6", [ip]) - # ipv6 - #end - - conn = IRC.Connection.lookup(state.connection_id) - client_opts = [] - |> Keyword.put(:network, conn.network) - client = if state.client && Process.alive?(state.client) do - Logger.info("Reconnecting client") - state.client - else - Logger.info("Connecting") - {:ok, client} = ExIRC.Client.start_link(debug: false) - ExIRC.Client.add_handler(client, self()) - client - end - - base_opts = [ - {:nodelay, true} - ] - - #{ip, opts} = case {ipv6, :inet_res.resolve(to_charlist(conn.host), :in, :aaaa)} do - # {ipv6, {:ok, {:dns_rec, _dns_header, _query, rrs = [{:dns_rr, _, _, _, _, _, _, _, _, _} | _], _, _}}} -> - # ip = rrs - # |> Enum.map(fn({:dns_rr, _, :aaaa, :in, _, _, ipv6, _, _, _}) -> ipv6 end) - # |> Enum.shuffle() - # |> List.first() - - # opts = [ - # :inet6, - # {:ifaddr, ipv6} - # ] - # {ip, opts} - # _ -> - {ip, opts} = {to_charlist(conn.host), []} - #end - - conn_fun = if conn.tls, do: :connect_ssl!, else: :connect! - apply(ExIRC.Client, conn_fun, [client, ip, conn.port, base_opts ++ opts]) - - {:noreply, %{state | client: client}} - end - - def handle_continue(:connected, state) do - state = Enum.reduce(Enum.reverse(state.buffer), state, fn(b, state) -> - {:noreply, state} = handle_cast(b, state) - state - end) - {:noreply, %{state | buffer: []}} - end - - def handle_cast(cast = {:send_message, _pid, _channel, _text}, state = %{connected: false, buffer: buffer}) do - {:noreply, %{state | buffer: [cast | buffer]}} - end - - def handle_cast({:send_message, pid, channel, text}, state = %{connected: true}) do - channels = if !Enum.member?(state.channels, channel) do - ExIRC.Client.join(state.client, channel) - [channel | state.channels] - else - state.channels - end - ExIRC.Client.msg(state.client, :privmsg, channel, text) - - meta = %{puppet: true, from: pid} - account = IRC.Account.get(state.account_id) - nick = make_nick(state) - sender = %ExIRC.SenderInfo{network: state.network, nick: suffix_nick(nick), user: nick, host: "puppet."} - reply_fun = fn(text) -> - IRC.Connection.broadcast_message(state.network, channel, text) - end - message = %IRC.Message{id: FlakeId.get(), at: NaiveDateTime.utc_now(), text: text, network: state.network, account: account, sender: sender, channel: channel, replyfun: reply_fun, trigger: IRC.Connection.extract_trigger(text), meta: meta} - message = case IRC.UserTrack.messaged(message) do - :ok -> message - {:ok, message} -> message - end - IRC.Connection.publish(message, ["#{message.network}/#{channel}:messages"]) - - idle = if length(state.buffer) == 0 do - :erlang.cancel_timer(state.idle) - :erlang.send_after(@max_idle, self(), :idle) - else - state.idle - end - - {:noreply, %{state | idle: idle, channels: channels}} - end - - def handle_info(:idle, state) do - ExIRC.Client.quit(state.client, "Puppet was idle for too long") - ExIRC.Client.stop!(state.client) - {:stop, :normal, state} - end - - def handle_info(:disconnected, state) do - {delay, backoff} = :backoff.fail(state.backoff) - Logger.info("#{inspect(self())} Disconnected -- reconnecting in #{inspect delay}ms") - Process.send_after(self(), :connect, delay) - {:noreply, %{state | connected: false, backoff: backoff}} - end - - def handle_info(:connect, state) do - {:noreply, state, {:continue, :connect}} - end - - # Connection successful - def handle_info({:connected, server, port}, state) do - Logger.info("#{inspect(self())} Connected to #{inspect(server)}:#{port} #{inspect state}") - {_, backoff} = :backoff.succeed(state.backoff) - base_nick = make_nick(state) - ExIRC.Client.logon(state.client, "", suffix_nick(base_nick), base_nick, "#{base_nick}'s puppet") - {:noreply, %{state | backoff: backoff, connected_server: server, connected_port: port}} - end - - # Logon successful - def handle_info(:logged_in, state) do - Logger.info("#{inspect(self())} Logged in") - {_, backoff} = :backoff.succeed(state.backoff) - # Create an UserTrack entry for the client so it's authenticated to the right account_id already. - IRC.UserTrack.connected(state.network, suffix_nick(make_nick(state)), make_nick(state), "puppet.", state.account_id, %{puppet: true}) - {:noreply, %{state | backoff: backoff}} - end - - # ISUP - def handle_info({:isup, network}, state) do - {:noreply, %{state | network: network, connected: true}, {:continue, :connected}} - end - - # Been kicked - def handle_info({:kicked, _sender, chan, _reason}, state) do - {:noreply, %{state | channels: state.channels -- [chan]}} - end - - def handle_info(_info, state) do - {:noreply, state} - end - - def make_nick(state) do - account = IRC.Account.get(state.account_id) - user = IRC.UserTrack.find_by_account(state.network, account) - base_nick = if(user, do: user.nick, else: account.name) - clean_nick = case String.split(base_nick, ":", parts: 2) do - ["@"<>nick, _] -> nick - [nick] -> nick - end - clean_nick - end - - if Mix.env == :dev do - def suffix_nick(nick), do: "#{nick}[d]" - else - def suffix_nick(nick), do: "#{nick}[p]" - end - -end diff --git a/lib/irc/user_track.ex b/lib/irc/user_track.ex deleted file mode 100644 index 1efa523..0000000 --- a/lib/irc/user_track.ex +++ /dev/null @@ -1,329 +0,0 @@ -defmodule IRC.UserTrack do - @moduledoc """ - User Track DB & Utilities - """ - - @ets IRC.UserTrack.Storage - # {uuid, network, nick, nicks, privilege_map} - # Privilege map: - # %{"#channel" => [:operator, :voice] - defmodule Storage do - - def delete(id) do - op(fn(ets) -> :ets.delete(ets, id) end) - end - - def insert(tuple) do - op(fn(ets) -> :ets.insert(ets, tuple) end) - end - - def clear_network(network) do - op(fn(ets) -> - spec = [ - {{:_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_, :_, :_}, - [ - {:==, :"$1", {:const, network}} - ], [:"$_"]} - ] - :ets.match_delete(ets, spec) - end) - end - - def op(fun) do - GenServer.call(__MODULE__, {:op, fun}) - end - - def start_link do - GenServer.start_link(__MODULE__, [], [name: __MODULE__]) - end - - def init([]) do - ets = :ets.new(__MODULE__, [:set, :named_table, :protected, {:read_concurrency, true}]) - {:ok, ets} - end - - def handle_call({:op, fun}, _from, ets) do - returned = try do - {:ok, fun.(ets)} - rescue - rescued -> {:error, rescued} - catch - rescued -> {:error, rescued} - end - {:reply, returned, ets} - end - - def terminate(_reason, ets) do - :ok - end - end - - defmodule Id, do: use EntropyString - - defmodule User do - defstruct [:id, :account, :network, :nick, {:nicks, []}, :username, :host, :realname, {:privileges, %{}}, {:last_active, %{}}, {:options, %{}}] - - def to_tuple(u = %__MODULE__{}) do - {u.id || IRC.UserTrack.Id.large_id, u.network, u.account, String.downcase(u.nick), u.nick, u.nicks || [], u.username, u.host, u.realname, u.privileges, u.last_active, u.options} - end - - #tuple size: 11 - def from_tuple({id, network, account, _downcased_nick, nick, nicks, username, host, realname, privs, last_active, opts}) do - struct = %__MODULE__{id: id, account: account, network: network, nick: nick, nicks: nicks, username: username, host: host, realname: realname, privileges: privs, last_active: last_active, options: opts} - end - end - - def find_by_account(%IRC.Account{id: id}) do - #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) - spec = [ - {{:_, :_, :"$2", :_, :_, :_, :_, :_, :_, :_, :_, :_}, - [ - {:==, :"$2", {:const, id}} - ], [:"$_"]} - ] - results = :ets.select(@ets, spec) - |> Enum.filter(& &1) - for obj <- results, do: User.from_tuple(obj) - end - - def find_by_account(network, nil) do - nil - end - - def find_by_account(network, %IRC.Account{id: id}) do - #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) - spec = [ - {{:_, :"$1", :"$2", :_, :_, :_, :_, :_, :_, :_, :_, :_}, - [ - {:andalso, {:==, :"$1", {:const, network}}, - {:==, :"$2", {:const, id}}} - ], [:"$_"]} - ] - case :ets.select(@ets, spec) do - results = [_r | _] -> - result = results - |> Enum.reject(fn({_, net, _, _, _, _, _, _, _, _, actives, opts}) -> network != "matrix" && net == "matrix" end) - |> Enum.reject(fn({_, net, _, _, _, _, _, _, _, _, actives, opts}) -> network != "telegram" && net == "telegram" end) - |> Enum.reject(fn({_, _, _, _, _, _, _, _, _, _, actives, opts}) -> network not in ["matrix", "telegram"] && Map.get(opts, :puppet) end) - |> Enum.sort_by(fn({_, _, _, _, _, _, _, _, _, _, actives, _}) -> - Map.get(actives, nil) - end, {:desc, NaiveDateTime}) - |> List.first - - if result, do: User.from_tuple(result) - _ -> nil - end - end - - def clear_network(network) do - Storage.clear_network(network) - end - - - def merge_account(old_id, new_id) do - #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) - spec = [ - {{:_, :_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_, :_}, - [ - {:==, :"$1", {:const, old_id}} - ], [:"$_"]} - ] - Enum.each(:ets.select(@ets, spec), fn({id, net, _, downcased_nick, nick, nicks, username, host, realname, privs, active, opts}) -> - Storage.op(fn(ets) -> - :ets.insert(@ets, {id, net, new_id, downcased_nick, nick, nicks, username, host, realname, privs, active, opts}) - end) - end) - end - - def find_by_nick(%ExIRC.Who{network: network, nick: nick}) do - find_by_nick(network, nick) - end - - - def find_by_nick(%ExIRC.SenderInfo{network: network, nick: nick}) do - find_by_nick(network, nick) - end - - def find_by_nick(network, nick) do - case :ets.match(@ets, {:"$1", network, :_, String.downcase(nick), :_, :_, :_, :_, :_, :_, :_, :_}) do - [[id] | _] -> lookup(id) - _ -> - nil - end - end - - def to_list, do: :ets.tab2list(@ets) - - def lookup(id) do - case :ets.lookup(@ets, id) do - [] -> nil - [tuple] -> User.from_tuple(tuple) - end - end - - def operator?(network, channel, nick) do - if user = find_by_nick(network, nick) do - privs = Map.get(user.privileges, channel, []) - Enum.member?(privs, :admin) || Enum.member?(privs, :operator) - else - false - end - end - - def channel(network, channel) do - Enum.filter(to_list(), fn({_, network, _, _, _, _, _, _, _, channels, _, _}) -> - Map.get(channels, channel) - end) - end - - # TODO - def connected(network, nick, user, host, account_id, opts \\ %{}) do - if account = IRC.Account.get(account_id) do - user = if user = find_by_nick(network, nick) do - user - else - user = %User{id: IRC.UserTrack.Id.large_id, account: account_id, network: network, nick: nick, username: user, host: host, privileges: %{}, options: opts} - Storage.op(fn(ets) -> - :ets.insert(ets, User.to_tuple(user)) - end) - user - end - - IRC.Connection.publish_event(network, %{type: :connect, user_id: user.id, account_id: user.account}) - :ok - else - :error - end - end - - def joined(c, s), do: joined(c,s,[]) - - def joined(channel, sender=%{nick: nick, user: uname, host: host}, privileges, touch \\ true) do - privileges = if IRC.admin?(sender) do - privileges ++ [:admin] - else privileges end - user = if user = find_by_nick(sender.network, nick) do - %User{user | username: uname, host: host, privileges: Map.put(user.privileges || %{}, channel, privileges)} - else - user = %User{id: IRC.UserTrack.Id.large_id, network: sender.network, nick: nick, username: uname, host: host, privileges: %{channel => privileges}} - - account = IRC.Account.lookup(user).id - user = %User{user | account: account} - end - user = touch_struct(user, channel) - - if touch && user.account do - IRC.Membership.touch(user.account, sender.network, channel) - end - - Storage.op(fn(ets) -> - :ets.insert(ets, User.to_tuple(user)) - end) - - IRC.Connection.publish_event({sender.network, channel}, %{type: :join, user_id: user.id, account_id: user.account}) - - user - end - - #def joined(network, channel, nick, privileges) do - # user = if user = find_by_nick(network, nick) do - # %User{user | privileges: Map.put(user.privileges, channel, privileges)} - # else - # %User{nick: nick, privileges: %{channel => privileges}} - # end - # - # Storage.op(fn(ets) -> - # :ets.insert(ets, User.to_tuple(user)) - # end) - #end - - def messaged(%IRC.Message{network: network, account: account, channel: chan, sender: %{nick: nick}} = m) do - {user, account} = if user = find_by_nick(network, nick) do - {touch_struct(user, chan), account || IRC.Account.lookup(user)} - else - user = %User{network: network, nick: nick, privileges: %{}} - account = IRC.Account.lookup(user) - {%User{user | account: account.id}, account} - end - Storage.insert(User.to_tuple(user)) - if chan, do: IRC.Membership.touch(account, network, chan) - if !m.account do - {:ok, %IRC.Message{m | account: account}} - else - :ok - end - end - - def renamed(network, old_nick, new_nick) do - if user = find_by_nick(network, old_nick) do - old_account = IRC.Account.lookup(user) - user = %User{user | nick: new_nick, nicks: [old_nick|user.nicks]} - account = IRC.Account.lookup(user, false) || old_account - user = %User{user | nick: new_nick, account: account.id, nicks: [old_nick|user.nicks]} - Storage.insert(User.to_tuple(user)) - channels = for {channel, _} <- user.privileges, do: channel - IRC.Connection.publish_event(network, %{type: :nick, user_id: user.id, account_id: account.id, nick: new_nick, old_nick: old_nick}) - end - end - - def change_privileges(network, channel, nick, {add, remove}) do - if user = find_by_nick(network, nick) do - privs = Map.get(user.privileges, channel) - - privs = Enum.reduce(add, privs, fn(priv, acc) -> [priv|acc] end) - privs = Enum.reduce(remove, privs, fn(priv, acc) -> List.delete(acc, priv) end) - - user = %User{user | privileges: Map.put(user.privileges, channel, privs)} - Storage.insert(User.to_tuple(user)) - IRC.Connection.publish_event({network, channel}, %{type: :privileges, user_id: user.id, account_id: user.account, added: add, removed: remove}) - end - end - - # XXX: Reason - def parted(channel, %{network: network, nick: nick}) do - parted(network, channel, nick) - end - - def parted(network, channel, nick) do - if user = find_by_nick(network, nick) do - if user.account do - IRC.Membership.touch(user.account, network, channel) - end - - privs = Map.delete(user.privileges, channel) - lasts = Map.delete(user.last_active, channel) - if Enum.count(privs) > 0 do - user = %User{user | privileges: privs} - Storage.insert(User.to_tuple(user)) - IRC.Connection.publish_event({network, channel}, %{type: :part, user_id: user.id, account_id: user.account, reason: nil}) - else - IRC.Connection.publish_event(network, %{type: :quit, user_id: user.id, account_id: user.account, reason: "Left all known channels"}) - Storage.delete(user.id) - end - end - end - - def quitted(sender, reason) do - if user = find_by_nick(sender.network, sender.nick) do - if user.account do - for {channel, _} <- user.privileges do - IRC.Membership.touch(user.account, sender.network, channel) - end - IRC.Connection.publish_event(sender.network, %{type: :quit, user_id: user.id, account_id: user.account, reason: reason}) - end - Storage.delete(user.id) - end - end - - defp touch_struct(user = %User{last_active: last_active}, channel) do - now = NaiveDateTime.utc_now() - last_active = last_active - |> Map.put(channel, now) - |> Map.put(nil, now) - %User{user | last_active: last_active} - end - - defp userchans(%{privileges: privileges}) do - for({chan, _} <- privileges, do: chan) - end -end diff --git a/lib/lsg/application.ex b/lib/lsg/application.ex deleted file mode 100644 index 4f3d1da..0000000 --- a/lib/lsg/application.ex +++ /dev/null @@ -1,56 +0,0 @@ -defmodule Nola.Application do - use Application - - # See https://hexdocs.pm/elixir/Application.html - # for more information on OTP Applications - def start(_type, _args) do - import Supervisor.Spec - - Logger.add_backend(Sentry.LoggerBackend) - :ok = Nola.Matrix.setup() - :ok = Nola.TelegramRoom.setup() - - # Define workers and child supervisors to be supervised - children = [ - # Start the endpoint when the application starts - supervisor(NolaWeb.Endpoint, []), - # Start your own worker by calling: Nola.Worker.start_link(arg1, arg2, arg3) - # worker(Nola.Worker, [arg1, arg2, arg3]), - worker(Registry, [[keys: :duplicate, name: Nola.BroadcastRegistry]], id: :registry_broadcast), - worker(Nola.IcecastAgent, []), - worker(Nola.Token, []), - worker(Nola.AuthToken, []), - Nola.Subnet, - {GenMagic.Pool, [name: Nola.GenMagic, pool_size: 2]}, - #worker(Nola.Icecast, []), - ] ++ Nola.IRC.application_childs - ++ Nola.Matrix.application_childs - - # See https://hexdocs.pm/elixir/Supervisor.html - # for other strategies and supported options - opts = [strategy: :one_for_one, name: Nola.Supervisor] - sup = Supervisor.start_link(children, opts) - start_telegram() - spawn_link(fn() -> Nola.IRC.after_start() end) - spawn_link(fn() -> Nola.Matrix.after_start() end) - spawn_link(fn() -> Nola.TelegramRoom.after_start() end) - sup - end - - # Tell Phoenix to update the endpoint configuration - # whenever the application is updated. - def config_change(changed, _new, removed) do - NolaWeb.Endpoint.config_change(changed, removed) - :ok - end - - defp start_telegram() do - token = Keyword.get(Application.get_env(:nola, :telegram, []), :key) - options = [ - username: Keyword.get(Application.get_env(:nola, :telegram, []), :nick, "beauttebot"), - purge: false - ] - telegram = Telegram.Bot.ChatBot.Supervisor.start_link({Nola.Telegram, token, options}) - end - -end diff --git a/lib/lsg/auth_token.ex b/lib/lsg/auth_token.ex deleted file mode 100644 index d125ea4..0000000 --- a/lib/lsg/auth_token.ex +++ /dev/null @@ -1,59 +0,0 @@ -defmodule Nola.AuthToken do - use GenServer - - def start_link() do - GenServer.start_link(__MODULE__, [], [name: __MODULE__]) - end - - def lookup(id) do - GenServer.call(__MODULE__, {:lookup, id}) - end - - def new_path(account, perks \\ nil) do - case new(account, perks) do - {:ok, id} -> - NolaWeb.Router.Helpers.login_path(NolaWeb.Endpoint, :token, id) - error -> - error - end - end - - def new_url(account, perks \\ nil) do - case new(account, perks) do - {:ok, id} -> - NolaWeb.Router.Helpers.login_url(NolaWeb.Endpoint, :token, id) - error -> - error - end - end - - def new(account, perks \\ nil) do - GenServer.call(__MODULE__, {:new, account, perks}) - end - - def init(_) do - {:ok, Map.new} - end - - def handle_call({:lookup, id}, _, state) do - IO.inspect(state) - with \ - {account, date, perks} <- Map.get(state, id), - d when d > 0 <- DateTime.diff(date, DateTime.utc_now()) - do - {:reply, {:ok, account, perks}, Map.delete(state, id)} - else - x -> - IO.inspect(x) - {:reply, {:error, :invalid_token}, state} - end - end - - def handle_call({:new, account, perks}, _, state) do - id = IRC.UserTrack.Id.token() - expire = DateTime.utc_now() - |> DateTime.add(15*60, :second) - {:reply, {:ok, id}, Map.put(state, id, {account, expire, perks})} - end - -end diff --git a/lib/lsg/icecast.ex b/lib/lsg/icecast.ex deleted file mode 100644 index 5a53192..0000000 --- a/lib/lsg/icecast.ex +++ /dev/null @@ -1,117 +0,0 @@ -defmodule Nola.Icecast do - use GenServer - require Logger - @hackney_pool :default - @httpoison_opts [hackney: [pool: @hackney_pool]] - @fuse __MODULE__ - - def start_link, do: GenServer.start_link(__MODULE__, [], []) - - def init(_) do - GenServer.cast(self(), :poll) - {:ok, nil} - end - - def handle_cast(:poll, state) do - state = poll(state) - {:noreply, state} - end - - def handle_info(:poll, state) do - state = poll(state) - {:noreply, state} - end - - defp poll(state) do - state = case request(base_url(), :get) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - #update_json_stats(Jason.decode(body)) - stats = update_stats(body) - if state != stats do - Logger.info "Icecast Update: " <> inspect(stats) - Nola.IcecastAgent.update(stats) - Registry.dispatch(Nola.BroadcastRegistry, "icecast", fn ws -> - for {pid, _} <- ws, do: send(pid, {:icecast, stats}) - end) - stats - else - state - end - error -> - Logger.error "Icecast HTTP Error: #{inspect error}" - state - end - interval = Application.get_env(:nola, :icecast_poll_interval, 60_000) - :timer.send_after(interval, :poll) - state - end - - defp update_stats(html) do - raw = Floki.find(html, "div.roundbox") - |> Enum.map(fn(html) -> - html = Floki.raw_html(html) - [{"h3", _, ["Mount Point /"<>mount]}] = Floki.find(html, "h3.mount") - stats = Floki.find(html, "tr") - |> Enum.map(fn({"tr", _, tds}) -> - [{"td", _, keys}, {"td", _, values}] = tds - key = List.first(keys) - value = List.first(values) - {key, value} - end) - |> Enum.into(Map.new) - {mount, stats} - end) - |> Enum.into(Map.new) - - live? = if Map.get(raw["live"], "Content Type:", false), do: true, else: false - np = if live? do - raw["live"]["Currently playing:"] - else - raw["autodj"]["Currently playing:"] - end - - genre = raw["live"]["Genre:"] || nil - %{np: np || "", live: live? || false, genre: genre} - end - - defp update_json_stats({:ok, body}) do - Logger.debug "JSON STATS: #{inspect body}" - end - - defp update_json_stats(error) do - Logger.error "Failed to decode JSON Stats: #{inspect error}" - end - - defp request(uri, method, body \\ [], headers \\ []) do - headers = [{"user-agent", "Nola-API[115ans.net, sys.115ans.net] href@random.sh"}] ++ headers - options = @httpoison_opts - case :ok do #:fuse.ask(@fuse, :sync) do - :ok -> run_request(method, uri, body, headers, options) - :blown -> :blown - end - end - - # This is to work around hackney's behaviour of returning `{:error, :closed}` when a pool connection has been closed - # (keep-alive expired). We just retry the request immediatly up to five times. - defp run_request(method, uri, body, headers, options), do: run_request(method, uri, body, headers, options, 0) - defp run_request(method, uri, body, headers, options, retries) when retries < 4 do - case HTTPoison.request(method, uri, body, headers, options) do - {:error, :closed} -> run_request(method, uri, body, headers, options, retries + 1) - other -> other - end - end - defp run_request(method, uri, body, headers, options, _exceeded_retries), do: {:error, :unavailable} - - # - # -- URIs - # - - defp stats_json_url do - base_url() <> "/status-json.xsl" - end - - defp base_url do - "http://91.121.59.45:8089" - end - -end diff --git a/lib/lsg/icecast_agent.ex b/lib/lsg/icecast_agent.ex deleted file mode 100644 index 8a3a72b..0000000 --- a/lib/lsg/icecast_agent.ex +++ /dev/null @@ -1,17 +0,0 @@ -defmodule Nola.IcecastAgent do - use Agent - - def start_link() do - Agent.start_link(fn -> nil end, name: __MODULE__) - end - - def update(stats) do - Agent.update(__MODULE__, fn(_old) -> stats end) - end - - def get do - Agent.get(__MODULE__, fn(stats) -> stats end) - end - -end - diff --git a/lib/lsg/lsg.ex b/lib/lsg/lsg.ex deleted file mode 100644 index 0acb76e..0000000 --- a/lib/lsg/lsg.ex +++ /dev/null @@ -1,30 +0,0 @@ -defmodule Nola do - - @default_brand [ - name: "Nola, - source_url: "https://phab.random.sh/source/Bot/", - owner: "Ashamed owner", - owner_email: "contact@my.nola.bot" - ] - - def env(), do: Application.get_env(:nola) - def env(key, default \\ nil), do: Application.get_env(:nola, key, default) - - def brand(), do: env(:brand, @default_brand) - def brand(key), do: Keyword.get(brand(), key) - def name(), do: brand(:name) - def source_url(), do: brand(:source_url) - - def data_path(suffix) do - Path.join(data_path(), suffix) - end - - def data_path do - Application.get_env(:nola, :data_path) - end - - def version do - Application.spec(:nola)[:vsn] - end - -end diff --git a/lib/lsg/subnet.ex b/lib/lsg/subnet.ex deleted file mode 100644 index ac9d8e6..0000000 --- a/lib/lsg/subnet.ex +++ /dev/null @@ -1,84 +0,0 @@ -defmodule Nola.Subnet do - use Agent - - def start_link(_) do - Agent.start_link(&setup/0, name: __MODULE__) - end - - def assignations() do - :dets.select(dets(), [{{:"$1", :"$2"}, [is_binary: :"$2"], [{{:"$1", :"$2"}}]}]) - end - - def find_subnet_for(binary) when is_binary(binary) do - case :dets.select(dets(), [{{:"$1", :"$2"}, [{:==, :"$2", binary}], [{{:"$1", :"$2"}}]}]) do - [{subnet, _}] -> subnet - _ -> nil - end - end - - def assign(binary) when is_binary(binary) do - result = if subnet = find_subnet_for(binary) do - {:ok, subnet} - else - Agent.get_and_update(__MODULE__, fn(dets) -> - {subnet, _} = available_select(dets) - :dets.insert(dets, {subnet, binary}) - :dets.sync(dets) - {{:new, subnet}, dets} - end) - end - - case result do - {:new, subnet} -> - ip = Pfx.host(subnet, 1) - set_reverse(binary, ip) - subnet - {:ok, subnet} -> - subnet - end - end - - def set_reverse(name, ip, value \\ nil) - - def set_reverse(name, ip, nil) do - set_reverse(name, ip, "#{name}.users.goulag.org") - end - - def set_reverse(_, ip, value) do - ptr_zone = "3.0.0.2.d.f.0.a.2.ip6.arpa" - ip_fqdn = Pfx.dns_ptr(ip) - ip_local = String.replace(ip_fqdn, ".#{ptr_zone}", "") - rev? = String.ends_with?(value, ".users.goulag.org") - if rev? do - {:ok, rev_zone} = PowerDNSex.show_zone("users.goulag.org") - rev_update? = Enum.any?(rev_zone.rrsets, fn(rr) -> rr.name == "#{ip_fqdn}." end) - record = %{name: "#{value}.", type: "AAAA", ttl: 8600, records: [%{content: ip, disabled: false}]} - if(rev_update?, do: PowerDNSex.update_record(rev_zone, record), else: PowerDNSex.create_record(rev_zone, record)) - end - {:ok, zone} = PowerDNSex.show_zone(ptr_zone) - update? = Enum.any?(zone.rrsets, fn(rr) -> rr.name == "#{ip_fqdn}." end) - record = %{name: "#{ip_fqdn}.", type: "PTR", ttl: 3600, records: [%{content: "#{value}.", disabled: false}]} - pdns = if(update?, do: PowerDNSex.update_record(zone, record), else: PowerDNSex.create_record(zone, record)) - :ok - end - - @doc false - def dets() do - (Nola.data_path() <> "/subnets.dets") |> String.to_charlist() - end - - @doc false - def setup() do - {:ok, dets} = :dets.open_file(dets(), []) - dets - end - - defp available_select(dets) do - spec = [{{:"$1", :"$2"}, [is_integer: :"$2"], [{{:"$1", :"$2"}}]}] - {subnets, _} = :dets.select(dets, spec, 20) - subnet = subnets - |> Enum.sort_by(fn({_, last}) -> last end) - |> List.first() - end - -end diff --git a/lib/lsg/token.ex b/lib/lsg/token.ex deleted file mode 100644 index 563ac72..0000000 --- a/lib/lsg/token.ex +++ /dev/null @@ -1,38 +0,0 @@ -defmodule Nola.Token do - use GenServer - - def start_link() do - GenServer.start_link(__MODULE__, [], [name: __MODULE__]) - end - - def lookup(id) do - with \ - [{_, cred, date}] <- :ets.lookup(__MODULE__.ETS, id), - IO.inspect("cred: #{inspect cred} valid for #{inspect date} now #{inspect DateTime.utc_now()}"), - d when d > 0 <- DateTime.diff(date, DateTime.utc_now()) - do - {:ok, cred} - else - err -> {:error, err} - end - end - - def new(cred) do - GenServer.call(__MODULE__, {:new, cred}) - end - - def init(_) do - ets = :ets.new(__MODULE__.ETS, [:ordered_set, :named_table, :protected, {:read_concurrency, true}]) - {:ok, ets} - end - - def handle_call({:new, cred}, _, ets) do - id = IRC.UserTrack.Id.large_id() - expire = DateTime.utc_now() - |> DateTime.add(15*60, :second) - obj = {id, cred, expire} - :ets.insert(ets, obj) - {:reply, {:ok, id}, ets} - end - -end diff --git a/lib/lsg_irc/admin_handler.ex b/lib/lsg_irc/admin_handler.ex deleted file mode 100644 index 9a5d557..0000000 --- a/lib/lsg_irc/admin_handler.ex +++ /dev/null @@ -1,41 +0,0 @@ -defmodule Nola.IRC.AdminHandler do - @moduledoc """ - # admin - - !op - op; requiert admin - """ - - def irc_doc, do: nil - - def start_link(client) do - GenServer.start_link(__MODULE__, [client]) - end - - def init([client]) do - ExIRC.Client.add_handler client, self - :ok = IRC.register("op") - {:ok, client} - end - - def handle_info({:irc, :trigger, "op", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang}, sender: sender}}, client) do - if IRC.admin?(sender) do - m.replyfun.({:mode, "+o"}) - else - m.replyfun.({:kick, "non"}) - end - {:noreply, client} - end - - def handle_info({:joined, chan, sender}, client) do - if IRC.admin?(sender) do - ExIRC.Client.mode(client, chan, "+o", sender.nick) - end - {:noreply, client} - end - - def handle_info(msg, client) do - {:noreply, client} - end - -end diff --git a/lib/lsg_irc/alcolog_plugin.ex b/lib/lsg_irc/alcolog_plugin.ex deleted file mode 100644 index 145e4fc..0000000 --- a/lib/lsg_irc/alcolog_plugin.ex +++ /dev/null @@ -1,1229 +0,0 @@ -defmodule Nola.IRC.AlcoologPlugin do - require Logger - - @moduledoc """ - # [alcoolog]({{context_path}}/alcoolog) - - * **!santai `` ` [annotation]`**: enregistre un nouveau verre de `montant` d'une boisson à `degrés d'alcool`. - * **!santai `` ``**: enregistre un nouveau verre de `cl` de la bière `beer name`, et checkin sur Untappd.com. - * **!moar `[cl]` : enregistre un verre équivalent au dernier !santai. - * **-santai**: annule la dernière entrée d'alcoolisme. - * **.alcoolisme**: état du channel en temps réel. - * **.alcoolisme ``**: points par jour, sur X j. - * **!alcoolisme `[pseudo]`**: affiche les points d'alcoolisme. - * **!alcoolisme `[pseudo]` ``**: affiche les points d'alcoolisme par jour sur X j. - * **+alcoolisme `` `` `[facteur de perte en mg/l (10, 15, 20, 25)]`**: Configure votre profil d'alcoolisme. - * **.sobre**: affiche quand la sobriété frappera sur le chan. - * **!sobre `[pseudo]`**: affiche quand la sobriété frappera pour `[pseudo]`. - * **!sobrepour ``**: affiche tu pourras être sobre pour ``, et si oui, combien de volumes d'alcool peuvent encore être consommés. - * **!alcoolog**: ([voir]({{context_path}}/alcoolog)) lien pour voir l'état/statistiques et historique de l'alcoolémie du channel. - * **!alcool `` ``**: donne le nombre d'unités d'alcool dans `` à `°`. - * **!soif**: c'est quand l'apéro ? - - 1 point = 1 volume d'alcool. - - Annotation: champ libre! - - --- - - ## `!txt`s - - * status utilisateur: `alcoolog.user_(sober|legal|legalhigh|high|toohigh|sick)(|_rising)` - * mauvaises boissons: `alcoolog.drink_(negative|zero|negative)` - * santo: `alcoolog.santo` - * santai: `alcoolog.santai` - * plus gros, moins gros: `alcoolog.(fatter|thinner)` - - """ - - def irc_doc, do: @moduledoc - - def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) - - # tuple dets: {nick, date, volumes, current_alcohol_level, nom, commentaire} - # tuple ets: {{nick, date}, volumes, current, nom, commentaire} - # tuple meta dets: {nick, map} - # %{:weight => float, :sex => true(h),false(f)} - @pubsub ~w(account) - @pubsub_triggers ~w(santai moar again bis santo santeau alcoolog sobre sobrepour soif alcoolisme alcool) - @default_user_meta %{weight: 77.4, sex: true, loss_factor: 15} - - def data_state() do - dets_filename = (Nola.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist - dets_meta_filename = (Nola.data_path() <> "/" <> "alcoolisme_meta.dets") |> String.to_charlist - %{dets: dets_filename, meta: dets_meta_filename, ets: __MODULE__.ETS} - end - - def init(_) do - triggers = for(t <- @pubsub_triggers, do: "trigger:"<>t) - for sub <- @pubsub ++ triggers do - {:ok, _} = Registry.register(IRC.PubSub, sub, plugin: __MODULE__) - end - dets_filename = (Nola.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist - {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) - ets = :ets.new(__MODULE__.ETS, [:ordered_set, :named_table, :protected, {:read_concurrency, true}]) - dets_meta_filename = (Nola.data_path() <> "/" <> "alcoolisme_meta.dets") |> String.to_charlist - {:ok, meta} = :dets.open_file(dets_meta_filename, [{:type,:set}]) - traverse_fun = fn(obj, dets) -> - case obj do - object = {nick, naive = %NaiveDateTime{}, volumes, active, cl, deg, name, comment} -> - date = naive - |> DateTime.from_naive!("Etc/UTC") - |> DateTime.to_unix() - new = {nick, date, volumes, active, cl, deg, name, comment, Map.new()} - :dets.delete_object(dets, object) - :dets.insert(dets, new) - :ets.insert(ets, {{nick, date}, volumes, active, cl, deg, name, comment, Map.new()}) - dets - - object = {nick, naive = %NaiveDateTime{}, volumes, active, cl, deg, name, comment, meta} -> - date = naive - |> DateTime.from_naive!("Etc/UTC") - |> DateTime.to_unix() - new = {nick, date, volumes, active, cl, deg, name, comment, Map.new()} - :dets.delete_object(dets, object) - :dets.insert(dets, new) - :ets.insert(ets, {{nick, date}, volumes, active, cl, deg, name, comment, Map.new()}) - dets - - object = {nick, date, volumes, active, cl, deg, name, comment, meta} -> - :ets.insert(ets, {{nick, date}, volumes, active, cl, deg, name, comment, meta}) - dets - - _ -> - dets - end - end - :dets.foldl(traverse_fun, dets, dets) - :dets.sync(dets) - state = %{dets: dets, meta: meta, ets: ets} - {:ok, state} - end - - @eau ["santo", "santeau"] - def handle_info({:irc, :trigger, santeau, m = %IRC.Message{trigger: %IRC.Trigger{args: _, type: :bang}}}, state) when santeau in @eau do - Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.santo") - {:noreply, state} - end - - def handle_info({:irc, :trigger, "soif", m = %IRC.Message{trigger: %IRC.Trigger{args: _, type: :bang}}}, state) do - now = DateTime.utc_now() - |> Timex.Timezone.convert("Europe/Paris") - apero = format_duration_from_now(%DateTime{now | hour: 18, minute: 0, second: 0}, false) - day_of_week = Date.day_of_week(now) - {txt, apero?} = cond do - now.hour >= 0 && now.hour < 6 -> - {["apéro tardif ? Je dis OUI ! SANTAI !"], true} - now.hour >= 6 && now.hour < 12 -> - if day_of_week >= 6 do - {["de l'alcool pour le petit dej ? le week-end, pas de problème !"], true} - else - {["C'est quand même un peu tôt non ? Prochain apéro #{apero}"], false} - end - now.hour >= 12 && (now.hour < 14) -> - {["oui! c'est l'apéro de midi! (et apéro #{apero})", - "tu peux attendre #{apero} ou y aller, il est midi !" - ], true} - now.hour == 17 -> - {[ - "ÇA APPROCHE !!! Apéro #{apero}", - "BIENTÔT !!! Apéro #{apero}", - "achetez vite les teilles, apéro dans #{apero}!", - "préparez les teilles, apéro dans #{apero}!" - ], false} - now.hour >= 14 && now.hour < 18 -> - weekend = if day_of_week >= 6 do - " ... ou maintenant en fait, c'est le week-end!" - else - "" - end - {["tiens bon! apéro #{apero}#{weekend}", - "courage... apéro dans #{apero}#{weekend}", - "pas encore :'( apéro dans #{apero}#{weekend}" - ], false} - true -> - {[ - "C'EST L'HEURE DE L'APÉRO !!! SANTAIIIIIIIIIIII !!!!" - ], true} - end - - txt = txt - |> Enum.shuffle() - |> Enum.random() - - m.replyfun.(txt) - - stats = get_full_statistics(state, m.account.id) - if !apero? && stats.active > 0.1 do - m.replyfun.("(... ou continue en fait, je suis pas ta mère !)") - end - - {:noreply, state} - end - - def handle_info({:irc, :trigger, "sobrepour", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do - args = Enum.join(args, " ") - {:ok, now} = DateTime.now("Europe/Paris", Tzdata.TimeZoneDatabase) - time = case args do - "demain " <> time -> - {h, m} = case String.split(time, [":", "h"]) do - [hour, ""] -> - IO.puts ("h #{inspect hour}") - {h, _} = Integer.parse(hour) - {h, 0} - [hour, min] when min != "" -> - {h, _} = Integer.parse(hour) - {m, _} = Integer.parse(min) - {h, m} - [hour] -> - IO.puts ("h #{inspect hour}") - {h, _} = Integer.parse(hour) - {h, 0} - _ -> {0, 0} - end - secs = ((60*60)*24) - day = DateTime.add(now, secs, :second, Tzdata.TimeZoneDatabase) - %DateTime{day | hour: h, minute: m, second: 0} - "après demain " <> time -> - secs = 2*((60*60)*24) - DateTime.add(now, secs, :second, Tzdata.TimeZoneDatabase) - datetime -> - case Timex.Parse.DateTime.Parser.parse(datetime, "{}") do - {:ok, dt} -> dt - _ -> nil - end - end - - if time do - meta = get_user_meta(state, m.account.id) - stats = get_full_statistics(state, m.account.id) - - duration = round(DateTime.diff(time, now)/60.0) - - IO.puts "diff #{inspect duration} sober in #{inspect stats.sober_in}" - - if duration < stats.sober_in do - int = stats.sober_in - duration - m.replyfun.("désolé, aucune chance! tu seras sobre #{format_minute_duration(int)} après!") - else - remaining = duration - stats.sober_in - if remaining < 30 do - m.replyfun.("moins de 30 minutes de sobriété, c'est impossible de boire plus") - else - loss_per_minute = ((meta.loss_factor/100)/60) - remaining_gl = (remaining-30)*loss_per_minute - m.replyfun.("marge de boisson: #{inspect remaining} minutes, #{remaining_gl} g/l") - end - end - - end - {:noreply, state} - end - - def handle_info({:irc, :trigger, "alcoolog", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :plus}}}, state) do - {:ok, token} = Nola.Token.new({:alcoolog, :index, m.sender.network, m.channel}) - url = NolaWeb.Router.Helpers.alcoolog_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel), token) - m.replyfun.("-> #{url}") - {:noreply, state} - end - - def handle_info({:irc, :trigger, "alcoolog", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :bang}}}, state) do - url = NolaWeb.Router.Helpers.alcoolog_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel)) - m.replyfun.("-> #{url}") - {:noreply, state} - end - - def handle_info({:irc, :trigger, "alcool", m = %IRC.Message{trigger: %IRC.Trigger{args: args = [cl, deg], type: :bang}}}, state) do - {cl, _} = Util.float_paparse(cl) - {deg, _} = Util.float_paparse(deg) - points = Alcool.units(cl, deg) - meta = get_user_meta(state, m.account.id) - k = if meta.sex, do: 0.7, else: 0.6 - weight = meta.weight - gl = (10*points)/(k*weight) - duration = round(gl/((meta.loss_factor/100)/60))+30 - sober_in_s = if duration > 0 do - duration = Timex.Duration.from_minutes(duration) - Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized) - else - "" - end - - m.replyfun.("Il y a #{Float.round(points+0.0, 4)} unités d'alcool dans #{cl}cl à #{deg}° (#{Float.round(gl + 0.0, 4)} g/l, #{sober_in_s})") - {:noreply, state} - end - - def handle_info({:irc, :trigger, "santai", m = %IRC.Message{trigger: %IRC.Trigger{args: [cl, deg | comment], type: :bang}}}, state) do - santai(m, state, cl, deg, comment) - {:noreply, state} - end - - @moar [ - "{{message.sender.nick}}: la même donc ?", - "{{message.sender.nick}}: et voilà la petite sœur !" - ] - - def handle_info({:irc, :trigger, "bis", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do - handle_info({:irc, :trigger, "moar", m}, state) - end - def handle_info({:irc, :trigger, "again", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do - handle_info({:irc, :trigger, "moar", m}, state) - end - - def handle_info({:irc, :trigger, "moar", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do - case get_statistics_for_nick(state, m.account.id) do - {_, obj = {_, _date, _points, _active, cl, deg, _name, comment, _meta}} -> - cl = case args do - [cls] -> - case Util.float_paparse(cls) do - {cl, _} -> cl - _ -> cl - end - _ -> cl - end - moar = @moar |> Enum.shuffle() |> Enum.random() |> Tmpl.render(m) |> m.replyfun.() - santai(m, state, cl, deg, comment, auto_set: true) - {_, obj = {_, date, points, _last_active, type, descr}} -> - case Regex.named_captures(~r/^(?\d+[.]\d+)cl\s+(?\d+[.]\d+)°$/, type) do - nil -> m.replyfun.("suce") - u -> - moar = @moar |> Enum.shuffle() |> Enum.random() |> Tmpl.render(m) |> m.replyfun.() - santai(m, state, u["cl"], u["deg"], descr, auto_set: true) - end - _ -> nil - end - {:noreply, state} - end - - defp santai(m, state, cl, deg, comment, options \\ []) do - comment = cond do - comment == [] -> nil - is_binary(comment) -> comment - comment == nil -> nil - true -> Enum.join(comment, " ") - end - - {cl, cl_extra} = case {Util.float_paparse(cl), cl} do - {{cl, extra}, _} -> {cl, extra} - {:error, "("<>_} -> - try do - {:ok, result} = Abacus.eval(cl) - {result, nil} - rescue - _ -> {nil, "cl: invalid calc expression"} - end - {:error, _} -> {nil, "cl: invalid value"} - end - - {deg, comment, auto_set, beer_id} = case Util.float_paparse(deg) do - {deg, _} -> {deg, comment, Keyword.get(options, :auto_set, false), nil} - :error -> - beername = if(comment, do: "#{deg} #{comment}", else: deg) - case Untappd.search_beer(beername, limit: 1) do - {:ok, %{"response" => %{"beers" => %{"count" => count, "items" => [%{"beer" => beer, "brewery" => brewery} | _]}}}} -> - {Map.get(beer, "beer_abv"), "#{Map.get(brewery, "brewery_name")}: #{Map.get(beer, "beer_name")}", true, Map.get(beer, "bid")} - _ -> - {deg, "could not find beer", false, nil} - end - end - - cond do - cl == nil -> m.replyfun.(cl_extra) - deg == nil -> m.replyfun.(comment) - cl >= 500 || deg >= 100 -> Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.drink_toohuge") - cl == 0 || deg == 0 -> Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.drink_zero") - cl < 0 || deg < 0 -> Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.drink_negative") - true -> - points = Alcool.units(cl, deg) - now = m.at || DateTime.utc_now() - |> DateTime.to_unix(:millisecond) - user_meta = get_user_meta(state, m.account.id) - name = "#{cl}cl #{deg}°" - old_stats = get_full_statistics(state, m.account.id) - meta = %{} - meta = Map.put(meta, "timestamp", now) - meta = Map.put(meta, "weight", user_meta.weight) - meta = Map.put(meta, "sex", user_meta.sex) - :ok = :dets.insert(state.dets, {m.account.id, now, points, if(old_stats, do: old_stats.active, else: 0), cl, deg, name, comment, meta}) - true = :ets.insert(state.ets, {{m.account.id, now}, points, if(old_stats, do: old_stats.active, else: 0),cl, deg, name, comment, meta}) - #sante = @santai |> Enum.map(fn(s) -> String.trim(String.upcase(s)) end) |> Enum.shuffle() |> Enum.random() - sante = Nola.IRC.TxtPlugin.random("alcoolog.santai") - k = if user_meta.sex, do: 0.7, else: 0.6 - weight = user_meta.weight - peak = Float.round((10*points||0.0)/(k*weight), 4) - stats = get_full_statistics(state, m.account.id) - sober_add = if old_stats && Map.get(old_stats || %{}, :sober_in) do - mins = round(stats.sober_in - old_stats.sober_in) - " [+#{mins}m]" - else - "" - end - nonow = DateTime.utc_now() - sober = nonow |> DateTime.add(round(stats.sober_in*60), :second) - |> Timex.Timezone.convert("Europe/Paris") - at = if nonow.day == sober.day do - {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "aujourd'hui {h24}:{m}", "fr") - detail - else - {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "{WDfull} {h24}:{m}", "fr") - detail - end - - up = if stats.active_drinks > 1 do - " " <> Enum.join(for(_ <- 1..stats.active_drinks, do: "▲")) <> "" - else - "" - end - - since_str = if stats.since && stats.since_min > 180 do - "(depuis: #{stats.since_s}) " - else - "" - end - - msg = fn(nick, extra) -> - "#{sante} #{nick} #{extra}#{up} #{format_points(points)} @#{stats.active}g/l [+#{peak} g/l]" - <> " (15m: #{stats.active15m}, 30m: #{stats.active30m}, 1h: #{stats.active1h}) #{since_str}(sobriété #{at} (dans #{stats.sober_in_s})#{sober_add}) !" - <> " (aujourd'hui #{stats.daily_volumes} points - #{stats.daily_gl} g/l)" - end - - meta = if beer_id do - Map.put(meta, "untappd:beer_id", beer_id) - else - meta - end - - if beer_id do - spawn(fn() -> - case Untappd.maybe_checkin(m.account, beer_id) do - {:ok, body} -> - badges = get_in(body, ["badges", "items"]) - if badges != [] do - badges_s = Enum.map(badges, fn(badge) -> Map.get(badge, "badge_name") end) - |> Enum.filter(fn(b) -> b end) - |> Enum.intersperse(", ") - |> Enum.join("") - badge = if(length(badges) > 1, do: "badges", else: "badge") - m.replyfun.("\\O/ Unlocked untappd #{badge}: #{badges_s}") - end - :ok - {:error, {:http_error, error}} when is_integer(error) -> m.replyfun.("Checkin to Untappd failed: #{to_string(error)}") - {:error, {:http_error, error}} -> m.replyfun.("Checkin to Untappd failed: #{inspect error}") - _ -> :error - end - end) - end - - local_extra = if auto_set do - if comment do - " #{comment} (#{cl}cl @ #{deg}°)" - else - "#{cl}cl @ #{deg}°" - end - else - "" - end - m.replyfun.(msg.(m.sender.nick, local_extra)) - notify = IRC.Membership.notify_channels(m.account) -- [{m.network,m.channel}] - for {net, chan} <- notify do - user = IRC.UserTrack.find_by_account(net, m.account) - nick = if(user, do: user.nick, else: m.account.name) - extra = " " <> present_type(name, comment) <> "" - IRC.Connection.broadcast_message(net, chan, msg.(nick, extra)) - end - - miss = cond do - points <= 0.6 -> :small - stats.active30m >= 2.9 && stats.active30m < 3 -> :miss3 - stats.active30m >= 1.9 && stats.active30m < 2 -> :miss2 - stats.active30m >= 0.9 && stats.active30m < 1 -> :miss1 - stats.active30m >= 0.45 && stats.active30m < 0.5 -> :miss05 - stats.active30m >= 0.20 && stats.active30m < 0.20 -> :miss025 - stats.active30m >= 3 && stats.active1h < 3.15 -> :small3 - stats.active30m >= 2 && stats.active1h < 2.15 -> :small2 - stats.active30m >= 1.5 && stats.active1h < 1.5 -> :small15 - stats.active30m >= 1 && stats.active1h < 1.15 -> :small1 - stats.active30m >= 0.5 && stats.active1h <= 0.51 -> :small05 - stats.active30m >= 0.25 && stats.active30m <= 0.255 -> :small025 - true -> nil - end - - if miss do - miss = Nola.IRC.TxtPlugin.random("alcoolog.#{to_string(miss)}") - if miss do - for {net, chan} <- IRC.Membership.notify_channels(m.account) do - user = IRC.UserTrack.find_by_account(net, m.account) - nick = if(user, do: user.nick, else: m.account.name) - IRC.Connection.broadcast_message(net, chan, "#{nick}: #{miss}") - end - end - end - end - end - - def handle_info({:irc, :trigger, "santai", m = %IRC.Message{trigger: %IRC.Trigger{args: _, type: :bang}}}, state) do - m.replyfun.("!santai [commentaire]") - {:noreply, state} - end - - def get_all_stats() do - IRC.Account.all_accounts() - |> Enum.map(fn(account) -> {account.id, get_full_statistics(account.id)} end) - |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) - |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) - end - - def get_channel_statistics(account, network, nil) do - IRC.Membership.expanded_members_or_friends(account, network, nil) - |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(account.id)} end) - |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) - |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) - end - - def get_channel_statistics(_, network, channel), do: get_channel_statistics(network, channel) - - def get_channel_statistics(network, channel) do - IRC.Membership.expanded_members(network, channel) - |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(account.id)} end) - |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) - |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) - end - - @spec since() :: %{IRC.Account.id() => DateTime.t()} - @doc "Returns the last time the user was at 0 g/l" - def since() do - :ets.foldr(fn({{acct, timestamp_or_date}, _vol, current, _cl, _deg, _name, _comment, _m}, acc) -> - if !Map.get(acc, acct) && current == 0 do - date = Util.to_date_time(timestamp_or_date) - Map.put(acc, acct, date) - else - acc - end - end, %{}, __MODULE__.ETS) - end - - def get_full_statistics(nick) do - get_full_statistics(data_state(), nick) - end - - defp get_full_statistics(state, nick) do - case get_statistics_for_nick(state, nick) do - {count, {_, last_at, last_points, last_active, last_cl, last_deg, last_type, last_descr, _meta}} -> - {active, active_drinks} = current_alcohol_level(state, nick) - {_, m30} = alcohol_level_rising(state, nick) - {rising, m15} = alcohol_level_rising(state, nick, 15) - {_, m5} = alcohol_level_rising(state, nick, 5) - {_, h1} = alcohol_level_rising(state, nick, 60) - - trend = if rising do - "▲" - else - "▼" - end - user_state = cond do - active <= 0.0 -> :sober - active <= 0.25 -> :low - active <= 0.50 -> :legal - active <= 1.0 -> :legalhigh - active <= 2.5 -> :high - active < 3 -> :toohigh - true -> :sick - end - - rising_file_key = if rising, do: "_rising", else: "" - txt_file = "alcoolog." <> "user_" <> to_string(user_state) <> rising_file_key - user_status = Nola.IRC.TxtPlugin.random(txt_file) - - meta = get_user_meta(state, nick) - minutes_til_sober = h1/((meta.loss_factor/100)/60) - minutes_til_sober = cond do - active < 0 -> 0 - m15 < 0 -> 15 - m30 < 0 -> 30 - h1 < 0 -> 60 - minutes_til_sober > 0 -> - Float.round(minutes_til_sober+60) - true -> 0 - end - - duration = Timex.Duration.from_minutes(minutes_til_sober) - sober_in_s = if minutes_til_sober > 0 do - Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized) - else - nil - end - - since = if active > 0 do - since() - |> Map.get(nick) - end - - since_diff = if since, do: Timex.diff(DateTime.utc_now(), since, :minutes) - since_duration = if since, do: Timex.Duration.from_minutes(since_diff) - since_s = if since, do: Timex.Format.Duration.Formatter.lformat(since_duration, "fr", :humanized) - - {total_volumes, total_gl} = user_stats(state, nick) - - - %{active: active, last_at: last_at, last_cl: last_cl, last_deg: last_deg, last_points: last_points, last_type: last_type, last_descr: last_descr, - trend_symbol: trend, - active5m: m5, active15m: m15, active30m: m30, active1h: h1, - rising: rising, - active_drinks: active_drinks, - user_status: user_status, - daily_gl: total_gl, daily_volumes: total_volumes, - sober_in: minutes_til_sober, sober_in_s: sober_in_s, - since: since, since_min: since_diff, since_s: since_s, - } - _ -> - nil - end - end - - def handle_info({:irc, :trigger, "sobre", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :dot}}}, state) do - nicks = IRC.Membership.expanded_members_or_friends(m.account, m.network, m.channel) - |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(state, account.id)} end) - |> Enum.filter(fn({_nick, status}) -> status && status.sober_in && status.sober_in > 0 end) - |> Enum.sort_by(fn({_, status}) -> status.sober_in end, & Enum.map(fn({nick, stats}) -> - now = DateTime.utc_now() - sober = now |> DateTime.add(round(stats.sober_in*60), :second) - |> Timex.Timezone.convert("Europe/Paris") - at = if now.day == sober.day do - {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "aujourd'hui {h24}:{m}", "fr") - detail - else - {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "{WDfull} {h24}:{m}", "fr") - detail - end - "#{nick} sobre #{at} (dans #{stats.sober_in_s})" - end) - |> Enum.intersperse(", ") - |> Enum.join("") - |> (fn(line) -> - case line do - "" -> "tout le monde est sobre......." - line -> line - end - end).() - |> m.replyfun.() - {:noreply, state} - end - - def handle_info({:irc, :trigger, "sobre", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do - account = case args do - [nick] -> IRC.Account.find_always_by_nick(m.network, m.channel, nick) - [] -> m.account - end - - if account do - user = IRC.UserTrack.find_by_account(m.network, account) - nick = if(user, do: user.nick, else: account.name) - stats = get_full_statistics(state, account.id) - if stats && stats.sober_in > 0 do - now = DateTime.utc_now() - sober = now |> DateTime.add(round(stats.sober_in*60), :second) - |> Timex.Timezone.convert("Europe/Paris") - at = if now.day == sober.day do - {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "aujourd'hui {h24}:{m}", "fr") - detail - else - {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "{WDfull} {h24}:{m}", "fr") - detail - end - m.replyfun.("#{nick} sera sobre #{at} (dans #{stats.sober_in_s})!") - else - m.replyfun.("#{nick} est déjà sobre. aidez le !") - end - else - m.replyfun.("inconnu") - end - {:noreply, state} - end - - def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :dot}}}, state) do - nicks = IRC.Membership.expanded_members_or_friends(m.account, m.network, m.channel) - |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(state, account.id)} end) - |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) - |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) - |> Enum.map(fn({nick, status}) -> - trend_symbol = if status.active_drinks > 1 do - Enum.join(for(_ <- 1..status.active_drinks, do: status.trend_symbol)) - else - status.trend_symbol - end - since_str = if status.since_min > 180 do - "depuis: #{status.since_s} | " - else - "" - end - "#{nick} #{status.user_status} #{trend_symbol} #{Float.round(status.active, 4)} g/l [#{since_str}sobre dans: #{status.sober_in_s}]" - end) - |> Enum.intersperse(", ") - |> Enum.join("") - - msg = if nicks == "" do - "wtf?!?! personne n'a bu!" - else - nicks - end - - m.replyfun.(msg) - {:noreply, state} - end - - def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [time], type: :dot}}}, state) do - time = case time do - "semaine" -> 7 - string -> - case Integer.parse(string) do - {time, "j"} -> time - {time, "J"} -> time - _ -> nil - end - end - - if time do - aday = time*((24 * 60)*60) - now = DateTime.utc_now() - before = now - |> DateTime.add(-aday, :second) - |> DateTime.to_unix(:millisecond) - over_time_stats(before, time, m, state) - else - m.replyfun.(".alcooolisme semaine|Xj") - end - {:noreply, state} - end - - def user_over_time(account, count) do - user_over_time(data_state(), account, count) - end - - def user_over_time(state, account, count) do - delay = count*((24 * 60)*60) - now = DateTime.utc_now() - before = DateTime.utc_now() - |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) - |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) - |> DateTime.to_unix(:millisecond) - #[ -# {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, -# [{:andalso, {:==, :"$1", :"$1"}, {:<, :"$2", {:const, 3000}}}], [:lol]} - #] - match = [{{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, - [{:andalso, {:>, :"$2", {:const, before}}, {:==, :"$1", {:const, account.id}}}], [:"$_"]} - ] - :ets.select(state.ets, match) - |> Enum.reduce(Map.new, fn({{_, ts}, vol, _, _, _, _, _, _}, acc) -> - date = DateTime.from_unix!(ts, :millisecond) - |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) - - date = if date.hour <= 8 do - DateTime.add(date, -(60*(60*(date.hour+1))), :second, Tzdata.TimeZoneDatabase) - else - date - end - |> DateTime.to_date() - - Map.put(acc, date, Map.get(acc, date, 0) + vol) - end) - end - - def user_over_time_gl(account, count) do - state = data_state() - meta = get_user_meta(state, account.id) - delay = count*((24 * 60)*60) - now = DateTime.utc_now() - before = DateTime.utc_now() - |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) - |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) - |> DateTime.to_unix(:millisecond) - #[ -# {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, -# [{:andalso, {:==, :"$1", :"$1"}, {:<, :"$2", {:const, 3000}}}], [:lol]} - #] - match = [{{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, - [{:andalso, {:>, :"$2", {:const, before}}, {:==, :"$1", {:const, account.id}}}], [:"$_"]} - ] - :ets.select(state.ets, match) - |> Enum.reduce(Map.new, fn({{_, ts}, vol, _, _, _, _, _, _}, acc) -> - date = DateTime.from_unix!(ts, :millisecond) - |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) - - date = if date.hour <= 8 do - DateTime.add(date, -(60*(60*(date.hour+1))), :second, Tzdata.TimeZoneDatabase) - else - date - end - |> DateTime.to_date() - weight = meta.weight - k = if meta.sex, do: 0.7, else: 0.6 - gl = (10*vol)/(k*weight) - - Map.put(acc, date, Map.get(acc, date, 0) + gl) - end) - end - - - - defp over_time_stats(before, j, m, state) do - #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _, _, _, _}) when date > before -> obj end) - match = [{{{:_, :"$1"}, :_, :_, :_, :_, :_, :_, :_}, - [{:>, :"$1", {:const, before}}], [:"$_"]} - ] - # tuple ets: {{nick, date}, volumes, current, nom, commentaire} - members = IRC.Membership.members_or_friends(m.account, m.network, m.channel) - drinks = :ets.select(state.ets, match) - |> Enum.filter(fn({{account, _}, _, _, _, _, _, _, _}) -> Enum.member?(members, account) end) - |> Enum.sort_by(fn({{_, ts}, _, _, _, _, _, _, _}) -> ts end, &>/2) - - top = Enum.reduce(drinks, %{}, fn({{nick, _}, vol, _, _, _, _, _, _}, acc) -> - all = Map.get(acc, nick, 0) - Map.put(acc, nick, all + vol) - end) - |> Enum.sort_by(fn({_nick, count}) -> count end, &>/2) - |> Enum.map(fn({nick, count}) -> - account = IRC.Account.get(nick) - user = IRC.UserTrack.find_by_account(m.network, account) - nick = if(user, do: user.nick, else: account.name) - "#{nick}: #{Float.round(count, 4)}" - end) - |> Enum.intersperse(", ") - - m.replyfun.("sur #{j} jours: #{top}") - {:noreply, state} - end - - def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :plus}}}, state) do - meta = get_user_meta(state, m.account.id) - hf = if meta.sex, do: "h", else: "f" - m.replyfun.("+alcoolisme sexe: #{hf} poids: #{meta.weight} facteur de perte: #{meta.loss_factor}") - {:noreply, state} - end - - def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [h, weight | rest], type: :plus}}}, state) do - h = case h do - "h" -> true - "f" -> false - _ -> nil - end - - weight = case Util.float_paparse(weight) do - {weight, _} -> weight - _ -> nil - end - - {factor} = case rest do - [factor] -> - case Util.float_paparse(factor) do - {float, _} -> {float} - _ -> {@default_user_meta.loss_factor} - end - _ -> {@default_user_meta.loss_factor} - end - - if h == nil || weight == nil do - m.replyfun.("paramètres invalides") - else - old_meta = get_user_meta(state, m.account.id) - meta = Map.merge(@default_user_meta, %{sex: h, weight: weight, loss_factor: factor}) - put_user_meta(state, m.account.id, meta) - cond do - old_meta.weight < meta.weight -> - Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.fatter") - old_meta.weight == meta.weight -> - m.replyfun.("aucun changement!") - true -> - Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.thinner") - end - end - - {:noreply, state} - end - - def handle_info({:irc, :trigger, "santai", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :minus}}}, state) do - case get_statistics_for_nick(state, m.account.id) do - {_, obj = {_, date, points, _last_active, _cl, _deg, type, descr, _meta}} -> - :dets.delete_object(state.dets, obj) - :ets.delete(state.ets, {m.account.id, date}) - m.replyfun.("supprimé: #{m.sender.nick} #{points} #{type} #{descr}") - Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.delete") - notify = IRC.Membership.notify_channels(m.account) -- [{m.network,m.channel}] - for {net, chan} <- notify do - user = IRC.UserTrack.find_by_account(net, m.account) - nick = if(user, do: user.nick, else: m.account.name) - IRC.Connection.broadcast_message(net, chan, "#{nick} -santai #{points} #{type} #{descr}") - end - {:noreply, state} - _ -> - {:noreply, state} - end - end - - - def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do - {account, duration} = case args do - [nick | rest] -> {IRC.Account.find_always_by_nick(m.network, m.channel, nick), rest} - [] -> {m.account, []} - end - if account do - duration = case duration do - ["semaine"] -> 7 - [j] -> - case Integer.parse(j) do - {j, "j"} -> j - _ -> nil - end - _ -> nil - end - user = IRC.UserTrack.find_by_account(m.network, account) - nick = if(user, do: user.nick, else: account.name) - if duration do - if duration > 90 do - m.replyfun.("trop gros, ça rentrera pas") - else - # duration stats - stats = user_over_time(state, account, duration) - |> Enum.sort_by(fn({k,_v}) -> k end, {:asc, Date}) - |> Enum.map(fn({date, count}) -> - "#{date.day}: #{Float.round(count, 2)}" - end) - |> Enum.intersperse(", ") - |> Enum.join("") - - if stats == "" do - m.replyfun.("alcoolisme a zéro sur #{duration}j :/") - else - m.replyfun.("alcoolisme de #{nick}, #{duration} derniers jours: #{stats}") - end - end - else - if stats = get_full_statistics(state, account.id) do - trend_symbol = if stats.active_drinks > 1 do - Enum.join(for(_ <- 1..stats.active_drinks, do: stats.trend_symbol)) - else - stats.trend_symbol - end - # TODO: Lookup nick for account_id - msg = "#{nick} #{stats.user_status} " - <> (if stats.active > 0 || stats.active15m > 0 || stats.active30m > 0 || stats.active1h > 0, do: ": #{trend_symbol} #{Float.round(stats.active, 4)}g/l ", else: "") - <> (if stats.active30m > 0 || stats.active1h > 0, do: "(15m: #{stats.active15m}, 30m: #{stats.active30m}, 1h: #{stats.active1h}) ", else: "") - <> (if stats.sober_in > 0, do: "— Sobre dans #{stats.sober_in_s} ", else: "") - <> (if stats.since && stats.since_min > 180, do: "— Paitai depuis #{stats.since_s} ", else: "") - <> "— Dernier verre: #{present_type(stats.last_type, stats.last_descr)} [#{Float.round(stats.last_points+0.0, 4)}] " - <> "#{format_duration_from_now(stats.last_at)} " - <> (if stats.daily_volumes > 0, do: "— Aujourd'hui: #{stats.daily_volumes} #{stats.daily_gl}g/l", else: "") - - m.replyfun.(msg) - else - m.replyfun.("honteux mais #{nick} n'a pas l'air alcoolique du tout. /kick") - end - end - else - m.replyfun.("je ne connais pas cet utilisateur") - end - {:noreply, state} - end - - - # Account merge - def handle_info({:account_change, old_id, new_id}, state) do - spec = [{{:"$1", :_, :_, :_, :_, :_, :_, :_, :_}, [{:==, :"$1", {:const, old_id}}], [:"$_"]}] - Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> - Logger.debug("alcolog/account_change:: merging #{old_id} -> #{new_id}") - rename_object_owner(table, state.ets, obj, old_id, new_id) - end) - case :dets.lookup(state.meta, {:meta, old_id}) do - [{_, meta}] -> - :dets.delete(state.meta, {:meta, old_id}) - :dets.insert(state.meta, {{:meta, new_id}, meta}) - _ -> - :ok - end - {:noreply, state} - end - - def terminate(_, state) do - for dets <- [state.dets, state.meta] do - :dets.sync(dets) - :dets.close(dets) - end - end - - defp rename_object_owner(table, ets, object = {old_id, date, volume, current, cl, deg, name, comment, meta}, old_id, new_id) do - :dets.delete_object(table, object) - :ets.delete(ets, {old_id, date}) - :dets.insert(table, {new_id, date, volume, current, cl, deg, name, comment, meta}) - :ets.insert(ets, {{new_id, date}, volume, current, cl, deg, name, comment, meta}) - end - - # Account: move from nick to account id - def handle_info({:accounts, accounts}, state) do - #for x={:account, _, _, _, _} <- accounts, do: handle_info(x, state) - #{:noreply, state} - mapping = Enum.reduce(accounts, Map.new, fn({:account, _net, _chan, nick, account_id}, acc) -> - Map.put(acc, String.downcase(nick), account_id) - end) - spec = [{{:"$1", :_, :_, :_, :_, :_, :_, :_, :_}, [], [:"$_"]}] - Logger.debug("accounts:: mappings #{inspect mapping}") - Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj = {nick, _date, _vol, _cur, _cl, _deg, _name, _comment, _meta}) -> - #Logger.debug("accounts:: item #{inspect(obj)}") - if new_id = Map.get(mapping, nick) do - Logger.debug("alcolog/accounts:: merging #{nick} -> #{new_id}") - rename_object_owner(table, state.ets, obj, nick, new_id) - end - end) - {:noreply, state} - end - - def handle_info({:account, _net, _chan, nick, account_id}, state) do - nick = String.downcase(nick) - spec = [{{:"$1", :_, :_, :_, :_, :_, :_, :_, :_}, [{:==, :"$1", {:const, nick}}], [:"$_"]}] - Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> - Logger.debug("alcoolog/account:: merging #{nick} -> #{account_id}") - rename_object_owner(table, state.ets, obj, nick, account_id) - end) - case :dets.lookup(state.meta, {:meta, nick}) do - [{_, meta}] -> - :dets.delete(state.meta, {:meta, nick}) - :dets.insert(state.meta, {{:meta, account_id}, meta}) - _ -> - :ok - end - {:noreply, state} - end - - def handle_info(t, state) do - Logger.debug("AlcoologPlugin: unhandled info #{inspect t}") - {:noreply, state} - end - - def nick_history(account) do - spec = [ - {{{:"$1", :_}, :_, :_, :_, :_, :_, :_, :_}, - [{:==, :"$1", {:const, account.id}}], - [:"$_"]} - ] - :ets.select(data_state().ets, spec) - end - - defp get_statistics_for_nick(state, account_id) do - qvc = :dets.lookup(state.dets, account_id) - |> Enum.sort_by(fn({_, ts, _, _, _, _, _, _, _}) -> ts end, & acc + (points||0) end) - last = List.last(qvc) || nil - {count, last} - end - - def present_type(type, descr) when descr in [nil, ""], do: "#{type}" - def present_type(type, description), do: "#{type} (#{description})" - - def format_points(int) when is_integer(int) and int > 0 do - "+#{Integer.to_string(int)}" - end - def format_points(int) when is_integer(int) and int < 0 do - Integer.to_string(int) - end - def format_points(int) when is_float(int) and int > 0 do - "+#{Float.to_string(Float.round(int,4))}" - end - def format_points(int) when is_float(int) and int < 0 do - Float.to_string(Float.round(int,4)) - end - def format_points(0), do: "0" - def format_points(0.0), do: "0" - - defp format_relative_timestamp(timestamp) do - alias Timex.Format.DateTime.Formatters - alias Timex.Timezone - date = timestamp - |> DateTime.from_unix!(:millisecond) - |> Timezone.convert("Europe/Paris") - - {:ok, relative} = Formatters.Relative.relative_to(date, Timex.now("Europe/Paris"), "{relative}", "fr") - {:ok, detail} = Formatters.Default.lformat(date, " ({h24}:{m})", "fr") - - relative <> detail - end - - defp put_user_meta(state, account_id, meta) do - :dets.insert(state.meta, {{:meta, account_id}, meta}) - :ok - end - - defp get_user_meta(%{meta: meta}, account_id) do - case :dets.lookup(meta, {:meta, account_id}) do - [{{:meta, _}, meta}] -> - Map.merge(@default_user_meta, meta) - _ -> - @default_user_meta - end - end - # Calcul g/l actuel: - # 1. load user meta - # 2. foldr ets - # for each object - # get_current_alcohol - # ((object g/l) - 0,15/l/60)* minutes_since_drink - # if minutes_since_drink < 10, reduce g/l (?!) - # acc + current_alcohol - # stop folding when ? - # - - def user_stats(account) do - user_stats(data_state(), account.id) - end - - defp user_stats(state = %{ets: ets}, account_id) do - meta = get_user_meta(state, account_id) - aday = (10 * 60)*60 - now = DateTime.utc_now() - before = now - |> DateTime.add(-aday, :second) - |> DateTime.to_unix(:millisecond) - #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end) - match = [ - {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, - [ - {:>, :"$2", {:const, before}}, - {:"=:=", {:const, account_id}, :"$1"} - ], [:"$_"]} - ] - # tuple ets: {{nick, date}, volumes, current, nom, commentaire} - drinks = :ets.select(ets, match) - # {date, single_peak} - total_volume = Enum.reduce(drinks, 0.0, fn({{_, date}, volume, _, _, _, _, _, _}, acc) -> - acc + volume - end) - k = if meta.sex, do: 0.7, else: 0.6 - weight = meta.weight - gl = (10*total_volume)/(k*weight) - {Float.round(total_volume + 0.0, 4), Float.round(gl + 0.0, 4)} - end - - defp alcohol_level_rising(state, account_id, minutes \\ 30) do - {now, _} = current_alcohol_level(state, account_id) - soon_date = DateTime.utc_now - |> DateTime.add(minutes*60, :second) - {soon, _} = current_alcohol_level(state, account_id, soon_date) - soon = cond do - soon < 0 -> 0.0 - true -> soon - end - #IO.puts "soon #{soon_date} - #{inspect soon} #{inspect now}" - {soon > now, Float.round(soon+0.0, 4)} - end - - defp current_alcohol_level(state = %{ets: ets}, account_id, now \\ nil) do - meta = get_user_meta(state, account_id) - aday = ((24*7) * 60)*60 - now = if now do - now - else - DateTime.utc_now() - end - before = now - |> DateTime.add(-aday, :second) - |> DateTime.to_unix(:millisecond) - #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end) - match = [ - {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, - [ - {:>, :"$2", {:const, before}}, - {:"=:=", {:const, account_id}, :"$1"} - ], [:"$_"]} - ] - # tuple ets: {{nick, date}, volumes, current, nom, commentaire} - drinks = :ets.select(ets, match) - |> Enum.sort_by(fn({{_, date}, _, _, _, _, _, _, _}) -> date end, & - k = if meta.sex, do: 0.7, else: 0.6 - weight = meta.weight - peak = (10*volume)/(k*weight) - date = case date do - ts when is_integer(ts) -> DateTime.from_unix!(ts, :millisecond) - date = %NaiveDateTime{} -> DateTime.from_naive!(date, "Etc/UTC") - date = %DateTime{} -> date - end - last_at = last_at || date - mins_since = round(DateTime.diff(now, date)/60.0) - #IO.puts "Drink: #{inspect({date, volume})} - mins since: #{inspect mins_since} - last drink at #{inspect last_at}" - # Apply loss since `last_at` on `all` - # - all = if last_at do - mins_since_last = round(DateTime.diff(date, last_at)/60.0) - loss = ((meta.loss_factor/100)/60)*(mins_since_last) - #IO.puts "Applying last drink loss: from #{all}, loss of #{inspect loss} (mins since #{inspect mins_since_first})" - cond do - (all-loss) > 0 -> all - loss - true -> 0.0 - end - else - all - end - #IO.puts "Applying last drink current before drink: #{inspect all}" - if mins_since < 30 do - per_min = (peak)/30.0 - current = (per_min*mins_since) - #IO.puts "Applying current drink 30m: from #{peak}, loss of #{inspect per_min}/min (mins since #{inspect mins_since})" - {all + current, date, [{date, current} | acc], active_drinks + 1} - else - {all + peak, date, [{date, peak} | acc], active_drinks} - end - end) - #IO.puts "last drink #{inspect last_drink_at}" - mins_since_last = if last_drink_at do - round(DateTime.diff(now, last_drink_at)/60.0) - else - 0 - end - # Si on a déjà bu y'a déjà moins 15 minutes (big up le binge drinking), on applique plus de perte - level = if mins_since_last > 15 do - loss = ((meta.loss_factor/100)/60)*(mins_since_last) - Float.round(all - loss, 4) - else - all - end - #IO.puts "\n LEVEL #{inspect level}\n\n\n\n" - cond do - level < 0 -> {0.0, 0} - true -> {level, active_drinks} - end - end - - defp format_duration_from_now(date, with_detail \\ true) do - date = if is_integer(date) do - date = DateTime.from_unix!(date, :millisecond) - |> Timex.Timezone.convert("Europe/Paris") - else - Util.to_naive_date_time(date) - end - now = DateTime.utc_now() - |> Timex.Timezone.convert("Europe/Paris") - {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(date, "({h24}:{m})", "fr") - - mins_since = round(DateTime.diff(now, date)/60.0) - if ago = format_minute_duration(mins_since) do - word = if mins_since > 0 do - "il y a " - else - "dans " - end - word <> ago <> if(with_detail, do: " #{detail}", else: "") - else - "maintenant #{detail}" - end - end - - defp format_minute_duration(minutes) do - sober_in_s = if (minutes != 0) do - duration = Timex.Duration.from_minutes(minutes) - Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized) - else - nil - end - end - -end diff --git a/lib/lsg_irc/alcoolog_announcer_plugin.ex b/lib/lsg_irc/alcoolog_announcer_plugin.ex deleted file mode 100644 index f90dc42..0000000 --- a/lib/lsg_irc/alcoolog_announcer_plugin.ex +++ /dev/null @@ -1,272 +0,0 @@ -defmodule Nola.IRC.AlcoologAnnouncerPlugin do - require Logger - - @moduledoc """ - Annonce changements d'alcoolog - """ - - @channel "#dmz" - - @seconds 30 - - @apero [ - "C'EST L'HEURE DE L'APÉRRROOOOOOOO !!", - "SAAAAANNNNNNNTTTTTTTTAAAAAAAAIIIIIIIIIIIIIIIIIIIIII", - "APÉRO ? APÉRO !", - {:timed, ["/!\\ à vos tires bouchons…", "… débouchez …", "… BUVEZ! SANTAIII!"]}, - "/!\\ ALERTE APÉRO /!\\", - "CED !!! VASE DE ROUGE !", - "DIDI UN PETIT RICARD™??!", - "ALLEZ GUIGUI UNE PETITE BIERE ?", - {:timed, ["/!\\ à vos tires bouchons…", "… débouchez …", "… BUVEZ! SANTAIII!"]}, - "APPPPAIIIRRREAAUUUUUUUUUUU" - ] - - def irc_doc, do: nil - - def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) - - def log(account) do - dets_filename = (Nola.data_path() <> "/" <> "alcoologlog.dets") |> String.to_charlist - {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) - from = ~U[2020-08-23 19:41:40.524154Z] - to = ~U[2020-08-24 19:41:40.524154Z] - select = [ - {{:"$1", :"$2", :_}, - [ - {:andalso, - {:andalso, {:==, :"$1", {:const, account.id}}, - {:>, :"$2", {:const, DateTime.to_unix(from)}}}, - {:<, :"$2", {:const, DateTime.to_unix(to)}}} - ], [:"$_"]} - ] - res = :dets.select(dets, select) - :dets.close(dets) - res - end - - def init(_) do - {:ok, _} = Registry.register(IRC.PubSub, "account", []) - stats = get_stats() - Process.send_after(self(), :stats, :timer.seconds(30)) - dets_filename = (Nola.data_path() <> "/" <> "alcoologlog.dets") |> String.to_charlist - {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) - ets = nil # :ets.new(__MODULE__.ETS, [:ordered_set, :named_table, :protected, {:read_concurrency, true}]) - #:ok = Nola.IRC.SettingPlugin.declare("alcoolog.alerts", __MODULE__, true, :boolean) - #:ok = Nola.IRC.SettingPlugin.declare("alcoolog.aperoalert", __MODULE__, true, :boolean) - # - {:ok, {stats, now(), dets, ets}}#, {:continue, :traverse}} - end - - def handle_continue(:traverse, state = {_, _, dets, ets}) do - traverse_fun = fn(obj, dets) -> - case obj do - {nick, %DateTime{} = dt, active} -> - :dets.delete_object(dets, obj) - :dets.insert(dets, {nick, DateTime.to_unix(dt), active}) - IO.puts("ok #{inspect obj}") - dets - {nick, ts, value} -> - :ets.insert(ets, { {nick, ts}, value }) - dets - end - end - :dets.foldl(traverse_fun, dets, dets) - :dets.sync(dets) - IO.puts("alcoolog announcer fixed") - {:noreply, state} - end - - def alcohol_reached(old, new, level) do - (old.active < level && new.active >= level) && (new.active5m >= level) - end - - def alcohol_below(old, new, level) do - (old.active > level && new.active <= level) && (new.active5m <= level) - end - - - def handle_info(:stats, {old_stats, old_now, dets, ets}) do - stats = get_stats() - now = now() - - if old_now.hour < 18 && now.hour == 18 do - apero = Enum.shuffle(@apero) - |> Enum.random() - - case apero do - {:timed, list} -> - spawn(fn() -> - for line <- list do - IRC.Connection.broadcast_message("evolu.net", "#dmz", line) - :timer.sleep(:timer.seconds(5)) - end - end) - string -> - IRC.Connection.broadcast_message("evolu.net", "#dmz", string) - end - - end - - #IO.puts "newstats #{inspect stats}" - events = for {acct, old} <- old_stats do - new = Map.get(stats, acct, nil) - #IO.puts "#{acct}: #{inspect(old)} -> #{inspect(new)}" - - now = DateTime.to_unix(DateTime.utc_now()) - if new && new[:active] do - :dets.insert(dets, {acct, now, new[:active]}) - :ets.insert(ets, {{acct, now}, new[:active]}) - else - :dets.insert(dets, {acct, now, 0.0}) - :ets.insert(ets, {{acct, now}, new[:active]}) - end - - event = cond do - old == nil -> nil - (old.active > 0) && (new == nil) -> :sober - new == nil -> nil - alcohol_reached(old, new, 0.5) -> :stopconduire - alcohol_reached(old, new, 1.0) -> :g1 - alcohol_reached(old, new, 2.0) -> :g2 - alcohol_reached(old, new, 3.0) -> :g3 - alcohol_reached(old, new, 4.0) -> :g4 - alcohol_reached(old, new, 5.0) -> :g5 - alcohol_reached(old, new, 6.0) -> :g6 - alcohol_reached(old, new, 7.0) -> :g7 - alcohol_reached(old, new, 10.0) -> :g10 - alcohol_reached(old, new, 13.74) -> :record - alcohol_below(old, new, 0.5) -> :conduire - alcohol_below(old, new, 1.0) -> :fini1g - alcohol_below(old, new, 2.0) -> :fini2g - alcohol_below(old, new, 3.0) -> :fini3g - alcohol_below(old, new, 4.0) -> :fini4g - (old.rising) && (!new.rising) -> :lowering - true -> nil - end - {acct, event} - end - - for {acct, event} <- events do - message = case event do - :g1 -> [ - "[vigicuite jaune] LE GRAMME! LE GRAMME O/", - "début de vigicuite jaune ! LE GRAMME ! \\O/", - "waiiiiiiii le grammmeee", - "bourraiiiiiiiiiiide 1 grammeeeeeeeeeee", - ] - :g2 -> [ - "[vigicuite orange] \\o_YAY 2 GRAMMES ! _o/", - "PAITAIIIIIIIIII DEUX GRAMMEESSSSSSSSSSSSSSSSS", - "bourrrrrraiiiiiiiiiiiiiiiide 2 grammeeeeeeeeeees", - ] - :g3 -> [ - "et un ! et deux ! et TROIS GRAMMEEESSSSSSS", - "[vigicuite rouge] _o/ BOURRAIIDDDEEEE 3 GRAMMESSSSSSSSS \\o/ \\o/" - ] - :g4 -> [ - "[vigicuite écarlate] et un, et deux, et trois, ET QUATRES GRAMMEESSSSSSSSSSSSSSSSSSSssssss" - ] - :g5 -> "[vigicuite écarlate+] PUTAIN 5 GRAMMES !" - :g6 -> "[vigicuite écarlate++] 6 grammes ? Vous pouvez joindre Alcool info service au 0 980 980 930" - :g7 -> "[vigicuite c'est la merde] 7 grammes. Le SAMU, c'est le 15." - :g10 -> "BORDLE 10 GRAMMES" - :record -> "RECORD DU MONDE BATTU ! >13.74g/l !!" - :fini1g -> [ - "fin d'alerte vigicuite jaune, passage en vert (<1g/l)", - "/!\\ alerte moins de 1g/l /!\\" - ] - :fini2g -> [ - "t'as moins de 2 g/l, faut se reprendre là [vigicuite jaune]" - ] - :fini3g -> [ - "fin d'alerte vigicuite rouge, passage en orange (<3g/l)" - ] - :fini4g -> [ - "fin d'alerte vigicuite écarlate, passage en rouge (<4g/l)" - ] - :lowering -> [ - "attention ça baisse!", - "tu vas quand même pas en rester là ?", - "IL FAUT CONTINUER À BOIRE !", - "t'abandonnes déjà ?", - "!santai ?", - "faut pas en rester là", - "il faut se resservir", - "coucou faut reboire", - "encore un petit verre ?", - "abwaaaaaaaaaaaaarrrrrrrrrrrrrr", - "taux d'alcoolémie en chute ! agissez avant qu'il soit trop tard!", - "ÇA BAISSE !!" - ] - :stopconduire -> [ - "0.5g! bientot le gramme?", - "tu peux plus prendre la route... mais... tu peux prendre la route du gramme! !santai !", - "fini la conduite!", - "0.5! continues faut pas en rester là!", - "beau début, continues !", - "ça monte! 0.5g/l!" - ] - :conduire -> [ - "tu peux conduire, ou recommencer à boire! niveau critique!", - "!santai ?", - "tu peux reprendre la route, ou reprendre la route du gramme..", - "attention, niveau critique!", - "il faut boire !!", - "trop de sang dans ton alcool, c'est mauvais pour la santé", - "faut pas en rester là !", - ] - :sober -> [ - "sobre…", - "/!\\ alerte sobriété /!\\", - "... sobre?!?!", - "sobre :(", - "attention, t'es sobre :/", - "danger, alcoolémie à 0.0 !", - "sobre! c'était bien on recommence quand ?", - "sobre ? Faut recommencer...", - "T'es sobre. Ne te laisses pas abattre- ton caviste peut aider.", - "Vous êtes sobre ? Ceci n'est pas une fatalité - resservez vous vite !" - ] - _ -> nil - end - message = case message do - m when is_binary(m) -> m - m when is_list(m) -> m |> Enum.shuffle() |> Enum.random() - nil -> nil - end - if message do - #IO.puts("#{acct}: #{message}") - account = IRC.Account.get(acct) - for {net, chan} <- IRC.Membership.notify_channels(account) do - user = IRC.UserTrack.find_by_account(net, account) - nick = if(user, do: user.nick, else: account.name) - IRC.Connection.broadcast_message(net, chan, "#{nick}: #{message}") - end - end - end - - timer() - - #IO.puts "tick stats ok" - {:noreply, {stats,now,dets,ets}} - end - - def handle_info(_, state) do - {:noreply, state} - end - - defp now() do - DateTime.utc_now() - |> Timex.Timezone.convert("Europe/Paris") - end - - defp get_stats() do - Enum.into(Nola.IRC.AlcoologPlugin.get_all_stats(), %{}) - end - - defp timer() do - Process.send_after(self(), :stats, :timer.seconds(@seconds)) - end - -end diff --git a/lib/lsg_irc/base_plugin.ex b/lib/lsg_irc/base_plugin.ex deleted file mode 100644 index a2b9ffb..0000000 --- a/lib/lsg_irc/base_plugin.ex +++ /dev/null @@ -1,131 +0,0 @@ -defmodule Nola.IRC.BasePlugin do - - def irc_doc, do: nil - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def init([]) do - regopts = [plugin: __MODULE__] - {:ok, _} = Registry.register(IRC.PubSub, "trigger:version", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "trigger:help", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "trigger:liquidrender", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "trigger:plugin", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "trigger:plugins", regopts) - {:ok, nil} - end - - def handle_info({:irc, :trigger, "plugins", msg = %{trigger: %{type: :bang, args: []}}}, _) do - enabled_string = IRC.Plugin.enabled() - |> Enum.map(fn(mod) -> - mod - |> Macro.underscore() - |> String.split("/", parts: :infinity) - |> List.last() - |> String.replace("_plugin", "") - |> Enum.sort() - end) - |> Enum.join(", ") - msg.replyfun.("Enabled plugins: #{enabled_string}") - {:noreply, nil} - end - - def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :query, args: [plugin]}} = m}, _) do - module = Module.concat([Nola.IRC, Macro.camelize(plugin<>"_plugin")]) - with true <- Code.ensure_loaded?(module), - pid when is_pid(pid) <- GenServer.whereis(module) - do - m.replyfun.("loaded, active: #{inspect(pid)}") - else - false -> m.replyfun.("not loaded") - nil -> - msg = case IRC.Plugin.get(module) do - :disabled -> "disabled" - {_, false, _} -> "disabled" - _ -> "not active" - end - m.replyfun.(msg) - end - {:noreply, nil} - end - - def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :plus, args: [plugin]}} = m}, _) do - module = Module.concat([Nola.IRC, Macro.camelize(plugin<>"_plugin")]) - with true <- Code.ensure_loaded?(module), - IRC.Plugin.switch(module, true), - {:ok, pid} <- IRC.Plugin.start(module) - do - m.replyfun.("started: #{inspect(pid)}") - else - false -> m.replyfun.("not loaded") - :ignore -> m.replyfun.("disabled or throttled") - {:error, _} -> m.replyfun.("start error") - end - {:noreply, nil} - end - - def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :tilde, args: [plugin]}} = m}, _) do - module = Module.concat([Nola.IRC, Macro.camelize(plugin<>"_plugin")]) - with true <- Code.ensure_loaded?(module), - pid when is_pid(pid) <- GenServer.whereis(module), - :ok <- GenServer.stop(pid), - {:ok, pid} <- IRC.Plugin.start(module) - do - m.replyfun.("restarted: #{inspect(pid)}") - else - false -> m.replyfun.("not loaded") - nil -> m.replyfun.("not active") - end - {:noreply, nil} - end - - - def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :minus, args: [plugin]}} = m}, _) do - module = Module.concat([Nola.IRC, Macro.camelize(plugin<>"_plugin")]) - with true <- Code.ensure_loaded?(module), - pid when is_pid(pid) <- GenServer.whereis(module), - :ok <- GenServer.stop(pid) - do - IRC.Plugin.switch(module, false) - m.replyfun.("stopped: #{inspect(pid)}") - else - false -> m.replyfun.("not loaded") - nil -> m.replyfun.("not active") - end - {:noreply, nil} - end - - def handle_info({:irc, :trigger, "liquidrender", m = %{trigger: %{args: args}}}, _) do - template = Enum.join(args, " ") - m.replyfun.(Tmpl.render(template, m)) - {:noreply, nil} - end - - def handle_info({:irc, :trigger, "help", m = %{trigger: %{type: :bang}}}, _) do - url = NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel)) - m.replyfun.("-> #{url}") - {:noreply, nil} - end - - def handle_info({:irc, :trigger, "version", message = %{trigger: %{type: :bang}}}, _) do - {:ok, vsn} = :application.get_key(:nola, :vsn) - ver = List.to_string(vsn) - url = NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :index) - elixir_ver = Application.started_applications() |> List.keyfind(:elixir, 0) |> elem(2) |> to_string() - otp_ver = :erlang.system_info(:system_version) |> to_string() |> String.trim() - system = :erlang.system_info(:system_architecture) |> to_string() - message.replyfun.([ - <<"🤖 I am a robot running", 2, "beautte, version #{ver}", 2, " — source: #{Nola.source_url()}">>, - "🦾 Elixir #{elixir_ver} #{otp_ver} on #{system}", - "👷‍♀️ Owner: href ", - "🌍 Web interface: #{url}" - ]) - {:noreply, nil} - end - - def handle_info(msg, _) do - {:noreply, nil} - end - -end diff --git a/lib/lsg_irc/bourosama_plugin.ex b/lib/lsg_irc/bourosama_plugin.ex deleted file mode 100644 index dd05144..0000000 --- a/lib/lsg_irc/bourosama_plugin.ex +++ /dev/null @@ -1,58 +0,0 @@ -defmodule Nola.IRC.BoursoramaPlugin do - - def irc_doc() do - """ - # bourses - - Un peu comme [finance](#finance), mais en un peu mieux, et un peu moins bien. - - Source: [boursorama.com](https://boursorama.com) - - * **!caca40** affiche l'état du cac40 - """ - end - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - @cac40_url "https://www.boursorama.com/bourse/actions/palmares/france/?france_filter%5Bmarket%5D=1rPCAC&france_filter%5Bsector%5D=&france_filter%5Bvariation%5D=50002&france_filter%5Bperiod%5D=1&france_filter%5Bfilter%5D=" - - def init(_) do - regopts = [plugin: __MODULE__] - {:ok, _} = Registry.register(IRC.PubSub, "trigger:cac40", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "trigger:caca40", regopts) - {:ok, nil} - end - - def handle_info({:irc, :trigger, cac, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang}}}, state) when cac in ["cac40", "caca40"] do - case HTTPoison.get(@cac40_url, [], []) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - html = Floki.parse(body) - board = Floki.find(body, "div.c-tradingboard") - - cac40 = Floki.find(board, ".c-tradingboard__main > .c-tradingboard__infos") - instrument = Floki.find(cac40, ".c-instrument") - last = Floki.find(instrument, "span[data-ist-last]") - |> Floki.text() - |> String.replace(" ", "") - variation = Floki.find(instrument, "span[data-ist-variation]") - |> Floki.text() - - sign = case variation do - "-"<>_ -> "▼" - "+" -> "▲" - _ -> "" - end - - m.replyfun.("caca40: #{sign} #{variation} #{last}") - - {:error, %HTTPoison.Response{status_code: code}} -> - m.replyfun.("caca40: erreur http #{code}") - - _ -> - m.replyfun.("caca40: erreur http") - end - end - -end diff --git a/lib/lsg_irc/buffer_plugin.ex b/lib/lsg_irc/buffer_plugin.ex deleted file mode 100644 index eece34e..0000000 --- a/lib/lsg_irc/buffer_plugin.ex +++ /dev/null @@ -1,44 +0,0 @@ -defmodule Nola.IRC.BufferPlugin do - @table __MODULE__.ETS - def irc_doc, do: nil - - def table(), do: @table - - def select_buffer(network, channel, limit \\ 50) do - import Ex2ms - spec = fun do {{n, c, _}, m} when n == ^network and (c == ^channel or is_nil(c)) -> m end - :ets.select(@table, spec, limit) - end - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def init(_) do - for e <- ~w(messages triggers events outputs) do - {:ok, _} = Registry.register(IRC.PubSub, e, plugin: __MODULE__) - end - {:ok, :ets.new(@table, [:named_table, :ordered_set, :protected])} - end - - def handle_info({:irc, :trigger, _, message}, ets), do: handle_message(message, ets) - def handle_info({:irc, :text, message}, ets), do: handle_message(message, ets) - def handle_info({:irc, :event, event}, ets), do: handle_message(event, ets) - - defp handle_message(message = %{network: network}, ets) do - key = {network, Map.get(message, :channel), ts(message.at)} - :ets.insert(ets, {key, message}) - {:noreply, ets} - end - - defp ts(nil), do: ts(NaiveDateTime.utc_now()) - - defp ts(naive = %NaiveDateTime{}) do - ts = naive - |> DateTime.from_naive!("Etc/UTC") - |> DateTime.to_unix() - - -ts - end - -end diff --git a/lib/lsg_irc/calc_plugin.ex b/lib/lsg_irc/calc_plugin.ex deleted file mode 100644 index 264370c..0000000 --- a/lib/lsg_irc/calc_plugin.ex +++ /dev/null @@ -1,37 +0,0 @@ -defmodule Nola.IRC.CalcPlugin do - @moduledoc """ - # calc - - * **!calc ``**: évalue l'expression mathématique ``. - """ - - def irc_doc, do: @moduledoc - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def init(_) do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:calc", [plugin: __MODULE__]) - {:ok, nil} - end - - def handle_info({:irc, :trigger, "calc", message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: expr_list}}}, state) do - expr = Enum.join(expr_list, " ") - result = try do - case Abacus.eval(expr) do - {:ok, result} -> result - error -> inspect(error) - end - rescue - error -> if(error[:message], do: "#{error.message}", else: "erreur") - end - message.replyfun.("#{message.sender.nick}: #{expr} = #{result}") - {:noreply, state} - end - - def handle_info(msg, state) do - {:noreply, state} - end - -end diff --git a/lib/lsg_irc/coronavirus_plugin.ex b/lib/lsg_irc/coronavirus_plugin.ex deleted file mode 100644 index d04d8f9..0000000 --- a/lib/lsg_irc/coronavirus_plugin.ex +++ /dev/null @@ -1,172 +0,0 @@ -defmodule Nola.IRC.CoronavirusPlugin do - require Logger - NimbleCSV.define(CovidCsv, separator: ",", escape: "\"") - @moduledoc """ - # Corona Virus - - Données de [Johns Hopkins University](https://github.com/CSSEGISandData/COVID-19) et mises à jour a peu près tous les jours. - - * `!coronavirus [France | Country]`: :-) - * `!coronavirus`: top 10 confirmés et non guéris - * `!coronavirus confirmés`: top 10 confirmés - * `!coronavirus morts`: top 10 morts - * `!coronavirus soignés`: top 10 soignés - """ - def irc_doc, do: @moduledoc - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def init(_) do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:coronavirus", [plugin: __MODULE__]) - {:ok, nil, {:continue, :init}} - :ignore - end - - def handle_continue(:init, _) do - date = Date.add(Date.utc_today(), -2) - {data, _} = fetch_data(%{}, date) - {data, next} = fetch_data(data) - :timer.send_after(next, :update) - {:noreply, %{data: data}} - end - - def handle_info(:update, state) do - {data, next} = fetch_data(state.data) - :timer.send_after(next, :update) - {:noreply, %{data: data}} - end - - def handle_info({:irc, :trigger, "coronavirus", m = %IRC.Message{trigger: %{type: :bang, args: args}}}, state) when args in [ - [], ["morts"], ["confirmés"], ["soignés"], ["malades"], ["n"], ["nmorts"], ["nsoignés"], ["nconfirmés"]] do - {field, name} = case args do - ["confirmés"] -> {:confirmed, "confirmés"} - ["morts"] -> {:deaths, "morts"} - ["soignés"] -> {:recovered, "soignés"} - ["nmorts"] -> {:new_deaths, "nouveaux morts"} - ["nconfirmés"] -> {:new_confirmed, "nouveaux confirmés"} - ["n"] -> {:new_current, "nouveaux malades"} - ["nsoignés"] -> {:new_recovered, "nouveaux soignés"} - _ -> {:current, "malades"} - end - IO.puts("FIELD #{inspect field}") - field_evol = String.to_atom("new_#{field}") - sorted = state.data - |> Enum.filter(fn({_, %{region: region}}) -> region == true end) - |> Enum.map(fn({location, data}) -> {location, Map.get(data, field, 0), Map.get(data, field_evol, 0)} end) - |> Enum.sort_by(fn({_,count,_}) -> count end, &>=/2) - |> Enum.take(10) - |> Enum.with_index() - |> Enum.map(fn({{location, count, evol}, index}) -> - ev = if String.starts_with?(name, "nouveaux") do - "" - else - " (#{Util.plusminus(evol)})" - end - "##{index+1}: #{location} #{count}#{ev}" - end) - |> Enum.intersperse(" - ") - |> Enum.join() - m.replyfun.("CORONAVIRUS TOP10 #{name}: " <> sorted) - {:noreply, state} - end - - def handle_info({:irc, :trigger, "coronavirus", m = %IRC.Message{trigger: %{type: :bang, args: location}}}, state) do - location = Enum.join(location, " ") |> String.downcase() - if data = Map.get(state.data, location) do - m.replyfun.("coronavirus: #{location}: " - <> "#{data.current} malades (#{Util.plusminus(data.new_current)}), " - <> "#{data.confirmed} confirmés (#{Util.plusminus(data.new_confirmed)}), " - <> "#{data.deaths} morts (#{Util.plusminus(data.new_deaths)}), " - <> "#{data.recovered} soignés (#{Util.plusminus(data.new_recovered)}) (@ #{data.update})") - end - {:noreply, state} - end - - def handle_info({:irc, :trigger, "coronavirus", m = %IRC.Message{trigger: %{type: :query, args: location}}}, state) do - m.replyfun.("https://github.com/CSSEGISandData/COVID-19") - {:noreply, state} - end - - # 1. Try to fetch data for today - # 2. Fetch yesterday if no results - defp fetch_data(current_data, date \\ nil) do - now = Date.utc_today() - url = fn(date) -> - "https://github.com/CSSEGISandData/COVID-19/raw/master/csse_covid_19_data/csse_covid_19_daily_reports/#{date}.csv" - end - request_date = date || now - Logger.debug("Coronavirus check date: #{inspect request_date}") - {:ok, date_s} = Timex.format({request_date.year, request_date.month, request_date.day}, "%m-%d-%Y", :strftime) - cur_url = url.(date_s) - Logger.debug "Fetching URL #{cur_url}" - case HTTPoison.get(cur_url, [], follow_redirect: true) do - {:ok, %HTTPoison.Response{status_code: 200, body: csv}} -> - # Parse CSV update data - data = csv - |> CovidCsv.parse_string() - |> Enum.reduce(%{}, fn(line, acc) -> - case line do - # FIPS,Admin2,Province_State,Country_Region,Last_Update,Lat,Long_,Confirmed,Deaths,Recovered,Active,Combined_Key - #0FIPS,Admin2,Province_State,Country_Region,Last_Update,Lat,Long_,Confirmed,Deaths,Recovered,Active,Combined_Key,Incidence_Rate,Case-Fatality_Ratio - [_, _, state, region, update, _lat, _lng, confirmed, deaths, recovered, _active, _combined_key, _incidence_rate, _fatality_ratio] -> - state = String.downcase(state) - region = String.downcase(region) - confirmed = String.to_integer(confirmed) - deaths = String.to_integer(deaths) - recovered = String.to_integer(recovered) - - current = (confirmed - recovered) - deaths - - entry = %{update: update, confirmed: confirmed, deaths: deaths, recovered: recovered, current: current, region: region} - - region_entry = Map.get(acc, region, %{update: nil, confirmed: 0, deaths: 0, recovered: 0, current: 0}) - region_entry = %{ - update: region_entry.update || update, - confirmed: region_entry.confirmed + confirmed, - deaths: region_entry.deaths + deaths, - current: region_entry.current + current, - recovered: region_entry.recovered + recovered, - region: true - } - - changes = if old = Map.get(current_data, region) do - %{ - new_confirmed: region_entry.confirmed - old.confirmed, - new_current: region_entry.current - old.current, - new_deaths: region_entry.deaths - old.deaths, - new_recovered: region_entry.recovered - old.recovered, - } - else - %{new_confirmed: 0, new_current: 0, new_deaths: 0, new_recovered: 0} - end - - region_entry = Map.merge(region_entry, changes) - - acc = Map.put(acc, region, region_entry) - - acc = if state && state != "" do - Map.put(acc, state, entry) - else - acc - end - - other -> - Logger.info("Coronavirus line failed: #{inspect line}") - acc - end - end) - Logger.info "Updated coronavirus database" - {data, :timer.minutes(60)} - {:ok, %HTTPoison.Response{status_code: 404}} -> - Logger.debug "Corona 404 #{cur_url}" - date = Date.add(date || now, -1) - fetch_data(current_data, date) - other -> - Logger.error "Coronavirus: Update failed #{inspect other}" - {current_data, :timer.minutes(5)} - end - end - -end diff --git a/lib/lsg_irc/correction_plugin.ex b/lib/lsg_irc/correction_plugin.ex deleted file mode 100644 index 5f9b278..0000000 --- a/lib/lsg_irc/correction_plugin.ex +++ /dev/null @@ -1,59 +0,0 @@ -defmodule Nola.IRC.CorrectionPlugin do - @moduledoc """ - # correction - - * `s/pattern/replace` replace `pattern` by `replace` in the last matching message - """ - - def irc_doc, do: @moduledoc - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def init(_) do - {:ok, _} = Registry.register(IRC.PubSub, "messages", [plugin: __MODULE__]) - {:ok, _} = Registry.register(IRC.PubSub, "triggers", [plugin: __MODULE__]) - {:ok, %{}} - end - - # Trigger fallback - def handle_info({:irc, :trigger, _, m = %IRC.Message{}}, state) do - {:noreply, correction(m, state)} - end - - def handle_info({:irc, :text, m = %IRC.Message{}}, state) do - {:noreply, correction(m, state)} - end - - def correction(m, state) do - history = Map.get(state, key(m), []) - if String.starts_with?(m.text, "s/") do - case String.split(m.text, "/") do - ["s", match, replace | _] -> - case Regex.compile(match) do - {:ok, reg} -> - repl = Enum.find(history, fn(m) -> Regex.match?(reg, m.text) end) - if repl do - new_text = String.replace(repl.text, reg, replace) - m.replyfun.("correction: <#{repl.sender.nick}> #{new_text}") - end - _ -> - m.replyfun.("correction: invalid regex") - end - _ -> m.replyfun.("correction: invalid regex format") - end - state - else - history = if length(history) > 100 do - {_, history} = List.pop_at(history, 99) - [m | history] - else - [m | history] - end - Map.put(state, key(m), history) - end - end - - defp key(%{network: net, channel: chan}), do: "#{net}/#{chan}" - -end diff --git a/lib/lsg_irc/dice_plugin.ex b/lib/lsg_irc/dice_plugin.ex deleted file mode 100644 index b5e7649..0000000 --- a/lib/lsg_irc/dice_plugin.ex +++ /dev/null @@ -1,66 +0,0 @@ -defmodule Nola.IRC.DicePlugin do - require Logger - - @moduledoc """ - # dice - - * **!dice `[1 | lancés]` `[6 | faces]`**: lance une ou plusieurs fois un dé de 6 ou autre faces - """ - - @default_faces 6 - @default_rolls 1 - @max_rolls 50 - - def short_irc_doc, do: "!dice (jeter un dé)" - defstruct client: nil, dets: nil - - def irc_doc, do: @moduledoc - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def init([]) do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:dice", [plugin: __MODULE__]) - {:ok, %__MODULE__{}} - end - - def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :bang, args: args}}}, state) do - to_integer = fn(string, default) -> - case Integer.parse(string) do - {int, _} -> int - _ -> default - end - end - - {rolls, faces} = case args do - [] -> {@default_rolls, @default_faces} - [faces, rolls] -> {to_integer.(rolls, @default_rolls), to_integer.(faces, @default_faces)} - [rolls] -> {to_integer.(rolls, @default_rolls), @default_faces} - end - - roll(state, message, faces, rolls) - - {:noreply, state} - end - - def handle_info(info, state) do - {:noreply, state} - end - - defp roll(state, message, faces, 1) when faces > 0 do - random = :crypto.rand_uniform(1, faces+1) - message.replyfun.("#{message.sender.nick} dice: #{random}") - end - defp roll(state, message, faces, rolls) when faces > 0 and rolls > 0 and rolls <= @max_rolls do - {results, acc} = Enum.map_reduce(Range.new(1, rolls), 0, fn(i, acc) -> - random = :crypto.rand_uniform(1, faces+1) - {random, acc + random} - end) - results = Enum.join(results, "; ") - message.replyfun.("#{message.sender.nick} dice: [#{acc}] #{results}") - end - - defp roll(_, _, _, _, _), do: nil - -end diff --git a/lib/lsg_irc/finance_plugin.ex b/lib/lsg_irc/finance_plugin.ex deleted file mode 100644 index 16d06ee..0000000 --- a/lib/lsg_irc/finance_plugin.ex +++ /dev/null @@ -1,190 +0,0 @@ -defmodule Nola.IRC.FinancePlugin do - require Logger - - @moduledoc """ - # finance - - Données de [alphavantage.co](https://alphavantage.co). - - ## forex / monnaies / crypto-monnaies - - * **`!forex [MONNAIE2]`**: taux de change entre deux monnaies. - * **`!forex `**: converti `montant` entre deux monnaies - * **`?currency `**: recherche une monnaie - - Utiliser le symbole des monnaies (EUR, USD, ...). - - ## bourses - - * **`!stocks `** - * **`?stocks `** cherche un symbole - - Pour les symboles non-US, ajouter le suffixe (RNO Paris: RNO.PAR). - - """ - - @currency_list "http://www.alphavantage.co/physical_currency_list/" - @crypto_list "http://www.alphavantage.co/digital_currency_list/" - - HTTPoison.start() - load_currency = fn(url) -> - resp = HTTPoison.get!(url) - resp.body - |> String.strip() - |> String.split("\n") - |> Enum.drop(1) - |> Enum.map(fn(line) -> - [symbol, name] = line - |> String.strip() - |> String.split(",", parts: 2) - {symbol, name} - end) - |> Enum.into(Map.new) - end - fiat = load_currency.(@currency_list) - crypto = load_currency.(@crypto_list) - @currencies Map.merge(fiat, crypto) - - def irc_doc, do: @moduledoc - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def init([]) do - regopts = [plugin: __MODULE__] - {:ok, _} = Registry.register(IRC.PubSub, "trigger:forex", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "trigger:currency", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "trigger:stocks", regopts) - {:ok, nil} - end - - - def handle_info({:irc, :trigger, "stocks", message = %{trigger: %{type: :query, args: args = search}}}, state) do - search = Enum.join(search, "%20") - url = "https://www.alphavantage.co/query?function=SYMBOL_SEARCH&keywords=#{search}&apikey=#{api_key()}" - case HTTPoison.get(url) do - {:ok, %HTTPoison.Response{status_code: 200, body: data}} -> - data = Poison.decode!(data) - if error = Map.get(data, "Error Message") do - Logger.error("AlphaVantage API invalid request #{url} - #{inspect error}") - message.replyfun.("stocks: requête invalide") - else - items = for item <- Map.get(data, "bestMatches") do - symbol = Map.get(item, "1. symbol") - name = Map.get(item, "2. name") - type = Map.get(item, "3. type") - region = Map.get(item, "4. region") - currency = Map.get(item, "8. currency") - "#{symbol}: #{name} (#{region}; #{currency}; #{type})" - end - |> Enum.join(", ") - items = if items == "" do - "no results!" - else - items - end - message.replyfun.(items) - end - {:ok, resp = %HTTPoison.Response{status_code: code}} -> - Logger.error "AlphaVantage API error: #{code} #{url} - #{inspect resp}" - message.replyfun.("forex: erreur (api #{code})") - {:error, %HTTPoison.Error{reason: error}} -> - Logger.error "AlphaVantage HTTP error: #{inspect error}" - message.replyfun.("forex: erreur (http #{inspect error})") - end - {:noreply, state} - end - - def handle_info({:irc, :trigger, "stocks", message = %{trigger: %{type: :bang, args: args = [symbol]}}}, state) do - url = "https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=#{symbol}&apikey=#{api_key()}" - case HTTPoison.get(url) do - {:ok, %HTTPoison.Response{status_code: 200, body: data}} -> - data = Poison.decode!(data) - if error = Map.get(data, "Error Message") do - Logger.error("AlphaVantage API invalid request #{url} - #{inspect error}") - message.replyfun.("stocks: requête invalide") - else - data = Map.get(data, "Global Quote") - open = Map.get(data, "02. open") - high = Map.get(data, "03. high") - low = Map.get(data, "04. low") - price = Map.get(data, "05. price") - volume = Map.get(data, "06. volume") - prev_close = Map.get(data, "08. previous close") - change = Map.get(data, "09. change") - change_pct = Map.get(data, "10. change percent") - - msg = "#{symbol}: #{price} #{change} [#{change_pct}] (high: #{high}, low: #{low}, open: #{open}, prev close: #{prev_close}) (volume: #{volume})" - message.replyfun.(msg) - end - {:ok, resp = %HTTPoison.Response{status_code: code}} -> - Logger.error "AlphaVantage API error: #{code} #{url} - #{inspect resp}" - message.replyfun.("stocks: erreur (api #{code})") - {:error, %HTTPoison.Error{reason: error}} -> - Logger.error "AlphaVantage HTTP error: #{inspect error}" - message.replyfun.("stocks: erreur (http #{inspect error})") - end - {:noreply, state} - end - - - def handle_info({:irc, :trigger, "forex", message = %{trigger: %{type: :bang, args: args = [_ | _]}}}, state) do - {amount, from, to} = case args do - [amount, from, to] -> - {amount, _} = Float.parse(amount) - {amount, from, to} - [from, to] -> - {1, from, to} - [from] -> - {1, from, "EUR"} - end - url = "https://www.alphavantage.co/query?function=CURRENCY_EXCHANGE_RATE&from_currency=#{from}&to_currency=#{to}&apikey=#{api_key()}" - case HTTPoison.get(url) do - {:ok, %HTTPoison.Response{status_code: 200, body: data}} -> - data = Poison.decode!(data) - if error = Map.get(data, "Error Message") do - Logger.error("AlphaVantage API invalid request #{url} - #{inspect error}") - message.replyfun.("forex: requête invalide") - else - data = Map.get(data, "Realtime Currency Exchange Rate") - from_name = Map.get(data, "2. From_Currency Name") - to_name = Map.get(data, "4. To_Currency Name") - rate = Map.get(data, "5. Exchange Rate") - {rate, _} = Float.parse(rate) - value = amount*rate - message.replyfun.("#{amount} #{from} (#{from_name}) -> #{value} #{to} (#{to_name}) (#{rate})") - end - {:ok, resp = %HTTPoison.Response{status_code: code}} -> - Logger.error "AlphaVantage API error: #{code} #{url} - #{inspect resp}" - message.replyfun.("forex: erreur (api #{code})") - {:error, %HTTPoison.Error{reason: error}} -> - Logger.error "AlphaVantage HTTP error: #{inspect error}" - message.replyfun.("forex: erreur (http #{inspect error})") - end - {:noreply, state} - end - - def handle_info({:irc, :trigger, "currency", message = %{trigger: %{type: :query, args: args = search}}}, state) do - search = Enum.join(search, " ") - results = Enum.filter(@currencies, fn({symbol, name}) -> - String.contains?(String.downcase(name), String.downcase(search)) || String.contains?(String.downcase(symbol), String.downcase(search)) - end) - |> Enum.map(fn({symbol, name}) -> - "#{symbol}: #{name}" - end) - |> Enum.join(", ") - - if results == "" do - message.replyfun.("no results!") - else - message.replyfun.(results) - end - {:noreply, state} - end - - defp api_key() do - Application.get_env(:nola, :alphavantage, []) - |> Keyword.get(:api_key, "demo") - end - -end diff --git a/lib/lsg_irc/gpt_plugin.ex b/lib/lsg_irc/gpt_plugin.ex deleted file mode 100644 index 2c8f182..0000000 --- a/lib/lsg_irc/gpt_plugin.ex +++ /dev/null @@ -1,259 +0,0 @@ -defmodule Nola.IRC.GptPlugin do - require Logger - import Irc.Plugin.TempRef - - def irc_doc() do - """ - # OpenAI GPT - - Uses OpenAI's GPT-3 API to bring natural language prompts to your IRC channel. - - _prompts_ are pre-defined prompts and parameters defined in the bot' CouchDB. - - _Runs_ (results of the inference of a _prompt_) are also stored in CouchDB and - may be resumed. - - * **!gpt** list GPT prompts - * **!gpt `[prompt]` ``** run a prompt - * **+gpt `[short ref|run id]` ``** continue a prompt - * **?gpt offensive ``** is content offensive ? - * **?gpt show `[short ref|run id]`** run information and web link - * **?gpt `[prompt]`** prompt information and web link - """ - end - - @couch_db "bot-plugin-openai-prompts" - @couch_run_db "bot-plugin-gpt-history" - @trigger "gpt" - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - defstruct [:temprefs] - - def get_result(id) do - Couch.get(@couch_run_db, id) - end - - def get_prompt(id) do - Couch.get(@couch_db, id) - end - - def init(_) do - regopts = [plugin: __MODULE__] - {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{@trigger}", regopts) - {:ok, %__MODULE__{temprefs: new_temp_refs()}} - end - - def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [prompt | args]}}}, state) do - case Couch.get(@couch_db, prompt) do - {:ok, prompt} -> {:noreply, prompt(m, prompt, Enum.join(args, " "), state)} - {:error, :not_found} -> - m.replyfun.("gpt: prompt '#{prompt}' does not exists") - {:noreply, state} - error -> - Logger.info("gpt: prompt load error: #{inspect error}") - m.replyfun.("gpt: database error") - {:noreply, state} - end - end - - def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: []}}}, state) do - case Couch.get(@couch_db, "_all_docs") do - {:ok, %{"rows" => []}} -> m.replyfun.("gpt: no prompts available") - {:ok, %{"rows" => prompts}} -> - prompts = prompts |> Enum.map(fn(prompt) -> Map.get(prompt, "id") end) |> Enum.join(", ") - m.replyfun.("gpt: prompts: #{prompts}") - error -> - Logger.info("gpt: prompt load error: #{inspect error}") - m.replyfun.("gpt: database error") - end - {:noreply, state} - end - - def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :plus, args: [ref_or_id | args]}}}, state) do - id = lookup_temp_ref(ref_or_id, state.temprefs, ref_or_id) - case Couch.get(@couch_run_db, id) do - {:ok, run} -> - Logger.debug("+gpt run: #{inspect run}") - {:noreply, continue_prompt(m, run, Enum.join(args, " "), state)} - {:error, :not_found} -> - m.replyfun.("gpt: ref or id not found or expired: #{inspect ref_or_id} (if using short ref, try using full id)") - {:noreply, state} - error -> - Logger.info("+gpt: run load error: #{inspect error}") - m.replyfun.("gpt: database error") - {:noreply, state} - end - end - - def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :query, args: ["offensive" | text]}}}, state) do - text = Enum.join(text, " ") - {moderate?, moderation} = moderation(text, m.account.id) - reply = cond do - moderate? -> "⚠️ #{Enum.join(moderation, ", ")}" - !moderate? && moderation -> "👍" - !moderate? -> "☠️ error" - end - m.replyfun.(reply) - {:noreply, state} - end - - def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :query, args: ["show", ref_or_id]}}}, state) do - id = lookup_temp_ref(ref_or_id, state.temprefs, ref_or_id) - url = if m.channel do - NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :result, m.network, NolaWeb.format_chan(m.channel), id) - else - NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :result, id) - end - m.replyfun.("→ #{url}") - {:noreply, state} - end - - def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :query, args: [prompt]}}}, state) do - url = if m.channel do - NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :prompt, m.network, NolaWeb.format_chan(m.channel), prompt) - else - NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :prompt, prompt) - end - m.replyfun.("→ #{url}") - {:noreply, state} - end - - def handle_info(info, state) do - Logger.debug("gpt: unhandled info: #{inspect info}") - {:noreply, state} - end - - defp continue_prompt(msg, run, content, state) do - prompt_id = Map.get(run, "prompt_id") - prompt_rev = Map.get(run, "prompt_rev") - - original_prompt = case Couch.get(@couch_db, prompt_id, rev: prompt_rev) do - {:ok, prompt} -> prompt - _ -> nil - end - - if original_prompt do - continue_prompt = %{"_id" => prompt_id, - "_rev" => prompt_rev, - "type" => Map.get(original_prompt, "type"), - "parent_run_id" => Map.get(run, "_id"), - "openai_params" => Map.get(run, "request") |> Map.delete("prompt")} - - continue_prompt = if prompt_string = Map.get(original_prompt, "continue_prompt") do - full_text = get_in(run, ~w(request prompt)) <> "\n" <> Map.get(run, "response") - continue_prompt - |> Map.put("prompt", prompt_string) - |> Map.put("prompt_format", "liquid") - |> Map.put("prompt_liquid_variables", %{"previous" => full_text}) - else - prompt_content_tag = if content != "", do: " {{content}}", else: "" - string = get_in(run, ~w(request prompt)) <> "\n" <> Map.get(run, "response") <> prompt_content_tag - continue_prompt - |> Map.put("prompt", string) - |> Map.put("prompt_format", "liquid") - end - - prompt(msg, continue_prompt, content, state) - else - msg.replyfun.("gpt: cannot continue this prompt: original prompt not found #{prompt_id}@v#{prompt_rev}") - state - end - end - - defp prompt(msg, prompt = %{"type" => "completions", "prompt" => prompt_template}, content, state) do - Logger.debug("gpt_plugin:prompt/4 #{inspect prompt}") - prompt_text = case Map.get(prompt, "prompt_format", "liquid") do - "liquid" -> Tmpl.render(prompt_template, msg, Map.merge(Map.get(prompt, "prompt_liquid_variables", %{}), %{"content" => content})) - "norender" -> prompt_template - end - - args = Map.get(prompt, "openai_params") - |> Map.put("prompt", prompt_text) - |> Map.put("user", msg.account.id) - - {moderate?, moderation} = moderation(content, msg.account.id) - if moderate?, do: msg.replyfun.("⚠️ offensive input: #{Enum.join(moderation, ", ")}") - - Logger.debug("GPT: request #{inspect args}") - case OpenAi.post("/v1/completions", args) do - {:ok, %{"choices" => [%{"text" => text, "finish_reason" => finish_reason} | _], "usage" => usage, "id" => gpt_id, "created" => created}} -> - text = String.trim(text) - {o_moderate?, o_moderation} = moderation(text, msg.account.id) - if o_moderate?, do: msg.replyfun.("🚨 offensive output: #{Enum.join(o_moderation, ", ")}") - msg.replyfun.(text) - doc = %{"id" => FlakeId.get(), - "prompt_id" => Map.get(prompt, "_id"), - "prompt_rev" => Map.get(prompt, "_rev"), - "network" => msg.network, - "channel" => msg.channel, - "nick" => msg.sender.nick, - "account_id" => (if msg.account, do: msg.account.id), - "request" => args, - "response" => text, - "message_at" => msg.at, - "reply_at" => DateTime.utc_now(), - "gpt_id" => gpt_id, - "gpt_at" => created, - "gpt_usage" => usage, - "type" => "completions", - "parent_run_id" => Map.get(prompt, "parent_run_id"), - "moderation" => %{"input" => %{flagged: moderate?, categories: moderation}, - "output" => %{flagged: o_moderate?, categories: o_moderation} - } - } - Logger.debug("Saving result to couch: #{inspect doc}") - {id, ref, temprefs} = case Couch.post(@couch_run_db, doc) do - {:ok, id, _rev} -> - {ref, temprefs} = put_temp_ref(id, state.temprefs) - {id, ref, temprefs} - error -> - Logger.error("Failed to save to Couch: #{inspect error}") - {nil, nil, state.temprefs} - end - stop = cond do - finish_reason == "stop" -> "" - finish_reason == "length" -> " — truncated" - true -> " — #{finish_reason}" - end - ref_and_prefix = if Map.get(usage, "completion_tokens", 0) == 0 do - "GPT had nothing else to say :( ↪ #{ref || "✗"}" - else - " ↪ #{ref || "✗"}" - end - msg.replyfun.(ref_and_prefix <> - stop <> - " — #{Map.get(usage, "total_tokens", 0)}" <> - " (#{Map.get(usage, "prompt_tokens", 0)}/#{Map.get(usage, "completion_tokens", 0)}) tokens" <> - " — #{id || "save failed"}") - %__MODULE__{state | temprefs: temprefs} - {:error, atom} when is_atom(atom) -> - Logger.error("gpt error: #{inspect atom}") - msg.replyfun.("gpt: ☠️ #{to_string(atom)}") - state - error -> - Logger.error("gpt error: #{inspect error}") - msg.replyfun.("gpt: ☠️ ") - state - end - end - - defp moderation(content, user_id) do - case OpenAi.post("/v1/moderations", %{"input" => content, "user" => user_id}) do - {:ok, %{"results" => [%{"flagged" => true, "categories" => categories} | _]}} -> - cat = categories - |> Enum.filter(fn({_key, value}) -> value end) - |> Enum.map(fn({key, _}) -> key end) - {true, cat} - {:ok, moderation} -> - Logger.debug("gpt: moderation: not flagged, #{inspect moderation}") - {false, true} - error -> - Logger.error("gpt: moderation error: #{inspect error}") - {false, false} - end - end - -end diff --git a/lib/lsg_irc/kick_roulette_plugin.ex b/lib/lsg_irc/kick_roulette_plugin.ex deleted file mode 100644 index 55b7da4..0000000 --- a/lib/lsg_irc/kick_roulette_plugin.ex +++ /dev/null @@ -1,32 +0,0 @@ -defmodule Nola.IRC.KickRoulettePlugin do - @moduledoc """ - # kick roulette - - * **!kick**, tentez votre chance… - """ - - def irc_doc, do: @moduledoc - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def init([]) do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:kick", [plugin: __MODULE__]) - {:ok, nil} - end - - def handle_info({:irc, :trigger, "kick", message = %{trigger: %{type: :bang, args: []}}}, _) do - if 5 == :crypto.rand_uniform(1, 6) do - spawn(fn() -> - :timer.sleep(:crypto.rand_uniform(200, 10_000)) - message.replyfun.({:kick, message.sender.nick, "perdu"}) - end) - end - {:noreply, nil} - end - - def handle_info(msg, _) do - {:noreply, nil} - end - -end diff --git a/lib/lsg_irc/last_fm_plugin.ex b/lib/lsg_irc/last_fm_plugin.ex deleted file mode 100644 index 03df675..0000000 --- a/lib/lsg_irc/last_fm_plugin.ex +++ /dev/null @@ -1,187 +0,0 @@ -defmodule Nola.IRC.LastFmPlugin do - require Logger - - @moduledoc """ - # last.fm - - * **!lastfm|np `[nick|username]`** - * **.lastfm|np** - * **+lastfm, -lastfm `; ?lastfm`** Configurer un nom d'utilisateur last.fm - """ - - @single_trigger ~w(lastfm np) - @pubsub_topics ~w(trigger:lastfm trigger:np) - - defstruct dets: nil - - def irc_doc, do: @moduledoc - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def init([]) do - regopts = [type: __MODULE__] - for t <- @pubsub_topics, do: {:ok, _} = Registry.register(IRC.PubSub, t, type: __MODULE__) - dets_filename = (Nola.data_path() <> "/" <> "lastfm.dets") |> String.to_charlist - {:ok, dets} = :dets.open_file(dets_filename, []) - {:ok, %__MODULE__{dets: dets}} - end - - def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :plus, args: [username]}}}, state) do - username = String.strip(username) - :ok = :dets.insert(state.dets, {message.account.id, username}) - message.replyfun.("#{message.sender.nick}: nom d'utilisateur last.fm configuré: \"#{username}\".") - {:noreply, state} - end - - def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :minus, args: []}}}, state) do - text = case :dets.lookup(state.dets, message.account.id) do - [{_nick, _username}] -> - :dets.delete(state.dets, message.account.id) - message.replyfun.("#{message.sender.nick}: nom d'utilisateur last.fm enlevé.") - _ -> nil - end - {:noreply, state} - end - - def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :query, args: []}}}, state) do - text = case :dets.lookup(state.dets, message.account.id) do - [{_nick, username}] -> - message.replyfun.("#{message.sender.nick}: #{username}.") - _ -> nil - end - {:noreply, state} - end - - def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :bang, args: []}}}, state) do - irc_now_playing(message.account.id, message, state) - {:noreply, state} - end - - def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :bang, args: [nick_or_user]}}}, state) do - irc_now_playing(nick_or_user, message, state) - {:noreply, state} - end - - def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :dot}}}, state) do - members = IRC.Membership.members(message.network, message.channel) - foldfun = fn({nick, user}, acc) -> [{nick,user}|acc] end - usernames = :dets.foldl(foldfun, [], state.dets) - |> Enum.uniq() - |> Enum.filter(fn({acct,_}) -> Enum.member?(members, acct) end) - |> Enum.map(fn({_, u}) -> u end) - for u <- usernames, do: irc_now_playing(u, message, state) - {:noreply, state} - end - - def handle_info(info, state) do - {:noreply, state} - end - - def terminate(_reason, state) do - if state.dets do - :dets.sync(state.dets) - :dets.close(state.dets) - end - :ok - end - - defp irc_now_playing(nick_or_user, message, state) do - nick_or_user = String.strip(nick_or_user) - - id_or_user = if account = IRC.Account.get(nick_or_user) || IRC.Account.find_always_by_nick(message.network, message.channel, nick_or_user) do - account.id - else - nick_or_user - end - - username = case :dets.lookup(state.dets, id_or_user) do - [{_, username}] -> username - _ -> id_or_user - end - - case now_playing(username) do - {:error, text} when is_binary(text) -> - message.replyfun.(text) - {:ok, map} when is_map(map) -> - track = fetch_track(username, map) - text = format_now_playing(map, track) - user = if account = IRC.Account.get(id_or_user) do - user = IRC.UserTrack.find_by_account(message.network, account) - if(user, do: user.nick, else: account.name) - else - username - end - if user && text do - message.replyfun.("#{user} #{text}") - else - message.replyfun.("#{username}: pas de résultat") - end - other -> - message.replyfun.("erreur :(") - end - end - - defp now_playing(user) do - api = Application.get_env(:nola, :lastfm)[:api_key] - url = "http://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&format=json&limit=1&extended=1" <> "&api_key=" <> api <> "&user="<> user - case HTTPoison.get(url) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> Jason.decode(body) - {:ok, %HTTPoison.Response{status_code: 404}} -> {:error, "last.fm: utilisateur \"#{user}\" inexistant"} - {:ok, %HTTPoison.Response{status_code: code}} -> {:error, "last.fm: erreur #{to_string(code)}"} - error -> - Logger.error "Lastfm http error: #{inspect error}" - :error - end - end - defp fetch_track(user, %{"recenttracks" => %{"track" => [ t = %{"name" => name, "artist" => %{"name" => artist}} | _]}}) do - api = Application.get_env(:nola, :lastfm)[:api_key] - url = "http://ws.audioscrobbler.com/2.0/?method=track.getInfo&format=json" <> "&api_key=" <> api <> "&username="<> user <> "&artist="<>URI.encode(artist)<>"&track="<>URI.encode(name) - case HTTPoison.get(url) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - case Jason.decode(body) do - {:ok, body} -> body["track"] || %{} - _ -> %{} - end - error -> - Logger.error "Lastfm http error: #{inspect error}" - :error - end - end - - defp format_now_playing(%{"recenttracks" => %{"track" => [track = %{"@attr" => %{"nowplaying" => "true"}} | _]}}, et) do - format_track(true, track, et) - end - - defp format_now_playing(%{"recenttracks" => %{"track" => [track | _]}}, et) do - format_track(false, track, et) - end - - defp format_now_playing(%{"error" => err, "message" => message}, _) do - "last.fm error #{err}: #{message}" - end - - defp format_now_playing(miss) do - nil - end - - defp format_track(np, track, extended) do - artist = track["artist"]["name"] - album = if track["album"]["#text"], do: " (" <> track["album"]["#text"] <> ")", else: "" - name = track["name"] <> album - action = if np, do: "écoute ", else: "a écouté" - love = if track["loved"] != "0", do: "❤️" - count = if x = extended["userplaycount"], do: "x#{x} #{love}" - tags = (get_in(extended, ["toptags", "tag"]) || []) - |> Enum.map(fn(tag) -> tag["name"] end) - |> Enum.filter(& &1) - |> Enum.join(", ") - - [action, artist, name, count, tags, track["url"]] - |> Enum.filter(& &1) - |> Enum.map(&String.trim(&1)) - |> Enum.join(" - ") - end - -end diff --git a/lib/lsg_irc/link_plugin.ex b/lib/lsg_irc/link_plugin.ex deleted file mode 100644 index dee78e8..0000000 --- a/lib/lsg_irc/link_plugin.ex +++ /dev/null @@ -1,271 +0,0 @@ -defmodule Nola.IRC.LinkPlugin do - @moduledoc """ - # Link Previewer - - An extensible link previewer for IRC. - - To extend the supported sites, create a new handler implementing the callbacks. - - See `link_plugin/` directory for examples. The first in list handler that returns true to the `match/2` callback will be used, - and if the handler returns `:error` or crashes, will fallback to the default preview. - - Unsupported websites will use the default link preview method, which is for html document the title, otherwise it'll use - the mimetype and size. - - ## Configuration: - - ``` - config :nola, Nola.IRC.LinkPlugin, - handlers: [ - Nola.IRC.LinkPlugin.Youtube: [ - invidious: true - ], - Nola.IRC.LinkPlugin.Twitter: [], - Nola.IRC.LinkPlugin.Imgur: [], - ] - ``` - - """ - - @ircdoc """ - # Link preview - - Previews links (just post a link!). - - Announces real URL after redirections and provides extended support for YouTube, Twitter and Imgur. - """ - def short_irc_doc, do: false - def irc_doc, do: @ircdoc - require Logger - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - @callback match(uri :: URI.t, options :: Keyword.t) :: {true, params :: Map.t} | false - @callback expand(uri :: URI.t, params :: Map.t, options :: Keyword.t) :: {:ok, lines :: [] | String.t} | :error - @callback post_match(uri :: URI.t, content_type :: binary, headers :: [], opts :: Keyword.t) :: {:body | :file, params :: Map.t} | false - @callback post_expand(uri :: URI.t, body :: binary() | Path.t, params :: Map.t, options :: Keyword.t) :: {:ok, lines :: [] | String.t} | :error - - @optional_callbacks [expand: 3, post_expand: 4] - - defstruct [:client] - - def init([]) do - {:ok, _} = Registry.register(IRC.PubSub, "messages", [plugin: __MODULE__]) - #{:ok, _} = Registry.register(IRC.PubSub, "messages:telegram", [plugin: __MODULE__]) - Logger.info("Link handler started") - {:ok, %__MODULE__{}} - end - - def handle_info({:irc, :text, message = %{text: text}}, state) do - String.split(text) - |> Enum.map(fn(word) -> - if String.starts_with?(word, "http://") || String.starts_with?(word, "https://") do - uri = URI.parse(word) - if uri.scheme && uri.host do - spawn(fn() -> - :timer.kill_after(:timer.seconds(30)) - case expand_link([uri]) do - {:ok, uris, text} -> - text = case uris do - [uri] -> text - [luri | _] -> - if luri.host == uri.host && luri.path == luri.path do - text - else - ["-> #{URI.to_string(luri)}", text] - end - end - if is_list(text) do - for line <- text, do: message.replyfun.(line) - else - message.replyfun.(text) - end - _ -> nil - end - end) - end - end - end) - {:noreply, state} - end - - def handle_info(msg, state) do - {:noreply, state} - end - - def terminate(_reason, state) do - :ok - end - - # 1. Match the first valid handler - # 2. Try to run the handler - # 3. If :error or crash, default link. - # If :skip, nothing - # 4. ? - - # Over five redirections: cancel. - def expand_link(acc = [_, _, _, _, _ | _]) do - {:ok, acc, "link redirects more than five times"} - end - - def expand_link(acc=[uri | _]) do - Logger.debug("link: expanding: #{inspect uri}") - handlers = Keyword.get(Application.get_env(:nola, __MODULE__, [handlers: []]), :handlers) - handler = Enum.reduce_while(handlers, nil, fn({module, opts}, acc) -> - Logger.debug("link: attempt expanding: #{inspect module} for #{inspect uri}") - module = Module.concat([module]) - case module.match(uri, opts) do - {true, params} -> {:halt, {module, params, opts}} - false -> {:cont, acc} - end - end) - run_expand(acc, handler) - end - - def run_expand(acc, nil) do - expand_default(acc) - end - - def run_expand(acc=[uri|_], {module, params, opts}) do - Logger.debug("link: expanding #{inspect uri} with #{inspect module}") - case module.expand(uri, params, opts) do - {:ok, data} -> {:ok, acc, data} - :error -> expand_default(acc) - :skip -> nil - end - rescue - e -> - Logger.error("link: rescued #{inspect uri} with #{inspect module}: #{inspect e}") - Logger.error(Exception.format(:error, e, __STACKTRACE__)) - expand_default(acc) - catch - e, b -> - Logger.error("link: catched #{inspect uri} with #{inspect module}: #{inspect {e, b}}") - expand_default(acc) - end - - defp get(url, headers \\ [], options \\ []) do - get_req(url, :hackney.get(url, headers, <<>>, options)) - end - - defp get_req(_, {:error, reason}) do - {:error, reason} - end - - defp get_req(url, {:ok, 200, headers, client}) do - headers = Enum.reduce(headers, %{}, fn({key, value}, acc) -> - Map.put(acc, String.downcase(key), value) - end) - content_type = Map.get(headers, "content-type", "application/octect-stream") - length = Map.get(headers, "content-length", "0") - {length, _} = Integer.parse(length) - - handlers = Keyword.get(Application.get_env(:nola, __MODULE__, [handlers: []]), :handlers) - handler = Enum.reduce_while(handlers, false, fn({module, opts}, acc) -> - module = Module.concat([module]) - try do - case module.post_match(url, content_type, headers, opts) do - {mode, params} when mode in [:body, :file] -> {:halt, {module, params, opts, mode}} - false -> {:cont, acc} - end - rescue - e -> - Logger.error(inspect(e)) - {:cont, false} - catch - e, b -> - Logger.error(inspect({b})) - {:cont, false} - end - end) - - cond do - handler != false and length <= 30_000_000 -> - case get_body(url, 30_000_000, client, handler, <<>>) do - {:ok, _} = ok -> ok - :error -> - {:ok, "file: #{content_type}, size: #{human_size(length)}"} - end - #String.starts_with?(content_type, "text/html") && length <= 30_000_000 -> - # get_body(url, 30_000_000, client, <<>>) - true -> - :hackney.close(client) - {:ok, "file: #{content_type}, size: #{human_size(length)}"} - end - end - - defp get_req(_, {:ok, redirect, headers, client}) when redirect in 300..399 do - headers = Enum.reduce(headers, %{}, fn({key, value}, acc) -> - Map.put(acc, String.downcase(key), value) - end) - location = Map.get(headers, "location") - - :hackney.close(client) - {:redirect, location} - end - - defp get_req(_, {:ok, status, headers, client}) do - :hackney.close(client) - {:error, status, headers} - end - - defp get_body(url, len, client, {handler, params, opts, mode} = h, acc) when len >= byte_size(acc) do - case :hackney.stream_body(client) do - {:ok, data} -> - get_body(url, len, client, h, << acc::binary, data::binary >>) - :done -> - body = case mode do - :body -> acc - :file -> - {:ok, tmpfile} = Plug.Upload.random_file("linkplugin") - File.write!(tmpfile, acc) - tmpfile - end - handler.post_expand(url, body, params, opts) - {:error, reason} -> - {:ok, "failed to fetch body: #{inspect reason}"} - end - end - - defp get_body(_, len, client, h, _acc) do - :hackney.close(client) - IO.inspect(h) - {:ok, "Error: file over 30"} - end - - def expand_default(acc = [uri = %URI{scheme: scheme} | _]) when scheme in ["http", "https"] do - Logger.debug("link: expanding #{uri} with default") - headers = [{"user-agent", "DmzBot (like TwitterBot)"}] - options = [follow_redirect: false, max_body_length: 30_000_000] - case get(URI.to_string(uri), headers, options) do - {:ok, text} -> - {:ok, acc, text} - {:redirect, link} -> - new_uri = URI.parse(link) - #new_uri = %URI{new_uri | scheme: scheme, authority: uri.authority, host: uri.host, port: uri.port} - expand_link([new_uri | acc]) - {:error, status, _headers} -> - text = Plug.Conn.Status.reason_phrase(status) - {:ok, acc, "Error: HTTP #{text} (#{status})"} - {:error, {:tls_alert, {:handshake_failure, err}}} -> - {:ok, acc, "TLS Error: #{to_string(err)}"} - {:error, reason} -> - {:ok, acc, "Error: #{to_string(reason)}"} - end - end - - # Unsupported scheme, came from a redirect. - def expand_default(acc = [uri | _]) do - {:ok, [uri], "-> #{URI.to_string(uri)}"} - end - - - defp human_size(bytes) do - bytes - |> FileSize.new(:b) - |> FileSize.scale() - |> FileSize.format() - end -end diff --git a/lib/lsg_irc/link_plugin/github.ex b/lib/lsg_irc/link_plugin/github.ex deleted file mode 100644 index 93e0892..0000000 --- a/lib/lsg_irc/link_plugin/github.ex +++ /dev/null @@ -1,49 +0,0 @@ -defmodule Nola.IRC.LinkPlugin.Github do - @behaviour Nola.IRC.LinkPlugin - - @impl true - def match(uri = %URI{host: "github.com", path: path}, _) do - case String.split(path, "/") do - ["", user, repo] -> - {true, %{user: user, repo: repo, path: "#{user}/#{repo}"}} - _ -> - false - end - end - - def match(_, _), do: false - - @impl true - def post_match(_, _, _, _), do: false - - @impl true - def expand(_uri, %{user: user, repo: repo}, _opts) do - case HTTPoison.get("https://api.github.com/repos/#{user}/#{repo}") do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - {:ok, json} = Jason.decode(body) - src = json["source"]["full_name"] - disabled = if(json["disabled"], do: " (disabled)", else: "") - archived = if(json["archived"], do: " (archived)", else: "") - fork = if src && src != json["full_name"] do - " (⑂ #{json["source"]["full_name"]})" - else - "" - end - start = "#{json["full_name"]}#{disabled}#{archived}#{fork} - #{json["description"]}" - tags = for(t <- json["topics"]||[], do: "##{t}") |> Enum.intersperse(", ") |> Enum.join("") - lang = if(json["language"], do: "#{json["language"]} - ", else: "") - issues = if(json["open_issues_count"], do: "#{json["open_issues_count"]} issues - ", else: "") - last_push = if at = json["pushed_at"] do - {:ok, date, _} = DateTime.from_iso8601(at) - " - last pushed #{DateTime.to_string(date)}" - else - "" - end - network = "#{lang}#{issues}#{json["stargazers_count"]} stars - #{json["subscribers_count"]} watchers - #{json["forks_count"]} forks#{last_push}" - {:ok, [start, tags, network]} - other -> - :error - end - end - -end diff --git a/lib/lsg_irc/link_plugin/html.ex b/lib/lsg_irc/link_plugin/html.ex deleted file mode 100644 index 56a8ceb..0000000 --- a/lib/lsg_irc/link_plugin/html.ex +++ /dev/null @@ -1,106 +0,0 @@ -defmodule Nola.IRC.LinkPlugin.HTML do - @behaviour Nola.IRC.LinkPlugin - - @impl true - def match(_, _), do: false - - @impl true - def post_match(_url, "text/html"<>_, _header, _opts) do - {:body, nil} - end - def post_match(_, _, _, _), do: false - - @impl true - def post_expand(url, body, _params, _opts) do - html = Floki.parse(body) - title = collect_title(html) - opengraph = collect_open_graph(html) - itemprops = collect_itemprops(html) - text = if Map.has_key?(opengraph, "title") && Map.has_key?(opengraph, "description") do - sitename = if sn = Map.get(opengraph, "site_name") do - "#{sn}" - else - "" - end - paywall? = if Map.get(opengraph, "article:content_tier", Map.get(itemprops, "article:content_tier", "free")) == "free" do - "" - else - "[paywall] " - end - section = if section = Map.get(opengraph, "article:section", Map.get(itemprops, "article:section", nil)) do - ": #{section}" - else - "" - end - date = case DateTime.from_iso8601(Map.get(opengraph, "article:published_time", Map.get(itemprops, "article:published_time", ""))) do - {:ok, date, _} -> - "#{Timex.format!(date, "%d/%m/%y", :strftime)}. " - _ -> - "" - end - uri = URI.parse(url) - - prefix = "#{paywall?}#{Map.get(opengraph, "site_name", uri.host)}#{section}" - prefix = unless prefix == "" do - "#{prefix} — " - else - "" - end - [clean_text("#{prefix}#{Map.get(opengraph, "title")}")] ++ IRC.splitlong(clean_text("#{date}#{Map.get(opengraph, "description")}")) - else - clean_text(title) - end - {:ok, text} - end - - defp collect_title(html) do - case Floki.find(html, "title") do - [{"title", [], [title]} | _] -> - String.trim(title) - _ -> - nil - end - end - - defp collect_open_graph(html) do - Enum.reduce(Floki.find(html, "head meta"), %{}, fn(tag, acc) -> - case tag do - {"meta", values, []} -> - name = List.keyfind(values, "property", 0, {nil, nil}) |> elem(1) - content = List.keyfind(values, "content", 0, {nil, nil}) |> elem(1) - case name do - "og:" <> key -> - Map.put(acc, key, content) - "article:"<>_ -> - Map.put(acc, name, content) - _other -> acc - end - _other -> acc - end - end) - end - - defp collect_itemprops(html) do - Enum.reduce(Floki.find(html, "[itemprop]"), %{}, fn(tag, acc) -> - case tag do - {"meta", values, []} -> - name = List.keyfind(values, "itemprop", 0, {nil, nil}) |> elem(1) - content = List.keyfind(values, "content", 0, {nil, nil}) |> elem(1) - case name do - "article:" <> key -> - Map.put(acc, name, content) - _other -> acc - end - _other -> acc - end - end) - end - - defp clean_text(text) do - text - |> String.replace("\n", " ") - |> HtmlEntities.decode() - end - - -end diff --git a/lib/lsg_irc/link_plugin/imgur.ex b/lib/lsg_irc/link_plugin/imgur.ex deleted file mode 100644 index 5d74956..0000000 --- a/lib/lsg_irc/link_plugin/imgur.ex +++ /dev/null @@ -1,96 +0,0 @@ -defmodule Nola.IRC.LinkPlugin.Imgur do - @behaviour Nola.IRC.LinkPlugin - - @moduledoc """ - # Imgur link preview - - No options. - - Needs to have a Imgur API key configured: - - ``` - config :nola, :imgur, - client_id: "xxxxxxxx", - client_secret: "xxxxxxxxxxxxxxxxxxxx" - ``` - """ - - @impl true - def match(uri = %URI{host: "imgur.io"}, arg) do - match(%URI{uri | host: "imgur.com"}, arg) - end - def match(uri = %URI{host: "i.imgur.io"}, arg) do - match(%URI{uri | host: "i.imgur.com"}, arg) - end - def match(uri = %URI{host: "imgur.com", path: "/a/"<>album_id}, _) do - {true, %{album_id: album_id}} - end - def match(uri = %URI{host: "imgur.com", path: "/gallery/"<>album_id}, _) do - {true, %{album_id: album_id}} - end - def match(uri = %URI{host: "i.imgur.com", path: "/"<>image}, _) do - [hash, _] = String.split(image, ".", parts: 2) - {true, %{image_id: hash}} - end - def match(_, _), do: false - - @impl true - def post_match(_, _, _, _), do: false - - def expand(_uri, %{album_id: album_id}, opts) do - expand_imgur_album(album_id, opts) - end - - def expand(_uri, %{image_id: image_id}, opts) do - expand_imgur_image(image_id, opts) - end - - def expand_imgur_image(image_id, opts) do - client_id = Keyword.get(Application.get_env(:nola, :imgur, []), :client_id, "42") - headers = [{"Authorization", "Client-ID #{client_id}"}] - options = [] - case HTTPoison.get("https://api.imgur.com/3/image/#{image_id}", headers, options) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - {:ok, json} = Jason.decode(body) - data = json["data"] - title = String.slice(data["title"] || data["description"], 0, 180) - nsfw = if data["nsfw"], do: "(NSFW) - ", else: " " - height = Map.get(data, "height") - width = Map.get(data, "width") - size = Map.get(data, "size") - {:ok, "image, #{width}x#{height}, #{size} bytes #{nsfw}#{title}"} - other -> - :error - end - end - - def expand_imgur_album(album_id, opts) do - client_id = Keyword.get(Application.get_env(:nola, :imgur, []), :client_id, "42") - headers = [{"Authorization", "Client-ID #{client_id}"}] - options = [] - case HTTPoison.get("https://api.imgur.com/3/album/#{album_id}", headers, options) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - {:ok, json} = Jason.decode(body) - data = json["data"] - title = data["title"] - nsfw = data["nsfw"] - nsfw = if nsfw, do: "(NSFW) - ", else: "" - if data["images_count"] == 1 do - [image] = data["images"] - title = if title || data["title"] do - title = [title, data["title"]] |> Enum.filter(fn(x) -> x end) |> Enum.uniq() |> Enum.join(" — ") - "#{title} — " - else - "" - end - {:ok, "#{nsfw}#{title}#{image["link"]}"} - else - title = if title, do: title, else: "Untitled album" - {:ok, "#{nsfw}#{title} - #{data["images_count"]} images"} - end - other -> - :error - end - end - -end diff --git a/lib/lsg_irc/link_plugin/pdf.ex b/lib/lsg_irc/link_plugin/pdf.ex deleted file mode 100644 index 5f72ef5..0000000 --- a/lib/lsg_irc/link_plugin/pdf.ex +++ /dev/null @@ -1,39 +0,0 @@ -defmodule Nola.IRC.LinkPlugin.PDF do - require Logger - @behaviour Nola.IRC.LinkPlugin - - @impl true - def match(_, _), do: false - - @impl true - def post_match(_url, "application/pdf"<>_, _header, _opts) do - {:file, nil} - end - - def post_match(_, _, _, _), do: false - - @impl true - def post_expand(url, file, _, _) do - case System.cmd("pdftitle", ["-p", file]) do - {text, 0} -> - text = text - |> String.trim() - - if text == "" do - :error - else - basename = Path.basename(url, ".pdf") - text = "[#{basename}] " <> text - |> String.split("\n") - {:ok, text} - end - {_, 127} -> - Logger.error("dependency `pdftitle` is missing, please install it: `pip3 install pdftitle`.") - :error - {error, code} -> - Logger.warn("command `pdftitle` exited with status code #{code}:\n#{inspect error}") - :error - end - end - -end diff --git a/lib/lsg_irc/link_plugin/redacted.ex b/lib/lsg_irc/link_plugin/redacted.ex deleted file mode 100644 index 7a6229d..0000000 --- a/lib/lsg_irc/link_plugin/redacted.ex +++ /dev/null @@ -1,18 +0,0 @@ -defmodule Nola.IRC.LinkPlugin.Redacted do - @behaviour Nola.IRC.LinkPlugin - - @impl true - def match(uri = %URI{host: "redacted.ch", path: "/torrent.php", query: query = "id="<>id}, _opts) do - %{"id" => id} = URI.decode_query(id) - {true, %{torrent: id}} - end - - def match(_, _), do: false - - @impl true - def post_match(_, _, _, _), do: false - - def expand(_uri, %{torrent: id}, _opts) do - end - -end diff --git a/lib/lsg_irc/link_plugin/reddit.ex b/lib/lsg_irc/link_plugin/reddit.ex deleted file mode 100644 index 79102e0..0000000 --- a/lib/lsg_irc/link_plugin/reddit.ex +++ /dev/null @@ -1,119 +0,0 @@ -defmodule Nola.IRC.LinkPlugin.Reddit do - @behaviour Nola.IRC.LinkPlugin - - @impl true - def match(uri = %URI{host: "reddit.com", path: path}, _) do - case String.split(path, "/") do - ["", "r", sub, "comments", post_id, _slug] -> - {true, %{mode: :post, path: path, sub: sub, post_id: post_id}} - ["", "r", sub, "comments", post_id, _slug, ""] -> - {true, %{mode: :post, path: path, sub: sub, post_id: post_id}} - ["", "r", sub, ""] -> - {true, %{mode: :sub, path: path, sub: sub}} - ["", "r", sub] -> - {true, %{mode: :sub, path: path, sub: sub}} -# ["", "u", user] -> -# {true, %{mode: :user, path: path, user: user}} - _ -> - false - end - end - - def match(uri = %URI{host: host, path: path}, opts) do - if String.ends_with?(host, ".reddit.com") do - match(%URI{uri | host: "reddit.com"}, opts) - else - false - end - end - - @impl true - def post_match(_, _, _, _), do: false - - @impl true - def expand(_, %{mode: :sub, sub: sub}, _opts) do - url = "https://api.reddit.com/r/#{sub}/about" - case HTTPoison.get(url) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - sr = Jason.decode!(body) - |> Map.get("data") - |> IO.inspect(limit: :infinity) - description = Map.get(sr, "public_description")||Map.get(sr, "description", "") - |> String.split("\n") - |> List.first() - name = if title = Map.get(sr, "title") do - Map.get(sr, "display_name_prefixed") <> ": " <> title - else - Map.get(sr, "display_name_prefixed") - end - nsfw = if Map.get(sr, "over18") do - "[NSFW] " - else - "" - end - quarantine = if Map.get(sr, "quarantine") do - "[Quarantined] " - else - "" - end - count = "#{Map.get(sr, "subscribers")} subscribers, #{Map.get(sr, "active_user_count")} active" - preview = "#{quarantine}#{nsfw}#{name} — #{description} (#{count})" - {:ok, preview} - _ -> - :error - end - end - - def expand(_uri, %{mode: :post, path: path, sub: sub, post_id: post_id}, _opts) do - case HTTPoison.get("https://api.reddit.com#{path}?sr_detail=true") do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - json = Jason.decode!(body) - op = List.first(json) - |> Map.get("data") - |> Map.get("children") - |> List.first() - |> Map.get("data") - |> IO.inspect(limit: :infinity) - sr = get_in(op, ["sr_detail", "display_name_prefixed"]) - {self?, url} = if Map.get(op, "selftext") == "" do - {false, Map.get(op, "url")} - else - {true, nil} - end - - self_str = if(self?, do: "text", else: url) - up = Map.get(op, "ups") - down = Map.get(op, "downs") - comments = Map.get(op, "num_comments") - nsfw = if Map.get(op, "over_18") do - "[NSFW] " - else - "" - end - state = cond do - Map.get(op, "hidden") -> "hidden" - Map.get(op, "archived") -> "archived" - Map.get(op, "locked") -> "locked" - Map.get(op, "quarantine") -> "quarantined" - Map.get(op, "removed_by") || Map.get(op, "removed_by_category") -> "removed" - Map.get(op, "banned_by") -> "banned" - Map.get(op, "pinned") -> "pinned" - Map.get(op, "stickied") -> "stickied" - true -> nil - end - flair = if flair = Map.get(op, "link_flair_text") do - "[#{flair}] " - else - "" - end - title = "#{nsfw}#{sr}: #{flair}#{Map.get(op, "title")}" - state_str = if(state, do: "#{state}, ") - content = "by u/#{Map.get(op, "author")} - #{state_str}#{up} up, #{down} down, #{comments} comments - #{self_str}" - - {:ok, [title, content]} - err -> - :error - end - end - -end diff --git a/lib/lsg_irc/link_plugin/twitter.ex b/lib/lsg_irc/link_plugin/twitter.ex deleted file mode 100644 index 640b193..0000000 --- a/lib/lsg_irc/link_plugin/twitter.ex +++ /dev/null @@ -1,158 +0,0 @@ -defmodule Nola.IRC.LinkPlugin.Twitter do - @behaviour Nola.IRC.LinkPlugin - - @moduledoc """ - # Twitter Link Preview - - Configuration: - - needs an API key and auth tokens: - - ``` - config :extwitter, :oauth, [ - consumer_key: "zzzzz", - consumer_secret: "xxxxxxx", - access_token: "yyyyyy", - access_token_secret: "ssshhhhhh" - ] - ``` - - options: - - * `expand_quoted`: Add the quoted tweet instead of its URL. Default: true. - """ - - def match(uri = %URI{host: twitter, path: path}, _opts) when twitter in ["twitter.com", "m.twitter.com", "mobile.twitter.com"] do - case String.split(path, "/", parts: 4) do - ["", _username, "status", status_id] -> - {status_id, _} = Integer.parse(status_id) - {true, %{status_id: status_id}} - _ -> false - end - end - - def match(_, _), do: false - - @impl true - def post_match(_, _, _, _), do: false - - def expand(_uri, %{status_id: status_id}, opts) do - expand_tweet(ExTwitter.show(status_id, tweet_mode: "extended"), opts) - end - - defp expand_tweet(nil, _opts) do - :error - end - - defp link_tweet(tweet_or_screen_id_tuple, opts, force_twitter_com \\ false) - - defp link_tweet({screen_name, id}, opts, force_twitter_com) do - path = "/#{screen_name}/status/#{id}" - nitter = Keyword.get(opts, :nitter) - host = if !force_twitter_com && nitter, do: nitter, else: "twitter.com" - "https://#{host}/#{screen_name}/status/#{id}" - end - - defp link_tweet(tweet, opts, force_twitter_com) do - link_tweet({tweet.user.screen_name, tweet.id}, opts, force_twitter_com) - end - - defp expand_tweet(tweet, opts) do - head = format_tweet_header(tweet, opts) - - # Format tweet text - text = expand_twitter_text(tweet, opts) - text = if tweet.quoted_status do - quote_url = link_tweet(tweet.quoted_status, opts, true) - String.replace(text, quote_url, "") - else - text - end - text = IRC.splitlong(text) - - reply_to = if tweet.in_reply_to_status_id do - reply_url = link_tweet({tweet.in_reply_to_screen_name, tweet.in_reply_to_status_id}, opts) - text = if tweet.in_reply_to_screen_name == tweet.user.screen_name, do: "continued from", else: "replying to" - <<3, 15, " ↪ ", text::binary, " ", reply_url::binary, 3>> - end - - quoted = if tweet.quoted_status do - full_text = tweet.quoted_status - |> expand_twitter_text(opts) - |> IRC.splitlong_with_prefix(">") - - head = format_tweet_header(tweet.quoted_status, opts, details: false, prefix: "↓ quoting") - - [head | full_text] - else - [] - end - - #<<2, "#{tweet.user.name} (@#{tweet.user.screen_name})", 2, " ", 3, 61, "#{foot} #{nitter_link}", 3>>, reply_to] ++ text ++ quoted - - text = [head, reply_to | text] ++ quoted - |> Enum.filter(& &1) - {:ok, text} - end - - defp expand_twitter_text(tweet, _opts) do - text = Enum.reduce(tweet.entities.urls, tweet.full_text, fn(entity, text) -> - String.replace(text, entity.url, entity.expanded_url) - end) - extended = tweet.extended_entities || %{media: []} - text = Enum.reduce(extended.media, text, fn(entity, text) -> - url = Enum.filter(extended.media, fn(e) -> entity.url == e.url end) - |> Enum.map(fn(e) -> - cond do - e.type == "video" -> e.expanded_url - true -> e.media_url_https - end - end) - |> Enum.join(" ") - String.replace(text, entity.url, url) - end) - |> HtmlEntities.decode() - end - - defp format_tweet_header(tweet, opts, format_opts \\ []) do - prefix = Keyword.get(format_opts, :prefix, nil) - details = Keyword.get(format_opts, :details, true) - - padded_prefix = if prefix, do: "#{prefix} ", else: "" - author = <> - - link = link_tweet(tweet, opts) - - {:ok, at} = Timex.parse(tweet.created_at, "%a %b %e %H:%M:%S %z %Y", :strftime) - {:ok, formatted_time} = Timex.format(at, "{relative}", :relative) - - nsfw = if tweet.possibly_sensitive, do: <<3, 52, "NSFW", 3>> - - rts = if tweet.retweet_count && tweet.retweet_count > 0, do: "#{tweet.retweet_count} RT" - likes = if tweet.favorite_count && tweet.favorite_count > 0, do: "#{tweet.favorite_count} ❤︎" - qrts = if tweet.quote_count && tweet.quote_count > 0, do: "#{tweet.quote_count} QRT" - replies = if tweet.reply_count && tweet.reply_count > 0, do: "#{tweet.reply_count} Reps" - - dmcad = if tweet.withheld_copyright, do: <<3, 52, "DMCA", 3>> - withheld_local = if tweet.withheld_in_countries && length(tweet.withheld_in_countries) > 0 do - "Withheld in #{length(tweet.withheld_in_countries)} countries" - end - - verified = if tweet.user.verified, do: <<3, 51, "✔", 3>> - - meta = if details do - [verified, nsfw, formatted_time, dmcad, withheld_local, rts, qrts, likes, replies] - else - [verified, nsfw, formatted_time, dmcad, withheld_local] - end - - meta = meta - |> Enum.filter(& &1) - |> Enum.join(" - ") - - meta = <<3, 15, meta::binary, " → #{link}", 3>> - - <> - end - -end diff --git a/lib/lsg_irc/link_plugin/youtube.ex b/lib/lsg_irc/link_plugin/youtube.ex deleted file mode 100644 index f7c7541..0000000 --- a/lib/lsg_irc/link_plugin/youtube.ex +++ /dev/null @@ -1,72 +0,0 @@ -defmodule Nola.IRC.LinkPlugin.YouTube do - @behaviour Nola.IRC.LinkPlugin - - @moduledoc """ - # YouTube link preview - - needs an API key: - - ``` - config :nola, :youtube, - api_key: "xxxxxxxxxxxxx" - ``` - - options: - - * `invidious`: Add a link to invidious. - """ - - @impl true - def match(uri = %URI{host: yt, path: "/watch", query: "v="<>video_id}, _opts) when yt in ["youtube.com", "www.youtube.com"] do - {true, %{video_id: video_id}} - end - - def match(%URI{host: "youtu.be", path: "/"<>video_id}, _opts) do - {true, %{video_id: video_id}} - end - - def match(_, _), do: false - - @impl true - def post_match(_, _, _, _), do: false - - @impl true - def expand(uri, %{video_id: video_id}, opts) do - key = Application.get_env(:nola, :youtube)[:api_key] - params = %{ - "part" => "snippet,contentDetails,statistics", - "id" => video_id, - "key" => key - } - headers = [] - options = [params: params] - case HTTPoison.get("https://www.googleapis.com/youtube/v3/videos", [], options) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - case Jason.decode(body) do - {:ok, json} -> - item = List.first(json["items"]) - if item do - snippet = item["snippet"] - duration = item["contentDetails"]["duration"] |> String.replace("PT", "") |> String.downcase - date = snippet["publishedAt"] - |> DateTime.from_iso8601() - |> elem(1) - |> Timex.format("{relative}", :relative) - |> elem(1) - - line = if host = Keyword.get(opts, :invidious) do - ["-> https://#{host}/watch?v=#{video_id}"] - else - [] - end - {:ok, line ++ ["#{snippet["title"]}", "— #{duration} — uploaded by #{snippet["channelTitle"]} — #{date}" - <> " — #{item["statistics"]["viewCount"]} views, #{item["statistics"]["likeCount"]} likes"]} - else - :error - end - _ -> :error - end - end - end - -end diff --git a/lib/lsg_irc/logger_plugin.ex b/lib/lsg_irc/logger_plugin.ex deleted file mode 100644 index b13f33a..0000000 --- a/lib/lsg_irc/logger_plugin.ex +++ /dev/null @@ -1,70 +0,0 @@ -defmodule Nola.IRC.LoggerPlugin do - require Logger - - @couch_db "bot-logs" - - def irc_doc(), do: nil - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def init([]) do - regopts = [plugin: __MODULE__] - {:ok, _} = Registry.register(IRC.PubSub, "triggers", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "irc:outputs", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "messages:private", regopts) - {:ok, nil} - end - - def handle_info({:irc, :trigger, _, m}, state) do - {:noreply, log(m, state)} - end - - def handle_info({:irc, :text, m}, state) do - {:noreply, log(m, state)} - end - - def handle_info(info, state) do - Logger.debug("logger_plugin: unhandled info: #{info}") - {:noreply, state} - end - - def log(entry, state) do - case Couch.post(@couch_db, format_to_db(entry)) do - {:ok, id, _rev} -> - Logger.debug("logger_plugin: saved: #{inspect id}") - state - error -> - Logger.error("logger_plugin: save failed: #{inspect error}") - end - rescue - e -> - Logger.error("logger_plugin: rescued processing for #{inspect entry}: #{inspect e}") - Logger.error(Exception.format(:error, e, __STACKTRACE__)) - state - catch - e, b -> - Logger.error("logger_plugin: catched processing for #{inspect entry}: #{inspect e}") - Logger.error(Exception.format(e, b, __STACKTRACE__)) - state - end - - def format_to_db(msg = %IRC.Message{id: id}) do - msg - |> Poison.encode!() - |> Map.drop("id") - - %{"_id" => id || FlakeId.get(), - "type" => "irc.message/v1", - "object" => msg} - end - - def format_to_db(anything) do - %{"_id" => FlakeId.get(), - "type" => "object", - "object" => anything} - end - -end diff --git a/lib/lsg_irc/lsg_irc.ex b/lib/lsg_irc/lsg_irc.ex deleted file mode 100644 index f64978a..0000000 --- a/lib/lsg_irc/lsg_irc.ex +++ /dev/null @@ -1,34 +0,0 @@ -defmodule Nola.IRC do - require Logger - - def env(), do: Nola.env(:irc) - def env(key, default \\ nil), do: Keyword.get(env(), key, default) - - def application_childs do - import Supervisor.Spec - - IRC.Connection.setup() - IRC.Plugin.setup() - - [ - worker(Registry, [[keys: :duplicate, name: IRC.ConnectionPubSub]], id: :registr_irc_conn), - worker(Registry, [[keys: :duplicate, name: IRC.PubSub]], id: :registry_irc), - worker(IRC.Membership, []), - worker(IRC.Account, []), - worker(IRC.UserTrack.Storage, []), - worker(IRC.Account.AccountPlugin, []), - supervisor(IRC.Plugin.Supervisor, [], [name: IRC.Plugin.Supervisor]), - supervisor(IRC.Connection.Supervisor, [], [name: IRC.Connection.Supervisor]), - supervisor(IRC.PuppetConnection.Supervisor, [], [name: IRC.PuppetConnection.Supervisor]), - ] - end - - # Start plugins first to let them get on connection events. - def after_start() do - Logger.info("Starting plugins") - IRC.Plugin.start_all() - Logger.info("Starting connections") - IRC.Connection.start_all() - end - -end diff --git a/lib/lsg_irc/outline_plugin.ex b/lib/lsg_irc/outline_plugin.ex deleted file mode 100644 index 820500e..0000000 --- a/lib/lsg_irc/outline_plugin.ex +++ /dev/null @@ -1,108 +0,0 @@ -defmodule Nola.IRC.OutlinePlugin do - @moduledoc """ - # outline auto-link - - Envoie un lien vers Outline quand un lien est envoyé. - - * **!outline ``** crée un lien outline pour ``. - * **+outline ``** active outline pour ``. - * **-outline ``** désactive outline pour ``. - """ - def short_irc_doc, do: false - def irc_doc, do: @moduledoc - require Logger - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - defstruct [:file, :hosts] - - def init([]) do - regopts = [plugin: __MODULE__] - {:ok, _} = Registry.register(IRC.PubSub, "trigger:outline", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) - file = Path.join(Nola.data_path, "/outline.txt") - hosts = case File.read(file) do - {:error, :enoent} -> - [] - {:ok, lines} -> - String.split(lines, "\n", trim: true) - end - {:ok, %__MODULE__{file: file, hosts: hosts}} - end - - def handle_info({:irc, :trigger, "outline", message = %IRC.Message{trigger: %IRC.Trigger{type: :plus, args: [host]}}}, state) do - state = %{state | hosts: [host | state.hosts]} - save(state) - message.replyfun.("ok") - {:noreply, state} - end - - def handle_info({:irc, :trigger, "outline", message = %IRC.Message{trigger: %IRC.Trigger{type: :minus, args: [host]}}}, state) do - state = %{state | hosts: List.delete(state.hosts, host)} - save(state) - message.replyfun.("ok") - {:noreply, state} - end - - def handle_info({:irc, :trigger, "outline", message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [url]}}}, state) do - line = "-> #{outline(url)}" - message.replyfun.(line) - end - - def handle_info({:irc, :text, message = %IRC.Message{text: text}}, state) do - String.split(text) - |> Enum.map(fn(word) -> - if String.starts_with?(word, "http://") || String.starts_with?(word, "https://") do - uri = URI.parse(word) - if uri.scheme && uri.host do - if Enum.any?(state.hosts, fn(host) -> String.ends_with?(uri.host, host) end) do - outline_url = outline(word) - line = "-> #{outline_url}" - message.replyfun.(line) - end - end - end - end) - {:noreply, state} - end - - def handle_info(msg, state) do - {:noreply, state} - end - - def save(state = %{file: file, hosts: hosts}) do - string = Enum.join(hosts, "\n") - File.write(file, string) - end - - def outline(url) do - unexpanded = "https://outline.com/#{url}" - headers = [ - {"User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:77.0) Gecko/20100101 Firefox/77.0"}, - {"Accept", "*/*"}, - {"Accept-Language", "en-US,en;q=0.5"}, - {"Origin", "https://outline.com"}, - {"DNT", "1"}, - {"Referer", unexpanded}, - {"Pragma", "no-cache"}, - {"Cache-Control", "no-cache"} - ] - params = %{"source_url" => url} - case HTTPoison.get("https://api.outline.com/v3/parse_article", headers, params: params) do - {:ok, %HTTPoison.Response{status_code: 200, body: json}} -> - body = Poison.decode!(json) - if Map.get(body, "success") do - code = get_in(body, ["data", "short_code"]) - "https://outline.com/#{code}" - else - unexpanded - end - error -> - Logger.info("outline.com error: #{inspect error}") - unexpanded - end - end - -end diff --git a/lib/lsg_irc/preums_plugin.ex b/lib/lsg_irc/preums_plugin.ex deleted file mode 100644 index f250e85..0000000 --- a/lib/lsg_irc/preums_plugin.ex +++ /dev/null @@ -1,276 +0,0 @@ -defmodule Nola.IRC.PreumsPlugin do - @moduledoc """ - # preums !!! - - * `!preums`: affiche le preums du jour - * `.preums`: stats des preums - """ - - # WIP Scores - # L'idée c'est de donner un score pour mettre un peu de challenge en pénalisant les preums faciles. - # - # Un preums ne vaut pas 1 point, mais plutôt 0.10 ou 0.05, et on arrondi au plus proche. C'est un jeu sur le long - # terme. Un gros bonus pourrait apporter beaucoup de points. - # - # Il faudrait ces données: - # - moyenne des preums - # - activité récente du channel et par nb actifs d'utilisateurs - # (aggréger memberships+usertrack last_active ?) - # (faire des stats d'activité habituelle (un peu a la pisg) ?) - # - preums consécutifs - # - # Malus: - # - est proche de la moyenne en faible activité - # - trop consécutif de l'utilisateur sauf si activité - # - # Bonus: - # - plus le preums est éloigné de la moyenne - # - après 18h double - # - plus l'activité est élévée, exponentiel selon la moyenne - # - derns entre 4 et 6 (pourrait être adapté selon les stats d'activité) - # - # WIP Badges: - # - derns - # - streaks - # - faciles - # - ? - - require Logger - - @perfects [~r/preum(s|)/i] - - # dets {{chan, day = {yyyy, mm, dd}}, nick, now, perfect?, text} - - def all(dets) do - :dets.foldl(fn(i, acc) -> [i|acc] end, [], dets) - end - - def all(dets, channel) do - fun = fn({{chan, date}, account_id, time, perfect, text}, acc) -> - if channel == chan do - [%{date: date, account_id: account_id, time: time, perfect: perfect, text: text} | acc] - else - acc - end - end - :dets.foldl(fun, [], dets) - end - - def topnicks(dets, channel, options \\ []) do - sort_elem = case Keyword.get(options, :sort_by, :score) do - :score -> 1 - :count -> 0 - end - - fun = fn(x = {{chan, date}, account_id, time, perfect, text}, acc) -> - if (channel == nil and chan) or (channel == chan) do - {count, points} = Map.get(acc, account_id, {0, 0}) - score = score(chan, account_id, time, perfect, text) - Map.put(acc, account_id, {count + 1, points + score}) - else - acc - end - end - :dets.foldl(fun, %{}, dets) - |> Enum.sort_by(fn({_account_id, value}) -> elem(value, sort_elem) end, &>=/2) - end - - def irc_doc, do: @moduledoc - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def dets do - (Nola.data_path() <> "/preums.dets") |> String.to_charlist() - end - - def init([]) do - regopts = [plugin: __MODULE__] - {:ok, _} = Registry.register(IRC.PubSub, "account", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "triggers", regopts) - {:ok, dets} = :dets.open_file(dets(), [{:repair, :force}]) - Util.ets_mutate_select_each(:dets, dets, [{:"$1", [], [:"$1"]}], fn(table, obj) -> - {key, nick, now, perfect, text} = obj - case key do - {{net, {bork,chan}}, date} -> - :dets.delete(table, key) - nick = if IRC.Account.get(nick) do - nick - else - if acct = IRC.Account.find_always_by_nick(net, nil, nick) do - acct.id - else - nick - end - end - :dets.insert(table, { { {net,chan}, date }, nick, now, perfect, text}) - {{_net, nil}, _} -> - :dets.delete(table, key) - {{net, chan}, date} -> - if !IRC.Account.get(nick) do - if acct = IRC.Account.find_always_by_nick(net, chan, nick) do - :dets.delete(table, key) - :dets.insert(table, { { {net,chan}, date }, acct.id, now, perfect, text}) - end - end - _ -> - Logger.debug("DID NOT FIX: #{inspect key}") - end - end) - {:ok, %{dets: dets}} - end - - # Latest - def handle_info({:irc, :trigger, "preums", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang}}}, state) do - channelkey = {m.network, m.channel} - state = handle_preums(m, state) - tz = timezone(channelkey) - {:ok, now} = DateTime.now(tz, Tzdata.TimeZoneDatabase) - date = {now.year, now.month, now.day} - key = {channelkey, date} - chan_cache = Map.get(state, channelkey, %{}) - item = if i = Map.get(chan_cache, date) do - i - else - case :dets.lookup(state.dets, key) do - [item = {^key, _account_id, _now, _perfect, _text}] -> item - _ -> nil - end - end - - if item do - {_, account_id, date, _perfect, text} = item - h = "#{date.hour}:#{date.minute}:#{date.second}" - account = IRC.Account.get(account_id) - user = IRC.UserTrack.find_by_account(m.network, account) - nick = if(user, do: user.nick, else: account.name) - m.replyfun.("preums: #{nick} à #{h}: “#{text}”") - end - {:noreply, state} - end - - # Stats - def handle_info({:irc, :trigger, "preums", m = %IRC.Message{trigger: %IRC.Trigger{type: :dot}}}, state) do - channel = {m.network, m.channel} - state = handle_preums(m, state) - top = topnicks(state.dets, channel, sort_by: :score) - |> Enum.map(fn({account_id, {count, score}}) -> - account = IRC.Account.get(account_id) - user = IRC.UserTrack.find_by_account(m.network, account) - nick = if(user, do: user.nick, else: account.name) - "#{nick}: #{score} (#{count})" - end) - |> Enum.intersperse(", ") - |> Enum.join("") - msg = unless top == "" do - "top preums: #{top}" - else - "vous êtes tous nuls" - end - m.replyfun.(msg) - {:noreply, state} - end - - # Help - def handle_info({:irc, :trigger, "preums", m = %IRC.Message{trigger: %IRC.Trigger{type: :query}}}, state) do - state = handle_preums(m, state) - msg = "!preums - preums du jour, .preums top preumseurs" - m.replymsg.(msg) - {:noreply, state} - end - - # Trigger fallback - def handle_info({:irc, :trigger, _, m = %IRC.Message{}}, state) do - state = handle_preums(m, state) - {:noreply, state} - end - - # Message fallback - def handle_info({:irc, :text, m = %IRC.Message{}}, state) do - {:noreply, handle_preums(m, state)} - end - - # Account - def handle_info({:account_change, old_id, new_id}, state) do - spec = [{{:_, :"$1", :_, :_, :_}, [{:==, :"$1", {:const, old_id}}], [:"$_"]}] - Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> - rename_object_owner(table, obj, new_id) - end) - {:noreply, state} - end - - # Account: move from nick to account id - # FIXME: Doesn't seem to work. - def handle_info({:accounts, accounts}, state) do - for x={:account, _net, _chan, _nick, _account_id} <- accounts do - handle_info(x, state) - end - {:noreply, state} - end - def handle_info({:account, _net, _chan, nick, account_id}, state) do - nick = String.downcase(nick) - spec = [{{:_, :"$1", :_, :_, :_}, [{:==, :"$1", {:const, nick}}], [:"$_"]}] - Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> - Logger.debug("account:: merging #{nick} -> #{account_id}") - rename_object_owner(table, obj, account_id) - end) - {:noreply, state} - end - - def handle_info(_, dets) do - {:noreply, dets} - end - - - defp rename_object_owner(table, object = {key, _, now, perfect, time}, new_id) do - :dets.delete_object(table, key) - :dets.insert(table, {key, new_id, now, perfect, time}) - end - - defp timezone(channel) do - env = Application.get_env(:nola, Nola.IRC.PreumsPlugin, []) - channels = Keyword.get(env, :channels, %{}) - channel_settings = Map.get(channels, channel, []) - default = Keyword.get(env, :default_tz, "Europe/Paris") - Keyword.get(channel_settings, :tz, default) || default - end - - defp handle_preums(%IRC.Message{channel: nil}, state) do - state - end - - defp handle_preums(m = %IRC.Message{text: text, sender: sender}, state) do - channel = {m.network, m.channel} - tz = timezone(channel) - {:ok, now} = DateTime.now(tz, Tzdata.TimeZoneDatabase) - date = {now.year, now.month, now.day} - key = {channel, date} - chan_cache = Map.get(state, channel, %{}) - unless i = Map.get(chan_cache, date) do - case :dets.lookup(state.dets, key) do - [item = {^key, _nick, _now, _perfect, _text}] -> - # Preums lost, but wasn't cached - Map.put(state, channel, %{date => item}) - [] -> - # Preums won! - perfect? = Enum.any?(@perfects, fn(perfect) -> Regex.match?(perfect, text) end) - item = {key, m.account.id, now, perfect?, text} - :dets.insert(state.dets, item) - :dets.sync(state.dets) - Map.put(state, channel, %{date => item}) - {:error, _} = error -> - Logger.error("#{__MODULE__} dets lookup failed: #{inspect error}") - state - end - else - state - end - end - - def score(_chan, _account, _time, _perfect, _text) do - 1 - end - - -end diff --git a/lib/lsg_irc/quatre_cent_vingt_plugin.ex b/lib/lsg_irc/quatre_cent_vingt_plugin.ex deleted file mode 100644 index 8953ea3..0000000 --- a/lib/lsg_irc/quatre_cent_vingt_plugin.ex +++ /dev/null @@ -1,149 +0,0 @@ -defmodule Nola.IRC.QuatreCentVingtPlugin do - require Logger - - @moduledoc """ - # 420 - - * **!420**: recorde un nouveau 420. - * **!420*x**: recorde un nouveau 420*x (*2 = 840, ...) (à vous de faire la multiplication). - * **!420 pseudo**: stats du pseudo. - """ - - @achievements %{ - 1 => ["[le premier… il faut bien commencer un jour]"], - 10 => ["T'en es seulement à 10 ? ╭∩╮(Ο_Ο)╭∩╮"], - 42 => ["Bravo, et est-ce que autant de pétards t'on aidés à trouver la Réponse ? ٩(- ̮̮̃-̃)۶ [42]"], - 100 => ["°º¤ø,¸¸,ø¤º°`°º¤ø,¸,ø¤°º¤ø,¸¸,ø¤º°`°º¤ø,¸ 100 °º¤ø,¸¸,ø¤º°`°º¤ø,¸,ø¤°º¤ø,¸¸,ø¤º°`°º¤ø,¸"], - 115 => [" ۜ\(סּںסּَ` )/ۜ 115!!"] - } - - @emojis [ - "\\o/", - "~o~", - "~~o∞~~", - "*\\o/*", - "**\\o/**", - "*ô*", - ] - - @coeffs Range.new(1, 100) - - def irc_doc, do: @moduledoc - - def start_link, do: GenServer.start_link(__MODULE__, [], name: __MODULE__) - - def init(_) do - for coeff <- @coeffs do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{420*coeff}", [plugin: __MODULE__]) - end - {:ok, _} = Registry.register(IRC.PubSub, "account", [plugin: __MODULE__]) - dets_filename = (Nola.data_path() <> "/420.dets") |> String.to_charlist - {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag},{:repair,:force}]) - {:ok, dets} - :ignore - end - - for coeff <- @coeffs do - qvc = to_string(420 * coeff) - def handle_info({:irc, :trigger, unquote(qvc), m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :bang}}}, dets) do - {count, last} = get_statistics_for_nick(dets, m.account.id) - count = count + unquote(coeff) - text = achievement_text(count) - now = DateTime.to_unix(DateTime.utc_now())-1 # this is ugly - for i <- Range.new(1, unquote(coeff)) do - :ok = :dets.insert(dets, {m.account.id, now+i}) - end - last_s = if last do - last_s = format_relative_timestamp(last) - " (le dernier était #{last_s})" - else - "" - end - m.replyfun.("#{m.sender.nick} 420 +#{unquote(coeff)} #{text}#{last_s}") - {:noreply, dets} - end - end - - def handle_info({:irc, :trigger, "420", m = %IRC.Message{trigger: %IRC.Trigger{args: [nick], type: :bang}}}, dets) do - account = IRC.Account.find_by_nick(m.network, nick) - if account do - text = case get_statistics_for_nick(dets, m.account.id) do - {0, _} -> "#{nick} n'a jamais !420 ... honte à lui." - {count, last} -> - last_s = format_relative_timestamp(last) - "#{nick} 420: total #{count}, le dernier #{last_s}" - end - m.replyfun.(text) - else - m.replyfun.("je connais pas de #{nick}") - end - {:noreply, dets} - end - - # Account - def handle_info({:account_change, old_id, new_id}, dets) do - spec = [{{:"$1", :_}, [{:==, :"$1", {:const, old_id}}], [:"$_"]}] - Util.ets_mutate_select_each(:dets, dets, spec, fn(table, obj) -> - rename_object_owner(table, obj, new_id) - end) - {:noreply, dets} - end - - # Account: move from nick to account id - def handle_info({:accounts, accounts}, dets) do - for x={:account, _net, _chan, _nick, _account_id} <- accounts do - handle_info(x, dets) - end - {:noreply, dets} - end - def handle_info({:account, _net, _chan, nick, account_id}, dets) do - nick = String.downcase(nick) - spec = [{{:"$1", :_}, [{:==, :"$1", {:const, nick}}], [:"$_"]}] - Util.ets_mutate_select_each(:dets, dets, spec, fn(table, obj) -> - Logger.debug("account:: merging #{nick} -> #{account_id}") - rename_object_owner(table, obj, account_id) - end) - {:noreply, dets} - end - - def handle_info(_, dets) do - {:noreply, dets} - end - - defp rename_object_owner(table, object = {_, at}, account_id) do - :dets.delete_object(table, object) - :dets.insert(table, {account_id, at}) - end - - - defp format_relative_timestamp(timestamp) do - alias Timex.Format.DateTime.Formatters - alias Timex.Timezone - date = timestamp - |> DateTime.from_unix! - |> Timezone.convert("Europe/Paris") - - {:ok, relative} = Formatters.Relative.relative_to(date, Timex.now("Europe/Paris"), "{relative}", "fr") - {:ok, detail} = Formatters.Default.lformat(date, " ({h24}:{m})", "fr") - - relative <> detail - end - - defp get_statistics_for_nick(dets, acct) do - qvc = :dets.lookup(dets, acct) |> Enum.sort - count = Enum.reduce(qvc, 0, fn(_, acc) -> acc + 1 end) - {_, last} = List.last(qvc) || {nil, nil} - {count, last} - end - - @achievements_keys Map.keys(@achievements) - defp achievement_text(count) when count in @achievements_keys do - Enum.random(Map.get(@achievements, count)) - end - - defp achievement_text(count) do - emoji = Enum.random(@emojis) - "#{emoji} [#{count}]" - end - -end diff --git a/lib/lsg_irc/radio_france_plugin.ex b/lib/lsg_irc/radio_france_plugin.ex deleted file mode 100644 index c2e966f..0000000 --- a/lib/lsg_irc/radio_france_plugin.ex +++ /dev/null @@ -1,133 +0,0 @@ -defmodule Nola.IRC.RadioFrancePlugin do - require Logger - - def irc_doc() do - """ - # radio france - - Qu'est ce qu'on écoute sur radio france ? - - * **!radiofrance `[station]`, !rf `[station]`** - * **!fip, !inter, !info, !bleu, !culture, !musique, !fip `[sous-station]`, !bleu `[région]`** - """ - end - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - @trigger "radiofrance" - @shortcuts ~w(fip inter info bleu culture musique) - - def init(_) do - regopts = [plugin: __MODULE__] - {:ok, _} = Registry.register(IRC.PubSub, "trigger:radiofrance", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "trigger:rf", regopts) - for s <- @shortcuts do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{s}", regopts) - end - {:ok, nil} - end - - def handle_info({:irc, :trigger, "rf", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang}}}, state) do - handle_info({:irc, :trigger, "radiofrance", m}, state) - end - - def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: []}}}, state) do - m.replyfun.("radiofrance: précisez la station!") - {:noreply, state} - end - - def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: args}}}, state) do - now(args_to_station(args), m) - {:noreply, state} - end - - def handle_info({:irc, :trigger, trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: args}}}, state) when trigger in @shortcuts do - now(args_to_station([trigger | args]), m) - {:noreply, state} - end - - defp args_to_station(args) do - args - |> Enum.map(&unalias/1) - |> Enum.map(&String.downcase/1) - |> Enum.join("_") - end - - def handle_info(info, state) do - Logger.debug("unhandled info: #{inspect info}") - {:noreply, state} - end - - defp now(station, m) when is_binary(station) do - case HTTPoison.get(np_url(station), [], []) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - json = Poison.decode!(body) - song? = !!get_in(json, ["now", "song"]) - station = reformat_station_name(get_in(json, ["now", "stationName"])) - now_title = get_in(json, ["now", "firstLine", "title"]) - now_subtitle = get_in(json, ["now", "secondLine", "title"]) - next_title = get_in(json, ["next", "firstLine", "title"]) - next_subtitle = get_in(json, ["next", "secondLine", "title"]) - next_song? = !!get_in(json, ["next", "song"]) - next_at = get_in(json, ["next", "startTime"]) - - now = format_title(song?, now_title, now_subtitle) - prefix = if song?, do: "🎶", else: "🎤" - m.replyfun.("#{prefix} #{station}: #{now}") - - next = format_title(song?, next_title, next_subtitle) - if next do - next_prefix = if next_at do - next_date = DateTime.from_unix!(next_at) - in_seconds = DateTime.diff(next_date, DateTime.utc_now()) - in_minutes = ceil(in_seconds / 60) - if in_minutes >= 5 do - if next_song?, do: "#{in_minutes}m 🔜", else: "dans #{in_minutes} minutes:" - else - if next_song?, do: "🔜", else: "suivi de:" - end - else - if next_song?, do: "🔜", else: "à suivre:" - end - m.replyfun.("#{next_prefix} #{next}") - end - - {:error, %HTTPoison.Response{status_code: 404}} -> - m.replyfun.("radiofrance: la radio \"#{station}\" n'existe pas") - - {:error, %HTTPoison.Response{status_code: code}} -> - m.replyfun.("radiofrance: erreur http #{code}") - - _ -> - m.replyfun.("radiofrance: ça n'a pas marché, rip") - end - end - - defp np_url(station), do: "https://www.radiofrance.fr/api/v2.0/stations/#{station}/live" - - defp unalias("inter"), do: "franceinter" - defp unalias("info"), do: "franceinfo" - defp unalias("bleu"), do: "francebleu" - defp unalias("culture"), do: "franceculture" - defp unalias("musique"), do: "francemusique" - defp unalias(station), do: station - - defp format_title(_, nil, nil) do - nil - end - defp format_title(true, title, artist) do - [artist, title] |> Enum.filter(& &1) |> Enum.join(" - ") - end - defp format_title(false, show, section) do - [show, section] |> Enum.filter(& &1) |> Enum.join(": ") - end - - defp reformat_station_name(station) do - station - |> String.replace("france", "france ") - |> String.replace("_", " ") - end - -end diff --git a/lib/lsg_irc/say_plugin.ex b/lib/lsg_irc/say_plugin.ex deleted file mode 100644 index 915b0f6..0000000 --- a/lib/lsg_irc/say_plugin.ex +++ /dev/null @@ -1,73 +0,0 @@ -defmodule Nola.IRC.SayPlugin do - - def irc_doc do - """ - # say - - Say something... - - * **!say `` ``** say something on `channel` - * **!asay `` ``** same but anonymously - - You must be a member of the channel. - """ - end - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def init([]) do - regopts = [type: __MODULE__] - {:ok, _} = Registry.register(IRC.PubSub, "trigger:say", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "trigger:asay", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "messages:private", regopts) - {:ok, nil} - end - - def handle_info({:irc, :trigger, "say", m = %{trigger: %{type: :bang, args: [target | text]}}}, state) do - text = Enum.join(text, " ") - say_for(m.account, target, text, true) - {:noreply, state} - end - - def handle_info({:irc, :trigger, "asay", m = %{trigger: %{type: :bang, args: [target | text]}}}, state) do - text = Enum.join(text, " ") - say_for(m.account, target, text, false) - {:noreply, state} - end - - def handle_info({:irc, :text, m = %{text: "say "<>rest}}, state) do - case String.split(rest, " ", parts: 2) do - [target, text] -> say_for(m.account, target, text, true) - _ -> nil - end - {:noreply, state} - end - - def handle_info({:irc, :text, m = %{text: "asay "<>rest}}, state) do - case String.split(rest, " ", parts: 2) do - [target, text] -> say_for(m.account, target, text, false) - _ -> nil - end - {:noreply, state} - end - - def handle_info(_, state) do - {:noreply, state} - end - - defp say_for(account, target, text, with_nick?) do - for {net, chan} <- IRC.Membership.of_account(account) do - chan2 = String.replace(chan, "#", "") - if (target == "#{net}/#{chan}" || target == "#{net}/#{chan2}" || target == chan || target == chan2) do - if with_nick? do - IRC.send_message_as(account, net, chan, text) - else - IRC.Connection.broadcast_message(net, chan, text) - end - end - end - end - -end diff --git a/lib/lsg_irc/script_plugin.ex b/lib/lsg_irc/script_plugin.ex deleted file mode 100644 index 94d4edf..0000000 --- a/lib/lsg_irc/script_plugin.ex +++ /dev/null @@ -1,42 +0,0 @@ -defmodule Nola.IRC.ScriptPlugin do - require Logger - - @moduledoc """ - Allows to run outside scripts. Scripts are expected to be long running and receive/send data as JSON over stdin/stdout. - - """ - - @ircdoc """ - # script - - Allows to run an outside script. - - * **+script `` `[command]`** défini/lance un script - * **-script ``** arrête un script - * **-script del ``** supprime un script - """ - - def irc_doc, do: @ircdoc - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def init([]) do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:script", [plugin: __MODULE__]) - dets_filename = (Nola.data_path() <> "/" <> "scripts.dets") |> String.to_charlist - {:ok, dets} = :dets.open_file(dets_filename, []) - {:ok, %{dets: dets}} - end - - def handle_info({:irc, :trigger, "script", m = %{trigger: %{type: :plus, args: [name | args]}}}, state) do - end - - def handle_info({:irc, :trigger, "script", m = %{trigger: %{type: :minus, args: args}}}, state) do - case args do - ["del", name] -> :ok #prout - [name] -> :ok#stop - end - end - -end diff --git a/lib/lsg_irc/seen_plugin.ex b/lib/lsg_irc/seen_plugin.ex deleted file mode 100644 index 2a4d0dd..0000000 --- a/lib/lsg_irc/seen_plugin.ex +++ /dev/null @@ -1,59 +0,0 @@ -defmodule Nola.IRC.SeenPlugin do - @moduledoc """ - # seen - - * **!seen ``** - """ - - def irc_doc, do: @moduledoc - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def init([]) do - regopts = [plugin: __MODULE__] - {:ok, _} = Registry.register(IRC.PubSub, "triggers", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) - dets_filename = (Nola.data_path() <> "/seen.dets") |> String.to_charlist() - {:ok, dets} = :dets.open_file(dets_filename, []) - {:ok, %{dets: dets}} - end - - def handle_info({:irc, :trigger, "seen", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [nick]}}}, state) do - witness(m, state) - m.replyfun.(last_seen(m.channel, nick, state)) - {:noreply, state} - end - - def handle_info({:irc, :trigger, _, m}, state) do - witness(m, state) - {:noreply, state} - end - - def handle_info({:irc, :text, m}, state) do - witness(m, state) - {:noreply, state} - end - - defp witness(%IRC.Message{channel: channel, text: text, sender: %{nick: nick}}, %{dets: dets}) do - :dets.insert(dets, {{channel, nick}, DateTime.utc_now(), text}) - :ok - end - - defp last_seen(channel, nick, %{dets: dets}) do - case :dets.lookup(dets, {channel, nick}) do - [{_, date, text}] -> - diff = round(DateTime.diff(DateTime.utc_now(), date)/60) - cond do - diff >= 30 -> - duration = Timex.Duration.from_minutes(diff) - format = Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized) - "#{nick} a parlé pour la dernière fois il y a #{format}: “#{text}”" - true -> "#{nick} est là..." - end - [] -> - "je ne connais pas de #{nick}" - end - end - -end diff --git a/lib/lsg_irc/sms_plugin.ex b/lib/lsg_irc/sms_plugin.ex deleted file mode 100644 index d8f7387..0000000 --- a/lib/lsg_irc/sms_plugin.ex +++ /dev/null @@ -1,165 +0,0 @@ -defmodule Nola.IRC.SmsPlugin do - @moduledoc """ - ## sms - - * **!sms `` ``** envoie un SMS. - """ - def short_irc_doc, do: false - def irc_doc, do: @moduledoc - require Logger - - def incoming(from, "enable "<>key) do - key = String.trim(key) - account = IRC.Account.find_meta_account("sms-validation-code", String.downcase(key)) - if account do - net = IRC.Account.get_meta(account, "sms-validation-target") - IRC.Account.put_meta(account, "sms-number", from) - IRC.Account.delete_meta(account, "sms-validation-code") - IRC.Account.delete_meta(account, "sms-validation-number") - IRC.Account.delete_meta(account, "sms-validation-target") - IRC.Connection.broadcast_message(net, account, "SMS Number #{from} added!") - send_sms(from, "Yay! Number linked to account #{account.name}") - end - end - - def incoming(from, message) do - account = IRC.Account.find_meta_account("sms-number", from) - if account do - reply_fun = fn(text) -> - send_sms(from, text) - end - trigger_text = if Enum.any?(IRC.Connection.triggers(), fn({trigger, _}) -> String.starts_with?(message, trigger) end) do - message - else - "!"<>message - end - message = %IRC.Message{ - id: FlakeId.get(), - transport: :sms, - network: "sms", - channel: nil, - text: message, - account: account, - sender: %ExIRC.SenderInfo{nick: account.name}, - replyfun: reply_fun, - trigger: IRC.Connection.extract_trigger(trigger_text) - } - Logger.debug("converted sms to message: #{inspect message}") - IRC.Connection.publish(message, ["messages:sms"]) - message - end - end - - def my_number() do - Keyword.get(Application.get_env(:nola, :sms, []), :number, "+33000000000") - end - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def path() do - account = Keyword.get(Application.get_env(:nola, :sms), :account) - "https://eu.api.ovh.com/1.0/sms/#{account}" - end - - def path(rest) do - Path.join(path(), rest) - end - - def send_sms(number, text) do - url = path("/virtualNumbers/#{my_number()}/jobs") - body = %{ - "message" => text, - "receivers" => [number], - #"senderForResponse" => true, - #"noStopClause" => true, - "charset" => "UTF-8", - "coding" => "8bit" - } |> Poison.encode!() - headers = [{"content-type", "application/json"}] ++ sign("POST", url, body) - options = [] - case HTTPoison.post(url, body, headers, options) do - {:ok, %HTTPoison.Response{status_code: 200}} -> :ok - {:ok, %HTTPoison.Response{status_code: code} = resp} -> - Logger.error("SMS Error: #{inspect resp}") - {:error, code} - {:error, error} -> {:error, error} - end - end - - def init([]) do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:sms", [plugin: __MODULE__]) - :ok = register_ovh_callback() - {:ok, %{}} - :ignore - end - - def handle_info({:irc, :trigger, "sms", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [nick | text]}}}, state) do - with \ - {:tree, false} <- {:tree, m.sender.nick == "Tree"}, - {_, %IRC.Account{} = account} <- {:account, IRC.Account.find_always_by_nick(m.network, m.channel, nick)}, - {_, number} when not is_nil(number) <- {:number, IRC.Account.get_meta(account, "sms-number")} - do - text = Enum.join(text, " ") - sender = if m.channel do - "#{m.channel} <#{m.sender.nick}> " - else - "<#{m.sender.nick}> " - end - case send_sms(number, sender<>text) do - :ok -> m.replyfun.("sent!") - {:error, error} -> m.replyfun.("not sent, error: #{inspect error}") - end - else - {:tree, _} -> m.replyfun.("Tree: va en enfer") - {:account, _} -> m.replyfun.("#{nick} not known") - {:number, _} -> m.replyfun.("#{nick} have not enabled sms") - end - {:noreply, state} - end - - def handle_info(msg, state) do - {:noreply, state} - end - - defp register_ovh_callback() do - url = path() - body = %{ - "callBack" =>NolaWeb.Router.Helpers.sms_url(NolaWeb.Endpoint, :ovh_callback), - "smsResponse" => %{ - "cgiUrl" => NolaWeb.Router.Helpers.sms_url(NolaWeb.Endpoint, :ovh_callback), - "responseType" => "cgi" - } - } |> Poison.encode!() - headers = [{"content-type", "application/json"}] ++ sign("PUT", url, body) - options = [] - case HTTPoison.put(url, body, headers, options) do - {:ok, %HTTPoison.Response{status_code: 200}} -> - :ok - error -> error - end - end - - defp sign(method, url, body) do - ts = DateTime.utc_now() |> DateTime.to_unix() - as = env(:app_secret) - ck = env(:consumer_key) - sign = Enum.join([as, ck, String.upcase(method), url, body, ts], "+") - sign_hex = :crypto.hash(:sha, sign) |> Base.encode16(case: :lower) - headers = [{"X-OVH-Application", env(:app_key)}, {"X-OVH-Timestamp", ts}, - {"X-OVH-Signature", "$1$"<>sign_hex}, {"X-Ovh-Consumer", ck}] - end - - def parse_number(num) do - {:error, :todo} - end - - defp env() do - Application.get_env(:nola, :sms) - end - - defp env(key) do - Keyword.get(env(), key) - end -end diff --git a/lib/lsg_irc/tell_plugin.ex b/lib/lsg_irc/tell_plugin.ex deleted file mode 100644 index ecc98df..0000000 --- a/lib/lsg_irc/tell_plugin.ex +++ /dev/null @@ -1,106 +0,0 @@ -defmodule Nola.IRC.TellPlugin do - use GenServer - - @moduledoc """ - # Tell - - * **!tell `` ``**: tell `message` to `nick` when they reconnect. - """ - - def irc_doc, do: @moduledoc - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def dets do - (Nola.data_path() <> "/tell.dets") |> String.to_charlist() - end - - def tell(m, target, message) do - GenServer.cast(__MODULE__, {:tell, m, target, message}) - end - - def init([]) do - regopts = [plugin: __MODULE__] - {:ok, _} = Registry.register(IRC.PubSub, "account", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "trigger:tell", regopts) - {:ok, dets} = :dets.open_file(dets(), [type: :bag]) - {:ok, %{dets: dets}} - end - - def handle_cast({:tell, m, target, message}, state) do - do_tell(state, m, target, message) - {:noreply, state} - end - - def handle_info({:irc, :trigger, "tell", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [target | message]}}}, state) do - do_tell(state, m, target, message) - {:noreply, state} - end - - def handle_info({:account, network, channel, nick, account_id}, state) do - messages = :dets.lookup(state.dets, {network, channel, account_id}) - if messages != [] do - strs = Enum.map(messages, fn({_, from, message, at}) -> - account = IRC.Account.get(from) - user = IRC.UserTrack.find_by_account(network, account) - fromnick = if user, do: user.nick, else: account.name - "#{nick}: <#{fromnick}> #{message}" - end) - Enum.each(strs, fn(s) -> IRC.Connection.broadcast_message(network, channel, s) end) - :dets.delete(state.dets, {network, channel, account_id}) - end - {:noreply, state} - end - - def handle_info({:account_change, old_id, new_id}, state) do - #:ets.fun2ms(fn({ {_net, _chan, target_id}, from_id, _, _} = obj) when (target_id == old_id) or (from_id == old_id) -> obj end) - spec = [{{{:"$1", :"$2", :"$3"}, :"$4", :_, :_}, [{:orelse, {:==, :"$3", {:const, old_id}}, {:==, :"$4", {:const, old_id}}}], [:"$_"]}] - Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> - case obj do - { {net, chan, ^old_id}, from_id, message, at } = obj -> - :dets.delete(obj) - :dets.insert(table, {{net, chan, new_id}, from_id, message, at}) - {key, ^old_id, message, at} = obj -> - :dets.delete(table, obj) - :dets.insert(table, {key, new_id, message, at}) - _ -> :ok - end - end) - {:noreply, state} - end - - - def handle_info(info, state) do - {:noreply, state} - end - - def terminate(_, state) do - :dets.close(state.dets) - :ok - end - - defp do_tell(state, m, nick_target, message) do - target = IRC.Account.find_always_by_nick(m.network, m.channel, nick_target) - message = Enum.join(message, " ") - with \ - {:target, %IRC.Account{} = target} <- {:target, target}, - {:same, false} <- {:same, target.id == m.account.id}, - target_user = IRC.UserTrack.find_by_account(m.network, target), - target_nick = if(target_user, do: target_user.nick, else: target.name), - present? = if(target_user, do: Map.has_key?(target_user.last_active, m.channel)), - {:absent, true, _} <- {:absent, !present?, target_nick}, - {:message, message} <- {:message, message} - do - obj = { {m.network, m.channel, target.id}, m.account.id, message, NaiveDateTime.utc_now()} - :dets.insert(state.dets, obj) - m.replyfun.("will tell to #{target_nick}") - else - {:same, _} -> m.replyfun.("are you so stupid that you need a bot to tell yourself things ?") - {:target, _} -> m.replyfun.("#{nick_target} unknown") - {:absent, _, nick} -> m.replyfun.("#{nick} is here, tell yourself!") - {:message, _} -> m.replyfun.("can't tell without a message") - end - end - -end diff --git a/lib/lsg_irc/txt_plugin.ex b/lib/lsg_irc/txt_plugin.ex deleted file mode 100644 index cab912a..0000000 --- a/lib/lsg_irc/txt_plugin.ex +++ /dev/null @@ -1,556 +0,0 @@ -defmodule Nola.IRC.TxtPlugin do - alias IRC.UserTrack - require Logger - - @moduledoc """ - # [txt]({{context_path}}/txt) - - * **.txt**: liste des fichiers et statistiques. - Les fichiers avec une `*` sont vérrouillés. - [Voir sur le web]({{context_path}}/txt). - - * **!txt**: lis aléatoirement une ligne dans tous les fichiers. - * **!txt ``**: recherche une ligne dans tous les fichiers. - - * **~txt**: essaie de générer une phrase (markov). - * **~txt ``**: essaie de générer une phrase commencant par ``. - - * **!`FICHIER`**: lis aléatoirement une ligne du fichier `FICHIER`. - * **!`FICHIER` ``**: lis la ligne `` du fichier `FICHIER`. - * **!`FICHIER` ``**: recherche une ligne contenant `` dans `FICHIER`. - - * **+txt `**: crée le fichier ``. - * **+`FICHIER` ``**: ajoute une ligne `` dans le fichier `FICHIER`. - * **-`FICHIER` ``**: supprime la ligne `` du fichier `FICHIER`. - - * **-txtrw, +txtrw**. op seulement. active/désactive le mode lecture seule. - * **+txtlock ``, -txtlock ``**. op seulement. active/désactive le verrouillage d'un fichier. - - Insérez `\\\\` pour faire un saut de ligne. - """ - - def short_irc_doc, do: "!txt https://sys.115ans.net/irc/txt " - def irc_doc, do: @moduledoc - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - defstruct triggers: %{}, rw: true, locks: nil, markov_handler: nil, markov: nil - - def random(file) do - GenServer.call(__MODULE__, {:random, file}) - end - - def reply_random(message, file) do - if line = random(file) do - line - |> format_line(nil, message) - |> message.replyfun.() - - line - end - end - - def init([]) do - dets_locks_filename = (Nola.data_path() <> "/" <> "txtlocks.dets") |> String.to_charlist - {:ok, locks} = :dets.open_file(dets_locks_filename, []) - markov_handler = Keyword.get(Application.get_env(:nola, __MODULE__, []), :markov_handler, Nola.IRC.TxtPlugin.Markov.Native) - {:ok, markov} = markov_handler.start_link() - {:ok, _} = Registry.register(IRC.PubSub, "triggers", [plugin: __MODULE__]) - {:ok, %__MODULE__{locks: locks, markov_handler: markov_handler, markov: markov, triggers: load()}} - end - - def handle_info({:received, "!reload", _, chan}, state) do - {:noreply, %__MODULE__{state | triggers: load()}} - end - - # - # ADMIN: RW/RO - # - - def handle_info({:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :plus}}}, state = %{rw: false}) do - if channel && UserTrack.operator?(msg.network, channel, msg.sender.nick) do - msg.replyfun.("txt: écriture réactivée") - {:noreply, %__MODULE__{state | rw: true}} - else - {:noreply, state} - end - end - - def handle_info({:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :minus}}}, state = %{rw: true}) do - if channel && UserTrack.operator?(msg.network, channel, msg.sender.nick) do - msg.replyfun.("txt: écriture désactivée") - {:noreply, %__MODULE__{state | rw: false}} - else - {:noreply, state} - end - end - - # - # ADMIN: LOCKS - # - - def handle_info({:irc, :trigger, "txtlock", msg = %{trigger: %{type: :plus, args: [trigger]}}}, state) do - with \ - {trigger, _} <- clean_trigger(trigger), - true <- UserTrack.operator?(msg.network, msg.channel, msg.sender.nick) - do - :dets.insert(state.locks, {trigger}) - msg.replyfun.("txt: #{trigger} verrouillé") - end - {:noreply, state} - end - - def handle_info({:irc, :trigger, "txtlock", msg = %{trigger: %{type: :minus, args: [trigger]}}}, state) do - with \ - {trigger, _} <- clean_trigger(trigger), - true <- UserTrack.operator?(msg.network, msg.channel, msg.sender.nick), - true <- :dets.member(state.locks, trigger) - do - :dets.delete(state.locks, trigger) - msg.replyfun.("txt: #{trigger} déverrouillé") - end - {:noreply, state} - end - - # - # FILE LIST - # - - def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :dot}}}, state) do - map = Enum.map(state.triggers, fn({key, data}) -> - ignore? = String.contains?(key, ".") - locked? = case :dets.lookup(state.locks, key) do - [{trigger}] -> "*" - _ -> "" - end - - unless ignore?, do: "#{key}: #{to_string(Enum.count(data))}#{locked?}" - end) - |> Enum.filter(& &1) - total = Enum.reduce(state.triggers, 0, fn({_, data}, acc) -> - acc + Enum.count(data) - end) - detail = Enum.join(map, ", ") - total = ". total: #{Enum.count(state.triggers)} fichiers, #{to_string(total)} lignes. Détail: https://sys.115ans.net/irc/txt" - - ro = if !state.rw, do: " (lecture seule activée)", else: "" - - (detail<>total<>ro) - |> msg.replyfun.() - {:noreply, state} - end - - # - # GLOBAL: RANDOM - # - - def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :bang, args: []}}}, state) do - result = Enum.reduce(state.triggers, [], fn({trigger, data}, acc) -> - Enum.reduce(data, acc, fn({l, _}, acc) -> - [{trigger, l} | acc] - end) - end) - |> Enum.shuffle() - - if !Enum.empty?(result) do - {source, line} = Enum.random(result) - msg.replyfun.(format_line(line, "#{source}: ", msg)) - end - {:noreply, state} - end - - def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :bang, args: args}}}, state) do - grep = Enum.join(args, " ") - |> String.downcase - |> :unicode.characters_to_nfd_binary() - - result = with_stateful_results(msg, {:bang,"txt",msg.network,msg.channel,grep}, fn() -> - Enum.reduce(state.triggers, [], fn({trigger, data}, acc) -> - if !String.contains?(trigger, ".") do - Enum.reduce(data, acc, fn({l, _}, acc) -> - [{trigger, l} | acc] - end) - else - acc - end - end) - |> Enum.filter(fn({_, line}) -> - line - |> String.downcase() - |> :unicode.characters_to_nfd_binary() - |> String.contains?(grep) - end) - |> Enum.shuffle() - end) - - if result do - {source, line} = result - msg.replyfun.(["#{source}: " | line]) - end - {:noreply, state} - end - - def with_stateful_results(msg, key, initfun) do - me = self() - scope = {msg.network, msg.channel || msg.sender.nick} - key = {__MODULE__, me, scope, key} - with_stateful_results(key, initfun) - end - - def with_stateful_results(key, initfun) do - pid = case :global.whereis_name(key) do - :undefined -> - start_stateful_results(key, initfun.()) - pid -> pid - end - if pid, do: wait_stateful_results(key, initfun, pid) - end - - def start_stateful_results(key, []) do - nil - end - - def start_stateful_results(key, list) do - me = self() - {pid, _} = spawn_monitor(fn() -> - Process.monitor(me) - stateful_results(me, list) - end) - :yes = :global.register_name(key, pid) - pid - end - - def wait_stateful_results(key, initfun, pid) do - send(pid, :get) - receive do - {:stateful_results, line} -> - line - {:DOWN, _ref, :process, ^pid, reason} -> - with_stateful_results(key, initfun) - after - 5000 -> - nil - end - end - - defp stateful_results(owner, []) do - send(owner, :empty) - :ok - end - - @stateful_results_expire :timer.minutes(30) - defp stateful_results(owner, [line | rest] = acc) do - receive do - :get -> - send(owner, {:stateful_results, line}) - stateful_results(owner, rest) - {:DOWN, _ref, :process, ^owner, _} -> - :ok - after - @stateful_results_expire -> :ok - end - end - - # - # GLOBAL: MARKOV - # - - def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :tilde, args: []}}}, state) do - case state.markov_handler.sentence(state.markov) do - {:ok, line} -> - msg.replyfun.(line) - error -> - Logger.error "Txt Markov error: "<>inspect error - end - {:noreply, state} - end - - def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :tilde, args: complete}}}, state) do - complete = Enum.join(complete, " ") - case state.markov_handler.complete_sentence(complete, state.markov) do - {:ok, line} -> - msg.replyfun.(line) - error -> - Logger.error "Txt Markov error: "<>inspect error - end - {:noreply, state} - end - - # - # TXT CREATE - # - - def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :plus, args: [trigger]}}}, state) do - with \ - {trigger, _} <- clean_trigger(trigger), - true <- can_write?(state, msg, trigger), - :ok <- create_file(trigger) - do - msg.replyfun.("#{trigger}.txt créé. Ajouter: `+#{trigger} …` ; Lire: `!#{trigger}`") - {:noreply, %__MODULE__{state | triggers: load()}} - else - _ -> {:noreply, state} - end - end - - # - # TXT: RANDOM - # - - def handle_info({:irc, :trigger, trigger, m = %{trigger: %{type: :query, args: opts}}}, state) do - {trigger, _} = clean_trigger(trigger) - if Map.get(state.triggers, trigger) do - url = if m.channel do - NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :txt, m.network, NolaWeb.format_chan(m.channel), trigger) - else - NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :txt, trigger) - end - m.replyfun.("-> #{url}") - end - {:noreply, state} - end - - def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :bang, args: opts}}}, state) do - {trigger, _} = clean_trigger(trigger) - line = get_random(msg, state.triggers, trigger, String.trim(Enum.join(opts, " "))) - if line do - msg.replyfun.(format_line(line, nil, msg)) - end - {:noreply, state} - end - - # - # TXT: ADD - # - - def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :plus, args: content}}}, state) do - with \ - true <- can_write?(state, msg, trigger), - {:ok, idx} <- add(state.triggers, msg.text) - do - msg.replyfun.("#{msg.sender.nick}: ajouté à #{trigger}. (#{idx})") - {:noreply, %__MODULE__{state | triggers: load()}} - else - {:error, {:jaro, string, idx}} -> - msg.replyfun.("#{msg.sender.nick}: doublon #{trigger}##{idx}: #{string}") - error -> - Logger.debug("txt add failed: #{inspect error}") - {:noreply, state} - end - end - - # - # TXT: DELETE - # - - def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :minus, args: [id]}}}, state) do - with \ - true <- can_write?(state, msg, trigger), - data <- Map.get(state.triggers, trigger), - {id, ""} <- Integer.parse(id), - {text, _id} <- Enum.find(data, fn({_, idx}) -> id-1 == idx end) - do - data = data |> Enum.into(Map.new) - data = Map.delete(data, text) - msg.replyfun.("#{msg.sender.nick}: #{trigger}.txt##{id} supprimée: #{text}") - dump(trigger, data) - {:noreply, %__MODULE__{state | triggers: load()}} - else - _ -> - {:noreply, state} - end - end - - def handle_info(:reload_markov, state=%__MODULE__{triggers: triggers, markov: markov}) do - state.markov_handler.reload(state.triggers, state.markov) - {:noreply, state} - end - - def handle_info(msg, state) do - {:noreply, state} - end - - def handle_call({:random, file}, _from, state) do - random = get_random(nil, state.triggers, file, []) - {:reply, random, state} - end - - def terminate(_reason, state) do - if state.locks do - :dets.sync(state.locks) - :dets.close(state.locks) - end - :ok - end - - # Load/Reloads text files from disk - defp load() do - triggers = Path.wildcard(directory() <> "/*.txt") - |> Enum.reduce(%{}, fn(path, m) -> - file = Path.basename(path) - key = String.replace(file, ".txt", "") - data = directory() <> file - |> File.read! - |> String.split("\n") - |> Enum.reject(fn(line) -> - cond do - line == "" -> true - !line -> true - true -> false - end - end) - |> Enum.with_index - Map.put(m, key, data) - end) - |> Enum.sort - |> Enum.into(Map.new) - - send(self(), :reload_markov) - triggers - end - - defp dump(trigger, data) do - data = data - |> Enum.sort_by(fn({_, idx}) -> idx end) - |> Enum.map(fn({text, _}) -> text end) - |> Enum.join("\n") - File.write!(directory() <> "/" <> trigger <> ".txt", data<>"\n", []) - end - - defp get_random(msg, triggers, trigger, []) do - if data = Map.get(triggers, trigger) do - {data, _idx} = Enum.random(data) - data - else - nil - end - end - - defp get_random(msg, triggers, trigger, opt) do - arg = case Integer.parse(opt) do - {pos, ""} -> {:index, pos} - {_pos, _some_string} -> {:grep, opt} - _error -> {:grep, opt} - end - get_with_param(msg, triggers, trigger, arg) - end - - defp get_with_param(msg, triggers, trigger, {:index, pos}) do - data = Map.get(triggers, trigger, %{}) - case Enum.find(data, fn({_, index}) -> index+1 == pos end) do - {text, _} -> text - _ -> nil - end - end - - defp get_with_param(msg, triggers, trigger, {:grep, query}) do - out = with_stateful_results(msg, {:grep, trigger, query}, fn() -> - data = Map.get(triggers, trigger, %{}) - regex = Regex.compile!("#{query}", "i") - Enum.filter(data, fn({txt, _}) -> Regex.match?(regex, txt) end) - |> Enum.map(fn({txt, _}) -> txt end) - |> Enum.shuffle() - end) - if out, do: out - end - - defp create_file(name) do - File.touch!(directory() <> "/" <> name <> ".txt") - :ok - end - - defp add(triggers, trigger_and_content) do - case String.split(trigger_and_content, " ", parts: 2) do - [trigger, content] -> - {trigger, _} = clean_trigger(trigger) - - - if Map.has_key?(triggers, trigger) do - jaro = Enum.find(triggers[trigger], fn({string, idx}) -> String.jaro_distance(content, string) > 0.9 end) - - if jaro do - {string, idx} = jaro - {:error, {:jaro, string, idx}} - else - File.write!(directory() <> "/" <> trigger <> ".txt", content<>"\n", [:append]) - idx = Enum.count(triggers[trigger])+1 - {:ok, idx} - end - else - {:error, :notxt} - end - _ -> {:error, :badarg} - end - end - - # fixme: this is definitely the ugliest thing i've ever done - defp clean_trigger(trigger) do - [trigger | opts] = trigger - |> String.strip - |> String.split(" ", parts: 2) - - trigger = trigger - |> String.downcase - |> :unicode.characters_to_nfd_binary() - |> String.replace(~r/[^a-z0-9._]/, "") - |> String.trim(".") - |> String.trim("_") - - {trigger, opts} - end - - def format_line(line, prefix, msg) do - prefix = unless(prefix, do: "", else: prefix) - prefix <> line - |> String.split("\\\\") - |> Enum.map(fn(line) -> - String.split(line, "\\\\\\\\") - end) - |> List.flatten() - |> Enum.map(fn(line) -> - String.trim(line) - |> Tmpl.render(msg) - end) - end - - def directory() do - Application.get_env(:nola, :data_path) <> "/irc.txt/" - end - - defp can_write?(%{rw: rw?, locks: locks}, msg = %{channel: nil, sender: sender}, trigger) do - admin? = IRC.admin?(sender) - locked? = case :dets.lookup(locks, trigger) do - [{trigger}] -> true - _ -> false - end - unlocked? = if rw? == false, do: false, else: !locked? - - can? = unlocked? || admin? - - if !can? do - reason = if !rw?, do: "lecture seule", else: "fichier vérrouillé" - msg.replyfun.("#{sender.nick}: permission refusée (#{reason})") - end - can? - end - - defp can_write?(state = %__MODULE__{rw: rw?, locks: locks}, msg = %{channel: channel, sender: sender}, trigger) do - admin? = IRC.admin?(sender) - operator? = IRC.UserTrack.operator?(msg.network, channel, sender.nick) - locked? = case :dets.lookup(locks, trigger) do - [{trigger}] -> true - _ -> false - end - unlocked? = if rw? == false, do: false, else: !locked? - can? = admin? || operator? || unlocked? - - if !can? do - reason = if !rw?, do: "lecture seule", else: "fichier vérrouillé" - msg.replyfun.("#{sender.nick}: permission refusée (#{reason})") - end - can? - end - -end diff --git a/lib/lsg_irc/txt_plugin/markov.ex b/lib/lsg_irc/txt_plugin/markov.ex deleted file mode 100644 index 2e30dfa..0000000 --- a/lib/lsg_irc/txt_plugin/markov.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule Nola.IRC.TxtPlugin.Markov do - - @type state :: any() - @callback start_link() :: {:ok, state()} - @callback reload(content :: Map.t, state()) :: any() - @callback sentence(state()) :: {:ok, String.t} | {:error, String.t} - @callback complete_sentence(state()) :: {:ok, String.t} | {:error, String.t} - -end diff --git a/lib/lsg_irc/txt_plugin/markov_native.ex b/lib/lsg_irc/txt_plugin/markov_native.ex deleted file mode 100644 index 4c403c2..0000000 --- a/lib/lsg_irc/txt_plugin/markov_native.ex +++ /dev/null @@ -1,33 +0,0 @@ -defmodule Nola.IRC.TxtPlugin.MarkovNative do - @behaviour Nola.IRC.TxtPlugin.Markov - - def start_link() do - ExChain.MarkovModel.start_link() - end - - def reload(data, markov) do - data = data - |> Enum.map(fn({_, data}) -> - for {line, _idx} <- data, do: line - end) - |> List.flatten - - ExChain.MarkovModel.populate_model(markov, data) - :ok - end - - def sentence(markov) do - case ExChain.SentenceGenerator.create_filtered_sentence(markov) do - {:ok, line, _, _} -> {:ok, line} - error -> error - end - end - - def complete_sentence(sentence, markov) do - case ExChain.SentenceGenerator.complete_sentence(markov, sentence) do - {line, _} -> {:ok, line} - error -> error - end - end - -end diff --git a/lib/lsg_irc/txt_plugin/markov_py_markovify.ex b/lib/lsg_irc/txt_plugin/markov_py_markovify.ex deleted file mode 100644 index b610ea8..0000000 --- a/lib/lsg_irc/txt_plugin/markov_py_markovify.ex +++ /dev/null @@ -1,39 +0,0 @@ -defmodule Nola.IRC.TxtPlugin.MarkovPyMarkovify do - - def start_link() do - {:ok, nil} - end - - def reload(_data, _markov) do - :ok - end - - def sentence(_) do - {:ok, run()} - end - - def complete_sentence(sentence, _) do - {:ok, run([sentence])} - end - - defp run(args \\ []) do - {binary, script} = script() - args = [script, Path.expand(Nola.IRC.TxtPlugin.directory()) | args] - IO.puts "Args #{inspect args}" - case MuonTrap.cmd(binary, args) do - {response, 0} -> response - {response, code} -> "error #{code}: #{response}" - end - end - - defp script() do - default_script = to_string(:code.priv_dir(:nola)) <> "/irc/txt/markovify.py" - env = Application.get_env(:nola, Nola.IRC.TxtPlugin, []) - |> Keyword.get(:py_markovify, []) - - {Keyword.get(env, :python, "python3"), Keyword.get(env, :script, default_script)} - end - - - -end diff --git a/lib/lsg_irc/untappd_plugin.ex b/lib/lsg_irc/untappd_plugin.ex deleted file mode 100644 index 50b0c4d..0000000 --- a/lib/lsg_irc/untappd_plugin.ex +++ /dev/null @@ -1,66 +0,0 @@ -defmodule Nola.IRC.UntappdPlugin do - - def irc_doc() do - """ - # [Untappd](https://untappd.com) - - * `!beer ` Information about the first beer matching `` - * `?beer ` List the 10 firsts beer matching `` - - _Note_: The best way to search is always "Brewery Name + Beer Name", such as "Dogfish 60 Minute". - - Link your Untappd account to the bot (for automated checkins on [alcoolog](#alcoolog), ...) with the `enable-untappd` command, in private. - """ - end - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def init(_) do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:beer", [plugin: __MODULE__]) - {:ok, %{}} - end - - def handle_info({:irc, :trigger, _, m = %IRC.Message{trigger: %{type: :bang, args: args}}}, state) do - case Untappd.search_beer(Enum.join(args, " "), limit: 1) do - {:ok, %{"response" => %{"beers" => %{"count" => count, "items" => [result | _]}}}} -> - %{"beer" => beer, "brewery" => brewery} = result - description = Map.get(beer, "beer_description") - |> String.replace("\n", " ") - |> String.replace("\r", " ") - |> String.trim() - beer_s = "#{Map.get(brewery, "brewery_name")}: #{Map.get(beer, "beer_name")} - #{Map.get(beer, "beer_abv")}°" - city = get_in(brewery, ["location", "brewery_city"]) - location = [Map.get(brewery, "brewery_type"), city, Map.get(brewery, "country_name")] - |> Enum.filter(fn(x) -> x end) - |> Enum.join(", ") - extra = "#{Map.get(beer, "beer_style")} - IBU: #{Map.get(beer, "beer_ibu")} - #{location}" - m.replyfun.([beer_s, extra, description]) - err -> - m.replyfun.("Error") - end - {:noreply, state} - end - - - def handle_info({:irc, :trigger, _, m = %IRC.Message{trigger: %{type: :query, args: args}}}, state) do - case Untappd.search_beer(Enum.join(args, " ")) do - {:ok, %{"response" => %{"beers" => %{"count" => count, "items" => results}}}} -> - beers = for %{"beer" => beer, "brewery" => brewery} <- results do - "#{Map.get(brewery, "brewery_name")}: #{Map.get(beer, "beer_name")} - #{Map.get(beer, "beer_abv")}°" - end - |> Enum.intersperse(", ") - |> Enum.join("") - m.replyfun.("#{count}. #{beers}") - err -> - m.replyfun.("Error") - end - {:noreply, state} - end - - def handle_info(info, state) do - {:noreply, state} - end - -end diff --git a/lib/lsg_irc/user_mention_plugin.ex b/lib/lsg_irc/user_mention_plugin.ex deleted file mode 100644 index eb230fd..0000000 --- a/lib/lsg_irc/user_mention_plugin.ex +++ /dev/null @@ -1,52 +0,0 @@ -defmodule Nola.IRC.UserMentionPlugin do - @moduledoc """ - # mention - - * **@`` ``**: notifie si possible le nick immédiatement via Telegram, SMS, ou équivalent à `!tell`. - """ - - require Logger - - def short_irc_doc, do: false - def irc_doc, do: @moduledoc - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def init(_) do - {:ok, _} = Registry.register(IRC.PubSub, "triggers", plugin: __MODULE__) - {:ok, nil} - end - - def handle_info({:irc, :trigger, nick, message = %IRC.Message{sender: sender, account: account, network: network, channel: channel, trigger: %IRC.Trigger{type: :at, args: content}}}, state) do - nick = nick - |> String.trim(":") - |> String.trim(",") - target = IRC.Account.find_always_by_nick(network, channel, nick) - if target do - telegram = IRC.Account.get_meta(target, "telegram-id") - sms = IRC.Account.get_meta(target, "sms-number") - text = "#{channel} <#{sender.nick}> #{Enum.join(content, " ")}" - - cond do - telegram -> - Nola.Telegram.send_message(telegram, "`#{channel}` <**#{sender.nick}**> #{Enum.join(content, " ")}") - sms -> - case Nola.IRC.SmsPlugin.send_sms(sms, text) do - {:error, code} -> message.replyfun("#{sender.nick}: erreur #{code} (sms)") - end - true -> - Nola.IRC.TellPlugin.tell(message, nick, content) - end - else - message.replyfun.("#{nick} m'est inconnu") - end - {:noreply, state} - end - - def handle_info(_, state) do - {:noreply, state} - end - -end diff --git a/lib/lsg_irc/wikipedia_plugin.ex b/lib/lsg_irc/wikipedia_plugin.ex deleted file mode 100644 index 3202e13..0000000 --- a/lib/lsg_irc/wikipedia_plugin.ex +++ /dev/null @@ -1,90 +0,0 @@ -defmodule Nola.IRC.WikipediaPlugin do - require Logger - - @moduledoc """ - # wikipédia - - * **!wp ``**: retourne le premier résultat de la `` Wikipedia - * **!wp**: un article Wikipédia au hasard - """ - - def irc_doc, do: @moduledoc - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def init(_) do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:wp", [plugin: __MODULE__]) - {:ok, nil} - end - - def handle_info({:irc, :trigger, _, message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: []}}}, state) do - irc_random(message) - {:noreply, state} - end - def handle_info({:irc, :trigger, _, message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: args}}}, state) do - irc_search(Enum.join(args, " "), message) - {:noreply, state} - end - - def handle_info(info, state) do - {:noreply, state} - end - - defp irc_search("", message), do: irc_random(message) - defp irc_search(query, message) do - params = %{ - "action" => "query", - "list" => "search", - "srsearch" => String.strip(query), - "srlimit" => 1, - } - case query_wikipedia(params) do - {:ok, %{"query" => %{"search" => [item | _]}}} -> - title = item["title"] - url = "https://fr.wikipedia.org/wiki/" <> String.replace(title, " ", "_") - msg = "Wikipédia: #{title} — #{url}" - message.replyfun.(msg) - _ -> - nil - end - end - - defp irc_random(message) do - params = %{ - "action" => "query", - "generator" => "random", - "grnnamespace" => 0, - "prop" => "info" - } - case query_wikipedia(params) do - {:ok, %{"query" => %{"pages" => map = %{}}}} -> - [{_, item}] = Map.to_list(map) - title = item["title"] - url = "https://fr.wikipedia.org/wiki/" <> String.replace(title, " ", "_") - msg = "Wikipédia: #{title} — #{url}" - message.replyfun.(msg) - _ -> - nil - end - end - - defp query_wikipedia(params) do - url = "https://fr.wikipedia.org/w/api.php" - params = params - |> Map.put("format", "json") - |> Map.put("utf8", "") - - case HTTPoison.get(url, [], params: params) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> Jason.decode(body) - {:ok, %HTTPoison.Response{status_code: 400, body: body}} -> - Logger.error "Wikipedia HTTP 400: #{inspect body}" - {:error, "http 400"} - error -> - Logger.error "Wikipedia http error: #{inspect error}" - {:error, "http client error"} - end - end - -end diff --git a/lib/lsg_irc/wolfram_alpha_plugin.ex b/lib/lsg_irc/wolfram_alpha_plugin.ex deleted file mode 100644 index 6ee06f0..0000000 --- a/lib/lsg_irc/wolfram_alpha_plugin.ex +++ /dev/null @@ -1,47 +0,0 @@ -defmodule Nola.IRC.WolframAlphaPlugin do - use GenServer - require Logger - - @moduledoc """ - # wolfram alpha - - * **`!wa `** lance `` sur WolframAlpha - """ - - def irc_doc, do: @moduledoc - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def init(_) do - {:ok, _} = Registry.register(IRC.PubSub, "trigger:wa", [plugin: __MODULE__]) - {:ok, nil} - end - - def handle_info({:irc, :trigger, _, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: query}}}, state) do - query = Enum.join(query, " ") - params = %{ - "appid" => Keyword.get(Application.get_env(:nola, :wolframalpha, []), :app_id, "NO_APP_ID"), - "units" => "metric", - "i" => query - } - url = "https://www.wolframalpha.com/input/?i=" <> URI.encode(query) - case HTTPoison.get("http://api.wolframalpha.com/v1/result", [], [params: params]) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - m.replyfun.(["#{query} -> #{body}", url]) - {:ok, %HTTPoison.Response{status_code: code, body: body}} -> - error = case {code, body} do - {501, b} -> "input invalide: #{body}" - {code, error} -> "erreur #{code}: #{body || ""}" - end - m.replyfun.("wa: #{error}") - {:error, %HTTPoison.Error{reason: reason}} -> - m.replyfun.("wa: erreur http: #{to_string(reason)}") - _ -> - m.replyfun.("wa: erreur http") - end - {:noreply, state} - end - -end diff --git a/lib/lsg_irc/youtube_plugin.ex b/lib/lsg_irc/youtube_plugin.ex deleted file mode 100644 index fb9bea2..0000000 --- a/lib/lsg_irc/youtube_plugin.ex +++ /dev/null @@ -1,104 +0,0 @@ -defmodule Nola.IRC.YouTubePlugin do - require Logger - - @moduledoc """ - # youtube - - * **!yt ``**, !youtube ``: retourne le premier résultat de la `` YouTube - """ - - defstruct client: nil - - def irc_doc, do: @moduledoc - - def start_link() do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - def init([]) do - for t <- ["trigger:yt", "trigger:youtube"], do: {:ok, _} = Registry.register(IRC.PubSub, t, [plugin: __MODULE__]) - {:ok, %__MODULE__{}} - end - - def handle_info({:irc, :trigger, _, message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: args}}}, state) do - irc_search(Enum.join(args, " "), message) - {:noreply, state} - end - - def handle_info(info, state) do - {:noreply, state} - end - - defp irc_search(query, message) do - case search(query) do - {:ok, %{"items" => [item | _]}} -> - url = "https://youtube.com/watch?v=" <> item["id"] - snippet = item["snippet"] - duration = item["contentDetails"]["duration"] |> String.replace("PT", "") |> String.downcase - date = snippet["publishedAt"] - |> DateTime.from_iso8601() - |> elem(1) - |> Timex.format("{relative}", :relative) - |> elem(1) - - info_line = "— #{duration} — uploaded by #{snippet["channelTitle"]} — #{date}" - <> " — #{item["statistics"]["viewCount"]} views, #{item["statistics"]["likeCount"]} likes," - <> " #{item["statistics"]["dislikeCount"]} dislikes" - message.replyfun.("#{snippet["title"]} — #{url}") - message.replyfun.(info_line) - {:error, error} -> - message.replyfun.("Erreur YouTube: "<>error) - _ -> - nil - end - end - - defp search(query) do - query = query - |> String.strip - key = Application.get_env(:nola, :youtube)[:api_key] - params = %{ - "key" => key, - "maxResults" => 1, - "part" => "id", - "safeSearch" => "none", - "type" => "video", - "q" => query, - } - url = "https://www.googleapis.com/youtube/v3/search" - case HTTPoison.get(url, [], params: params) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - {:ok, json} = Jason.decode(body) - item = List.first(json["items"]) - if item do - video_id = item["id"]["videoId"] - params = %{ - "part" => "snippet,contentDetails,statistics", - "id" => video_id, - "key" => key - } - headers = [] - options = [params: params] - case HTTPoison.get("https://www.googleapis.com/youtube/v3/videos", [], options) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - Jason.decode(body) - {:ok, %HTTPoison.Response{status_code: code, body: body}} -> - Logger.error "YouTube HTTP #{code}: #{inspect body}" - {:error, "http #{code}"} - error -> - Logger.error "YouTube http error: #{inspect error}" - :error - end - else - :error - end - {:ok, %HTTPoison.Response{status_code: code, body: body}} -> - Logger.error "YouTube HTTP #{code}: #{inspect body}" - {:error, "http #{code}"} - error -> - Logger.error "YouTube http error: #{inspect error}" - :error - end - end - -end diff --git a/lib/lsg_matrix/matrix.ex b/lib/lsg_matrix/matrix.ex deleted file mode 100644 index 9334816..0000000 --- a/lib/lsg_matrix/matrix.ex +++ /dev/null @@ -1,169 +0,0 @@ -defmodule Nola.Matrix do - require Logger - alias Polyjuice.Client - - @behaviour MatrixAppService.Adapter.Room - @behaviour MatrixAppService.Adapter.Transaction - @behaviour MatrixAppService.Adapter.User - @env Mix.env - - def dets(part) do - (Nola.data_path() <> "/matrix-#{to_string(part)}.dets") |> String.to_charlist() - end - - def setup() do - {:ok, _} = :dets.open_file(dets(:rooms), []) - {:ok, _} = :dets.open_file(dets(:room_aliases), []) - {:ok, _} = :dets.open_file(dets(:users), []) - :ok - end - - def myself?("@_dev:random.sh"), do: true - def myself?("@_bot:random.sh"), do: true - def myself?("@_dev."<>_), do: true - def myself?("@_bot."<>_), do: true - def myself?(_), do: false - - def mxc_to_http(mxc = "mxc://"<>_) do - uri = URI.parse(mxc) - %URI{uri | scheme: "https", path: "/_matrix/media/r0/download/#{uri.authority}#{uri.path}"} - |> URI.to_string() - end - - def get_or_create_matrix_user(id) do - if mxid = lookup_user(id) do - mxid - else - opts = [ - type: "m.login.application_service", - inhibit_login: true, - device_id: "APP_SERVICE", - initial_device_display_name: "Application Service", - username: if(@env == :dev, do: "_dev.#{id}", else: "_bot.#{id}") - ] - Logger.debug("Registering user for #{id}") - {:ok, %{"user_id" => mxid}} = Polyjuice.Client.LowLevel.register(client(), opts) - :dets.insert(dets(:users), {id, mxid}) - end - end - - def lookup_user(id) do - case :dets.lookup(dets(:users), id) do - [{_, matrix_id}] -> matrix_id - _ -> nil - end - end - - def user_name("@"<>name) do - [username, _] = String.split(name, ":", parts: 2) - username - end - - def application_childs() do - import Supervisor.Spec - [ - supervisor(Nola.Matrix.Room.Supervisor, [], [name: IRC.PuppetConnection.Supervisor]), - ] - end - - def after_start() do - rooms = :dets.foldl(fn({id, _, _, _}, acc) -> [id | acc] end, [], dets(:rooms)) - for room <- rooms, do: Nola.Matrix.Room.start(room) - end - - def lookup_room(room) do - case :dets.lookup(dets(:rooms), room) do - [{_, network, channel, opts}] -> {:ok, Map.merge(opts, %{network: network, channel: channel})} - _ -> {:error, :no_such_room} - end - end - - def lookup_room_alias(room_alias) do - case :dets.lookup(dets(:room_aliases), room_alias) do - [{_, room_id}] -> {:ok, room_id} - _ -> {:error, :no_such_room_alias} - end - end - - def lookup_or_create_room(room_alias) do - case lookup_room_alias(room_alias) do - {:ok, room_id} -> {:ok, room_id} - {:error, :no_such_room_alias} -> create_room(room_alias) - end - end - - def create_room(room_alias) do - Logger.debug("Matrix: creating room #{inspect room_alias}") - localpart = localpart(room_alias) - with {:ok, network, channel} <- extract_network_channel_from_localpart(localpart), - %IRC.Connection{} <- IRC.Connection.get_network(network, channel), - room = [visibility: :public, room_alias_name: localpart, name: if(network == "random", do: channel, else: "#{network}/#{channel}")], - {:ok, %{"room_id" => room_id}} <- Client.Room.create_room(client(), room) do - Logger.info("Matrix: created room #{room_alias} #{room_id}") - :dets.insert(dets(:rooms), {room_id, network, channel, %{}}) - :dets.insert(dets(:room_aliases), {room_alias, room_id}) - {:ok, room_id} - else - nil -> {:error, :no_such_network_channel} - error -> error - end - end - - def localpart(room_alias) do - [<<"#", localpart :: binary>>, _] = String.split(room_alias, ":", parts: 2) - localpart - end - - def extract_network_channel_from_localpart(localpart) do - s = localpart - |> String.replace("dev.", "") - |> String.split("/", parts: 2) - - case s do - [network, channel] -> {:ok, network, channel} - [channel] -> {:ok, "random", channel} - _ -> {:error, :invalid_localpart} - end - end - - @impl MatrixAppService.Adapter.Room - def query_alias(room_alias) do - case lookup_or_create_room(room_alias) do - {:ok, room_id} -> - Nola.Matrix.Room.start(room_id) - :ok - error -> error - end - end - - @impl MatrixAppService.Adapter.Transaction - def new_event(event = %MatrixAppService.Event{}) do - Logger.debug("New matrix event: #{inspect event}") - if event.room_id do - Nola.Matrix.Room.start_and_send_matrix_event(event.room_id, event) - end - :noop - end - - @impl MatrixAppService.Adapter.User - def query_user(user_id) do - Logger.warn("Matrix lookup user: #{inspect user_id}") - :error - end - - def client(opts \\ []) do - base_url = Application.get_env(:matrix_app_service, :base_url) - access_token = Application.get_env(:matrix_app_service, :access_token) - default_opts = [ - access_token: access_token, - device_id: "APP_SERVICE", - application_service: true, - user_id: nil - ] - opts = Keyword.merge(default_opts, opts) - - Polyjuice.Client.LowLevel.create(base_url, opts) - end - - -end diff --git a/lib/lsg_matrix/plug.ex b/lib/lsg_matrix/plug.ex deleted file mode 100644 index c64ed11..0000000 --- a/lib/lsg_matrix/plug.ex +++ /dev/null @@ -1,25 +0,0 @@ -defmodule Nola.Matrix.Plug do - - defmodule Auth do - def init(state) do - state - end - - def call(conn, _) do - hs = Application.get_env(:matrix_app_service, :homeserver_token) - MatrixAppServiceWeb.AuthPlug.call(conn, hs) - end - end - - defmodule SetConfig do - def init(state) do - state - end - - def call(conn, _) do - config = Application.get_all_env(:matrix_app_service) - MatrixAppServiceWeb.SetConfigPlug.call(conn, config) - end - end - -end diff --git a/lib/lsg_matrix/room.ex b/lib/lsg_matrix/room.ex deleted file mode 100644 index c790760..0000000 --- a/lib/lsg_matrix/room.ex +++ /dev/null @@ -1,196 +0,0 @@ -defmodule Nola.Matrix.Room do - require Logger - alias Nola.Matrix - alias Polyjuice.Client - import Matrix, only: [client: 0, client: 1, user_name: 1, myself?: 1] - - defmodule Supervisor do - use DynamicSupervisor - - def start_link() do - DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) - end - - def start_child(room_id) do - spec = %{id: room_id, start: {Nola.Matrix.Room, :start_link, [room_id]}, restart: :transient} - DynamicSupervisor.start_child(__MODULE__, spec) - end - - @impl true - def init(_init_arg) do - DynamicSupervisor.init( - strategy: :one_for_one, - max_restarts: 10, - max_seconds: 1 - ) - end - end - - def start(room_id) do - __MODULE__.Supervisor.start_child(room_id) - end - - def start_link(room_id) do - GenServer.start_link(__MODULE__, [room_id], name: name(room_id)) - end - - def start_and_send_matrix_event(room_id, event) do - pid = if pid = whereis(room_id) do - pid - else - case __MODULE__.start(room_id) do - {:ok, pid} -> pid - {:error, {:already_started, pid}} -> pid - :ignore -> nil - end - end - if(pid, do: send(pid, {:matrix_event, event})) - end - - def whereis(room_id) do - {:global, name} = name(room_id) - case :global.whereis_name(name) do - :undefined -> nil - pid -> pid - end - end - - def name(room_id) do - {:global, {__MODULE__, room_id}} - end - - def init([room_id]) do - case Matrix.lookup_room(room_id) do - {:ok, state} -> - Logger.metadata(matrix_room: room_id) - - {:ok, _} = Registry.register(IRC.PubSub, "#{state.network}:events", plugin: __MODULE__) - for t <- ["messages", "triggers", "outputs", "events"] do - {:ok, _} = Registry.register(IRC.PubSub, "#{state.network}/#{state.channel}:#{t}", plugin: __MODULE__) - end - - state = state - |> Map.put(:id, room_id) - Logger.info("Started Matrix room #{room_id}") - {:ok, state, {:continue, :update_state}} - error -> - Logger.info("Received event for nonexistent room #{inspect room_id}: #{inspect error}") - :ignore - end - end - - def handle_continue(:update_state, state) do - {:ok, s} = Client.Room.get_state(client(), state.id) - members = Enum.reduce(s, [], fn(s, acc) -> - if s["type"] == "m.room.member" do - if s["content"]["membership"] == "join" do - [s["user_id"] | acc] - else - # XXX: The user left, remove from IRC.Memberships ? - acc - end - else - acc - end - end) - |> Enum.filter(& &1) - - for m <- members, do: IRC.UserTrack.joined(state.id, %{network: "matrix", nick: m, user: m, host: "matrix."}, [], true) - - accounts = IRC.UserTrack.channel(state.network, state.channel) - |> Enum.filter(& &1) - |> Enum.map(fn(tuple) -> IRC.UserTrack.User.from_tuple(tuple).account end) - |> Enum.uniq() - |> Enum.each(fn(account_id) -> - introduce_irc_account(account_id, state) - end) - - {:noreply, state} - end - - def handle_info({:irc, :text, message}, state), do: handle_irc(message, state) - def handle_info({:irc, :out, message}, state), do: handle_irc(message, state) - def handle_info({:irc, :trigger, _, message}, state), do: handle_irc(message, state) - def handle_info({:irc, :event, event}, state), do: handle_irc(event, state) - def handle_info({:matrix_event, event}, state) do - if myself?(event.user_id) do - {:noreply, state} - else - handle_matrix(event, state) - end - end - - def handle_irc(message = %IRC.Message{account: account}, state) do - unless Map.get(message.meta, :puppet) && Map.get(message.meta, :from) == self() do - opts = if Map.get(message.meta, :self) || is_nil(account) do - [] - else - mxid = Matrix.get_or_create_matrix_user(account.id) - [user_id: mxid] - end - Client.Room.send_message(client(opts),state.id, message.text) - end - {:noreply, state} - end - - def handle_irc(%{type: :join, account_id: account_id}, state) do - introduce_irc_account(account_id, state) - {:noreply, state} - end - - def handle_irc(%{type: quit_or_part, account_id: account_id}, state) when quit_or_part in [:quit, :part] do - mxid = Matrix.get_or_create_matrix_user(account_id) - Client.Room.leave(client(user_id: mxid), state.id) - {:noreply, state} - end - - - def handle_irc(event, state) do - Logger.warn("Skipped irc event #{inspect event}") - {:noreply, state} - end - - def handle_matrix(event = %{type: "m.room.member", user_id: user_id, content: %{"membership" => "join"}}, state) do - _account = get_account(event, state) - IRC.UserTrack.joined(state.id, %{network: "matrix", nick: user_id, user: user_id, host: "matrix."}, [], true) - {:noreply, state} - end - - def handle_matrix(event = %{type: "m.room.member", user_id: user_id, content: %{"membership" => "leave"}}, state) do - IRC.UserTrack.parted(state.id, %{network: "matrix", nick: user_id}) - {:noreply, state} - end - - def handle_matrix(event = %{type: "m.room.message", user_id: user_id, content: %{"msgtype" => "m.text", "body" => text}}, state) do - IRC.send_message_as(get_account(event, state), state.network, state.channel, text, true) - {:noreply, state} - end - - def handle_matrix(event, state) do - Logger.warn("Skipped matrix event #{inspect event}") - {:noreply, state} - end - - def get_account(%{user_id: user_id}, %{id: id}) do - IRC.Account.find_by_nick("matrix", user_id) - end - - defp introduce_irc_account(account_id, state) do - mxid = Matrix.get_or_create_matrix_user(account_id) - account = IRC.Account.get(account_id) - user = IRC.UserTrack.find_by_account(state.network, account) - base_nick = if(user, do: user.nick, else: account.name) - case Client.Profile.put_displayname(client(user_id: mxid), base_nick) do - :ok -> :ok - error -> - Logger.warn("Failed to update profile for #{mxid}: #{inspect error}") - end - case Client.Room.join(client(user_id: mxid), state.id) do - {:ok, _} -> :ok - error -> - Logger.warn("Failed to join room for #{mxid}: #{inspect error}") - end - :ok - end - -end diff --git a/lib/lsg_telegram/room.ex b/lib/lsg_telegram/room.ex deleted file mode 100644 index ca8a437..0000000 --- a/lib/lsg_telegram/room.ex +++ /dev/null @@ -1,188 +0,0 @@ -defmodule Nola.TelegramRoom do - require Logger - @behaviour Telegram.ChatBot - alias Telegram.Api - - @couch "bot-telegram-rooms" - - def rooms(), do: rooms(:with_docs) - - @spec rooms(:with_docs | :ids) :: [Map.t | integer( )] - def rooms(:with_docs) do - case Couch.get(@couch, :all_docs, include_docs: true) do - {:ok, %{"rows" => rows}} -> {:ok, for(%{"doc" => doc} <- rows, do: doc)} - error = {:error, _} -> error - end - end - - def rooms(:ids) do - case Couch.get(@couch, :all_docs) do - {:ok, %{"rows" => rows}} -> {:ok, for(%{"id" => id} <- rows, do: id)} - error = {:error, _} -> error - end - end - - def room(id, opts \\ []) do - Couch.get(@couch, id, opts) - end - - # TODO: Create couch - def setup() do - :ok - end - - def after_start() do - for id <- room(:ids), do: Telegram.Bot.ChatBot.Chat.Session.Supervisor.start_child(Nola.Telegram, Integer.parse(id) |> elem(0)) - end - - @impl Telegram.ChatBot - def init(id) when is_integer(id) and id < 0 do - token = Keyword.get(Application.get_env(:nola, :telegram, []), :key) - {:ok, chat} = Api.request(token, "getChat", chat_id: id) - Logger.metadata(transport: :telegram, id: id, telegram_room_id: id) - tg_room = case room(id) do - {:ok, tg_room = %{"network" => _net, "channel" => _chan}} -> tg_room - {:error, :not_found} -> - [net, chan] = String.split(chat["title"], "/", parts: 2) - {net, chan} = case IRC.Connection.get_network(net, chan) do - %IRC.Connection{} -> {net, chan} - _ -> {nil, nil} - end - {:ok, _id, _rev} = Couch.post(@couch, %{"_id" => id, "network" => net, "channel" => nil}) - {:ok, tg_room} = room(id) - tg_room - end - %{"network" => net, "channel" => chan} = tg_room - Logger.info("Starting ChatBot for room #{id} \"#{chat["title"]}\" #{inspect tg_room}") - irc_plumbed = if net && chan do - {:ok, _} = Registry.register(IRC.PubSub, "#{net}/#{chan}:messages", plugin: __MODULE__) - {:ok, _} = Registry.register(IRC.PubSub, "#{net}/#{chan}:triggers", plugin: __MODULE__) - {:ok, _} = Registry.register(IRC.PubSub, "#{net}/#{chan}:outputs", plugin: __MODULE__) - true - else - Logger.warn("Did not found telegram match for #{id} \"#{chat["title"]}\"") - false - end - {:ok, %{id: id, net: net, chan: chan, irc: irc_plumbed}} - end - - def init(id) do - Logger.error("telegram_room: bad id (not room id)", transport: :telegram, id: id, telegram_room_id: id) - :ignoree - end - - defp find_or_create_meta_account(from = %{"id" => user_id}, state) do - if account = IRC.Account.find_meta_account("telegram-id", user_id) do - account - else - first_name = Map.get(from, "first_name") - last_name = Map.get(from, "last_name") - name = [first_name, last_name] - |> Enum.filter(& &1) - |> Enum.join(" ") - - username = Map.get(from, "username", first_name) - - account = username - |> IRC.Account.new_account() - |> IRC.Account.update_account_name(name) - |> IRC.Account.put_meta("telegram-id", user_id) - - Logger.info("telegram_room: created account #{account.id} for telegram user #{user_id}") - account - end - end - - def handle_update(%{"message" => %{"from" => from = %{"id" => user_id}, "text" => text}}, _token, state) do - account = find_or_create_meta_account(from, state) - connection = IRC.Connection.get_network(state.net) - IRC.send_message_as(account, state.net, state.chan, text, true) - {:ok, state} - end - - def handle_update(data = %{"message" => %{"from" => from = %{"id" => user_id}, "location" => %{"latitude" => lat, "longitude" => lon}}}, _token, state) do - account = find_or_create_meta_account(from, state) - connection = IRC.Connection.get_network(state.net) - IRC.send_message_as(account, state.net, state.chan, "@ #{lat}, #{lon}", true) - {:ok, state} - end - - for type <- ~w(photo voice video document animation) do - def handle_update(data = %{"message" => %{unquote(type) => _}}, token, state) do - upload(unquote(type), data, token, state) - end - end - - def handle_update(update, token, state) do - {:ok, state} - end - - def handle_info({:irc, _, _, message}, state) do - handle_info({:irc, nil, message}, state) - end - - def handle_info({:irc, _, message = %IRC.Message{sender: %{nick: nick}, text: text}}, state) do - if Map.get(message.meta, :from) == self() do - else - body = if Map.get(message.meta, :self), do: text, else: "<#{nick}> #{text}" - Nola.Telegram.send_message(state.id, body) - end - {:ok, state} - end - - def handle_info(info, state) do - Logger.info("UNhandled #{inspect info}") - {:ok, state} - end - - defp upload(_type, %{"message" => m = %{"chat" => %{"id" => chat_id}, "from" => from = %{"id" => user_id}}}, token, state) do - account = find_or_create_meta_account(from, state) - if account do - {content, type} = cond do - m["photo"] -> {m["photo"], "photo"} - m["voice"] -> {m["voice"], "voice message"} - m["video"] -> {m["video"], "video"} - m["document"] -> {m["document"], "file"} - m["animation"] -> {m["animation"], "gif"} - end - - file = if is_list(content) && Enum.count(content) > 1 do - Enum.sort_by(content, fn(p) -> p["file_size"] end, &>=/2) - |> List.first() - else - content - end - - file_id = file["file_id"] - file_unique_id = file["file_unique_id"] - text = if(m["caption"], do: m["caption"] <> " ", else: "") - - spawn(fn() -> - with \ - {:ok, file} <- Telegram.Api.request(token, "getFile", file_id: file_id), - path = "https://api.telegram.org/file/bot#{token}/#{file["file_path"]}", - {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.get(path), - <> = body, - {:ok, magic} <- GenMagic.Pool.perform(Nola.GenMagic, {:bytes, smol_body}), - bucket = Application.get_env(:nola, :s3, []) |> Keyword.get(:bucket), - ext = Path.extname(file["file_path"]), - s3path = "#{account.id}/#{file_unique_id}#{ext}", - s3req = ExAws.S3.put_object(bucket, s3path, body, acl: :public_read, content_type: magic.mime_type), - {:ok, _} <- ExAws.request(s3req) - do - path = NolaWeb.Router.Helpers.url(NolaWeb.Endpoint) <> "/files/#{s3path}" - txt = "#{type}: #{text}#{path}" - connection = IRC.Connection.get_network(state.net) - IRC.send_message_as(account, state.net, state.chan, txt, true) - else - error -> - Telegram.Api.request(token, "sendMessage", chat_id: chat_id, text: "File upload failed, sorry.") - Logger.error("Failed upload from Telegram: #{inspect error}") - end - end) - - {:ok, state} - end - end - -end diff --git a/lib/lsg_telegram/telegram.ex b/lib/lsg_telegram/telegram.ex deleted file mode 100644 index 1c6a9a9..0000000 --- a/lib/lsg_telegram/telegram.ex +++ /dev/null @@ -1,233 +0,0 @@ -defmodule Nola.Telegram do - require Logger - @behaviour Telegram.ChatBot - - def my_path() do - "https://t.me/beauttebot" - end - - def send_message(id, text, md2 \\ false) do - md = if md2, do: "MarkdownV2", else: "Markdown" - token = Keyword.get(Application.get_env(:nola, :telegram, []), :key) - Telegram.Bot.ChatBot.Chat.Session.Supervisor.start_child(Nola.Telegram, id) - Telegram.Api.request(token, "sendMessage", chat_id: id, text: text, parse_mode: "Markdown") - end - - @impl Telegram.ChatBot - def init(chat_id) when chat_id < 0 do - {:ok, state} = Nola.TelegramRoom.init(chat_id) - {:ok, %{room_state: state}} - end - def init(chat_id) do - Logger.info("Telegram session starting: #{chat_id}") - account = IRC.Account.find_meta_account("telegram-id", chat_id) - account_id = if account, do: account.id - {:ok, %{account: account_id}} - end - - @impl Telegram.ChatBot - def handle_update(update, token, %{room_state: room_state}) do - {:ok, room_state} = Nola.TelegramRoom.handle_update(update, token, room_state) - {:ok, %{room_state: room_state}} - end - - def handle_update(%{"message" => m = %{"chat" => %{"type" => "private"}, "text" => text = "/start"<>_}}, _token, state) do - text = "*Welcome to beautte!*\n\nQuery the bot on IRC and say \"enable-telegram\" to continue." - send_message(m["chat"]["id"], text) - {:ok, %{account: nil}} - end - - def handle_update(%{"message" => m = %{"chat" => %{"type" => "private"}, "text" => text = "/enable"<>_}}, _token, state) do - key = case String.split(text, " ") do - ["/enable", key | _] -> key - _ -> "nil" - end - - #Handled message "1247435154:AAGnSSCnySn0RuVxy_SUcDEoOX_rbF6vdq0" %{"message" => - # %{"chat" => %{"first_name" => "J", "id" => 2075406, "type" => "private", "username" => "ahref"}, - # "date" => 1591027272, "entities" => - # [%{"length" => 7, "offset" => 0, "type" => "bot_command"}], - # "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"}, - # "message_id" => 11, "text" => "/enable salope"}, "update_id" => 764148578} - account = IRC.Account.find_meta_account("telegram-validation-code", String.downcase(key)) - text = if account do - net = IRC.Account.get_meta(account, "telegram-validation-target") - IRC.Account.put_meta(account, "telegram-id", m["chat"]["id"]) - IRC.Account.put_meta(account, "telegram-username", m["chat"]["username"]) - IRC.Account.put_meta(account, "telegram-username", m["chat"]["username"]) - IRC.Account.delete_meta(account, "telegram-validation-code") - IRC.Account.delete_meta(account, "telegram-validation-target") - IRC.Connection.broadcast_message(net, account, "Telegram #{m["chat"]["username"]} account added!") - "Yay! Linked to account **#{account.name}**." - else - "Token invalid" - end - send_message(m["chat"]["id"], text) - {:ok, %{account: account.id}} - end - - #[debug] Unhandled update: %{"message" => - # %{"chat" => %{"first_name" => "J", "id" => 2075406, "type" => "private", "username" => "ahref"}, - # "date" => 1591096015, - # "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"}, - # "message_id" => 29, - # "photo" => [ - # %{"file_id" => "AgACAgQAAxkBAAMdXtYyz4RQqLcpOlR6xKK3w3NayHAAAnCzMRuL4bFSgl_cTXMl4m5G_T0kXQADAQADAgADbQADZVMBAAEaBA", - # "file_size" => 9544, "file_unique_id" => "AQADRv09JF0AA2VTAQAB", "height" => 95, "width" => 320}, - # %{"file_id" => "AgACAgQAAxkBAAMdXtYyz4RQqLcpOlR6xKK3w3NayHAAAnCzMRuL4bFSgl_cTXMl4m5G_T0kXQADAQADAgADeAADZFMBAAEaBA", - # "file_size" => 21420, "file_unique_id" => "AQADRv09JF0AA2RTAQAB", "height" => 148, "width" => 501}]}, - # "update_id" => 218161546} - - for type <- ~w(photo voice video document animation) do - def handle_update(data = %{"message" => %{unquote(type) => _}}, token, state) do - start_upload(unquote(type), data, token, state) - end - end - - #[debug] Unhandled update: %{"callback_query" => - # %{ - # "chat_instance" => "-7948978714441865930", "data" => "evolu.net/#dmz", - # "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"}, - # "id" => "8913804780149600", - # "message" => %{"chat" => %{"first_name" => "J", "id" => 2075406, "type" => "private", "username" => "ahref"}, - # "date" => 1591098553, "from" => %{"first_name" => "devbeautte", "id" => 1293058838, "is_bot" => true, "username" => "devbeauttebot"}, - # "message_id" => 62, - # "reply_markup" => %{"inline_keyboard" => [[%{"callback_data" => "random/#", "text" => "random/#"}, - # %{"callback_data" => "evolu.net/#dmz", "text" => "evolu.net/#dmz"}]]}, - # "text" => "Where should I send the file?"} - # } - # , "update_id" => 218161568} - - #def handle_update(t, %{"callback_query" => cb = %{"data" => "resend", "id" => id, "message" => m = %{"message_id" => m_id, "chat" => %{"id" => chat_id}, "reply_to_message" => op}}}) do - #end - - def handle_update(%{"callback_query" => cb = %{"data" => "start-upload:"<>target, "id" => id, "message" => m = %{"message_id" => m_id, "chat" => %{"id" => chat_id}, "reply_to_message" => op}}}, t, state) do - account = IRC.Account.find_meta_account("telegram-id", chat_id) - if account do - target = case String.split(target, "/") do - ["everywhere"] -> IRC.Membership.of_account(account) - [net, chan] -> [{net, chan}] - end - Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "Processing...", reply_markup: %{}) - - {content, type} = cond do - op["photo"] -> {op["photo"], ""} - op["voice"] -> {op["voice"], " a voice message"} - op["video"] -> {op["video"], ""} - op["document"] -> {op["document"], ""} - op["animation"] -> {op["animation"], ""} - end - - file = if is_list(content) && Enum.count(content) > 1 do - Enum.sort_by(content, fn(p) -> p["file_size"] end, &>=/2) - |> List.first() - else - content - end - file_id = file["file_id"] - file_unique_id = file["file_unique_id"] - text = if(op["caption"], do: ": "<> op["caption"] <> "", else: "") - resend = %{"inline_keyboard" => [ [%{"text" => "re-share", "callback_data" => "resend"}] ]} - spawn(fn() -> - with \ - {:ok, file} <- Telegram.Api.request(t, "getFile", file_id: file_id), - path = "https://api.telegram.org/file/bot#{t}/#{file["file_path"]}", - {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.get(path), - <> = body, - {:ok, magic} <- GenMagic.Pool.perform(Nola.GenMagic, {:bytes, smol_body}), - bucket = Application.get_env(:nola, :s3, []) |> Keyword.get(:bucket), - ext = Path.extname(file["file_path"]), - s3path = "#{account.id}/#{file_unique_id}#{ext}", - Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "*Uploading...*", reply_markup: %{}, parse_mode: "MarkdownV2"), - s3req = ExAws.S3.put_object(bucket, s3path, body, acl: :public_read, content_type: magic.mime_type), - {:ok, _} <- ExAws.request(s3req) - do - path = NolaWeb.Router.Helpers.url(NolaWeb.Endpoint) <> "/files/#{s3path}" - sent = for {net, chan} <- target do - txt = "sent#{type}#{text} #{path}" - IRC.send_message_as(account, net, chan, txt) - "#{net}/#{chan}" - end - if caption = op["caption"], do: as_irc_message(chat_id, caption, account) - text = "Sent on " <> Enum.join(sent, ", ") <> " !" - Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "_Sent!_", reply_markup: %{}, parse_mode: "MarkdownV2") - else - error -> - Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "Something failed.", reply_markup: %{}, parse_mode: "MarkdownV2") - Logger.error("Failed upload from Telegram: #{inspect error}") - end - end) - end - {:ok, state} - end - - def handle_update(%{"message" => m = %{"chat" => %{"id" => id, "type" => "private"}, "text" => text}}, _, state) do - account = IRC.Account.find_meta_account("telegram-id", id) - if account do - as_irc_message(id, text, account) - end - {:ok, state} - end - - def handle_update(m, _, state) do - Logger.debug("Unhandled update: #{inspect m}") - {:ok, state} - end - - @impl Telegram.ChatBot - def handle_info(info, %{room_state: room_state}) do - {:ok, room_state} = Nola.TelegramRoom.handle_info(info, room_state) - {:ok, %{room_state: room_state}} - end - - def handle_info(_info, state) do - {:ok, state} - end - - defp as_irc_message(id, text, account) do - reply_fun = fn(text) -> send_message(id, text) end - trigger_text = cond do - String.starts_with?(text, "/") -> - "/"<>text = text - "!"<>text - Enum.any?(IRC.Connection.triggers(), fn({trigger, _}) -> String.starts_with?(text, trigger) end) -> - text - true -> - "!"<>text - end - message = %IRC.Message{ - id: FlakeId.get(), - transport: :telegram, - network: "telegram", - channel: nil, - text: text, - account: account, - sender: %ExIRC.SenderInfo{nick: account.name}, - replyfun: reply_fun, - trigger: IRC.Connection.extract_trigger(trigger_text), - at: nil - } - IRC.Connection.publish(message, ["messages:private", "messages:telegram", "telegram/#{account.id}:messages"]) - message - end - - defp start_upload(_type, %{"message" => m = %{"chat" => %{"id" => id, "type" => "private"}}}, token, state) do - account = IRC.Account.find_meta_account("telegram-id", id) - if account do - text = if(m["text"], do: m["text"], else: nil) - targets = IRC.Membership.of_account(account) - |> Enum.map(fn({net, chan}) -> "#{net}/#{chan}" end) - |> Enum.map(fn(i) -> %{"text" => i, "callback_data" => "start-upload:#{i}"} end) - kb = if Enum.count(targets) > 1 do - [%{"text" => "everywhere", "callback_data" => "start-upload:everywhere"}] ++ targets - else - targets - end - |> Enum.chunk_every(2) - keyboard = %{"inline_keyboard" => kb} - Telegram.Api.request(token, "sendMessage", chat_id: id, text: "Where should I send this file?", reply_markup: keyboard, reply_to_message_id: m["message_id"], parse_mode: "MarkdownV2") - end - {:ok, state} - end - -end diff --git a/lib/lsg_web/channels/user_socket.ex b/lib/lsg_web/channels/user_socket.ex deleted file mode 100644 index eadd4e0..0000000 --- a/lib/lsg_web/channels/user_socket.ex +++ /dev/null @@ -1,37 +0,0 @@ -defmodule NolaWeb.UserSocket do - use Phoenix.Socket - - ## Channels - # channel "room:*", NolaWeb.RoomChannel - - ## Transports - #transport :websocket, Phoenix.Transports.WebSocket - # transport :longpoll, Phoenix.Transports.LongPoll - - # Socket params are passed from the client and can - # be used to verify and authenticate a user. After - # verification, you can put default assigns into - # the socket that will be set for all channels, ie - # - # {:ok, assign(socket, :user_id, verified_user_id)} - # - # To deny connection, return `:error`. - # - # See `Phoenix.Token` documentation for examples in - # performing token verification on connect. - def connect(_params, socket) do - {:ok, socket} - end - - # Socket id's are topics that allow you to identify all sockets for a given user: - # - # def id(socket), do: "user_socket:#{socket.assigns.user_id}" - # - # Would allow you to broadcast a "disconnect" event and terminate - # all active sockets and channels for a given user: - # - # NolaWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) - # - # Returning `nil` makes this socket anonymous. - def id(_socket), do: nil -end diff --git a/lib/lsg_web/components/component.ex b/lib/lsg_web/components/component.ex deleted file mode 100644 index fff8263..0000000 --- a/lib/lsg_web/components/component.ex +++ /dev/null @@ -1,44 +0,0 @@ -defmodule NolaWeb.Component do - use Phoenix.Component - - @date_time_default_format "%F %H:%M" - @date_time_formats %{"time-24-with-seconds" => "%H:%M:%S"} - def naive_date_time_utc(assigns = %{at: nil}) do - "" - end - - def naive_date_time_utc(assigns = %{format: format}) do - assigns = assign(assigns, :format, Map.get(@date_time_formats, format, format)) - ~H""" - - """ - end - def naive_date_time_utc(assigns) do - naive_date_time_utc(assign(assigns, :format, "%F %H:%M")) - end - def get_luxon_format("%H:%M:%S"), do: "TIME_24_WITH_SECONDS" - - def nick(assigns = %{self: false}) do - ~H""" - - <%= @nick %> - - """ - end - - def nick(assigns = %{self: true}) do - ~H""" - - You - - """ - end - - -end diff --git a/lib/lsg_web/components/event_component.ex b/lib/lsg_web/components/event_component.ex deleted file mode 100644 index 8af3c67..0000000 --- a/lib/lsg_web/components/event_component.ex +++ /dev/null @@ -1,43 +0,0 @@ -defmodule NolaWeb.EventComponent do - use Phoenix.Component - - def content(assigns = %{event: %{type: :day_changed}}) do - ~H""" - Day changed: - <%= Date.to_string(@date) %> - """ - end - - def content(assigns = %{event: %{type: :quit}}) do - ~H""" - - has quit: - <%= @reason %> - """ - end - - def content(assigns = %{event: %{type: :part}}) do - ~H""" - - has left: - <%= @reason %> - """ - end - - def content(assigns = %{event: %{type: :nick}}) do - ~H""" - <%= @old_nick %> - is now known as - - """ - end - - def content(assigns = %{event: %{type: :join}}) do - ~H""" - - joined - """ - end - - -end diff --git a/lib/lsg_web/components/message_component.ex b/lib/lsg_web/components/message_component.ex deleted file mode 100644 index 5d0386b..0000000 --- a/lib/lsg_web/components/message_component.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule NolaWeb.MessageComponent do - use Phoenix.Component - - def content(assigns) do - ~H""" - -
<%= @message.sender.nick %>
-
<%= @text %>
- """ - end - -end diff --git a/lib/lsg_web/context_plug.ex b/lib/lsg_web/context_plug.ex deleted file mode 100644 index ebededa..0000000 --- a/lib/lsg_web/context_plug.ex +++ /dev/null @@ -1,92 +0,0 @@ -defmodule NolaWeb.ContextPlug do - import Plug.Conn - import Phoenix.Controller - - def init(opts \\ []) do - opts || [] - end - - def get_account(conn) do - cond do - get_session(conn, :account) -> get_session(conn, :account) - get_session(conn, :oidc_id) -> if account = IRC.Account.find_meta_account("identity-id", get_session(conn, :oidc_id)), do: account.id - true -> nil - end - end - - def call(conn, opts) do - account = with \ - {:account, account_id} when is_binary(account_id) <- {:account, get_account(conn)}, - {:account, account} when not is_nil(account) <- {:account, IRC.Account.get(account_id)} - do - account - else - _ -> nil - end - - network = Map.get(conn.params, "network") - network = if network == "-", do: nil, else: network - - oidc_account = IRC.Account.find_meta_account("identity-id", get_session(conn, :oidc_id)) - - conns = IRC.Connection.get_network(network) - chan = if c = Map.get(conn.params, "chan") do - NolaWeb.reformat_chan(c) - end - chan_conn = IRC.Connection.get_network(network, chan) - - memberships = if account do - IRC.Membership.of_account(account) - end - - auth_required = cond do - Keyword.get(opts, :restrict) == :public -> false - account == nil -> true - network == nil -> false - Keyword.get(opts, :restrict) == :logged_in -> false - network && chan -> - !Enum.member?(memberships, {network, chan}) - network -> - !Enum.any?(memberships, fn({n, _}) -> n == network end) - end - - bot = cond do - network && chan && chan_conn -> chan_conn.nick - network && conns -> conns.nick - true -> nil - end - - - cond do - account && auth_required -> - conn - |> put_status(404) - |> text("Page not found") - |> halt() - auth_required -> - conn - |> put_status(403) - |> render(NolaWeb.AlcoologView, "auth.html", bot: bot, no_header: true, network: network) - |> halt() - (network && !conns) -> - conn - |> put_status(404) - |> text("Page not found") - |> halt() - (chan && !chan_conn) -> - conn - |> put_status(404) - |> text("Page not found") - |> halt() - true -> - conn = conn - |> assign(:network, network) - |> assign(:chan, chan) - |> assign(:bot, bot) - |> assign(:account, account) - |> assign(:oidc_account, oidc_account) - |> assign(:memberships, memberships) - end - end - -end diff --git a/lib/lsg_web/controllers/alcoolog_controller.ex b/lib/lsg_web/controllers/alcoolog_controller.ex deleted file mode 100644 index 3081762..0000000 --- a/lib/lsg_web/controllers/alcoolog_controller.ex +++ /dev/null @@ -1,323 +0,0 @@ -defmodule NolaWeb.AlcoologController do - use NolaWeb, :controller - require Logger - - plug NolaWeb.ContextPlug when action not in [:token] - plug NolaWeb.ContextPlug, [restrict: :public] when action in [:token] - - def token(conn, %{"token" => token}) do - case Nola.Token.lookup(token) do - {:ok, {:alcoolog, :index, network, channel}} -> index(conn, nil, network, channel) - err -> - Logger.debug("AlcoologControler: token #{inspect err} invalid") - conn - |> put_status(404) - |> text("Page not found") - end - end - - def nick(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do - profile_account = IRC.Account.find_always_by_nick(network, nick, nick) - days = String.to_integer(Map.get(params, "days", "180")) - friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) - if friend? do - stats = Nola.IRC.AlcoologPlugin.get_full_statistics(profile_account.id) - history = for {{nick, ts}, points, active, cl, deg, type, descr, meta} <- Nola.IRC.AlcoologPlugin.nick_history(profile_account) do - %{ - at: ts |> DateTime.from_unix!(:millisecond), - points: points, - active: active, - cl: cl, - deg: deg, - type: type, - description: descr, - meta: meta - } - end - history = Enum.sort(history, &(DateTime.compare(&1.at, &2.at) != :lt)) - |> IO.inspect() - conn - |> assign(:title, "alcoolog #{nick}") - |> render("user.html", network: network, profile: profile_account, days: days, nick: nick, history: history, stats: stats) - else - conn - |> put_status(404) - |> text("Page not found") - end - end - - def nick_stats_json(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do - profile_account = IRC.Account.find_always_by_nick(network, nick, nick) - friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) - if friend? do - stats = Nola.IRC.AlcoologPlugin.get_full_statistics(profile_account.id) - - conn - |> put_resp_content_type("application/json") - |> text(Jason.encode!(stats)) - else - conn - |> put_status(404) - |> json([]) - end - end - - def nick_gls_json(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do - profile_account = IRC.Account.find_always_by_nick(network, nick, nick) - friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) - count = String.to_integer(Map.get(params, "days", "180")) - if friend? do - data = Nola.IRC.AlcoologPlugin.user_over_time_gl(profile_account, count) - delay = count*((24 * 60)*60) - now = DateTime.utc_now() - start_date = DateTime.utc_now() - |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) - |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) - |> DateTime.to_date() - |> Date.to_erl() - filled = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) - |> Enum.to_list - |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) - |> Enum.map(&Date.from_erl!(&1)) - |> Enum.map(fn(date) -> - %{date: date, gls: Map.get(data, date, 0)} - end) - |> Enum.sort(&(Date.compare(&1.date, &2.date) != :gt)) - - conn - |> put_resp_content_type("application/json") - |> text(Jason.encode!(filled)) - else - conn - |> put_status(404) - |> json([]) - end - end - - - - def nick_volumes_json(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do - profile_account = IRC.Account.find_always_by_nick(network, nick, nick) - friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) - count = String.to_integer(Map.get(params, "days", "180")) - if friend? do - data = Nola.IRC.AlcoologPlugin.user_over_time(profile_account, count) - delay = count*((24 * 60)*60) - now = DateTime.utc_now() - start_date = DateTime.utc_now() - |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) - |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) - |> DateTime.to_date() - |> Date.to_erl() - filled = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) - |> Enum.to_list - |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) - |> Enum.map(&Date.from_erl!(&1)) - |> Enum.map(fn(date) -> - %{date: date, volumes: Map.get(data, date, 0)} - end) - |> Enum.sort(&(Date.compare(&1.date, &2.date) != :gt)) - - conn - |> put_resp_content_type("application/json") - |> text(Jason.encode!(filled)) - else - conn - |> put_status(404) - |> json([]) - end - end - - def nick_log_json(conn = %{assigns: %{account: account}}, %{"network" => network, "nick" => nick}) do - profile_account = IRC.Account.find_always_by_nick(network, nick, nick) - friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) - if friend? do - history = for {{nick, ts}, points, active, cl, deg, type, descr, meta} <- Nola.IRC.AlcoologPlugin.nick_history(profile_account) do - %{ - at: ts |> DateTime.from_unix!(:millisecond) |> DateTime.to_iso8601(), - points: points, - active: active, - cl: cl, - deg: deg, - type: type, - description: descr, - meta: meta - } - end - last = List.last(history) - {_, active} = Nola.IRC.AlcoologPlugin.user_stats(profile_account) - last = %{last | active: active, at: DateTime.utc_now() |> DateTime.to_iso8601()} - history = history ++ [last] - - conn - |> put_resp_content_type("application/json") - |> text(Jason.encode!(history)) - else - conn - |> put_status(404) - |> json([]) - end - end - - def nick_history_json(conn = %{assigns: %{account: account}}, %{"network" => network, "nick" => nick}) do - profile_account = IRC.Account.find_always_by_nick(network, nick, nick) - friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) - if friend? do - history = for {_, date, value} <- Nola.IRC.AlcoologAnnouncerPlugin.log(profile_account) do - %{date: DateTime.to_iso8601(date), value: value} - end - conn - |> put_resp_content_type("application/json") - |> text(Jason.encode!(history)) - else - conn - |> put_status(404) - |> json([]) - end - end - - def index(conn = %{assigns: %{account: account}}, %{"network" => network, "chan" => channel}) do - index(conn, account, network, NolaWeb.reformat_chan(channel)) - end - - def index(conn = %{assigns: %{account: account}}, _) do - index(conn, account, nil, nil) - end - - #def index(conn, params) do - # network = Map.get(params, "network") - # chan = if c = Map.get(params, "chan") do - # NolaWeb.reformat_chan(c) - # end - # irc_conn = if network do - # IRC.Connection.get_network(network, chan) - # end - # bot = if(irc_conn, do: irc_conn.nick)# - # - # conn - # |> put_status(403) - # |> render("auth.html", network: network, channel: chan, irc_conn: conn, bot: bot) - #end - - def index(conn, account, network, channel) do - aday = ((24 * 60)*60) - now = DateTime.utc_now() - before7 = now - |> DateTime.add(-(7*aday), :second) - |> DateTime.to_unix(:millisecond) - before15 = now - |> DateTime.add(-(15*aday), :second) - |> DateTime.to_unix(:millisecond) - before31 = now - |> DateTime.add(-(31*aday), :second) - |> DateTime.to_unix(:millisecond) - #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end) - match = [ - {{{:_, :"$1"}, :_, :_, :_, :_, :_, :_, :_}, - [ - {:>, :"$1", {:const, before15}}, - ], [:"$_"]} - ] - - # tuple ets: {{nick, date}, volumes, current, nom, commentaire} - members = IRC.Membership.expanded_members_or_friends(account, network, channel) - members_ids = Enum.map(members, fn({account, _, nick}) -> account.id end) - member_names = Enum.reduce(members, %{}, fn({account, _, nick}, acc) -> Map.put(acc, account.id, nick) end) - drinks = :ets.select(Nola.IRC.AlcoologPlugin.ETS, match) - |> Enum.filter(fn({{account, _}, _vol, _cur, _cl, _deg, _name, _cmt, _meta}) -> Enum.member?(members_ids, account) end) - |> Enum.map(fn({{account, _}, _, _, _, _, _, _, _} = object) -> {object, Map.get(member_names, account)} end) - |> Enum.sort_by(fn({{{_, ts}, _, _, _, _, _, _, _}, _}) -> ts end, &>/2) - - stats = Nola.IRC.AlcoologPlugin.get_channel_statistics(account, network, channel) - - top = Enum.reduce(drinks, %{}, fn({{{account_id, _}, vol, _, _, _, _, _, _}, _}, acc) -> - nick = Map.get(member_names, account_id) - all = Map.get(acc, nick, 0) - Map.put(acc, nick, all + vol) - end) - |> Enum.sort_by(fn({_nick, count}) -> count end, &>/2) - # {date, single_peak} - # - conn - |> assign(:title, "alcoolog") - |> render("index.html", network: network, channel: channel, drinks: drinks, top: top, stats: stats) - end - - def index_gls_json(conn = %{assigns: %{account: account}}, %{"network" => network, "chan" => channel}) do - count = 30 - channel = NolaWeb.reformat_chan(channel) - members = IRC.Membership.expanded_members_or_friends(account, network, channel) - members_ids = Enum.map(members, fn({account, _, nick}) -> account.id end) - member_names = Enum.reduce(members, %{}, fn({account, _, nick}, acc) -> Map.put(acc, account.id, nick) end) - delay = count*((24 * 60)*60) - now = DateTime.utc_now() - start_date = DateTime.utc_now() - |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) - |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) - |> DateTime.to_date() - |> Date.to_erl() - filled = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) - |> Enum.to_list - |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) - |> Enum.map(&Date.from_erl!(&1)) - |> Enum.map(fn(date) -> - {date, (for {a, _, _} <- members, into: Map.new, do: {Map.get(member_names, a.id, a.id), 0})} - end) - |> Enum.into(Map.new) - - gls = Enum.reduce(members, filled, fn({account, _, _}, gls) -> - Enum.reduce(Nola.IRC.AlcoologPlugin.user_over_time_gl(account, count), gls, fn({date, gl}, gls) -> - u = Map.get(gls, date, %{}) - |> Map.put(Map.get(member_names, account.id, account.id), gl) - Map.put(gls, date, u) - end) - end) - - dates = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) - |> Enum.to_list - |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) - |> Enum.map(&Date.from_erl!(&1)) - - filled2 = Enum.map(member_names, fn({_, name}) -> - history = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) - |> Enum.to_list - |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) - |> Enum.map(&Date.from_erl!(&1)) - |> Enum.map(fn(date) -> - get_in(gls, [date, name]) #%{date: date, gl: get_in(gls, [date, name])} - end) - if Enum.all?(history, fn(x) -> x == 0 end) do - nil - else - %{name: name, history: history} - end - end) - |> Enum.filter(fn(x) -> x end) - - conn - |> put_resp_content_type("application/json") - |> text(Jason.encode!(%{labels: dates, data: filled2})) - end - - def minisync(conn, %{"user_id" => user_id, "key" => key, "value" => value}) do - account = IRC.Account.get(user_id) - if account do - ds = Nola.IRC.AlcoologPlugin.data_state() - meta = Nola.IRC.AlcoologPlugin.get_user_meta(ds, account.id) - case Float.parse(value) do - {val, _} -> - new_meta = Map.put(meta, String.to_existing_atom(key), val) - Nola.IRC.AlcoologPlugin.put_user_meta(ds, account.id, new_meta) - _ -> - conn - |> put_status(:unprocessable_entity) - |> text("invalid value") - end - else - conn - |> put_status(:not_found) - |> text("not found") - end - end - -end diff --git a/lib/lsg_web/controllers/gpt_controller.ex b/lib/lsg_web/controllers/gpt_controller.ex deleted file mode 100644 index 038b235..0000000 --- a/lib/lsg_web/controllers/gpt_controller.ex +++ /dev/null @@ -1,33 +0,0 @@ -defmodule NolaWeb.GptController do - use NolaWeb, :controller - require Logger - - plug NolaWeb.ContextPlug - - def result(conn, params = %{"id" => result_id}) do - case Nola.IRC.GptPlugin.get_result(result_id) do - {:ok, result} -> - network = Map.get(params, "network") - channel = if c = Map.get(params, "chan"), do: NolaWeb.reformat_chan(c) - render(conn, "result.html", network: network, channel: channel, result: result) - {:error, :not_found} -> - conn - |> put_status(404) - |> text("Page not found") - end - end - - def prompt(conn, params = %{"id" => prompt_id}) do - case Nola.IRC.GptPlugin.get_prompt(prompt_id) do - {:ok, prompt} -> - network = Map.get(params, "network") - channel = if c = Map.get(params, "chan"), do: NolaWeb.reformat_chan(c) - render(conn, "prompt.html", network: network, channel: channel, prompt: prompt) - {:error, :not_found} -> - conn - |> put_status(404) - |> text("Page not found") - end - end - -end diff --git a/lib/lsg_web/controllers/icecast_see_controller.ex b/lib/lsg_web/controllers/icecast_see_controller.ex deleted file mode 100644 index 877ad4e..0000000 --- a/lib/lsg_web/controllers/icecast_see_controller.ex +++ /dev/null @@ -1,41 +0,0 @@ -defmodule NolaWeb.IcecastSseController do - use NolaWeb, :controller - require Logger - - @ping_interval 20_000 - - def sse(conn, _params) do - conn - |> put_resp_header("X-Accel-Buffering", "no") - |> put_resp_header("content-type", "text/event-stream") - |> send_chunked(200) - |> subscribe - |> send_sse_message("ping", "ping") - |> send_sse_message("icecast", Nola.IcecastAgent.get) - |> sse_loop - end - - def subscribe(conn) do - :timer.send_interval(@ping_interval, {:event, :ping}) - {:ok, _} = Registry.register(Nola.BroadcastRegistry, "icecast", []) - conn - end - - def sse_loop(conn) do - {type, event} = receive do - {:event, :ping} -> {"ping", "ping"} - {:icecast, stats} -> {"icecast", stats} - end - - conn - |> send_sse_message(type, event) - |> sse_loop() - end - - defp send_sse_message(conn, type, data) do - json = Jason.encode!(%{type => data}) - {:ok, conn} = chunk(conn, "event: #{type}\ndata: #{json}\n\n") - conn - end - -end diff --git a/lib/lsg_web/controllers/irc_auth_sse_controller.ex b/lib/lsg_web/controllers/irc_auth_sse_controller.ex deleted file mode 100644 index 62ee2b5..0000000 --- a/lib/lsg_web/controllers/irc_auth_sse_controller.ex +++ /dev/null @@ -1,66 +0,0 @@ -defmodule NolaWeb.IrcAuthSseController do - use NolaWeb, :controller - require Logger - - @ping_interval 20_000 - @expire_delay :timer.minutes(3) - - def sse(conn, params) do - perks = if uri = Map.get(params, "redirect_to") do - {:redirect, uri} - else - nil - end - token = String.downcase(EntropyString.random_string(65)) - conn - |> assign(:token, token) - |> assign(:perks, perks) - |> put_resp_header("X-Accel-Buffering", "no") - |> put_resp_header("content-type", "text/event-stream") - |> send_chunked(200) - |> subscribe() - |> send_sse_message("token", token) - |> sse_loop - end - - def subscribe(conn) do - :timer.send_interval(@ping_interval, {:event, :ping}) - :timer.send_after(@expire_delay, {:event, :expire}) - {:ok, _} = Registry.register(IRC.PubSub, "messages:private", []) - conn - end - - def sse_loop(conn) do - {type, event, exit} = receive do - {:event, :ping} -> {"ping", "ping", false} - {:event, :expire} -> {"expire", "expire", true} - {:irc, :text, %{account: account, text: token} = m} -> - if String.downcase(String.trim(token)) == conn.assigns.token do - path = Nola.AuthToken.new_path(account.id, conn.assigns.perks) - m.replyfun.("ok!") - {"authenticated", path, true} - else - {nil, nil, false} - end - _ -> {nil, nil, false} - end - - conn = if type do - send_sse_message(conn, type, event) - else - conn - end - - if exit do - conn - else - sse_loop(conn) - end - end - - defp send_sse_message(conn, type, data) do - {:ok, conn} = chunk(conn, "event: #{type}\ndata: #{data}\n\n") - conn - end - -end diff --git a/lib/lsg_web/controllers/irc_controller.ex b/lib/lsg_web/controllers/irc_controller.ex deleted file mode 100644 index c617e78..0000000 --- a/lib/lsg_web/controllers/irc_controller.ex +++ /dev/null @@ -1,101 +0,0 @@ -defmodule NolaWeb.IrcController do - use NolaWeb, :controller - - plug NolaWeb.ContextPlug - - def index(conn, params) do - network = Map.get(params, "network") - channel = if c = Map.get(params, "chan"), do: NolaWeb.reformat_chan(c) - commands = for mod <- Enum.uniq([IRC.Account.AccountPlugin] ++ IRC.Plugin.enabled()) do - if is_atom(mod) do - identifier = Module.split(mod) |> List.last |> String.replace("Plugin", "") |> Macro.underscore - {identifier, mod.irc_doc()} - end - end - |> Enum.filter(& &1) - |> Enum.filter(fn({_, doc}) -> doc end) - members = cond do - network && channel -> Enum.map(IRC.UserTrack.channel(network, channel), fn(tuple) -> IRC.UserTrack.User.from_tuple(tuple) end) - true -> - IRC.Membership.of_account(conn.assigns.account) - end - render conn, "index.html", network: network, commands: commands, channel: channel, members: members - end - - def txt(conn, %{"name" => name}) do - if String.contains?(name, ".txt") do - name = String.replace(name, ".txt", "") - data = data() - if Map.has_key?(data, name) do - lines = Enum.join(data[name], "\n") - text(conn, lines) - else - conn - |> put_status(404) - |> text("Not found") - end - else - do_txt(conn, name) - end - end - def txt(conn, _), do: do_txt(conn, nil) - - - defp do_txt(conn, nil) do - doc = Nola.IRC.TxtPlugin.irc_doc() - data = data() - main = Enum.filter(data, fn({trigger, _}) -> !String.contains?(trigger, ".") end) |> Enum.into(Map.new) - system = Enum.filter(data, fn({trigger, _}) -> String.contains?(trigger, ".") end) |> Enum.into(Map.new) - lines = Enum.reduce(main, 0, fn({_, lines}, acc) -> acc + Enum.count(lines) end) - conn - |> assign(:title, "txt") - |> render("txts.html", data: main, doc: doc, files: Enum.count(main), lines: lines, system: system) - end - - defp do_txt(conn, txt) do - data = data() - base_url = cond do - conn.assigns[:chan] -> "/#{conn.assigns.network}/#{NolaWeb.format_chan(conn.assigns.chan)}" - true -> "/-" - end - if lines = Map.get(data, txt) do - lines = Enum.map(lines, fn(line) -> - line - |> String.split("\\\\") - |> Enum.intersperse(Phoenix.HTML.Tag.tag(:br)) - end) - conn - |> assign(:breadcrumbs, [{"txt", "#{base_url}/txt"}]) - |> assign(:title, "#{txt}.txt") - |> render("txt.html", name: txt, data: lines, doc: nil) - else - conn - |> put_status(404) - |> text("Not found") - end - end - - defp data() do - dir = Application.get_env(:nola, :data_path) <> "/irc.txt/" - Path.wildcard(dir <> "/*.txt") - |> Enum.reduce(%{}, fn(path, m) -> - path = String.split(path, "/") - file = List.last(path) - key = String.replace(file, ".txt", "") - data = dir <> file - |> File.read! - |> String.split("\n") - |> Enum.reject(fn(line) -> - cond do - line == "" -> true - !line -> true - true -> false - end - end) - Map.put(m, key, data) - end) - |> Enum.sort - |> Enum.into(Map.new) - end - -end diff --git a/lib/lsg_web/controllers/network_controller.ex b/lib/lsg_web/controllers/network_controller.ex deleted file mode 100644 index 800294f..0000000 --- a/lib/lsg_web/controllers/network_controller.ex +++ /dev/null @@ -1,11 +0,0 @@ -defmodule NolaWeb.NetworkController do - use NolaWeb, :controller - plug NolaWeb.ContextPlug - - def index(conn, %{"network" => network}) do - conn - |> assign(:title, network) - |> render("index.html") - end - -end diff --git a/lib/lsg_web/controllers/open_id_controller.ex b/lib/lsg_web/controllers/open_id_controller.ex deleted file mode 100644 index d3fef5d..0000000 --- a/lib/lsg_web/controllers/open_id_controller.ex +++ /dev/null @@ -1,64 +0,0 @@ -defmodule NolaWeb.OpenIdController do - use NolaWeb, :controller - plug NolaWeb.ContextPlug, restrict: :public - require Logger - - def login(conn, _) do - url = OAuth2.Client.authorize_url!(new_client(), scope: "openid", state: Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)) - redirect(conn, external: url) - end - - def callback(conn, %{"error" => error_code, "error_description" => error}) do - Logger.warn("OpenId error: #{error_code} #{error}") - render(conn, "error.html", error: error) - end - - def callback(conn, %{"code" => code, "state" => state}) do - with \ - client = %{token: %OAuth2.AccessToken{access_token: json}} = OAuth2.Client.get_token!(new_client(), state: state, code: code), - {:ok, %{"access_token" => token}} <- Jason.decode(json), - client = %OAuth2.Client{client | token: %OAuth2.AccessToken{access_token: token}}, - {:ok, %OAuth2.Response{body: body}} <- OAuth2.Client.get(client, "/userinfo"), - {:ok, %{"sub" => id, "preferred_username" => username}} <- Jason.decode(body) - do - if account = conn.assigns.account do - if !IRC.Account.get_meta(account, "identity-id") do # XXX: And oidc id not linked yet - IRC.Account.put_meta(account, "identity-id", id) - end - IRC.Account.put_meta(account, "identity-username", username) - conn - else - conn - end - - conn - |> put_session(:oidc_id, id) - |> put_flash(:info, "Logged in!") - |> redirect(to: Routes.path(conn, "/")) - else - {:error, %OAuth2.Response{status_code: 401}} -> - Logger.error("OpenID: Unauthorized token") - render(conn, "error.html", error: "The token is invalid.") - {:error, %OAuth2.Error{reason: reason}} -> - Logger.error("Error: #{inspect reason}") - render(conn, "error.html", error: reason) - end - end - - def callback(conn, _params) do - render(conn, "error.html", error: "Unspecified error.") - end - - defp new_client() do - config = Application.get_env(:nola, :oidc) - OAuth2.Client.new([ - strategy: OAuth2.Strategy.AuthCode, - client_id: config[:client_id], - client_secret: config[:client_secret], - site: config[:base_url], - authorize_url: config[:authorize_url], - token_url: config[:token_url], - redirect_uri: Routes.open_id_url(NolaWeb.Endpoint, :callback) - ]) - end -end diff --git a/lib/lsg_web/controllers/page_controller.ex b/lib/lsg_web/controllers/page_controller.ex deleted file mode 100644 index 2ac4d0a..0000000 --- a/lib/lsg_web/controllers/page_controller.ex +++ /dev/null @@ -1,53 +0,0 @@ -defmodule NolaWeb.PageController do - use NolaWeb, :controller - - plug NolaWeb.ContextPlug when action not in [:token] - plug NolaWeb.ContextPlug, [restrict: :public] when action in [:token] - - def token(conn, %{"token" => token}) do - with \ - {:ok, account, perks} <- Nola.AuthToken.lookup(token) - do - IO.puts("Authenticated account #{inspect account}") - conn = put_session(conn, :account, account) - case perks do - nil -> redirect(conn, to: "/") - {:redirect, path} -> redirect(conn, to: path) - {:external_redirect, url} -> redirect(conn, external: url) - end - else - z -> - IO.inspect(z) - text(conn, "Error: invalid or expired token") - end - end - - def index(conn = %{assigns: %{account: account}}, _) do - memberships = IRC.Membership.of_account(account) - users = IRC.UserTrack.find_by_account(account) - metas = IRC.Account.get_all_meta(account) - predicates = IRC.Account.get_predicates(account) - conn - |> assign(:title, account.name) - |> render("user.html", users: users, memberships: memberships, metas: metas, predicates: predicates) - end - - def irc(conn, _) do - bot_helps = for mod <- Nola.IRC.env(:handlers) do - mod.irc_doc() - end - render conn, "irc.html", bot_helps: bot_helps - end - - def authenticate(conn, _) do - with \ - {:account, account_id} when is_binary(account_id) <- {:account, get_session(conn, :account)}, - {:account, account} when not is_nil(account) <- {:account, IRC.Account.get(account_id)} - do - assign(conn, :account, account) - else - _ -> conn - end - end - -end diff --git a/lib/lsg_web/controllers/sms_controller.ex b/lib/lsg_web/controllers/sms_controller.ex deleted file mode 100644 index 575655c..0000000 --- a/lib/lsg_web/controllers/sms_controller.ex +++ /dev/null @@ -1,10 +0,0 @@ -defmodule NolaWeb.SmsController do - use NolaWeb, :controller - require Logger - - def ovh_callback(conn, %{"senderid" => from, "message" => message}) do - spawn(fn() -> Nola.IRC.SmsPlugin.incoming(from, String.trim(message)) end) - text(conn, "") - end - -end diff --git a/lib/lsg_web/controllers/untappd_controller.ex b/lib/lsg_web/controllers/untappd_controller.ex deleted file mode 100644 index d3a540d..0000000 --- a/lib/lsg_web/controllers/untappd_controller.ex +++ /dev/null @@ -1,18 +0,0 @@ -defmodule NolaWeb.UntappdController do - use NolaWeb, :controller - - def callback(conn, %{"code" => code}) do - with \ - {:account, account_id} when is_binary(account_id) <- {:account, get_session(conn, :account)}, - {:account, account} when not is_nil(account) <- {:account, IRC.Account.get(account_id)}, - {:ok, auth_token} <- Untappd.auth_callback(code) - do - IRC.Account.put_meta(account, "untappd-token", auth_token) - text(conn, "OK!") - else - {:account, _} -> text(conn, "Error: account not found") - :error -> text(conn, "Error: untappd authentication failed") - end - end - -end diff --git a/lib/lsg_web/endpoint.ex b/lib/lsg_web/endpoint.ex deleted file mode 100644 index a401f54..0000000 --- a/lib/lsg_web/endpoint.ex +++ /dev/null @@ -1,62 +0,0 @@ -defmodule NolaWeb.Endpoint do - use Sentry.PlugCapture - use Phoenix.Endpoint, otp_app: :nola - - # Serve at "/" the static files from "priv/static" directory. - # - # You should set gzip to true if you are running phoenix.digest - # when deploying your static files in production. - plug Plug.Static, - at: "/", from: :nola, gzip: false, - only: ~w(assets css js fonts images favicon.ico robots.txt) - - # Code reloading can be explicitly enabled under the - # :code_reloader configuration of your endpoint. - if 42==43 && code_reloading? do - socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket - plug Phoenix.LiveReloader - plug Phoenix.CodeReloader - end - - plug Plug.RequestId - plug Plug.Logger - - plug Plug.Parsers, - parsers: [:urlencoded, :multipart, :json], - pass: ["*/*"], - json_decoder: Jason - - plug Sentry.PlugContext - plug Plug.MethodOverride - plug Plug.Head - - @session_options [store: :cookie, - key: "_nola_key", - signing_salt: "+p7K3wrj"] - - - socket "/live", Phoenix.LiveView.Socket, - websocket: [connect_info: [session: @session_options]] - - # The session will be stored in the cookie and signed, - # this means its contents can be read but not tampered with. - # Set :encryption_salt if you would also like to encrypt it. - plug Plug.Session, @session_options - - plug NolaWeb.Router - - @doc """ - Callback invoked for dynamically configuring the endpoint. - - It receives the endpoint configuration and checks if - configuration should be loaded from the system environment. - """ - def init(_key, config) do - if config[:load_from_system_env] do - port = System.get_env("PORT") || raise "expected the PORT environment variable to be set" - {:ok, Keyword.put(config, :http, [:inet6, port: port])} - else - {:ok, config} - end - end -end diff --git a/lib/lsg_web/gettext.ex b/lib/lsg_web/gettext.ex deleted file mode 100644 index a43cb0d..0000000 --- a/lib/lsg_web/gettext.ex +++ /dev/null @@ -1,24 +0,0 @@ -defmodule NolaWeb.Gettext do - @moduledoc """ - A module providing Internationalization with a gettext-based API. - - By using [Gettext](https://hexdocs.pm/gettext), - your module gains a set of macros for translations, for example: - - import NolaWeb.Gettext - - # Simple translation - gettext "Here is the string to translate" - - # Plural translation - ngettext "Here is the string to translate", - "Here are the strings to translate", - 3 - - # Domain-based translation - dgettext "errors", "Here is the error message to translate" - - See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. - """ - use Gettext, otp_app: :nola -end diff --git a/lib/lsg_web/live/chat_live.ex b/lib/lsg_web/live/chat_live.ex deleted file mode 100644 index 276b362..0000000 --- a/lib/lsg_web/live/chat_live.ex +++ /dev/null @@ -1,120 +0,0 @@ -defmodule NolaWeb.ChatLive do - use Phoenix.LiveView - use Phoenix.HTML - require Logger - - def mount(%{"network" => network, "chan" => chan}, %{"account" => account_id}, socket) do - chan = NolaWeb.reformat_chan(chan) - connection = IRC.Connection.get_network(network, chan) - account = IRC.Account.get(account_id) - membership = IRC.Membership.of_account(IRC.Account.get("DRgpD4fLf8PDJMLp8Dtb")) - if account && connection && Enum.member?(membership, {connection.network, chan}) do - {:ok, _} = Registry.register(IRC.PubSub, "#{connection.network}:events", plugin: __MODULE__) - for t <- ["messages", "triggers", "outputs", "events"] do - {:ok, _} = Registry.register(IRC.PubSub, "#{connection.network}/#{chan}:#{t}", plugin: __MODULE__) - end - - IRC.PuppetConnection.start(account, connection) - - users = IRC.UserTrack.channel(connection.network, chan) - |> Enum.map(fn(tuple) -> IRC.UserTrack.User.from_tuple(tuple) end) - |> Enum.reduce(Map.new, fn(user = %{id: id}, acc) -> - Map.put(acc, id, user) - end) - - backlog = case Nola.IRC.BufferPlugin.select_buffer(connection.network, chan) do - {backlog, _} -> - {backlog, _} = Enum.reduce(backlog, {backlog, nil}, &reduce_contextual_event/2) - Enum.reverse(backlog) - _ -> [] - end - - socket = socket - |> assign(:connection_id, connection.id) - |> assign(:network, connection.network) - |> assign(:chan, chan) - |> assign(:title, "live") - |> assign(:channel, chan) - |> assign(:account_id, account.id) - |> assign(:backlog, backlog) - |> assign(:users, users) - |> assign(:counter, 0) - - {:ok, socket} - else - {:ok, redirect(socket, to: "/")} - end - end - - def handle_event("send", %{"message" => %{"text" => text}}, socket) do - account = IRC.Account.get(socket.assigns.account_id) - IRC.send_message_as(account, socket.assigns.network, socket.assigns.channel, text, true) - {:noreply, assign(socket, :counter, socket.assigns.counter + 1)} - end - - def handle_info({:irc, :event, event = %{type: :join, user_id: id}}, socket) do - if user = IRC.UserTrack.lookup(id) do - socket = socket - |> assign(:users, Map.put(socket.assigns.users, id, user)) - |> append_to_backlog(event) - {:noreply, socket} - else - {:noreply, socket} - end - end - - def handle_info({:irc, :event, event = %{type: :nick, user_id: id, nick: nick}}, socket) do - socket = socket - |> assign(:users, update_in(socket.assigns.users, [id, :nick], nick)) - |> append_to_backlog(event) - {:noreply, socket} - end - - def handle_info({:irc, :event, event = %{type: :quit, user_id: id}}, socket) do - socket = socket - |> assign(:users, Map.delete(socket.assigns.users, id)) - |> append_to_backlog(event) - {:noreply, socket} - end - - def handle_info({:irc, :event, event = %{type: :part, user_id: id}}, socket) do - socket = socket - |> assign(:users, Map.delete(socket.assigns.users, id)) - |> append_to_backlog(event) - {:noreply, socket} - end - - def handle_info({:irc, :trigger, _, message}, socket) do - handle_info({:irc, nil, message}, socket) - end - - def handle_info({:irc, :text, message}, socket) do - IO.inspect({:live_message, message}) - socket = socket - |> append_to_backlog(message) - {:noreply, socket} - end - - def handle_info(info, socket) do - Logger.debug("Unhandled info: #{inspect info}") - {:noreply, socket} - end - - defp append_to_backlog(socket, line) do - {add, _} = reduce_contextual_event(line, {[], List.last(socket.assigns.backlog)}) - assign(socket, :backlog, socket.assigns.backlog ++ add) - end - - defp reduce_contextual_event(line, {acc, nil}) do - {[line | acc], line} - end - defp reduce_contextual_event(line, {acc, last}) do - if NaiveDateTime.to_date(last.at) != NaiveDateTime.to_date(line.at) do - {[%{type: :day_changed, date: NaiveDateTime.to_date(line.at), at: nil}, line | acc], line} - else - {[line | acc], line} - end - - end - -end diff --git a/lib/lsg_web/live/chat_live.html.heex b/lib/lsg_web/live/chat_live.html.heex deleted file mode 100644 index 29cd6a1..0000000 --- a/lib/lsg_web/live/chat_live.html.heex +++ /dev/null @@ -1,91 +0,0 @@ -
- -
-
-

- <%= @network %> - <%= @chan %> -

- -
-
- -
- -
-

- Disconnected :'( -

-

- Oh no error >:( -

- -
    - <%= for message <- @backlog do %> - <%= if is_map(message) && Map.get(message, :__struct__) == IRC.Message do %> -
  • - -
  • - <% end %> - - <%= if is_binary(message) do %> -
  • <%= message %>
  • - <% end %> - - <%= if is_map(message) && Map.get(message, :type) do %> -
  • - - * * * - - - -
  • - <% end %> - <% end %> -
-
- - - -
- - <.form let={f} id={"form-#{@counter}"} for={:message} phx-submit="send" class="w-full px-4 pt-4"> -
-
- <%= text_input f, :text, class: "focus:ring-indigo-500 focus:border-indigo-500 block w-full border rounded-md pl-4 sm:text-sm border-gray-300", autofocus: true, 'phx-hook': "AutoFocus", autocomplete: "off", placeholder: "Don't be shy, say something…" %> - <%= submit content_tag(:span, "Send"), class: "-ml-px relative inline-flex items-center space-x-2 px-4 py-2 border border-gray-300 text-sm font-medium rounded-r-md text-gray-700 bg-gray-50 hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500"%> -
-
- -
diff --git a/lib/lsg_web/lsg_web.ex b/lib/lsg_web/lsg_web.ex deleted file mode 100644 index 906e961..0000000 --- a/lib/lsg_web/lsg_web.ex +++ /dev/null @@ -1,99 +0,0 @@ -defmodule NolaWeb do - @moduledoc """ - The entrypoint for defining your web interface, such - as controllers, views, channels and so on. - - This can be used in your application as: - - use NolaWeb, :controller - use NolaWeb, :view - - The definitions below will be executed for every view, - controller, etc, so keep them short and clean, focused - on imports, uses and aliases. - - Do NOT define functions inside the quoted expressions - below. Instead, define any helper function in modules - and import those modules here. - """ - - def format_chan("##") do - "♯♯" - end - - def format_chan("#") do - "♯" - end - - def format_chan("#"<>chan) do - chan - end - - def format_chan(chan = "!"<>_), do: chan - - def reformat_chan("♯") do - "#" - end - def reformat_chan("♯♯") do - "##" - end - def reformat_chan(chan = "!"<>_), do: chan - - def reformat_chan(chan) do - "#"<>chan - end - - def controller do - quote do - use Phoenix.Controller, namespace: NolaWeb - import Plug.Conn - import NolaWeb.Router.Helpers - import NolaWeb.Gettext - alias NolaWeb.Router.Helpers, as: Routes - end - end - - def view do - quote do - use Phoenix.View, root: "lib/nola_web/templates", - namespace: NolaWeb - - # Import convenience functions from controllers - import Phoenix.Controller, only: [get_flash: 2, view_module: 1] - - # Use all HTML functionality (forms, tags, etc) - use Phoenix.HTML - - import NolaWeb.Router.Helpers - import NolaWeb.ErrorHelpers - import NolaWeb.Gettext - - import Phoenix.LiveView.Helpers - - alias NolaWeb.Router.Helpers, as: Routes - end - end - - def router do - quote do - use Phoenix.Router - import Plug.Conn - import Phoenix.Controller - import Phoenix.LiveView.Router - end - end - - def channel do - quote do - use Phoenix.Channel - import NolaWeb.Gettext - end - end - - @doc """ - When used, dispatch to the appropriate controller/view/etc. - """ - defmacro __using__(which) when is_atom(which) do - apply(__MODULE__, which, []) - end -end diff --git a/lib/lsg_web/router.ex b/lib/lsg_web/router.ex deleted file mode 100644 index 5658fda..0000000 --- a/lib/lsg_web/router.ex +++ /dev/null @@ -1,85 +0,0 @@ -defmodule NolaWeb.Router do - use NolaWeb, :router - - pipeline :browser do - plug :accepts, ["html", "txt"] - plug :fetch_session - plug :fetch_flash - plug :fetch_live_flash - plug :protect_from_forgery - plug :put_secure_browser_headers - plug :put_root_layout, {NolaWeb.LayoutView, :root} - end - - pipeline :api do - plug :accepts, ["json", "sse"] - end - - pipeline :matrix_app_service do - plug :accepts, ["json"] - plug Nola.Matrix.Plug.Auth - plug Nola.Matrix.Plug.SetConfig - end - - scope "/api", NolaWeb do - pipe_through :api - get "/irc-auth.sse", IrcAuthSseController, :sse - post "/sms/callback/Ovh", SmsController, :ovh_callback, as: :sms - end - - scope "/", NolaWeb do - pipe_through :browser - get "/", PageController, :index - - get "/login/irc/:token", PageController, :token, as: :login - get "/login/oidc", OpenIdController, :login - get "/login/oidc/callback", OpenIdController, :callback - - get "/api/untappd/callback", UntappdController, :callback, as: :untappd_callback - - get "/-", IrcController, :index - get "/-/txt", IrcController, :txt - get "/-/txt/:name", IrcController, :txt - get "/-/gpt/prompt/:id", GptController, :task - get "/-/gpt/result/:id", GptController, :result - - get "/-/alcoolog", AlcoologController, :index - get "/-/alcoolog/~/:account_name", AlcoologController, :index - get "/:network", NetworkController, :index - get "/:network/~:nick/alcoolog", AlcoologController, :nick - get "/:network/~:nick/alcoolog/log.json", AlcoologController, :nick_log_json - get "/:network/~:nick/alcoolog/gls.json", AlcoologController, :nick_gls_json - get "/:network/~:nick/alcoolog/volumes.json", AlcoologController, :nick_volumes_json - get "/:network/~:nick/alcoolog/history.json", AlcoologController, :nick_history_json - get "/:network/~:nick/alcoolog/stats.json", AlcoologController, :nick_stats_json - get "/:network/:chan/alcoolog", AlcoologController, :index - get "/:network/:chan/alcoolog/gls.json", AlcoologController, :index_gls_json - get "/:network/:chan/gpt/prompt/:id", GptController, :task - get "/:network/:chan/gpt/result/:id", GptController, :result - put "/api/alcoolog/minisync/:user_id/meta/:key", AlcoologController, :minisync_put_meta - - get "/:network/:chan", IrcController, :index - live "/:network/:chan/live", ChatLive - get "/:network/:chan/txt", IrcController, :txt - get "/:network/:chan/txt/:name", IrcController, :txt - get "/:network/:channel/preums", IrcController, :preums - get "/:network/:chan/alcoolog/t/:token", AlcoologController, :token - end - - scope "/_matrix/:appservice", MatrixAppServiceWeb.V1, as: :matrix do - pipe_through :matrix_app_service - - put "/transactions/:txn_id", TransactionController, :push - - get "/users/:user_id", UserController, :query - get "/rooms/*room_alias", RoomController, :query - - get "/thirdparty/protocol/:protocol", ThirdPartyController, :query_protocol - get "/thirdparty/user/:protocol", ThirdPartyController, :query_users - get "/thirdparty/location/:protocol", ThirdPartyController, :query_locations - get "/thirdparty/location", ThirdPartyController, :query_location_by_alias - get "/thirdparty/user", ThirdPartyController, :query_user_by_id - end - - -end diff --git a/lib/lsg_web/templates/alcoolog/auth.html.eex b/lib/lsg_web/templates/alcoolog/auth.html.eex deleted file mode 100644 index 6e5cedc..0000000 --- a/lib/lsg_web/templates/alcoolog/auth.html.eex +++ /dev/null @@ -1,43 +0,0 @@ -
-

authentication

-
- <%= link("connect using random.sh", to: "/login/oidc", class: "inline-block font-medium underline") %> -
-
- -
-

- <%= if @bot, do: "Send this to #{@bot} on #{@network}:", else: "Find your bot nickname and send:" %>

- /msg <%= @bot || "the-bot-nickname" %> web -

- ... then come back to this address. -

-
- - diff --git a/lib/lsg_web/templates/alcoolog/index.html.eex b/lib/lsg_web/templates/alcoolog/index.html.eex deleted file mode 100644 index 5a5423a..0000000 --- a/lib/lsg_web/templates/alcoolog/index.html.eex +++ /dev/null @@ -1,205 +0,0 @@ - - -<%= if @stats == [] do %> -
-
-
- - - -
-
-

- CATASTROPHE! Personne n'a bu!!!! -

-
-
-
-<% end %> - -
    - <%= for {nick, status} <- @stats do %> -
  • -
    -
    -
    -

    <%= link nick, to: alcoolog_path(@conn, :nick, @network, nick) %>

    - <% rising_class = if status.rising, do: "teal", else: "red" %> - - <%= status.trend_symbol %> <%= Float.round(status.active, 4) %> g/l - -
    -

    - <%= status.last_cl %>cl @ <%= status.last_deg %>° - <%= if status.last_descr && status.last_descr != "" do %> -
    <%= status.last_descr %> - <% end %> -
    <%= NolaWeb.LayoutView.format_time(status.last_at) %> -

    - -

    -
    - — sobre dans: <%= status.sober_in_s %>
    - <%= if status.since do %> - — depuis: <%= status.since_s %>
    - <% end %> - - — 15m: <%= status.active15m %> g/l - 30m: <%= status.active30m %> g/l - 1h: <%= status.active1h %> g/l
    - — aujourd'hui: <%= status.daily_volumes %> points, <%= status.daily_gl %> g/l -
    -

    - - -
    -
    -
  • - <% end %> -
- -<%= if @stats == %{} do %> -
-
-
- - - -
-
-

- ENCORE PIRE! Aucune boisson enregistrée! -

-
-
-
-<% else %> - - - -

Classement 15 jours

- -
    - <%= for {{nick, count}, rank} <- Enum.with_index(@top) do %> - <% rank = rank + 1 %> - <% trophy = rank <= 3 %> - <% {colour, text} = case rank do -1 -> {"yellow-500", "font-semibold text-base"} -2 -> {"gray-500", "font-medium text-base"} -3 -> {"orange-300", "font-medium text-base"} -_ -> {"gray-300", ""} - end %> -
  • - <%= if trophy do %> - - - - <% end %> - #<%= rank %>: <%= link nick, to: alcoolog_path(@conn, :nick, @network, nick) %> -
    - <%= Float.round(count, 4) %> -
  • - <% end %> -
- -

Historique

-
-
-
- - - - - - - - - - - <%= for {{{{account, date}, points, _active, cl, deg, nom, comment, _meta}, nick}, index} <- Enum.with_index(@drinks) do %> - <% class = if(Integer.is_even(index), do: "bg-gray-50", else: "bg-white") %> - <% date = DateTime.from_unix!(date, :millisecond) %> - - - - - - - <% end %> - -
- date - - nick - -   - -   -
- <%= NolaWeb.LayoutView.format_time(date, false) %> - - <%= link nick, to: alcoolog_path(@conn, :nick, @network, nick) %> - <%= cl %>cl <%= deg %>°<%= comment||"" %>
-
-
-
-<% end %> - -<%= if @conn.assigns.account && (@network || @channel) do %> - <%= link("alcoolog global", to: alcoolog_path(@conn, :index)) %> -<% end %> - - - - - - - - diff --git a/lib/lsg_web/templates/alcoolog/user.html.eex b/lib/lsg_web/templates/alcoolog/user.html.eex deleted file mode 100644 index d7f716b..0000000 --- a/lib/lsg_web/templates/alcoolog/user.html.eex +++ /dev/null @@ -1,170 +0,0 @@ -<%= if @stats.active > 0 do %> -

- <% rising_class = if @stats.rising, do: "teal", else: "red" %> - - <%= @stats.trend_symbol %> <%= Float.round(@stats.active, 4) %> g/l - - - <%= @stats.last_cl %>cl @ <%= @stats.last_deg %>° - - <%= if @stats.last_descr && @stats.last_descr != "" do %> - <%= @stats.last_descr %> - <% end %> - - - <%= NolaWeb.LayoutView.format_time(@stats.last_at) %> - -

- -

- a commencé il y a <%= @stats.since_s %> - — - sobre dans <%= @stats.sober_in_s %> -

-<% else %> -

- est sobre! - -

- dernier verre - <%= @stats.last_cl %>cl @ <%= @stats.last_deg %>° - - <%= if @stats.last_descr && @stats.last_descr != "" do %> - <%= @stats.last_descr %> - <% end %> - - - <%= NolaWeb.LayoutView.format_time(@stats.last_at) %> -

-<% end %> - - - - - -

Historique

-
-
-
- - - - - - - - - - <%= for {%{at: date, cl: cl, deg: deg, description: comment}, index} <- Enum.with_index(@history) do %> - <% class = if(Integer.is_even(index), do: "bg-gray-50", else: "bg-white") %> - - - - - - <% end %> - -
- date - -   - -   -
- <%= NolaWeb.LayoutView.format_time(date, false) %> - <%= cl %>cl <%= deg %>°<%= comment||"" %>
-
-
-
- -
- - - - - - - - diff --git a/lib/lsg_web/templates/irc/index.html.eex b/lib/lsg_web/templates/irc/index.html.eex deleted file mode 100644 index 182624d..0000000 --- a/lib/lsg_web/templates/irc/index.html.eex +++ /dev/null @@ -1,44 +0,0 @@ - - -<%= if @members != [] do %> -
    - <%= for user <- @members do %>
  • <%= user.nick %>
  • <% end %> -
-<% end %> - -
- <%= for {identifier, help} <- @commands do %> - <%= if help do %> -
-
<%= NolaWeb.LayoutView.liquid_markdown(@conn, help) %>
-
- <% end %> - <% end %> -
- -


- -

- - Légende:
- entre < >: argument obligatoire,
- entre [ ]: argument optionel; [1 | ]: argument optionel avec valeur par défaut. -
-

- -


- -

- - running beautte version <%= Nola.version() %> — git - -

diff --git a/lib/lsg_web/templates/irc/txt.html.eex b/lib/lsg_web/templates/irc/txt.html.eex deleted file mode 100644 index fd4ea00..0000000 --- a/lib/lsg_web/templates/irc/txt.html.eex +++ /dev/null @@ -1,27 +0,0 @@ - - -
    - <%= for {txt, id} <- Enum.with_index(@data) do %> -
  1. - - <%= txt %> - # -
  2. - <% end %> -
- -

- télécharger au format texte -

diff --git a/lib/lsg_web/templates/irc/txts.html.eex b/lib/lsg_web/templates/irc/txts.html.eex deleted file mode 100644 index aff0c5d..0000000 --- a/lib/lsg_web/templates/irc/txts.html.eex +++ /dev/null @@ -1,49 +0,0 @@ -
-

- <%= @lines %> lignes dans <%= @files %> fichiers - Aide - Fichiers système -

-
    - <%= for {txt, data} <- @data do %> - <% base_url = cond do - @conn.assigns[:chan] -> "/#{@conn.assigns.network}/#{NolaWeb.format_chan(@conn.assigns.chan)}" - true -> "/-" - end %> -
  • -
    -
    - <%= txt %> -

    <%= Enum.count(data) %> lignes

    -
    -
    -
  • - <% end %> -
-
- -
<%= NolaWeb.LayoutView.liquid_markdown(@conn, @doc) %>
- -
-

- Fichiers système -

- -
    - <%= for {txt, data} <- @system do %> - <% base_url = cond do - @conn.assigns[:chan] -> "/#{@conn.assigns.network}/#{NolaWeb.format_chan(@conn.assigns.chan)}" - true -> "/-" - end %> -
  • -
    -
    - <%= txt %> -

    <%= Enum.count(data) %> lignes

    -
    -
    -
  • - <% end %> -
- -
diff --git a/lib/lsg_web/templates/layout/app.html.eex b/lib/lsg_web/templates/layout/app.html.eex deleted file mode 100644 index c774369..0000000 --- a/lib/lsg_web/templates/layout/app.html.eex +++ /dev/null @@ -1,126 +0,0 @@ -
-
- -
-
-

- <%= if n = @conn.assigns[:network] do %><%= n %> › <% end %> - <%= if c = @conn.assigns[:chan] do %><%= c %> › <% end %> - <%= for({name, href} <- Enum.uniq(@conn.assigns[:breadcrumbs]||[]), do: [link(name, to: href), raw(" › ")]) %> - <%= @conn.assigns[:title] %> -

-
-
-
- -
-
- -
- <%= @inner_content %> -
- -
-
-
diff --git a/lib/lsg_web/templates/layout/root.html.leex b/lib/lsg_web/templates/layout/root.html.leex deleted file mode 100644 index 6a48506..0000000 --- a/lib/lsg_web/templates/layout/root.html.leex +++ /dev/null @@ -1,18 +0,0 @@ - - - - <%= page_title(@conn) %> - - - - - - <%= Map.get(assigns, :title, "") %> - "> - <%= csrf_meta_tag() %> - - - - <%= @inner_content %> - - diff --git a/lib/lsg_web/templates/network/index.html.eex b/lib/lsg_web/templates/network/index.html.eex deleted file mode 100644 index fc024dd..0000000 --- a/lib/lsg_web/templates/network/index.html.eex +++ /dev/null @@ -1 +0,0 @@ -pouet diff --git a/lib/lsg_web/templates/open_id/error.html.eex b/lib/lsg_web/templates/open_id/error.html.eex deleted file mode 100644 index d1b35b9..0000000 --- a/lib/lsg_web/templates/open_id/error.html.eex +++ /dev/null @@ -1,3 +0,0 @@ -

OpenID authentication error

- -

<%= @error %>

diff --git a/lib/lsg_web/templates/page/api.html.eex b/lib/lsg_web/templates/page/api.html.eex deleted file mode 100644 index 03dfa6b..0000000 --- a/lib/lsg_web/templates/page/api.html.eex +++ /dev/null @@ -1,35 +0,0 @@ -

sys.115ans.net/api

- -

Icecast Status

- -

GET /api/icecast.json

- -

- Content-Type: application/json -

- -

-{
-  "np": String,
-  "genre": null | String,
-  "live": false | true
-}
-
- -

GET /api/icecast.sse

-

- Content-Type: text/event-stream -

- -

- Stream of: -

- -
    -
  • icecast events (same format as /api/icecast.json)
  • -
  • ping events (to keep-alive connection. You can safely ignore them)
  • -
-

- On client connection, the server sends the latest icecast status known. -

- diff --git a/lib/lsg_web/templates/page/index.html.eex b/lib/lsg_web/templates/page/index.html.eex deleted file mode 100644 index 1b8519a..0000000 --- a/lib/lsg_web/templates/page/index.html.eex +++ /dev/null @@ -1 +0,0 @@ -

vOv

diff --git a/lib/lsg_web/templates/page/irc.html.eex b/lib/lsg_web/templates/page/irc.html.eex deleted file mode 100644 index f6598ee..0000000 --- a/lib/lsg_web/templates/page/irc.html.eex +++ /dev/null @@ -1,19 +0,0 @@ -

bot `115ans

- -

Si vous cherchez l'IRC c'est par là.

- - - -
- <%= for help <- @bot_helps do %> -
<%= help |> Earmark.as_html! |> raw() %>
- <% end %> -
- diff --git a/lib/lsg_web/templates/page/user.html.eex b/lib/lsg_web/templates/page/user.html.eex deleted file mode 100644 index de9f718..0000000 --- a/lib/lsg_web/templates/page/user.html.eex +++ /dev/null @@ -1,43 +0,0 @@ -
-
    -
  • <%= link("Help", to: "/-") %>
  • - <%= unless List.keyfind(@metas, "identity-id", 0) do %> -
  • <%= link("Connect with random.sh", to: Routes.open_id_path(@conn, :login)) %>
  • - <% end %> -
- -

channels

-
    - <%= for {net, channel} <- @memberships do %> -
  • - <% url = NolaWeb.Router.Helpers.irc_path(NolaWeb.Endpoint, :index, net, NolaWeb.format_chan(channel)) %> - <%= link([net, ": ", content_tag(:strong, channel)], to: url) %> -
  • - <% end %> -
- -

connections

-
    - <%= for user <- @users do %> -
  • - <%= user.network %>: <%= user.nick %>!<%= user.username %>@<%= user.host %> <%= user.realname %>
    - <%= Enum.join(Enum.intersperse(Enum.map(user.privileges, fn({c, _}) -> c end), ", ")) %> -
  • - <% end %> -
- -

account

-
    -
  • account-id: <%= @conn.assigns.account.id %>
  • - <%= for {k, v} <- @metas do %> -
  • <%= k %>: <%= to_string(v) %>
  • - <% end %> -
- -irc auths: -
    - <%= for {net, {predicate, v}} <- @predicates do %> -
  • <%= net %>: <%= to_string(predicate) %>, <%= v %>
  • - <% end %> -
-
diff --git a/lib/lsg_web/templates/page/widget.html.eex b/lib/lsg_web/templates/page/widget.html.eex deleted file mode 100644 index 65853b3..0000000 --- a/lib/lsg_web/templates/page/widget.html.eex +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - -"> - - - - -
"> -
-
-
<%= @icecast.genre %>
-
<%= @icecast.np %>
-
-
- - diff --git a/lib/lsg_web/views/alcoolog_view.ex b/lib/lsg_web/views/alcoolog_view.ex deleted file mode 100644 index ad52472..0000000 --- a/lib/lsg_web/views/alcoolog_view.ex +++ /dev/null @@ -1,6 +0,0 @@ -defmodule NolaWeb.AlcoologView do - use NolaWeb, :view - require Integer - -end - diff --git a/lib/lsg_web/views/error_helpers.ex b/lib/lsg_web/views/error_helpers.ex deleted file mode 100644 index 25214bd..0000000 --- a/lib/lsg_web/views/error_helpers.ex +++ /dev/null @@ -1,40 +0,0 @@ -defmodule NolaWeb.ErrorHelpers do - @moduledoc """ - Conveniences for translating and building error messages. - """ - - use Phoenix.HTML - - @doc """ - Generates tag for inlined form input errors. - """ - def error_tag(form, field) do - Enum.map(Keyword.get_values(form.errors, field), fn (error) -> - content_tag :span, translate_error(error), class: "help-block" - end) - end - - @doc """ - Translates an error message using gettext. - """ - def translate_error({msg, opts}) do - # Because error messages were defined within Ecto, we must - # call the Gettext module passing our Gettext backend. We - # also use the "errors" domain as translations are placed - # in the errors.po file. - # Ecto will pass the :count keyword if the error message is - # meant to be pluralized. - # On your own code and templates, depending on whether you - # need the message to be pluralized or not, this could be - # written simply as: - # - # dngettext "errors", "1 file", "%{count} files", count - # dgettext "errors", "is invalid" - # - if count = opts[:count] do - Gettext.dngettext(NolaWeb.Gettext, "errors", msg, msg, count, opts) - else - Gettext.dgettext(NolaWeb.Gettext, "errors", msg, opts) - end - end -end diff --git a/lib/lsg_web/views/error_view.ex b/lib/lsg_web/views/error_view.ex deleted file mode 100644 index 5cad939..0000000 --- a/lib/lsg_web/views/error_view.ex +++ /dev/null @@ -1,17 +0,0 @@ -defmodule NolaWeb.ErrorView do - use NolaWeb, :view - - def render("404.html", _assigns) do - "Page not found" - end - - def render("500.html", _assigns) do - "Internal server error" - end - - # In case no render clause matches or no - # template is found, let's render it as 500 - def template_not_found(_template, assigns) do - render "500.html", assigns - end -end diff --git a/lib/lsg_web/views/irc_view.ex b/lib/lsg_web/views/irc_view.ex deleted file mode 100644 index 331d91f..0000000 --- a/lib/lsg_web/views/irc_view.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule NolaWeb.IrcView do - use NolaWeb, :view -end diff --git a/lib/lsg_web/views/layout_view.ex b/lib/lsg_web/views/layout_view.ex deleted file mode 100644 index 2bffc6f..0000000 --- a/lib/lsg_web/views/layout_view.ex +++ /dev/null @@ -1,81 +0,0 @@ -defmodule NolaWeb.LayoutView do - use NolaWeb, :view - - def liquid_markdown(conn, text) do - context_path = cond do - conn.assigns[:chan] -> "/#{conn.assigns[:network]}/#{NolaWeb.format_chan(conn.assigns[:chan])}" - conn.assigns[:network] -> "/#{conn.assigns[:network]}/-" - true -> "/-" - end - - {:ok, ast} = Liquex.parse(text) - context = Liquex.Context.new(%{ - "context_path" => context_path - }) - {content, _} = Liquex.render(ast, context) - content - |> to_string() - |> Earmark.as_html!() - |> raw() - end - - def page_title(conn) do - target = cond do - conn.assigns[:chan] -> - "#{conn.assigns.chan} @ #{conn.assigns.network}" - conn.assigns[:network] -> conn.assigns.network - true -> Keyword.get(Nola.name()) - end - - breadcrumb_title = Enum.map(Map.get(conn.assigns, :breadcrumbs)||[], fn({title, _href}) -> title end) - - title = [conn.assigns[:title], breadcrumb_title, target] - |> List.flatten() - |> Enum.uniq() - |> Enum.filter(fn(x) -> x end) - |> Enum.intersperse(" / ") - |> Enum.join() - - content_tag(:title, title) - end - - def format_time(date, with_relative \\ true) do - alias Timex.Format.DateTime.Formatters - alias Timex.Timezone - date = if is_integer(date) do - date - |> DateTime.from_unix!(:millisecond) - |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) - else - date - |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) - end - - now = DateTime.now!("Europe/Paris", Tzdata.TimeZoneDatabase) - - now_week = Timex.iso_week(now) - date_week = Timex.iso_week(date) - - {y, w} = now_week - now_last_week = {y, w-1} - now_last_roll = 7-Timex.days_to_beginning_of_week(now) - - date_date = DateTime.to_date(date) - now_date = DateTime.to_date(date) - - format = cond do - date.year != now.year -> "{D}/{M}/{YYYY} {h24}:{m}" - date_date == now_date -> "{h24}:{m}" - (now_week == date_week) || (date_week == now_last_week && (Date.day_of_week(date) >= now_last_roll)) -> "{WDfull} {h24}:{m}" - (now.year == date.year && now.month == date.month) -> "{WDfull} {D} {h24}:{m}" - true -> "{WDfull} {D} {M} {h24}:{m}" - end - - {:ok, relative} = Formatters.Relative.relative_to(date, Timex.now("Europe/Paris"), "{relative}", "fr") - {:ok, full} = Formatters.Default.lformat(date, "{WDfull} {D} {YYYY} {h24}:{m}", "fr") #"{h24}:{m} {WDfull} {D}", "fr") - {:ok, detail} = Formatters.Default.lformat(date, format, "fr") #"{h24}:{m} {WDfull} {D}", "fr") - - content_tag(:time, if(with_relative, do: relative, else: detail), [title: full]) - end - -end diff --git a/lib/lsg_web/views/network_view.ex b/lib/lsg_web/views/network_view.ex deleted file mode 100644 index 7a24db1..0000000 --- a/lib/lsg_web/views/network_view.ex +++ /dev/null @@ -1,4 +0,0 @@ -defmodule NolaWeb.NetworkView do - use NolaWeb, :view - -end diff --git a/lib/lsg_web/views/open_id_view.ex b/lib/lsg_web/views/open_id_view.ex deleted file mode 100644 index bd8089b..0000000 --- a/lib/lsg_web/views/open_id_view.ex +++ /dev/null @@ -1,4 +0,0 @@ -defmodule NolaWeb.OpenIdView do - use NolaWeb, :view - -end diff --git a/lib/lsg_web/views/page_view.ex b/lib/lsg_web/views/page_view.ex deleted file mode 100644 index 1bfaadd..0000000 --- a/lib/lsg_web/views/page_view.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule NolaWeb.PageView do - use NolaWeb, :view -end diff --git a/lib/nola/application.ex b/lib/nola/application.ex new file mode 100644 index 0000000..4f3d1da --- /dev/null +++ b/lib/nola/application.ex @@ -0,0 +1,56 @@ +defmodule Nola.Application do + use Application + + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + def start(_type, _args) do + import Supervisor.Spec + + Logger.add_backend(Sentry.LoggerBackend) + :ok = Nola.Matrix.setup() + :ok = Nola.TelegramRoom.setup() + + # Define workers and child supervisors to be supervised + children = [ + # Start the endpoint when the application starts + supervisor(NolaWeb.Endpoint, []), + # Start your own worker by calling: Nola.Worker.start_link(arg1, arg2, arg3) + # worker(Nola.Worker, [arg1, arg2, arg3]), + worker(Registry, [[keys: :duplicate, name: Nola.BroadcastRegistry]], id: :registry_broadcast), + worker(Nola.IcecastAgent, []), + worker(Nola.Token, []), + worker(Nola.AuthToken, []), + Nola.Subnet, + {GenMagic.Pool, [name: Nola.GenMagic, pool_size: 2]}, + #worker(Nola.Icecast, []), + ] ++ Nola.IRC.application_childs + ++ Nola.Matrix.application_childs + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: Nola.Supervisor] + sup = Supervisor.start_link(children, opts) + start_telegram() + spawn_link(fn() -> Nola.IRC.after_start() end) + spawn_link(fn() -> Nola.Matrix.after_start() end) + spawn_link(fn() -> Nola.TelegramRoom.after_start() end) + sup + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + def config_change(changed, _new, removed) do + NolaWeb.Endpoint.config_change(changed, removed) + :ok + end + + defp start_telegram() do + token = Keyword.get(Application.get_env(:nola, :telegram, []), :key) + options = [ + username: Keyword.get(Application.get_env(:nola, :telegram, []), :nick, "beauttebot"), + purge: false + ] + telegram = Telegram.Bot.ChatBot.Supervisor.start_link({Nola.Telegram, token, options}) + end + +end diff --git a/lib/nola/auth_token.ex b/lib/nola/auth_token.ex new file mode 100644 index 0000000..d125ea4 --- /dev/null +++ b/lib/nola/auth_token.ex @@ -0,0 +1,59 @@ +defmodule Nola.AuthToken do + use GenServer + + def start_link() do + GenServer.start_link(__MODULE__, [], [name: __MODULE__]) + end + + def lookup(id) do + GenServer.call(__MODULE__, {:lookup, id}) + end + + def new_path(account, perks \\ nil) do + case new(account, perks) do + {:ok, id} -> + NolaWeb.Router.Helpers.login_path(NolaWeb.Endpoint, :token, id) + error -> + error + end + end + + def new_url(account, perks \\ nil) do + case new(account, perks) do + {:ok, id} -> + NolaWeb.Router.Helpers.login_url(NolaWeb.Endpoint, :token, id) + error -> + error + end + end + + def new(account, perks \\ nil) do + GenServer.call(__MODULE__, {:new, account, perks}) + end + + def init(_) do + {:ok, Map.new} + end + + def handle_call({:lookup, id}, _, state) do + IO.inspect(state) + with \ + {account, date, perks} <- Map.get(state, id), + d when d > 0 <- DateTime.diff(date, DateTime.utc_now()) + do + {:reply, {:ok, account, perks}, Map.delete(state, id)} + else + x -> + IO.inspect(x) + {:reply, {:error, :invalid_token}, state} + end + end + + def handle_call({:new, account, perks}, _, state) do + id = IRC.UserTrack.Id.token() + expire = DateTime.utc_now() + |> DateTime.add(15*60, :second) + {:reply, {:ok, id}, Map.put(state, id, {account, expire, perks})} + end + +end diff --git a/lib/nola/icecast.ex b/lib/nola/icecast.ex new file mode 100644 index 0000000..5a53192 --- /dev/null +++ b/lib/nola/icecast.ex @@ -0,0 +1,117 @@ +defmodule Nola.Icecast do + use GenServer + require Logger + @hackney_pool :default + @httpoison_opts [hackney: [pool: @hackney_pool]] + @fuse __MODULE__ + + def start_link, do: GenServer.start_link(__MODULE__, [], []) + + def init(_) do + GenServer.cast(self(), :poll) + {:ok, nil} + end + + def handle_cast(:poll, state) do + state = poll(state) + {:noreply, state} + end + + def handle_info(:poll, state) do + state = poll(state) + {:noreply, state} + end + + defp poll(state) do + state = case request(base_url(), :get) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + #update_json_stats(Jason.decode(body)) + stats = update_stats(body) + if state != stats do + Logger.info "Icecast Update: " <> inspect(stats) + Nola.IcecastAgent.update(stats) + Registry.dispatch(Nola.BroadcastRegistry, "icecast", fn ws -> + for {pid, _} <- ws, do: send(pid, {:icecast, stats}) + end) + stats + else + state + end + error -> + Logger.error "Icecast HTTP Error: #{inspect error}" + state + end + interval = Application.get_env(:nola, :icecast_poll_interval, 60_000) + :timer.send_after(interval, :poll) + state + end + + defp update_stats(html) do + raw = Floki.find(html, "div.roundbox") + |> Enum.map(fn(html) -> + html = Floki.raw_html(html) + [{"h3", _, ["Mount Point /"<>mount]}] = Floki.find(html, "h3.mount") + stats = Floki.find(html, "tr") + |> Enum.map(fn({"tr", _, tds}) -> + [{"td", _, keys}, {"td", _, values}] = tds + key = List.first(keys) + value = List.first(values) + {key, value} + end) + |> Enum.into(Map.new) + {mount, stats} + end) + |> Enum.into(Map.new) + + live? = if Map.get(raw["live"], "Content Type:", false), do: true, else: false + np = if live? do + raw["live"]["Currently playing:"] + else + raw["autodj"]["Currently playing:"] + end + + genre = raw["live"]["Genre:"] || nil + %{np: np || "", live: live? || false, genre: genre} + end + + defp update_json_stats({:ok, body}) do + Logger.debug "JSON STATS: #{inspect body}" + end + + defp update_json_stats(error) do + Logger.error "Failed to decode JSON Stats: #{inspect error}" + end + + defp request(uri, method, body \\ [], headers \\ []) do + headers = [{"user-agent", "Nola-API[115ans.net, sys.115ans.net] href@random.sh"}] ++ headers + options = @httpoison_opts + case :ok do #:fuse.ask(@fuse, :sync) do + :ok -> run_request(method, uri, body, headers, options) + :blown -> :blown + end + end + + # This is to work around hackney's behaviour of returning `{:error, :closed}` when a pool connection has been closed + # (keep-alive expired). We just retry the request immediatly up to five times. + defp run_request(method, uri, body, headers, options), do: run_request(method, uri, body, headers, options, 0) + defp run_request(method, uri, body, headers, options, retries) when retries < 4 do + case HTTPoison.request(method, uri, body, headers, options) do + {:error, :closed} -> run_request(method, uri, body, headers, options, retries + 1) + other -> other + end + end + defp run_request(method, uri, body, headers, options, _exceeded_retries), do: {:error, :unavailable} + + # + # -- URIs + # + + defp stats_json_url do + base_url() <> "/status-json.xsl" + end + + defp base_url do + "http://91.121.59.45:8089" + end + +end diff --git a/lib/nola/icecast_agent.ex b/lib/nola/icecast_agent.ex new file mode 100644 index 0000000..8a3a72b --- /dev/null +++ b/lib/nola/icecast_agent.ex @@ -0,0 +1,17 @@ +defmodule Nola.IcecastAgent do + use Agent + + def start_link() do + Agent.start_link(fn -> nil end, name: __MODULE__) + end + + def update(stats) do + Agent.update(__MODULE__, fn(_old) -> stats end) + end + + def get do + Agent.get(__MODULE__, fn(stats) -> stats end) + end + +end + diff --git a/lib/nola/nola.ex b/lib/nola/nola.ex new file mode 100644 index 0000000..0acb76e --- /dev/null +++ b/lib/nola/nola.ex @@ -0,0 +1,30 @@ +defmodule Nola do + + @default_brand [ + name: "Nola, + source_url: "https://phab.random.sh/source/Bot/", + owner: "Ashamed owner", + owner_email: "contact@my.nola.bot" + ] + + def env(), do: Application.get_env(:nola) + def env(key, default \\ nil), do: Application.get_env(:nola, key, default) + + def brand(), do: env(:brand, @default_brand) + def brand(key), do: Keyword.get(brand(), key) + def name(), do: brand(:name) + def source_url(), do: brand(:source_url) + + def data_path(suffix) do + Path.join(data_path(), suffix) + end + + def data_path do + Application.get_env(:nola, :data_path) + end + + def version do + Application.spec(:nola)[:vsn] + end + +end diff --git a/lib/nola/subnet.ex b/lib/nola/subnet.ex new file mode 100644 index 0000000..ac9d8e6 --- /dev/null +++ b/lib/nola/subnet.ex @@ -0,0 +1,84 @@ +defmodule Nola.Subnet do + use Agent + + def start_link(_) do + Agent.start_link(&setup/0, name: __MODULE__) + end + + def assignations() do + :dets.select(dets(), [{{:"$1", :"$2"}, [is_binary: :"$2"], [{{:"$1", :"$2"}}]}]) + end + + def find_subnet_for(binary) when is_binary(binary) do + case :dets.select(dets(), [{{:"$1", :"$2"}, [{:==, :"$2", binary}], [{{:"$1", :"$2"}}]}]) do + [{subnet, _}] -> subnet + _ -> nil + end + end + + def assign(binary) when is_binary(binary) do + result = if subnet = find_subnet_for(binary) do + {:ok, subnet} + else + Agent.get_and_update(__MODULE__, fn(dets) -> + {subnet, _} = available_select(dets) + :dets.insert(dets, {subnet, binary}) + :dets.sync(dets) + {{:new, subnet}, dets} + end) + end + + case result do + {:new, subnet} -> + ip = Pfx.host(subnet, 1) + set_reverse(binary, ip) + subnet + {:ok, subnet} -> + subnet + end + end + + def set_reverse(name, ip, value \\ nil) + + def set_reverse(name, ip, nil) do + set_reverse(name, ip, "#{name}.users.goulag.org") + end + + def set_reverse(_, ip, value) do + ptr_zone = "3.0.0.2.d.f.0.a.2.ip6.arpa" + ip_fqdn = Pfx.dns_ptr(ip) + ip_local = String.replace(ip_fqdn, ".#{ptr_zone}", "") + rev? = String.ends_with?(value, ".users.goulag.org") + if rev? do + {:ok, rev_zone} = PowerDNSex.show_zone("users.goulag.org") + rev_update? = Enum.any?(rev_zone.rrsets, fn(rr) -> rr.name == "#{ip_fqdn}." end) + record = %{name: "#{value}.", type: "AAAA", ttl: 8600, records: [%{content: ip, disabled: false}]} + if(rev_update?, do: PowerDNSex.update_record(rev_zone, record), else: PowerDNSex.create_record(rev_zone, record)) + end + {:ok, zone} = PowerDNSex.show_zone(ptr_zone) + update? = Enum.any?(zone.rrsets, fn(rr) -> rr.name == "#{ip_fqdn}." end) + record = %{name: "#{ip_fqdn}.", type: "PTR", ttl: 3600, records: [%{content: "#{value}.", disabled: false}]} + pdns = if(update?, do: PowerDNSex.update_record(zone, record), else: PowerDNSex.create_record(zone, record)) + :ok + end + + @doc false + def dets() do + (Nola.data_path() <> "/subnets.dets") |> String.to_charlist() + end + + @doc false + def setup() do + {:ok, dets} = :dets.open_file(dets(), []) + dets + end + + defp available_select(dets) do + spec = [{{:"$1", :"$2"}, [is_integer: :"$2"], [{{:"$1", :"$2"}}]}] + {subnets, _} = :dets.select(dets, spec, 20) + subnet = subnets + |> Enum.sort_by(fn({_, last}) -> last end) + |> List.first() + end + +end diff --git a/lib/nola/token.ex b/lib/nola/token.ex new file mode 100644 index 0000000..563ac72 --- /dev/null +++ b/lib/nola/token.ex @@ -0,0 +1,38 @@ +defmodule Nola.Token do + use GenServer + + def start_link() do + GenServer.start_link(__MODULE__, [], [name: __MODULE__]) + end + + def lookup(id) do + with \ + [{_, cred, date}] <- :ets.lookup(__MODULE__.ETS, id), + IO.inspect("cred: #{inspect cred} valid for #{inspect date} now #{inspect DateTime.utc_now()}"), + d when d > 0 <- DateTime.diff(date, DateTime.utc_now()) + do + {:ok, cred} + else + err -> {:error, err} + end + end + + def new(cred) do + GenServer.call(__MODULE__, {:new, cred}) + end + + def init(_) do + ets = :ets.new(__MODULE__.ETS, [:ordered_set, :named_table, :protected, {:read_concurrency, true}]) + {:ok, ets} + end + + def handle_call({:new, cred}, _, ets) do + id = IRC.UserTrack.Id.large_id() + expire = DateTime.utc_now() + |> DateTime.add(15*60, :second) + obj = {id, cred, expire} + :ets.insert(ets, obj) + {:reply, {:ok, id}, ets} + end + +end diff --git a/lib/nola_irc/account.ex b/lib/nola_irc/account.ex new file mode 100644 index 0000000..45680f8 --- /dev/null +++ b/lib/nola_irc/account.ex @@ -0,0 +1,451 @@ +defmodule IRC.Account do + alias IRC.UserTrack.User + + @moduledoc """ + Account registry.... + + Maps a network predicate: + * `{net, {:nick, nickname}}` + * `{net, {:account, account}}` + * `{net, {:mask, user@host}}` + to an unique identifier, that can be shared over multiple networks. + + If a predicate cannot be found for an existing account, a new account will be made in the database. + + To link two existing accounts from different network onto a different one, a merge operation is provided. + + """ + + # FIXME: Ensure uniqueness of name? + + @derive {Poison.Encoder, except: [:token]} + defstruct [:id, :name, :token] + @type t :: %__MODULE__{id: id(), name: String.t()} + @type id :: String.t() + + defimpl Inspect, for: __MODULE__ do + import Inspect.Algebra + + def inspect(%{id: id, name: name}, opts) do + concat(["#IRC.Account[", id, " ", name, "]"]) + end + end + + def file(base) do + to_charlist(Nola.data_path() <> "/account_#{base}.dets") + end + + defp from_struct(%__MODULE__{id: id, name: name, token: token}) do + {id, name, token} + end + + defp from_tuple({id, name, token}) do + %__MODULE__{id: id, name: name, token: token} + end + + def start_link() do + GenServer.start_link(__MODULE__, [], [name: __MODULE__]) + end + + def init(_) do + {:ok, accounts} = :dets.open_file(file("db"), []) + {:ok, meta} = :dets.open_file(file("meta"), []) + {:ok, predicates} = :dets.open_file(file("predicates"), [{:type, :set}]) + {:ok, %{accounts: accounts, meta: meta, predicates: predicates}} + end + + def get(id) do + case :dets.lookup(file("db"), id) do + [account] -> from_tuple(account) + _ -> nil + end + end + + def get_by_name(name) do + spec = [{{:_, :"$1", :_}, [{:==, :"$1", {:const, name}}], [:"$_"]}] + case :dets.select(file("db"), spec) do + [account] -> from_tuple(account) + _ -> nil + end + end + + def get_meta(%__MODULE__{id: id}, key, default \\ nil) do + case :dets.lookup(file("meta"), {id, key}) do + [{_, value}] -> (value || default) + _ -> default + end + end + + @spec find_meta_accounts(String.t()) :: [{account :: t(), value :: String.t()}, ...] + @doc "Find all accounts that have a meta of `key`." + def find_meta_accounts(key) do + spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$2", {:const, key}}], [{{:"$1", :"$3"}}]}] + for {id, val} <- :dets.select(file("meta"), spec), do: {get(id), val} + end + + @doc "Find an account given a specific meta `key` and `value`." + @spec find_meta_account(String.t(), String.t()) :: t() | nil + def find_meta_account(key, value) do + #spec = [{{{:"$1", :"$2"}, :"$3"}, [:andalso, {:==, :"$2", {:const, key}}, {:==, :"$3", {:const, value}}], [:"$1"]}] + spec = [{{{:"$1", :"$2"}, :"$3"}, [{:andalso, {:==, :"$2", {:const, key}}, {:==, {:const, value}, :"$3"}}], [:"$1"]}] + case :dets.select(file("meta"), spec) do + [id] -> get(id) + _ -> nil + end + end + + def get_all_meta(%__MODULE__{id: id}) do + spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$1", {:const, id}}], [{{:"$2", :"$3"}}]}] + :dets.select(file("meta"), spec) + end + + def put_user_meta(account = %__MODULE__{}, key, value) do + put_meta(account, "u:"<>key, value) + end + + def put_meta(%__MODULE__{id: id}, key, value) do + :dets.insert(file("meta"), {{id, key}, value}) + end + + def delete_meta(%__MODULE__{id: id}, key) do + :dets.delete(file("meta"), {id, key}) + end + + def all_accounts() do + :dets.traverse(file("db"), fn(obj) -> {:continue, from_tuple(obj)} end) + end + + def all_predicates() do + :dets.traverse(file("predicates"), fn(obj) -> {:continue, obj} end) + end + + def all_meta() do + :dets.traverse(file("meta"), fn(obj) -> {:continue, obj} end) + end + + def merge_account(old_id, new_id) do + if old_id != new_id do + spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, old_id}}], [:"$1"]}] + predicates = :dets.select(file("predicates"), spec) + for pred <- predicates, do: :ok = :dets.insert(file("predicates"), {pred, new_id}) + spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$1", {:const, old_id}}], [{{:"$2", :"$3"}}]}] + metas = :dets.select(file("meta"), spec) + for {k,v} <- metas do + :dets.delete(file("meta"), {{old_id, k}}) + :ok = :dets.insert(file("meta"), {{new_id, k}, v}) + end + :dets.delete(file("db"), old_id) + IRC.Membership.merge_account(old_id, new_id) + IRC.UserTrack.merge_account(old_id, new_id) + IRC.Connection.dispatch("account", {:account_change, old_id, new_id}) + IRC.Connection.dispatch("conn", {:account_change, old_id, new_id}) + end + :ok + end + + @doc "Find an account by a logged in user" + def find_by_nick(network, nick) do + do_lookup(%ExIRC.SenderInfo{nick: nick, network: network}, false) + end + + @doc "Always find an account by nickname, even if offline. Uses predicates and then account name." + def find_always_by_nick(network, chan, nick) do + with \ + nil <- find_by_nick(network, nick), + nil <- do_lookup(%User{network: network, nick: nick}, false), + nil <- get_by_name(nick) + do + nil + else + %__MODULE__{} = account -> + memberships = IRC.Membership.of_account(account) + if Enum.any?(memberships, fn({net, ch}) -> (net == network) or (chan && chan == ch) end) do + account + else + nil + end + end + end + + def find(something) do + do_lookup(something, false) + end + + def lookup(something, make_default \\ true) do + account = do_lookup(something, make_default) + if account && Map.get(something, :nick) do + IRC.Connection.dispatch("account", {:account_auth, Map.get(something, :nick), account.id}) + end + account + end + + def handle_info(_, state) do + {:noreply, state} + end + + def handle_cast(_, state) do + {:noreply, state} + end + + def handle_call(_, _, state) do + {:noreply, state} + end + + def terminate(_, state) do + for {_, dets} <- state do + :dets.sync(dets) + :dets.close(dets) + end + end + + defp do_lookup(message = %IRC.Message{account: account_id}, make_default) when is_binary(account_id) do + get(account_id) + end + + defp do_lookup(sender = %ExIRC.Who{}, make_default) do + if user = IRC.UserTrack.find_by_nick(sender) do + lookup(user, make_default) + else + #FIXME this will never work with continued lookup by other methods as Who isn't compatible + lookup_by_nick(sender, :dets.lookup(file("predicates"), {sender.network,{:nick, sender.nick}}), make_default) + end + end + + defp do_lookup(sender = %ExIRC.SenderInfo{}, make_default) do + lookup(IRC.UserTrack.find_by_nick(sender), make_default) + end + + defp do_lookup(user = %User{account: id}, make_default) when is_binary(id) do + get(id) + end + + defp do_lookup(user = %User{network: server, nick: nick}, make_default) do + lookup_by_nick(user, :dets.lookup(file("predicates"), {server,{:nick, nick}}), make_default) + end + + defp do_lookup(nil, _) do + nil + end + + defp lookup_by_nick(_, [{_, id}], _make_default) do + get(id) + end + + defp lookup_by_nick(user, _, make_default) do + #authenticate_by_host(user) + if make_default, do: new_account(user), else: nil + end + + def new_account(nick) do + id = EntropyString.large_id() + :dets.insert(file("db"), {id, nick, EntropyString.token()}) + get(id) + end + + def new_account(%{nick: nick, network: server}) do + id = EntropyString.large_id() + :dets.insert(file("db"), {id, nick, EntropyString.token()}) + :dets.insert(file("predicates"), {{server, {:nick, nick}}, id}) + get(id) + end + + def update_account_name(account = %__MODULE__{id: id}, name) do + account = %__MODULE__{account | name: name} + :dets.insert(file("db"), from_struct(account)) + get(id) + end + + def get_predicates(%__MODULE__{} = account) do + spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}] + :dets.select(file("predicates"), spec) + end + + defmodule AccountPlugin do + @moduledoc """ + # Account + + * **account** Get current account id and token + * **auth `` ``** Authenticate and link the current nickname to an account + * **auth** list authentications methods + * **whoami** list currently authenticated users + * **web** get a one-time login link to web + * **enable-telegram** Link a Telegram account + * **enable-sms** Link a SMS number + * **enable-untappd** Link a Untappd account + * **set-name** set account name + * **setusermeta puppet-nick ``** Set puppet IRC nickname + """ + + def irc_doc, do: @moduledoc + def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "messages:private", []) + {:ok, nil} + end + + def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "help"}}, state) do + text = [ + "account: show current account and auth token", + "auth: show authentications methods", + "whoami: list authenticated users", + "set-name : set account name", + "web: login to web", + "enable-sms | disable-sms: enable/change or disable sms", + "enable-telegram: link/change telegram", + "enable-untappd: link untappd account", + "getmeta: show meta datas", + "setusermeta: set user meta", + ] + m.replyfun.(text) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "auth"}}, state) do + spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}] + predicates = :dets.select(IRC.Account.file("predicates"), spec) + text = for {net, {key, value}} <- predicates, do: "#{net}: #{to_string(key)}: #{value}" + m.replyfun.(text) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "whoami"}}, state) do + users = for user <- IRC.UserTrack.find_by_account(m.account) do + chans = Enum.map(user.privileges, fn({chan, _}) -> chan end) + |> Enum.join(" ") + "#{user.network} - #{user.nick}!#{user.username}@#{user.host} - #{chans}" + end + m.replyfun.(users) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "account"}}, state) do + account = IRC.Account.lookup(m.sender) + text = ["Account Id: #{account.id}", + "Authenticate to this account from another network: \"auth #{account.id} #{account.token}\" to the other bot!"] + m.replyfun.(text) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{sender: sender, text: "auth"<>_}}, state) do + #account = IRC.Account.lookup(m.sender) + case String.split(m.text, " ") do + ["auth", id, token] -> + join_account(m, id, token) + _ -> + m.replyfun.("Invalid parameters") + end + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "set-name "<>name}}, state) do + IRC.Account.update_account_name(account, name) + m.replyfun.("Name changed: #{name}") + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{text: "disable-sms"}}, state) do + if IRC.Account.get_meta(m.account, "sms-number") do + IRC.Account.delete_meta(m.account, "sms-number") + m.replfyun.("SMS disabled.") + else + m.replyfun.("SMS already disabled.") + end + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{text: "web"}}, state) do + auth_url = Untappd.auth_url() + login_url = Nola.AuthToken.new_url(m.account.id, nil) + m.replyfun.("-> " <> login_url) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{text: "enable-sms"}}, state) do + code = String.downcase(EntropyString.small_id()) + IRC.Account.put_meta(m.account, "sms-validation-code", code) + IRC.Account.put_meta(m.account, "sms-validation-target", m.network) + number = Nola.IRC.SmsPlugin.my_number() + text = "To enable or change your number for SMS messaging, please send:" + <> " \"enable #{code}\" to #{number}" + m.replyfun.(text) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{text: "enable-telegram"}}, state) do + code = String.downcase(EntropyString.small_id()) + IRC.Account.delete_meta(m.account, "telegram-id") + IRC.Account.put_meta(m.account, "telegram-validation-code", code) + IRC.Account.put_meta(m.account, "telegram-validation-target", m.network) + text = "To enable or change your number for telegram messaging, please open #{Nola.Telegram.my_path()} and send:" + <> " \"/enable #{code}\"" + m.replyfun.(text) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{text: "enable-untappd"}}, state) do + auth_url = Untappd.auth_url() + login_url = Nola.AuthToken.new_url(m.account.id, {:external_redirect, auth_url}) + m.replyfun.(["To link your Untappd account, open this URL:", login_url]) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{text: "getmeta"<>_}}, state) do + result = case String.split(m.text, " ") do + ["getmeta"] -> + for {k, v} <- IRC.Account.get_all_meta(m.account) do + case k do + "u:"<>key -> "(user) #{key}: #{v}" + key -> "#{key}: #{v}" + end + end + ["getmeta", key] -> + value = IRC.Account.get_meta(m.account, key) + text = if value do + "#{key}: #{value}" + else + "#{key} is not defined" + end + _ -> + "usage: getmeta [key]" + end + m.replyfun.(result) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{text: "setusermet"<>_}}, state) do + result = case String.split(m.text, " ") do + ["setusermeta", key, value] -> + IRC.Account.put_user_meta(m.account, key, value) + "ok" + _ -> + "usage: setusermeta " + end + m.replyfun.(result) + {:noreply, state} + end + + def handle_info(_, state) do + {:noreply, state} + end + + defp join_account(m, id, token) do + old_account = IRC.Account.lookup(m.sender) + new_account = IRC.Account.get(id) + if new_account && token == new_account.token do + case IRC.Account.merge_account(old_account.id, new_account.id) do + :ok -> + if old_account.id == new_account.id do + m.replyfun.("Already authenticated, but hello") + else + m.replyfun.("Accounts merged!") + end + _ -> m.replyfun.("Something failed :(") + end + else + m.replyfun.("Invalid token") + end + end + + end + +end diff --git a/lib/nola_irc/connection.ex b/lib/nola_irc/connection.ex new file mode 100644 index 0000000..86d8279 --- /dev/null +++ b/lib/nola_irc/connection.ex @@ -0,0 +1,521 @@ +defmodule IRC.Connection do + require Logger + use Ecto.Schema + + @moduledoc """ + # IRC Connection + + Provides a nicer abstraction over ExIRC's handlers. + + ## Start connections + + ``` + IRC.Connection.start_link(host: "irc.random.sh", port: 6697, nick: "pouetbot", channels: ["#dev"]) + + ## PubSub topics + + * `account` -- accounts change + * {:account_change, old_account_id, new_account_id} # Sent when account merged + * {:accounts, [{:account, network, channel, nick, account_id}] # Sent on bot join + * {:account, network, nick, account_id} # Sent on user join + * `message` -- aill messages (without triggers) + * `message:private` -- all messages without a channel + * `message:#CHANNEL` -- all messages within `#CHANNEL` + * `triggers` -- all triggers + * `trigger:TRIGGER` -- any message with a trigger `TRIGGER` + + ## Replying to %IRC.Message{} + + Each `IRC.Message` comes with a dedicated `replyfun`, to which you only have to pass either: + + """ + def irc_doc, do: nil + + @min_backoff :timer.seconds(5) + @max_backoff :timer.seconds(2*60) + + embedded_schema do + field :network, :string + field :host, :string + field :port, :integer + field :nick, :string + field :user, :string + field :name, :string + field :pass, :string + field :tls, :boolean, default: false + field :channels, {:array, :string}, default: [] + end + + defmodule Supervisor do + use DynamicSupervisor + + def start_link() do + DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) + end + + def start_child(%IRC.Connection{} = conn) do + spec = %{id: conn.id, start: {IRC.Connection, :start_link, [conn]}, restart: :transient} + DynamicSupervisor.start_child(__MODULE__, spec) + end + + @impl true + def init(_init_arg) do + DynamicSupervisor.init( + strategy: :one_for_one, + max_restarts: 10, + max_seconds: 1 + ) + end + end + + + def changeset(params) do + import Ecto.Changeset + + %__MODULE__{id: EntropyString.large_id()} + |> cast(params, [:network, :host, :port, :nick, :user, :name, :pass, :channels, :tls]) + |> validate_required([:host, :port, :nick, :user, :name]) + |> apply_action(:insert) + end + + def to_tuple(%__MODULE__{} = conn) do + {conn.id, conn.network, conn.host, conn.port, conn.nick, conn.user, conn.name, conn.pass, conn.tls, conn.channels, nil} + end + + def from_tuple({id, network, host, port, nick, user, name, pass, tls, channels, _}) do + %__MODULE__{id: id, network: network, host: host, port: port, nick: nick, user: user, name: name, pass: pass, tls: tls, channels: channels} + end + + ## -- MANAGER API + + def setup() do + :dets.open_file(dets(), []) + end + + def dets(), do: to_charlist(Nola.data_path("/connections.dets")) + + def lookup(id) do + case :dets.lookup(dets(), id) do + [object | _] -> from_tuple(object) + _ -> nil + end + end + + def connections() do + :dets.foldl(fn(object, acc) -> [from_tuple(object) | acc] end, [], dets()) + end + + def start_all() do + for conn <- connections(), do: {conn, IRC.Connection.Supervisor.start_child(conn)} + end + + def get_network(network, channel \\ nil) do + spec = [{{:_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_, :_}, + [{:==, :"$1", {:const, network}}], [:"$_"]}] + results = Enum.map(:dets.select(dets(), spec), fn(object) -> from_tuple(object) end) + if channel do + Enum.find(results, fn(conn) -> Enum.member?(conn.channels, channel) end) + else + List.first(results) + end + end + + def get_host_nick(host, port, nick) do + spec = [{{:_, :_, :"$1", :"$2", :"$3", :_, :_, :_, :_, :_, :_}, + [{:andalso, + {:andalso, {:==, :"$1", {:const, host}}, {:==, :"$2", {:const, port}}}, + {:==, :"$3", {:const, nick}}}], + [:"$_"]} + ] + case :dets.select(dets(), spec) do + [object] -> from_tuple(object) + [] -> nil + end + end + + def delete_connection(%__MODULE__{id: id} = conn) do + :dets.delete(dets(), id) + stop_connection(conn) + :ok + end + + def start_connection(%__MODULE__{} = conn) do + IRC.Connection.Supervisor.start_child(conn) + end + + def stop_connection(%__MODULE__{id: id}) do + case :global.whereis_name(id) do + pid when is_pid(pid) -> + GenServer.stop(pid, :normal) + _ -> :error + end + end + + def add_connection(opts) do + case changeset(opts) do + {:ok, conn} -> + if existing = get_host_nick(conn.host, conn.port, conn.nick) do + {:error, {:existing, conn}} + else + :dets.insert(dets(), to_tuple(conn)) + IRC.Connection.Supervisor.start_child(conn) + end + error -> error + end + end + + def update_connection(connection) do + :dets.insert(dets(), to_tuple(connection)) + end + + def start_link(conn) do + GenServer.start_link(__MODULE__, [conn], name: {:global, conn.id}) + end + + def broadcast_message(net, chan, message) do + dispatch("conn", {:broadcast, net, chan, message}, IRC.ConnectionPubSub) + end + def broadcast_message(list, message) when is_list(list) do + for {net, chan} <- list do + broadcast_message(net, chan, message) + end + end + + def privmsg(channel, line) do + GenServer.cast(__MODULE__, {:privmsg, channel, line}) + end + + def init([conn]) do + Logger.metadata(conn: conn.id) + backoff = :backoff.init(@min_backoff, @max_backoff) + |> :backoff.type(:jitter) + {:ok, %{client: nil, backoff: backoff, conn: conn, connected_server: nil, connected_port: nil, network: conn.network}, {:continue, :connect}} + end + + @triggers %{ + "!" => :bang, + "+" => :plus, + "-" => :minus, + "?" => :query, + "." => :dot, + "~" => :tilde, + "@" => :at, + "++" => :plus_plus, + "--" => :minus_minus, + "!!" => :bang_bang, + "??" => :query_query, + ".." => :dot_dot, + "~~" => :tilde_tilde, + "@@" => :at_at + } + + def handle_continue(:connect, state) do + client_opts = [] + |> Keyword.put(:network, state.conn.network) + {:ok, _} = Registry.register(IRC.ConnectionPubSub, "conn", []) + client = if state.client && Process.alive?(state.client) do + Logger.info("Reconnecting client") + state.client + else + Logger.info("Connecting") + {:ok, client} = ExIRC.Client.start_link(debug: false) + ExIRC.Client.add_handler(client, self()) + client + end + + opts = [{:nodelay, true}] + conn_fun = if state.conn.tls, do: :connect_ssl!, else: :connect! + apply(ExIRC.Client, conn_fun, [client, to_charlist(state.conn.host), state.conn.port, opts]) + + {:noreply, %{state | client: client}} + end + + def handle_info(:disconnected, state) do + {delay, backoff} = :backoff.fail(state.backoff) + Logger.info("#{inspect(self())} Disconnected -- reconnecting in #{inspect delay}ms") + Process.send_after(self(), :connect, delay) + {:noreply, %{state | backoff: backoff}} + end + + def handle_info(:connect, state) do + {:noreply, state, {:continue, :connect}} + end + + def handle_cast({:privmsg, channel, line}, state) do + irc_reply(state, {channel, nil}, line) + {:noreply, state} + end + + # Connection successful + def handle_info({:connected, server, port}, state) do + Logger.info("#{inspect(self())} Connected to #{inspect(server)}:#{port} #{inspect state}") + {_, backoff} = :backoff.succeed(state.backoff) + ExIRC.Client.logon(state.client, state.conn.pass || "", state.conn.nick, state.conn.user, state.conn.name) + {:noreply, %{state | backoff: backoff, connected_server: server, connected_port: port}} + end + + # Logon successful + def handle_info(:logged_in, state) do + Logger.info("#{inspect(self())} Logged in") + {_, backoff} = :backoff.succeed(state.backoff) + Enum.map(state.conn.channels, &ExIRC.Client.join(state.client, &1)) + {:noreply, %{state | backoff: backoff}} + end + + # ISUP + def handle_info({:isup, network}, state) when is_binary(network) do + IRC.UserTrack.clear_network(state.network) + if network != state.network do + Logger.warn("Possibly misconfigured network: #{network} != #{state.network}") + end + {:noreply, state} + end + + # Been kicked + def handle_info({:kicked, _sender, chan, _reason}, state) do + ExIRC.Client.join(state.client, chan) + {:noreply, state} + end + + # Received something in a channel + def handle_info({:received, text, sender, chan}, state) do + user = if user = IRC.UserTrack.find_by_nick(state.network, sender.nick) do + user + else + Logger.error("Could not lookup user for message: #{inspect {state.network, chan, sender.nick}}") + user = IRC.UserTrack.joined(chan, sender, []) + ExIRC.Client.who(state.client, chan) # Rewho everything in case of need ? We shouldn't not know that user.. + user + end + if !user do + ExIRC.Client.who(state.client, chan) # Rewho everything in case of need ? We shouldn't not know that user.. + Logger.error("Could not lookup user nor create it for message: #{inspect {state.network, chan, sender.nick}}") + else + if !Map.get(user.options, :puppet) do + reply_fun = fn(text) -> irc_reply(state, {chan, sender}, text) end + account = IRC.Account.lookup(sender) + message = %IRC.Message{id: FlakeId.get(), transport: :irc, at: NaiveDateTime.utc_now(), text: text, network: state.network, + account: account, sender: sender, channel: chan, replyfun: reply_fun, + trigger: extract_trigger(text)} + message = case IRC.UserTrack.messaged(message) do + :ok -> message + {:ok, message} -> message + end + publish(message, ["#{message.network}/#{chan}:messages"]) + end + end + {:noreply, state} + end + + # Received a private message + def handle_info({:received, text, sender}, state) do + reply_fun = fn(text) -> irc_reply(state, {sender.nick, sender}, text) end + account = IRC.Account.lookup(sender) + message = %IRC.Message{id: FlakeId.get(), transport: :irc, text: text, network: state.network, at: NaiveDateTime.utc_now(), + account: account, sender: sender, replyfun: reply_fun, trigger: extract_trigger(text)} + message = case IRC.UserTrack.messaged(message) do + :ok -> message + {:ok, message} -> message + end + publish(message, ["messages:private", "#{message.network}/#{account.id}:messages"]) + {:noreply, state} + end + + ## -- Broadcast + def handle_info({:broadcast, net, account = %IRC.Account{}, message}, state) do + if net == state.conn.network do + user = IRC.UserTrack.find_by_account(net, account) + if user do + irc_reply(state, {user.nick, nil}, message) + end + end + {:noreply, state} + end + def handle_info({:broadcast, net, chan, message}, state) do + if net == state.conn.network && Enum.member?(state.conn.channels, chan) do + irc_reply(state, {chan, nil}, message) + end + {:noreply, state} + end + + ## -- UserTrack + + def handle_info({:joined, channel}, state) do + ExIRC.Client.who(state.client, channel) + {:noreply, state} + end + + def handle_info({:who, channel, whos}, state) do + accounts = Enum.map(whos, fn(who = %ExIRC.Who{nick: nick, operator?: operator}) -> + priv = if operator, do: [:operator], else: [] + # Don't touch -- on WHO the bot joined, not the users. + IRC.UserTrack.joined(channel, who, priv, false) + account = IRC.Account.lookup(who) + if account do + {:account, who.network, channel, who.nick, account.id} + end + end) + |> Enum.filter(fn(x) -> x end) + dispatch("account", {:accounts, accounts}) + {:noreply, state} + end + + def handle_info({:quit, reason, sender}, state) do + IRC.UserTrack.quitted(sender, reason) + {:noreply, state} + end + + def handle_info({:joined, channel, sender}, state) do + IRC.UserTrack.joined(channel, sender, []) + account = IRC.Account.lookup(sender) + if account do + dispatch("account", {:account, sender.network, channel, sender.nick, account.id}) + end + {:noreply, state} + end + + def handle_info({:kicked, nick, _by, channel, _reason}, state) do + IRC.UserTrack.parted(state.network, channel, nick) + {:noreply, state} + end + + def handle_info({:parted, channel, %ExIRC.SenderInfo{nick: nick}}, state) do + IRC.UserTrack.parted(state.network, channel, nick) + {:noreply, state} + end + + def handle_info({:mode, [channel, mode, nick]}, state) do + track_mode(state.network, channel, nick, mode) + {:noreply, state} + end + + def handle_info({:nick_changed, old_nick, new_nick}, state) do + IRC.UserTrack.renamed(state.network, old_nick, new_nick) + {:noreply, state} + end + + def handle_info(unhandled, client) do + Logger.debug("unhandled: #{inspect unhandled}") + {:noreply, client} + end + + def publish(pub), do: publish(pub, []) + + def publish(m = %IRC.Message{trigger: nil}, keys) do + dispatch(["messages"] ++ keys, {:irc, :text, m}) + end + + def publish(m = %IRC.Message{trigger: t = %IRC.Trigger{trigger: trigger}}, keys) do + dispatch(["triggers", "#{m.network}/#{m.channel}:triggers", "trigger:"<>trigger], {:irc, :trigger, trigger, m}) + end + + def publish_event(net, event = %{type: _}) when is_binary(net) do + event = event + |> Map.put(:at, NaiveDateTime.utc_now()) + |> Map.put(:network, net) + dispatch("#{net}:events", {:irc, :event, event}) + end + def publish_event({net, chan}, event = %{type: type}) do + event = event + |> Map.put(:at, NaiveDateTime.utc_now()) + |> Map.put(:network, net) + |> Map.put(:channel, chan) + dispatch("#{net}/#{chan}:events", {:irc, :event, event}) + end + + def dispatch(keys, content, sub \\ IRC.PubSub) + + def dispatch(key, content, sub) when is_binary(key), do: dispatch([key], content, sub) + def dispatch(keys, content, sub) when is_list(keys) do + Logger.debug("dispatch #{inspect keys} = #{inspect content}") + for key <- keys do + spawn(fn() -> Registry.dispatch(sub, key, fn h -> + for {pid, _} <- h, do: send(pid, content) + end) end) + end + end + + # + # Triggers + # + + def triggers, do: @triggers + + for {trigger, name} <- @triggers do + def extract_trigger(unquote(trigger)<>text) do + text = String.strip(text) + [trigger | args] = String.split(text, " ") + %IRC.Trigger{type: unquote(name), trigger: String.downcase(trigger), args: args} + end + end + + def extract_trigger(_), do: nil + + # + # IRC Replies + # + + # irc_reply(ExIRC.Client pid, {channel or nick, ExIRC.Sender}, binary | replies + # replies :: {:kick, reason} | {:kick, nick, reason} | {:mode, mode, nick} + defp irc_reply(state = %{client: client, network: network}, {target, _}, text) when is_binary(text) or is_list(text) do + lines = IRC.splitlong(text) + |> Enum.map(fn(x) -> if(is_list(x), do: x, else: String.split(x, "\n")) end) + |> List.flatten() + outputs = for line <- lines do + ExIRC.Client.msg(client, :privmsg, target, line) + {:irc, :out, %IRC.Message{id: FlakeId.get(), transport: :irc, network: network, + channel: target, text: line, sender: %ExIRC.SenderInfo{nick: state.conn.nick}, at: NaiveDateTime.utc_now(), meta: %{self: true}}} + end + for f <- outputs, do: dispatch(["irc:outputs", "#{network}/#{target}:outputs"], f) + end + + defp irc_reply(%{client: client}, {target, %{nick: nick}}, {:kick, reason}) do + ExIRC.Client.kick(client, target, nick, reason) + end + + defp irc_reply(%{client: client}, {target, _}, {:kick, nick, reason}) do + ExIRC.Client.kick(client, target, nick, reason) + end + + defp irc_reply(%{client: client}, {target, %{nick: nick}}, {:mode, mode}) do + ExIRC.Client.mode(%{client: client}, target, mode, nick) + end + + defp irc_reply(%{client: client}, target, {:mode, mode, nick}) do + ExIRC.Client.mode(client, target, mode, nick) + end + + defp irc_reply(%{client: client}, target, {:channel_mode, mode}) do + ExIRC.Client.mode(client, target, mode) + end + + defp track_mode(network, channel, nick, "+o") do + IRC.UserTrack.change_privileges(network, channel, nick, {[:operator], []}) + :ok + end + + defp track_mode(network, channel, nick, "-o") do + IRC.UserTrack.change_privileges(network, channel, nick, {[], [:operator]}) + :ok + end + + defp track_mode(network, channel, nick, "+v") do + IRC.UserTrack.change_privileges(network, channel, nick, {[:voice], []}) + :ok + end + + defp track_mode(network, channel, nick, "-v") do + IRC.UserTrack.change_privileges(network, channel, nick, {[], [:voice]}) + :ok + end + + defp track_mode(network, channel, nick, mode) do + Logger.warn("Unhandled track_mode: #{inspect {nick, mode}}") + :ok + end + + defp server(%{conn: %{host: host, port: port}}) do + host <> ":" <> to_string(port) + end + +end diff --git a/lib/nola_irc/conns.ex b/lib/nola_irc/conns.ex new file mode 100644 index 0000000..b0e5d3c --- /dev/null +++ b/lib/nola_irc/conns.ex @@ -0,0 +1,3 @@ +defmodule IRC.Conns do + +end diff --git a/lib/nola_irc/irc.ex b/lib/nola_irc/irc.ex new file mode 100644 index 0000000..71d6d93 --- /dev/null +++ b/lib/nola_irc/irc.ex @@ -0,0 +1,79 @@ +defmodule IRC do + + defmodule Message do + @derive {Poison.Encoder, except: [:replyfun]} + defstruct [:id, + :text, + {:transport, :irc}, + :network, + :account, + :sender, + :channel, + :trigger, + :replyfun, + :at, + {:meta, %{}} + ] + end + defmodule Trigger do + @derive Poison.Encoder + defstruct [:type, :trigger, :args] + end + + def send_message_as(account, network, channel, text, force_puppet \\ false) do + connection = IRC.Connection.get_network(network) + if connection && (force_puppet || IRC.PuppetConnection.whereis(account, connection)) do + IRC.PuppetConnection.start_and_send_message(account, connection, channel, text) + else + user = IRC.UserTrack.find_by_account(network, account) + nick = if(user, do: user.nick, else: account.name) + IRC.Connection.broadcast_message(network, channel, "<#{nick}> #{text}") + end + end + + def register(key) do + case Registry.register(IRC.PubSub, key, []) do + {:ok, _} -> :ok + error -> error + end + end + + def admin?(%Message{sender: sender}), do: admin?(sender) + + def admin?(%{nick: nick, user: user, host: host}) do + for {n, u, h} <- Nola.IRC.env(:admins, []) do + admin_part_match?(n, nick) && admin_part_match?(u, user) && admin_part_match?(h, host) + end + |> Enum.any? + end + + defp admin_part_match?(:_, _), do: true + defp admin_part_match?(a, a), do: true + defp admin_part_match?(_, _), do: false + + @max_chars 440 + + def splitlong(string, max_chars \\ 440) + + def splitlong(string, max_chars) when is_list(string) do + Enum.map(string, fn(s) -> splitlong(s, max_chars) end) + |> List.flatten() + end + + def splitlong(string, max_chars) do + string + |> String.codepoints + |> Enum.chunk_every(max_chars) + |> Enum.map(&Enum.join/1) + end + + def splitlong_with_prefix(string, prefix, max_chars \\ 440) do + prefix = "#{prefix} " + max_chars = max_chars - (length(String.codepoints(prefix))) + string + |> String.codepoints + |> Enum.chunk_every(max_chars) + |> Enum.map(fn(line) -> prefix <> Enum.join(line) end) + end + +end diff --git a/lib/nola_irc/membership.ex b/lib/nola_irc/membership.ex new file mode 100644 index 0000000..b727dfd --- /dev/null +++ b/lib/nola_irc/membership.ex @@ -0,0 +1,129 @@ +defmodule IRC.Membership do + @moduledoc """ + Memberships (users in channels) + """ + + # Key: {account, net, channel} + # Format: {key, last_seen} + + defp dets() do + to_charlist(Nola.data_path <> "/memberships.dets") + end + + def start_link() do + GenServer.start_link(__MODULE__, [], [name: __MODULE__]) + end + + def init(_) do + dets = :dets.open_file(dets(), []) + {:ok, dets} + end + + def of_account(%IRC.Account{id: id}) do + spec = [{{{:"$1", :"$2", :"$3"}, :_}, [{:==, :"$1", {:const, id}}], [{{:"$2", :"$3"}}]}] + :dets.select(dets(), spec) + end + + def merge_account(old_id, new_id) do + #iex(37)> :ets.fun2ms(fn({{old_id, _, _}, _}=obj) when old_id == "42" -> obj end) + spec = [{{{:"$1", :_, :_}, :_}, [{:==, :"$1", {:const, old_id}}], [:"$_"]}] + Util.ets_mutate_select_each(:dets, dets(), spec, fn(table, obj = {{_old, net, chan}, ts}) -> + :dets.delete_object(table, obj) + :dets.insert(table, {{new_id, net, chan}, ts}) + end) + end + + def touch(%IRC.Account{id: id}, network, channel) do + :dets.insert(dets(), {{id, network, channel}, NaiveDateTime.utc_now()}) + end + def touch(account_id, network, channel) do + if account = IRC.Account.get(account_id) do + touch(account, network, channel) + end + end + + def notify_channels(account, minutes \\ 30, last_active \\ true) do + not_before = NaiveDateTime.add(NaiveDateTime.utc_now(), (minutes*-60), :second) + spec = [{{{:"$1", :_, :_}, :_}, [{:==, :"$1", {:const, account.id}}], [:"$_"]}] + memberships = :dets.select(dets(), spec) + |> Enum.sort_by(fn({_, ts}) -> ts end, {:desc, NaiveDateTime}) + active_memberships = Enum.filter(memberships, fn({_, ts}) -> NaiveDateTime.compare(ts, not_before) == :gt end) + cond do + active_memberships == [] && last_active -> + case memberships do + [{{_, net, chan}, _}|_] -> [{net, chan}] + _ -> [] + end + active_memberships == [] -> + [] + true -> + Enum.map(active_memberships, fn({{_, net, chan}, _}) -> {net,chan} end) + end + end + + def members_or_friends(account, _network, nil) do + friends(account) + end + + def members_or_friends(_, network, channel) do + members(network, channel) + end + + def expanded_members_or_friends(account, network, channel) do + expand(network, members_or_friends(account, network, channel)) + end + + def expanded_members(network, channel) do + expand(network, members(network, channel)) + end + + def members(network, channel) do + #iex(19)> :ets.fun2ms(fn({{id, net, chan}, ts}) when net == network and chan == channel and ts > min_seen -> id end) + limit = 0 # NaiveDateTime.add(NaiveDateTime.utc_now, 30*((24*-60)*60), :second) + spec = [ + {{{:"$1", :"$2", :"$3"}, :"$4"}, + [ + {:andalso, + {:andalso, {:==, :"$2", {:const, network}}, {:==, :"$3", {:const, channel}}}, + {:>, :"$4", {:const, limit}}} + ], [:"$1"]} + ] + :dets.select(dets(), spec) + end + + def friends(account = %IRC.Account{id: id}) do + for({net, chan} <- of_account(account), do: members(net, chan)) + |> List.flatten() + |> Enum.uniq() + end + + def handle_info(_, dets) do + {:noreply, dets} + end + + def handle_cast(_, dets) do + {:noreply, dets} + end + + def handle_call(_, _, dets) do + {:noreply, dets} + end + + def terminate(_, dets) do + :dets.sync(dets) + :dets.close(dets) + end + + defp expand(network, list) do + for id <- list do + if account = IRC.Account.get(id) do + user = IRC.UserTrack.find_by_account(network, account) + nick = if(user, do: user.nick, else: account.name) + {account, user, nick} + end + end + |> Enum.filter(fn(x) -> x end) + end + + +end diff --git a/lib/nola_irc/nola_irc.ex b/lib/nola_irc/nola_irc.ex new file mode 100644 index 0000000..f64978a --- /dev/null +++ b/lib/nola_irc/nola_irc.ex @@ -0,0 +1,34 @@ +defmodule Nola.IRC do + require Logger + + def env(), do: Nola.env(:irc) + def env(key, default \\ nil), do: Keyword.get(env(), key, default) + + def application_childs do + import Supervisor.Spec + + IRC.Connection.setup() + IRC.Plugin.setup() + + [ + worker(Registry, [[keys: :duplicate, name: IRC.ConnectionPubSub]], id: :registr_irc_conn), + worker(Registry, [[keys: :duplicate, name: IRC.PubSub]], id: :registry_irc), + worker(IRC.Membership, []), + worker(IRC.Account, []), + worker(IRC.UserTrack.Storage, []), + worker(IRC.Account.AccountPlugin, []), + supervisor(IRC.Plugin.Supervisor, [], [name: IRC.Plugin.Supervisor]), + supervisor(IRC.Connection.Supervisor, [], [name: IRC.Connection.Supervisor]), + supervisor(IRC.PuppetConnection.Supervisor, [], [name: IRC.PuppetConnection.Supervisor]), + ] + end + + # Start plugins first to let them get on connection events. + def after_start() do + Logger.info("Starting plugins") + IRC.Plugin.start_all() + Logger.info("Starting connections") + IRC.Connection.start_all() + end + +end diff --git a/lib/nola_irc/plugin/temp_ref.ex b/lib/nola_irc/plugin/temp_ref.ex new file mode 100644 index 0000000..923fa1a --- /dev/null +++ b/lib/nola_irc/plugin/temp_ref.ex @@ -0,0 +1,95 @@ +defmodule Irc.Plugin.TempRef do + @moduledoc """ + This module allows to easily implement local temporary simple references for easy access from IRC. + + For example, your plugin output could be acted on, and instead of giving the burden for the user to + write or copy that uuid, you could give them a small alphanumeric reference to use instead. + + You can configure how many and for how long the references are kept. + + ## Usage + + `import Irc.Plugin.TempRef` + + ```elixir + defmodule Irc.MyPlugin do + defstruct [:temprefs] + + def init(_) do + # … + {:ok, %__MODULE__{temprefs: new_temp_refs()} + end + end + ``` + """ + + defstruct [:refs, :max, :expire, :build_fun, :build_increase_fun, :build_options] + + defmodule SimpleAlphaNumericBuilder do + def build(options) do + length = Keyword.get(options, :length, 3) + for _ <- 1..length, into: "", do: <> + end + + def increase(options) do + Keyword.put(options, :length, Keyword.get(options, :length, 3) + 1) + end + end + + def new_temp_refs(options \\ []) do + %__MODULE__{ + refs: Keyword.get(options, :init_refs, []), + max: Keyword.get(options, :max, []), + expire: Keyword.get(options, :expire, :infinity), + build_fun: Keyword.get(options, :build_fun, &__MODULE__.SimpleAlphaNumericBuilder.build/1), + build_increase_fun: Keyword.get(options, :build_increase_fun, &__MODULE__.SimpleAlphaNumericBuilder.increase/1), + build_options: Keyword.get(options, :build_options, [length: 3]) + } + end + + def janitor_refs(state = %__MODULE__{}) do + if length(state.refs) > state.max do + %__MODULE__{refs: state.refs |> Enum.reverse() |> tl() |> Enum.reverse()} + else + state + end + end + + def put_temp_ref(data, state = %__MODULE__{}) do + state = janitor_refs(state) + key = new_nonexisting_key(state) + if key do + ref = {key, DateTime.utc_now(), data} + {key, %__MODULE__{state | refs: [ref | state.refs]}} + else + {nil, state} + end + end + + def lookup_temp_ref(key, state, default \\ nil) do + case List.keyfind(state.refs, key, 0) do + {_, _, data} -> data + _ -> default + end + end + + defp new_nonexisting_key(state, i) when i > 50 do + nil + end + + defp new_nonexisting_key(state = %__MODULE__{refs: refs}, i \\ 1) do + build_options = if rem(i, 5) == 0 do + state.build_increase_fun.(state.build_options) + else + state.build_options + end + + key = state.build_fun.(state.build_options) + if !List.keymember?(refs, key, 0) do + key + else + new_nonexisting_key(state, i + 1) + end + end + +end diff --git a/lib/nola_irc/plugin_supervisor.ex b/lib/nola_irc/plugin_supervisor.ex new file mode 100644 index 0000000..a65ad09 --- /dev/null +++ b/lib/nola_irc/plugin_supervisor.ex @@ -0,0 +1,99 @@ +defmodule IRC.Plugin do + require Logger + + defmodule Supervisor do + use DynamicSupervisor + require Logger + + def start_link() do + DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) + end + + def start_child(module, opts \\ []) do + Logger.info("Starting #{module}") + spec = %{id: {IRC.Plugin,module}, start: {IRC.Plugin, :start_link, [module, opts]}, name: module, restart: :transient} + case DynamicSupervisor.start_child(__MODULE__, spec) do + {:ok, _} = res -> res + :ignore -> + Logger.warn("Ignored #{module}") + :ignore + {:error,_} = res -> + Logger.error("Could not start #{module}: #{inspect(res, pretty: true)}") + res + end + end + + @impl true + def init(_init_arg) do + DynamicSupervisor.init( + strategy: :one_for_one, + max_restarts: 10, + max_seconds: 1 + ) + end + end + + def dets(), do: to_charlist(Nola.data_path("/plugins.dets")) + + def setup() do + :dets.open_file(dets(), []) + end + + def enabled() do + :dets.foldl(fn + {name, true, _}, acc -> [name | acc] + _, acc -> acc + end, [], dets()) + end + + def start_all() do + for mod <- enabled(), do: {mod, IRC.Plugin.Supervisor.start_child(mod)} + end + + def declare(module) do + case get(module) do + :disabled -> :dets.insert(dets(), {module, true, nil}) + _ -> nil + end + end + + def start(module, opts \\ []) do + IRC.Plugin.Supervisor.start_child(module) + end + + @doc "Enables a plugin" + def enable(name), do: switch(name, true) + + @doc "Disables a plugin" + def disable(name), do: switch(name, false) + + @doc "Enables or disables a plugin" + def switch(name, value) when is_boolean(value) do + last = case get(name) do + {:ok, last} -> last + _ -> nil + end + :dets.insert(dets(), {name, value, last}) + end + + @spec get(module()) :: {:ok, last_start :: nil | non_neg_integer()} | :disabled + def get(name) do + case :dets.lookup(dets(), name) do + [{name, enabled, last_start}] -> {:ok, enabled, last_start} + _ -> :disabled + end + end + + def start_link(module, options \\ []) do + with {:disabled, {_, true, last}} <- {:disabled, get(module)}, + {:throttled, false} <- {:throttled, false} + do + module.start_link() + else + {error, _} -> + Logger.info("Plugin: #{to_string(module)} ignored start: #{to_string(error)}") + :ignore + end + end + +end diff --git a/lib/nola_irc/puppet_connection.ex b/lib/nola_irc/puppet_connection.ex new file mode 100644 index 0000000..91a26b3 --- /dev/null +++ b/lib/nola_irc/puppet_connection.ex @@ -0,0 +1,238 @@ +defmodule IRC.PuppetConnection do + require Logger + @min_backoff :timer.seconds(5) + @max_backoff :timer.seconds(2*60) + @max_idle :timer.hours(12) + @env Mix.env + + defmodule Supervisor do + use DynamicSupervisor + + def start_link() do + DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) + end + + def start_child(%IRC.Account{id: account_id}, %IRC.Connection{id: connection_id}) do + spec = %{id: {account_id, connection_id}, start: {IRC.PuppetConnection, :start_link, [account_id, connection_id]}, restart: :transient} + DynamicSupervisor.start_child(__MODULE__, spec) + end + + @impl true + def init(_init_arg) do + DynamicSupervisor.init( + strategy: :one_for_one, + max_restarts: 10, + max_seconds: 1 + ) + end + end + + def whereis(account = %IRC.Account{id: account_id}, connection = %IRC.Connection{id: connection_id}) do + {:global, name} = name(account_id, connection_id) + case :global.whereis_name(name) do + :undefined -> nil + pid -> pid + end + end + + def send_message(account = %IRC.Account{id: account_id}, connection = %IRC.Connection{id: connection_id}, channel, text) do + GenServer.cast(name(account_id, connection_id), {:send_message, self(), channel, text}) + end + + def start_and_send_message(account = %IRC.Account{id: account_id}, connection = %IRC.Connection{id: connection_id}, channel, text) do + {:global, name} = name(account_id, connection_id) + pid = whereis(account, connection) + pid = if !pid do + case IRC.PuppetConnection.Supervisor.start_child(account, connection) do + {:ok, pid} -> pid + {:error, {:already_started, pid}} -> pid + end + else + pid + end + GenServer.cast(pid, {:send_message, self(), channel, text}) + end + + def start(account = %IRC.Account{}, connection = %IRC.Connection{}) do + IRC.PuppetConnection.Supervisor.start_child(account, connection) + end + + def start_link(account_id, connection_id) do + GenServer.start_link(__MODULE__, [account_id, connection_id], name: name(account_id, connection_id)) + end + + def name(account_id, connection_id) do + {:global, {PuppetConnection, account_id, connection_id}} + end + + def init([account_id, connection_id]) do + account = %IRC.Account{} = IRC.Account.get(account_id) + connection = %IRC.Connection{} = IRC.Connection.lookup(connection_id) + Logger.metadata(puppet_conn: account.id <> "@" <> connection.id) + backoff = :backoff.init(@min_backoff, @max_backoff) + |> :backoff.type(:jitter) + idle = :erlang.send_after(@max_idle, self, :idle) + {:ok, %{client: nil, backoff: backoff, idle: idle, connected: false, buffer: [], channels: [], connection_id: connection_id, account_id: account_id, connected_server: nil, connected_port: nil, network: connection.network}, {:continue, :connect}} + end + + def handle_continue(:connect, state) do + #ipv6 = if @env == :prod do + # subnet = Nola.Subnet.assign(state.account_id) + # IRC.Account.put_meta(IRC.Account.get(state.account_id), "subnet", subnet) + # ip = Pfx.host(subnet, 1) + # {:ok, ipv6} = :inet_parse.ipv6_address(to_charlist(ip)) + # System.cmd("add-ip6", [ip]) + # ipv6 + #end + + conn = IRC.Connection.lookup(state.connection_id) + client_opts = [] + |> Keyword.put(:network, conn.network) + client = if state.client && Process.alive?(state.client) do + Logger.info("Reconnecting client") + state.client + else + Logger.info("Connecting") + {:ok, client} = ExIRC.Client.start_link(debug: false) + ExIRC.Client.add_handler(client, self()) + client + end + + base_opts = [ + {:nodelay, true} + ] + + #{ip, opts} = case {ipv6, :inet_res.resolve(to_charlist(conn.host), :in, :aaaa)} do + # {ipv6, {:ok, {:dns_rec, _dns_header, _query, rrs = [{:dns_rr, _, _, _, _, _, _, _, _, _} | _], _, _}}} -> + # ip = rrs + # |> Enum.map(fn({:dns_rr, _, :aaaa, :in, _, _, ipv6, _, _, _}) -> ipv6 end) + # |> Enum.shuffle() + # |> List.first() + + # opts = [ + # :inet6, + # {:ifaddr, ipv6} + # ] + # {ip, opts} + # _ -> + {ip, opts} = {to_charlist(conn.host), []} + #end + + conn_fun = if conn.tls, do: :connect_ssl!, else: :connect! + apply(ExIRC.Client, conn_fun, [client, ip, conn.port, base_opts ++ opts]) + + {:noreply, %{state | client: client}} + end + + def handle_continue(:connected, state) do + state = Enum.reduce(Enum.reverse(state.buffer), state, fn(b, state) -> + {:noreply, state} = handle_cast(b, state) + state + end) + {:noreply, %{state | buffer: []}} + end + + def handle_cast(cast = {:send_message, _pid, _channel, _text}, state = %{connected: false, buffer: buffer}) do + {:noreply, %{state | buffer: [cast | buffer]}} + end + + def handle_cast({:send_message, pid, channel, text}, state = %{connected: true}) do + channels = if !Enum.member?(state.channels, channel) do + ExIRC.Client.join(state.client, channel) + [channel | state.channels] + else + state.channels + end + ExIRC.Client.msg(state.client, :privmsg, channel, text) + + meta = %{puppet: true, from: pid} + account = IRC.Account.get(state.account_id) + nick = make_nick(state) + sender = %ExIRC.SenderInfo{network: state.network, nick: suffix_nick(nick), user: nick, host: "puppet."} + reply_fun = fn(text) -> + IRC.Connection.broadcast_message(state.network, channel, text) + end + message = %IRC.Message{id: FlakeId.get(), at: NaiveDateTime.utc_now(), text: text, network: state.network, account: account, sender: sender, channel: channel, replyfun: reply_fun, trigger: IRC.Connection.extract_trigger(text), meta: meta} + message = case IRC.UserTrack.messaged(message) do + :ok -> message + {:ok, message} -> message + end + IRC.Connection.publish(message, ["#{message.network}/#{channel}:messages"]) + + idle = if length(state.buffer) == 0 do + :erlang.cancel_timer(state.idle) + :erlang.send_after(@max_idle, self(), :idle) + else + state.idle + end + + {:noreply, %{state | idle: idle, channels: channels}} + end + + def handle_info(:idle, state) do + ExIRC.Client.quit(state.client, "Puppet was idle for too long") + ExIRC.Client.stop!(state.client) + {:stop, :normal, state} + end + + def handle_info(:disconnected, state) do + {delay, backoff} = :backoff.fail(state.backoff) + Logger.info("#{inspect(self())} Disconnected -- reconnecting in #{inspect delay}ms") + Process.send_after(self(), :connect, delay) + {:noreply, %{state | connected: false, backoff: backoff}} + end + + def handle_info(:connect, state) do + {:noreply, state, {:continue, :connect}} + end + + # Connection successful + def handle_info({:connected, server, port}, state) do + Logger.info("#{inspect(self())} Connected to #{inspect(server)}:#{port} #{inspect state}") + {_, backoff} = :backoff.succeed(state.backoff) + base_nick = make_nick(state) + ExIRC.Client.logon(state.client, "", suffix_nick(base_nick), base_nick, "#{base_nick}'s puppet") + {:noreply, %{state | backoff: backoff, connected_server: server, connected_port: port}} + end + + # Logon successful + def handle_info(:logged_in, state) do + Logger.info("#{inspect(self())} Logged in") + {_, backoff} = :backoff.succeed(state.backoff) + # Create an UserTrack entry for the client so it's authenticated to the right account_id already. + IRC.UserTrack.connected(state.network, suffix_nick(make_nick(state)), make_nick(state), "puppet.", state.account_id, %{puppet: true}) + {:noreply, %{state | backoff: backoff}} + end + + # ISUP + def handle_info({:isup, network}, state) do + {:noreply, %{state | network: network, connected: true}, {:continue, :connected}} + end + + # Been kicked + def handle_info({:kicked, _sender, chan, _reason}, state) do + {:noreply, %{state | channels: state.channels -- [chan]}} + end + + def handle_info(_info, state) do + {:noreply, state} + end + + def make_nick(state) do + account = IRC.Account.get(state.account_id) + user = IRC.UserTrack.find_by_account(state.network, account) + base_nick = if(user, do: user.nick, else: account.name) + clean_nick = case String.split(base_nick, ":", parts: 2) do + ["@"<>nick, _] -> nick + [nick] -> nick + end + clean_nick + end + + if Mix.env == :dev do + def suffix_nick(nick), do: "#{nick}[d]" + else + def suffix_nick(nick), do: "#{nick}[p]" + end + +end diff --git a/lib/nola_irc/user_track.ex b/lib/nola_irc/user_track.ex new file mode 100644 index 0000000..1efa523 --- /dev/null +++ b/lib/nola_irc/user_track.ex @@ -0,0 +1,329 @@ +defmodule IRC.UserTrack do + @moduledoc """ + User Track DB & Utilities + """ + + @ets IRC.UserTrack.Storage + # {uuid, network, nick, nicks, privilege_map} + # Privilege map: + # %{"#channel" => [:operator, :voice] + defmodule Storage do + + def delete(id) do + op(fn(ets) -> :ets.delete(ets, id) end) + end + + def insert(tuple) do + op(fn(ets) -> :ets.insert(ets, tuple) end) + end + + def clear_network(network) do + op(fn(ets) -> + spec = [ + {{:_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_, :_, :_}, + [ + {:==, :"$1", {:const, network}} + ], [:"$_"]} + ] + :ets.match_delete(ets, spec) + end) + end + + def op(fun) do + GenServer.call(__MODULE__, {:op, fun}) + end + + def start_link do + GenServer.start_link(__MODULE__, [], [name: __MODULE__]) + end + + def init([]) do + ets = :ets.new(__MODULE__, [:set, :named_table, :protected, {:read_concurrency, true}]) + {:ok, ets} + end + + def handle_call({:op, fun}, _from, ets) do + returned = try do + {:ok, fun.(ets)} + rescue + rescued -> {:error, rescued} + catch + rescued -> {:error, rescued} + end + {:reply, returned, ets} + end + + def terminate(_reason, ets) do + :ok + end + end + + defmodule Id, do: use EntropyString + + defmodule User do + defstruct [:id, :account, :network, :nick, {:nicks, []}, :username, :host, :realname, {:privileges, %{}}, {:last_active, %{}}, {:options, %{}}] + + def to_tuple(u = %__MODULE__{}) do + {u.id || IRC.UserTrack.Id.large_id, u.network, u.account, String.downcase(u.nick), u.nick, u.nicks || [], u.username, u.host, u.realname, u.privileges, u.last_active, u.options} + end + + #tuple size: 11 + def from_tuple({id, network, account, _downcased_nick, nick, nicks, username, host, realname, privs, last_active, opts}) do + struct = %__MODULE__{id: id, account: account, network: network, nick: nick, nicks: nicks, username: username, host: host, realname: realname, privileges: privs, last_active: last_active, options: opts} + end + end + + def find_by_account(%IRC.Account{id: id}) do + #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) + spec = [ + {{:_, :_, :"$2", :_, :_, :_, :_, :_, :_, :_, :_, :_}, + [ + {:==, :"$2", {:const, id}} + ], [:"$_"]} + ] + results = :ets.select(@ets, spec) + |> Enum.filter(& &1) + for obj <- results, do: User.from_tuple(obj) + end + + def find_by_account(network, nil) do + nil + end + + def find_by_account(network, %IRC.Account{id: id}) do + #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) + spec = [ + {{:_, :"$1", :"$2", :_, :_, :_, :_, :_, :_, :_, :_, :_}, + [ + {:andalso, {:==, :"$1", {:const, network}}, + {:==, :"$2", {:const, id}}} + ], [:"$_"]} + ] + case :ets.select(@ets, spec) do + results = [_r | _] -> + result = results + |> Enum.reject(fn({_, net, _, _, _, _, _, _, _, _, actives, opts}) -> network != "matrix" && net == "matrix" end) + |> Enum.reject(fn({_, net, _, _, _, _, _, _, _, _, actives, opts}) -> network != "telegram" && net == "telegram" end) + |> Enum.reject(fn({_, _, _, _, _, _, _, _, _, _, actives, opts}) -> network not in ["matrix", "telegram"] && Map.get(opts, :puppet) end) + |> Enum.sort_by(fn({_, _, _, _, _, _, _, _, _, _, actives, _}) -> + Map.get(actives, nil) + end, {:desc, NaiveDateTime}) + |> List.first + + if result, do: User.from_tuple(result) + _ -> nil + end + end + + def clear_network(network) do + Storage.clear_network(network) + end + + + def merge_account(old_id, new_id) do + #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) + spec = [ + {{:_, :_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_, :_}, + [ + {:==, :"$1", {:const, old_id}} + ], [:"$_"]} + ] + Enum.each(:ets.select(@ets, spec), fn({id, net, _, downcased_nick, nick, nicks, username, host, realname, privs, active, opts}) -> + Storage.op(fn(ets) -> + :ets.insert(@ets, {id, net, new_id, downcased_nick, nick, nicks, username, host, realname, privs, active, opts}) + end) + end) + end + + def find_by_nick(%ExIRC.Who{network: network, nick: nick}) do + find_by_nick(network, nick) + end + + + def find_by_nick(%ExIRC.SenderInfo{network: network, nick: nick}) do + find_by_nick(network, nick) + end + + def find_by_nick(network, nick) do + case :ets.match(@ets, {:"$1", network, :_, String.downcase(nick), :_, :_, :_, :_, :_, :_, :_, :_}) do + [[id] | _] -> lookup(id) + _ -> + nil + end + end + + def to_list, do: :ets.tab2list(@ets) + + def lookup(id) do + case :ets.lookup(@ets, id) do + [] -> nil + [tuple] -> User.from_tuple(tuple) + end + end + + def operator?(network, channel, nick) do + if user = find_by_nick(network, nick) do + privs = Map.get(user.privileges, channel, []) + Enum.member?(privs, :admin) || Enum.member?(privs, :operator) + else + false + end + end + + def channel(network, channel) do + Enum.filter(to_list(), fn({_, network, _, _, _, _, _, _, _, channels, _, _}) -> + Map.get(channels, channel) + end) + end + + # TODO + def connected(network, nick, user, host, account_id, opts \\ %{}) do + if account = IRC.Account.get(account_id) do + user = if user = find_by_nick(network, nick) do + user + else + user = %User{id: IRC.UserTrack.Id.large_id, account: account_id, network: network, nick: nick, username: user, host: host, privileges: %{}, options: opts} + Storage.op(fn(ets) -> + :ets.insert(ets, User.to_tuple(user)) + end) + user + end + + IRC.Connection.publish_event(network, %{type: :connect, user_id: user.id, account_id: user.account}) + :ok + else + :error + end + end + + def joined(c, s), do: joined(c,s,[]) + + def joined(channel, sender=%{nick: nick, user: uname, host: host}, privileges, touch \\ true) do + privileges = if IRC.admin?(sender) do + privileges ++ [:admin] + else privileges end + user = if user = find_by_nick(sender.network, nick) do + %User{user | username: uname, host: host, privileges: Map.put(user.privileges || %{}, channel, privileges)} + else + user = %User{id: IRC.UserTrack.Id.large_id, network: sender.network, nick: nick, username: uname, host: host, privileges: %{channel => privileges}} + + account = IRC.Account.lookup(user).id + user = %User{user | account: account} + end + user = touch_struct(user, channel) + + if touch && user.account do + IRC.Membership.touch(user.account, sender.network, channel) + end + + Storage.op(fn(ets) -> + :ets.insert(ets, User.to_tuple(user)) + end) + + IRC.Connection.publish_event({sender.network, channel}, %{type: :join, user_id: user.id, account_id: user.account}) + + user + end + + #def joined(network, channel, nick, privileges) do + # user = if user = find_by_nick(network, nick) do + # %User{user | privileges: Map.put(user.privileges, channel, privileges)} + # else + # %User{nick: nick, privileges: %{channel => privileges}} + # end + # + # Storage.op(fn(ets) -> + # :ets.insert(ets, User.to_tuple(user)) + # end) + #end + + def messaged(%IRC.Message{network: network, account: account, channel: chan, sender: %{nick: nick}} = m) do + {user, account} = if user = find_by_nick(network, nick) do + {touch_struct(user, chan), account || IRC.Account.lookup(user)} + else + user = %User{network: network, nick: nick, privileges: %{}} + account = IRC.Account.lookup(user) + {%User{user | account: account.id}, account} + end + Storage.insert(User.to_tuple(user)) + if chan, do: IRC.Membership.touch(account, network, chan) + if !m.account do + {:ok, %IRC.Message{m | account: account}} + else + :ok + end + end + + def renamed(network, old_nick, new_nick) do + if user = find_by_nick(network, old_nick) do + old_account = IRC.Account.lookup(user) + user = %User{user | nick: new_nick, nicks: [old_nick|user.nicks]} + account = IRC.Account.lookup(user, false) || old_account + user = %User{user | nick: new_nick, account: account.id, nicks: [old_nick|user.nicks]} + Storage.insert(User.to_tuple(user)) + channels = for {channel, _} <- user.privileges, do: channel + IRC.Connection.publish_event(network, %{type: :nick, user_id: user.id, account_id: account.id, nick: new_nick, old_nick: old_nick}) + end + end + + def change_privileges(network, channel, nick, {add, remove}) do + if user = find_by_nick(network, nick) do + privs = Map.get(user.privileges, channel) + + privs = Enum.reduce(add, privs, fn(priv, acc) -> [priv|acc] end) + privs = Enum.reduce(remove, privs, fn(priv, acc) -> List.delete(acc, priv) end) + + user = %User{user | privileges: Map.put(user.privileges, channel, privs)} + Storage.insert(User.to_tuple(user)) + IRC.Connection.publish_event({network, channel}, %{type: :privileges, user_id: user.id, account_id: user.account, added: add, removed: remove}) + end + end + + # XXX: Reason + def parted(channel, %{network: network, nick: nick}) do + parted(network, channel, nick) + end + + def parted(network, channel, nick) do + if user = find_by_nick(network, nick) do + if user.account do + IRC.Membership.touch(user.account, network, channel) + end + + privs = Map.delete(user.privileges, channel) + lasts = Map.delete(user.last_active, channel) + if Enum.count(privs) > 0 do + user = %User{user | privileges: privs} + Storage.insert(User.to_tuple(user)) + IRC.Connection.publish_event({network, channel}, %{type: :part, user_id: user.id, account_id: user.account, reason: nil}) + else + IRC.Connection.publish_event(network, %{type: :quit, user_id: user.id, account_id: user.account, reason: "Left all known channels"}) + Storage.delete(user.id) + end + end + end + + def quitted(sender, reason) do + if user = find_by_nick(sender.network, sender.nick) do + if user.account do + for {channel, _} <- user.privileges do + IRC.Membership.touch(user.account, sender.network, channel) + end + IRC.Connection.publish_event(sender.network, %{type: :quit, user_id: user.id, account_id: user.account, reason: reason}) + end + Storage.delete(user.id) + end + end + + defp touch_struct(user = %User{last_active: last_active}, channel) do + now = NaiveDateTime.utc_now() + last_active = last_active + |> Map.put(channel, now) + |> Map.put(nil, now) + %User{user | last_active: last_active} + end + + defp userchans(%{privileges: privileges}) do + for({chan, _} <- privileges, do: chan) + end +end diff --git a/lib/nola_matrix/matrix.ex b/lib/nola_matrix/matrix.ex new file mode 100644 index 0000000..9334816 --- /dev/null +++ b/lib/nola_matrix/matrix.ex @@ -0,0 +1,169 @@ +defmodule Nola.Matrix do + require Logger + alias Polyjuice.Client + + @behaviour MatrixAppService.Adapter.Room + @behaviour MatrixAppService.Adapter.Transaction + @behaviour MatrixAppService.Adapter.User + @env Mix.env + + def dets(part) do + (Nola.data_path() <> "/matrix-#{to_string(part)}.dets") |> String.to_charlist() + end + + def setup() do + {:ok, _} = :dets.open_file(dets(:rooms), []) + {:ok, _} = :dets.open_file(dets(:room_aliases), []) + {:ok, _} = :dets.open_file(dets(:users), []) + :ok + end + + def myself?("@_dev:random.sh"), do: true + def myself?("@_bot:random.sh"), do: true + def myself?("@_dev."<>_), do: true + def myself?("@_bot."<>_), do: true + def myself?(_), do: false + + def mxc_to_http(mxc = "mxc://"<>_) do + uri = URI.parse(mxc) + %URI{uri | scheme: "https", path: "/_matrix/media/r0/download/#{uri.authority}#{uri.path}"} + |> URI.to_string() + end + + def get_or_create_matrix_user(id) do + if mxid = lookup_user(id) do + mxid + else + opts = [ + type: "m.login.application_service", + inhibit_login: true, + device_id: "APP_SERVICE", + initial_device_display_name: "Application Service", + username: if(@env == :dev, do: "_dev.#{id}", else: "_bot.#{id}") + ] + Logger.debug("Registering user for #{id}") + {:ok, %{"user_id" => mxid}} = Polyjuice.Client.LowLevel.register(client(), opts) + :dets.insert(dets(:users), {id, mxid}) + end + end + + def lookup_user(id) do + case :dets.lookup(dets(:users), id) do + [{_, matrix_id}] -> matrix_id + _ -> nil + end + end + + def user_name("@"<>name) do + [username, _] = String.split(name, ":", parts: 2) + username + end + + def application_childs() do + import Supervisor.Spec + [ + supervisor(Nola.Matrix.Room.Supervisor, [], [name: IRC.PuppetConnection.Supervisor]), + ] + end + + def after_start() do + rooms = :dets.foldl(fn({id, _, _, _}, acc) -> [id | acc] end, [], dets(:rooms)) + for room <- rooms, do: Nola.Matrix.Room.start(room) + end + + def lookup_room(room) do + case :dets.lookup(dets(:rooms), room) do + [{_, network, channel, opts}] -> {:ok, Map.merge(opts, %{network: network, channel: channel})} + _ -> {:error, :no_such_room} + end + end + + def lookup_room_alias(room_alias) do + case :dets.lookup(dets(:room_aliases), room_alias) do + [{_, room_id}] -> {:ok, room_id} + _ -> {:error, :no_such_room_alias} + end + end + + def lookup_or_create_room(room_alias) do + case lookup_room_alias(room_alias) do + {:ok, room_id} -> {:ok, room_id} + {:error, :no_such_room_alias} -> create_room(room_alias) + end + end + + def create_room(room_alias) do + Logger.debug("Matrix: creating room #{inspect room_alias}") + localpart = localpart(room_alias) + with {:ok, network, channel} <- extract_network_channel_from_localpart(localpart), + %IRC.Connection{} <- IRC.Connection.get_network(network, channel), + room = [visibility: :public, room_alias_name: localpart, name: if(network == "random", do: channel, else: "#{network}/#{channel}")], + {:ok, %{"room_id" => room_id}} <- Client.Room.create_room(client(), room) do + Logger.info("Matrix: created room #{room_alias} #{room_id}") + :dets.insert(dets(:rooms), {room_id, network, channel, %{}}) + :dets.insert(dets(:room_aliases), {room_alias, room_id}) + {:ok, room_id} + else + nil -> {:error, :no_such_network_channel} + error -> error + end + end + + def localpart(room_alias) do + [<<"#", localpart :: binary>>, _] = String.split(room_alias, ":", parts: 2) + localpart + end + + def extract_network_channel_from_localpart(localpart) do + s = localpart + |> String.replace("dev.", "") + |> String.split("/", parts: 2) + + case s do + [network, channel] -> {:ok, network, channel} + [channel] -> {:ok, "random", channel} + _ -> {:error, :invalid_localpart} + end + end + + @impl MatrixAppService.Adapter.Room + def query_alias(room_alias) do + case lookup_or_create_room(room_alias) do + {:ok, room_id} -> + Nola.Matrix.Room.start(room_id) + :ok + error -> error + end + end + + @impl MatrixAppService.Adapter.Transaction + def new_event(event = %MatrixAppService.Event{}) do + Logger.debug("New matrix event: #{inspect event}") + if event.room_id do + Nola.Matrix.Room.start_and_send_matrix_event(event.room_id, event) + end + :noop + end + + @impl MatrixAppService.Adapter.User + def query_user(user_id) do + Logger.warn("Matrix lookup user: #{inspect user_id}") + :error + end + + def client(opts \\ []) do + base_url = Application.get_env(:matrix_app_service, :base_url) + access_token = Application.get_env(:matrix_app_service, :access_token) + default_opts = [ + access_token: access_token, + device_id: "APP_SERVICE", + application_service: true, + user_id: nil + ] + opts = Keyword.merge(default_opts, opts) + + Polyjuice.Client.LowLevel.create(base_url, opts) + end + + +end diff --git a/lib/nola_matrix/plug.ex b/lib/nola_matrix/plug.ex new file mode 100644 index 0000000..c64ed11 --- /dev/null +++ b/lib/nola_matrix/plug.ex @@ -0,0 +1,25 @@ +defmodule Nola.Matrix.Plug do + + defmodule Auth do + def init(state) do + state + end + + def call(conn, _) do + hs = Application.get_env(:matrix_app_service, :homeserver_token) + MatrixAppServiceWeb.AuthPlug.call(conn, hs) + end + end + + defmodule SetConfig do + def init(state) do + state + end + + def call(conn, _) do + config = Application.get_all_env(:matrix_app_service) + MatrixAppServiceWeb.SetConfigPlug.call(conn, config) + end + end + +end diff --git a/lib/nola_matrix/room.ex b/lib/nola_matrix/room.ex new file mode 100644 index 0000000..c790760 --- /dev/null +++ b/lib/nola_matrix/room.ex @@ -0,0 +1,196 @@ +defmodule Nola.Matrix.Room do + require Logger + alias Nola.Matrix + alias Polyjuice.Client + import Matrix, only: [client: 0, client: 1, user_name: 1, myself?: 1] + + defmodule Supervisor do + use DynamicSupervisor + + def start_link() do + DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) + end + + def start_child(room_id) do + spec = %{id: room_id, start: {Nola.Matrix.Room, :start_link, [room_id]}, restart: :transient} + DynamicSupervisor.start_child(__MODULE__, spec) + end + + @impl true + def init(_init_arg) do + DynamicSupervisor.init( + strategy: :one_for_one, + max_restarts: 10, + max_seconds: 1 + ) + end + end + + def start(room_id) do + __MODULE__.Supervisor.start_child(room_id) + end + + def start_link(room_id) do + GenServer.start_link(__MODULE__, [room_id], name: name(room_id)) + end + + def start_and_send_matrix_event(room_id, event) do + pid = if pid = whereis(room_id) do + pid + else + case __MODULE__.start(room_id) do + {:ok, pid} -> pid + {:error, {:already_started, pid}} -> pid + :ignore -> nil + end + end + if(pid, do: send(pid, {:matrix_event, event})) + end + + def whereis(room_id) do + {:global, name} = name(room_id) + case :global.whereis_name(name) do + :undefined -> nil + pid -> pid + end + end + + def name(room_id) do + {:global, {__MODULE__, room_id}} + end + + def init([room_id]) do + case Matrix.lookup_room(room_id) do + {:ok, state} -> + Logger.metadata(matrix_room: room_id) + + {:ok, _} = Registry.register(IRC.PubSub, "#{state.network}:events", plugin: __MODULE__) + for t <- ["messages", "triggers", "outputs", "events"] do + {:ok, _} = Registry.register(IRC.PubSub, "#{state.network}/#{state.channel}:#{t}", plugin: __MODULE__) + end + + state = state + |> Map.put(:id, room_id) + Logger.info("Started Matrix room #{room_id}") + {:ok, state, {:continue, :update_state}} + error -> + Logger.info("Received event for nonexistent room #{inspect room_id}: #{inspect error}") + :ignore + end + end + + def handle_continue(:update_state, state) do + {:ok, s} = Client.Room.get_state(client(), state.id) + members = Enum.reduce(s, [], fn(s, acc) -> + if s["type"] == "m.room.member" do + if s["content"]["membership"] == "join" do + [s["user_id"] | acc] + else + # XXX: The user left, remove from IRC.Memberships ? + acc + end + else + acc + end + end) + |> Enum.filter(& &1) + + for m <- members, do: IRC.UserTrack.joined(state.id, %{network: "matrix", nick: m, user: m, host: "matrix."}, [], true) + + accounts = IRC.UserTrack.channel(state.network, state.channel) + |> Enum.filter(& &1) + |> Enum.map(fn(tuple) -> IRC.UserTrack.User.from_tuple(tuple).account end) + |> Enum.uniq() + |> Enum.each(fn(account_id) -> + introduce_irc_account(account_id, state) + end) + + {:noreply, state} + end + + def handle_info({:irc, :text, message}, state), do: handle_irc(message, state) + def handle_info({:irc, :out, message}, state), do: handle_irc(message, state) + def handle_info({:irc, :trigger, _, message}, state), do: handle_irc(message, state) + def handle_info({:irc, :event, event}, state), do: handle_irc(event, state) + def handle_info({:matrix_event, event}, state) do + if myself?(event.user_id) do + {:noreply, state} + else + handle_matrix(event, state) + end + end + + def handle_irc(message = %IRC.Message{account: account}, state) do + unless Map.get(message.meta, :puppet) && Map.get(message.meta, :from) == self() do + opts = if Map.get(message.meta, :self) || is_nil(account) do + [] + else + mxid = Matrix.get_or_create_matrix_user(account.id) + [user_id: mxid] + end + Client.Room.send_message(client(opts),state.id, message.text) + end + {:noreply, state} + end + + def handle_irc(%{type: :join, account_id: account_id}, state) do + introduce_irc_account(account_id, state) + {:noreply, state} + end + + def handle_irc(%{type: quit_or_part, account_id: account_id}, state) when quit_or_part in [:quit, :part] do + mxid = Matrix.get_or_create_matrix_user(account_id) + Client.Room.leave(client(user_id: mxid), state.id) + {:noreply, state} + end + + + def handle_irc(event, state) do + Logger.warn("Skipped irc event #{inspect event}") + {:noreply, state} + end + + def handle_matrix(event = %{type: "m.room.member", user_id: user_id, content: %{"membership" => "join"}}, state) do + _account = get_account(event, state) + IRC.UserTrack.joined(state.id, %{network: "matrix", nick: user_id, user: user_id, host: "matrix."}, [], true) + {:noreply, state} + end + + def handle_matrix(event = %{type: "m.room.member", user_id: user_id, content: %{"membership" => "leave"}}, state) do + IRC.UserTrack.parted(state.id, %{network: "matrix", nick: user_id}) + {:noreply, state} + end + + def handle_matrix(event = %{type: "m.room.message", user_id: user_id, content: %{"msgtype" => "m.text", "body" => text}}, state) do + IRC.send_message_as(get_account(event, state), state.network, state.channel, text, true) + {:noreply, state} + end + + def handle_matrix(event, state) do + Logger.warn("Skipped matrix event #{inspect event}") + {:noreply, state} + end + + def get_account(%{user_id: user_id}, %{id: id}) do + IRC.Account.find_by_nick("matrix", user_id) + end + + defp introduce_irc_account(account_id, state) do + mxid = Matrix.get_or_create_matrix_user(account_id) + account = IRC.Account.get(account_id) + user = IRC.UserTrack.find_by_account(state.network, account) + base_nick = if(user, do: user.nick, else: account.name) + case Client.Profile.put_displayname(client(user_id: mxid), base_nick) do + :ok -> :ok + error -> + Logger.warn("Failed to update profile for #{mxid}: #{inspect error}") + end + case Client.Room.join(client(user_id: mxid), state.id) do + {:ok, _} -> :ok + error -> + Logger.warn("Failed to join room for #{mxid}: #{inspect error}") + end + :ok + end + +end diff --git a/lib/nola_plugins/admin_handler.ex b/lib/nola_plugins/admin_handler.ex new file mode 100644 index 0000000..9a5d557 --- /dev/null +++ b/lib/nola_plugins/admin_handler.ex @@ -0,0 +1,41 @@ +defmodule Nola.IRC.AdminHandler do + @moduledoc """ + # admin + + !op + op; requiert admin + """ + + def irc_doc, do: nil + + def start_link(client) do + GenServer.start_link(__MODULE__, [client]) + end + + def init([client]) do + ExIRC.Client.add_handler client, self + :ok = IRC.register("op") + {:ok, client} + end + + def handle_info({:irc, :trigger, "op", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang}, sender: sender}}, client) do + if IRC.admin?(sender) do + m.replyfun.({:mode, "+o"}) + else + m.replyfun.({:kick, "non"}) + end + {:noreply, client} + end + + def handle_info({:joined, chan, sender}, client) do + if IRC.admin?(sender) do + ExIRC.Client.mode(client, chan, "+o", sender.nick) + end + {:noreply, client} + end + + def handle_info(msg, client) do + {:noreply, client} + end + +end diff --git a/lib/nola_plugins/alcolog_plugin.ex b/lib/nola_plugins/alcolog_plugin.ex new file mode 100644 index 0000000..145e4fc --- /dev/null +++ b/lib/nola_plugins/alcolog_plugin.ex @@ -0,0 +1,1229 @@ +defmodule Nola.IRC.AlcoologPlugin do + require Logger + + @moduledoc """ + # [alcoolog]({{context_path}}/alcoolog) + + * **!santai `` ` [annotation]`**: enregistre un nouveau verre de `montant` d'une boisson à `degrés d'alcool`. + * **!santai `` ``**: enregistre un nouveau verre de `cl` de la bière `beer name`, et checkin sur Untappd.com. + * **!moar `[cl]` : enregistre un verre équivalent au dernier !santai. + * **-santai**: annule la dernière entrée d'alcoolisme. + * **.alcoolisme**: état du channel en temps réel. + * **.alcoolisme ``**: points par jour, sur X j. + * **!alcoolisme `[pseudo]`**: affiche les points d'alcoolisme. + * **!alcoolisme `[pseudo]` ``**: affiche les points d'alcoolisme par jour sur X j. + * **+alcoolisme `` `` `[facteur de perte en mg/l (10, 15, 20, 25)]`**: Configure votre profil d'alcoolisme. + * **.sobre**: affiche quand la sobriété frappera sur le chan. + * **!sobre `[pseudo]`**: affiche quand la sobriété frappera pour `[pseudo]`. + * **!sobrepour ``**: affiche tu pourras être sobre pour ``, et si oui, combien de volumes d'alcool peuvent encore être consommés. + * **!alcoolog**: ([voir]({{context_path}}/alcoolog)) lien pour voir l'état/statistiques et historique de l'alcoolémie du channel. + * **!alcool `` ``**: donne le nombre d'unités d'alcool dans `` à `°`. + * **!soif**: c'est quand l'apéro ? + + 1 point = 1 volume d'alcool. + + Annotation: champ libre! + + --- + + ## `!txt`s + + * status utilisateur: `alcoolog.user_(sober|legal|legalhigh|high|toohigh|sick)(|_rising)` + * mauvaises boissons: `alcoolog.drink_(negative|zero|negative)` + * santo: `alcoolog.santo` + * santai: `alcoolog.santai` + * plus gros, moins gros: `alcoolog.(fatter|thinner)` + + """ + + def irc_doc, do: @moduledoc + + def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) + + # tuple dets: {nick, date, volumes, current_alcohol_level, nom, commentaire} + # tuple ets: {{nick, date}, volumes, current, nom, commentaire} + # tuple meta dets: {nick, map} + # %{:weight => float, :sex => true(h),false(f)} + @pubsub ~w(account) + @pubsub_triggers ~w(santai moar again bis santo santeau alcoolog sobre sobrepour soif alcoolisme alcool) + @default_user_meta %{weight: 77.4, sex: true, loss_factor: 15} + + def data_state() do + dets_filename = (Nola.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist + dets_meta_filename = (Nola.data_path() <> "/" <> "alcoolisme_meta.dets") |> String.to_charlist + %{dets: dets_filename, meta: dets_meta_filename, ets: __MODULE__.ETS} + end + + def init(_) do + triggers = for(t <- @pubsub_triggers, do: "trigger:"<>t) + for sub <- @pubsub ++ triggers do + {:ok, _} = Registry.register(IRC.PubSub, sub, plugin: __MODULE__) + end + dets_filename = (Nola.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist + {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) + ets = :ets.new(__MODULE__.ETS, [:ordered_set, :named_table, :protected, {:read_concurrency, true}]) + dets_meta_filename = (Nola.data_path() <> "/" <> "alcoolisme_meta.dets") |> String.to_charlist + {:ok, meta} = :dets.open_file(dets_meta_filename, [{:type,:set}]) + traverse_fun = fn(obj, dets) -> + case obj do + object = {nick, naive = %NaiveDateTime{}, volumes, active, cl, deg, name, comment} -> + date = naive + |> DateTime.from_naive!("Etc/UTC") + |> DateTime.to_unix() + new = {nick, date, volumes, active, cl, deg, name, comment, Map.new()} + :dets.delete_object(dets, object) + :dets.insert(dets, new) + :ets.insert(ets, {{nick, date}, volumes, active, cl, deg, name, comment, Map.new()}) + dets + + object = {nick, naive = %NaiveDateTime{}, volumes, active, cl, deg, name, comment, meta} -> + date = naive + |> DateTime.from_naive!("Etc/UTC") + |> DateTime.to_unix() + new = {nick, date, volumes, active, cl, deg, name, comment, Map.new()} + :dets.delete_object(dets, object) + :dets.insert(dets, new) + :ets.insert(ets, {{nick, date}, volumes, active, cl, deg, name, comment, Map.new()}) + dets + + object = {nick, date, volumes, active, cl, deg, name, comment, meta} -> + :ets.insert(ets, {{nick, date}, volumes, active, cl, deg, name, comment, meta}) + dets + + _ -> + dets + end + end + :dets.foldl(traverse_fun, dets, dets) + :dets.sync(dets) + state = %{dets: dets, meta: meta, ets: ets} + {:ok, state} + end + + @eau ["santo", "santeau"] + def handle_info({:irc, :trigger, santeau, m = %IRC.Message{trigger: %IRC.Trigger{args: _, type: :bang}}}, state) when santeau in @eau do + Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.santo") + {:noreply, state} + end + + def handle_info({:irc, :trigger, "soif", m = %IRC.Message{trigger: %IRC.Trigger{args: _, type: :bang}}}, state) do + now = DateTime.utc_now() + |> Timex.Timezone.convert("Europe/Paris") + apero = format_duration_from_now(%DateTime{now | hour: 18, minute: 0, second: 0}, false) + day_of_week = Date.day_of_week(now) + {txt, apero?} = cond do + now.hour >= 0 && now.hour < 6 -> + {["apéro tardif ? Je dis OUI ! SANTAI !"], true} + now.hour >= 6 && now.hour < 12 -> + if day_of_week >= 6 do + {["de l'alcool pour le petit dej ? le week-end, pas de problème !"], true} + else + {["C'est quand même un peu tôt non ? Prochain apéro #{apero}"], false} + end + now.hour >= 12 && (now.hour < 14) -> + {["oui! c'est l'apéro de midi! (et apéro #{apero})", + "tu peux attendre #{apero} ou y aller, il est midi !" + ], true} + now.hour == 17 -> + {[ + "ÇA APPROCHE !!! Apéro #{apero}", + "BIENTÔT !!! Apéro #{apero}", + "achetez vite les teilles, apéro dans #{apero}!", + "préparez les teilles, apéro dans #{apero}!" + ], false} + now.hour >= 14 && now.hour < 18 -> + weekend = if day_of_week >= 6 do + " ... ou maintenant en fait, c'est le week-end!" + else + "" + end + {["tiens bon! apéro #{apero}#{weekend}", + "courage... apéro dans #{apero}#{weekend}", + "pas encore :'( apéro dans #{apero}#{weekend}" + ], false} + true -> + {[ + "C'EST L'HEURE DE L'APÉRO !!! SANTAIIIIIIIIIIII !!!!" + ], true} + end + + txt = txt + |> Enum.shuffle() + |> Enum.random() + + m.replyfun.(txt) + + stats = get_full_statistics(state, m.account.id) + if !apero? && stats.active > 0.1 do + m.replyfun.("(... ou continue en fait, je suis pas ta mère !)") + end + + {:noreply, state} + end + + def handle_info({:irc, :trigger, "sobrepour", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do + args = Enum.join(args, " ") + {:ok, now} = DateTime.now("Europe/Paris", Tzdata.TimeZoneDatabase) + time = case args do + "demain " <> time -> + {h, m} = case String.split(time, [":", "h"]) do + [hour, ""] -> + IO.puts ("h #{inspect hour}") + {h, _} = Integer.parse(hour) + {h, 0} + [hour, min] when min != "" -> + {h, _} = Integer.parse(hour) + {m, _} = Integer.parse(min) + {h, m} + [hour] -> + IO.puts ("h #{inspect hour}") + {h, _} = Integer.parse(hour) + {h, 0} + _ -> {0, 0} + end + secs = ((60*60)*24) + day = DateTime.add(now, secs, :second, Tzdata.TimeZoneDatabase) + %DateTime{day | hour: h, minute: m, second: 0} + "après demain " <> time -> + secs = 2*((60*60)*24) + DateTime.add(now, secs, :second, Tzdata.TimeZoneDatabase) + datetime -> + case Timex.Parse.DateTime.Parser.parse(datetime, "{}") do + {:ok, dt} -> dt + _ -> nil + end + end + + if time do + meta = get_user_meta(state, m.account.id) + stats = get_full_statistics(state, m.account.id) + + duration = round(DateTime.diff(time, now)/60.0) + + IO.puts "diff #{inspect duration} sober in #{inspect stats.sober_in}" + + if duration < stats.sober_in do + int = stats.sober_in - duration + m.replyfun.("désolé, aucune chance! tu seras sobre #{format_minute_duration(int)} après!") + else + remaining = duration - stats.sober_in + if remaining < 30 do + m.replyfun.("moins de 30 minutes de sobriété, c'est impossible de boire plus") + else + loss_per_minute = ((meta.loss_factor/100)/60) + remaining_gl = (remaining-30)*loss_per_minute + m.replyfun.("marge de boisson: #{inspect remaining} minutes, #{remaining_gl} g/l") + end + end + + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, "alcoolog", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :plus}}}, state) do + {:ok, token} = Nola.Token.new({:alcoolog, :index, m.sender.network, m.channel}) + url = NolaWeb.Router.Helpers.alcoolog_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel), token) + m.replyfun.("-> #{url}") + {:noreply, state} + end + + def handle_info({:irc, :trigger, "alcoolog", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :bang}}}, state) do + url = NolaWeb.Router.Helpers.alcoolog_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel)) + m.replyfun.("-> #{url}") + {:noreply, state} + end + + def handle_info({:irc, :trigger, "alcool", m = %IRC.Message{trigger: %IRC.Trigger{args: args = [cl, deg], type: :bang}}}, state) do + {cl, _} = Util.float_paparse(cl) + {deg, _} = Util.float_paparse(deg) + points = Alcool.units(cl, deg) + meta = get_user_meta(state, m.account.id) + k = if meta.sex, do: 0.7, else: 0.6 + weight = meta.weight + gl = (10*points)/(k*weight) + duration = round(gl/((meta.loss_factor/100)/60))+30 + sober_in_s = if duration > 0 do + duration = Timex.Duration.from_minutes(duration) + Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized) + else + "" + end + + m.replyfun.("Il y a #{Float.round(points+0.0, 4)} unités d'alcool dans #{cl}cl à #{deg}° (#{Float.round(gl + 0.0, 4)} g/l, #{sober_in_s})") + {:noreply, state} + end + + def handle_info({:irc, :trigger, "santai", m = %IRC.Message{trigger: %IRC.Trigger{args: [cl, deg | comment], type: :bang}}}, state) do + santai(m, state, cl, deg, comment) + {:noreply, state} + end + + @moar [ + "{{message.sender.nick}}: la même donc ?", + "{{message.sender.nick}}: et voilà la petite sœur !" + ] + + def handle_info({:irc, :trigger, "bis", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do + handle_info({:irc, :trigger, "moar", m}, state) + end + def handle_info({:irc, :trigger, "again", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do + handle_info({:irc, :trigger, "moar", m}, state) + end + + def handle_info({:irc, :trigger, "moar", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do + case get_statistics_for_nick(state, m.account.id) do + {_, obj = {_, _date, _points, _active, cl, deg, _name, comment, _meta}} -> + cl = case args do + [cls] -> + case Util.float_paparse(cls) do + {cl, _} -> cl + _ -> cl + end + _ -> cl + end + moar = @moar |> Enum.shuffle() |> Enum.random() |> Tmpl.render(m) |> m.replyfun.() + santai(m, state, cl, deg, comment, auto_set: true) + {_, obj = {_, date, points, _last_active, type, descr}} -> + case Regex.named_captures(~r/^(?\d+[.]\d+)cl\s+(?\d+[.]\d+)°$/, type) do + nil -> m.replyfun.("suce") + u -> + moar = @moar |> Enum.shuffle() |> Enum.random() |> Tmpl.render(m) |> m.replyfun.() + santai(m, state, u["cl"], u["deg"], descr, auto_set: true) + end + _ -> nil + end + {:noreply, state} + end + + defp santai(m, state, cl, deg, comment, options \\ []) do + comment = cond do + comment == [] -> nil + is_binary(comment) -> comment + comment == nil -> nil + true -> Enum.join(comment, " ") + end + + {cl, cl_extra} = case {Util.float_paparse(cl), cl} do + {{cl, extra}, _} -> {cl, extra} + {:error, "("<>_} -> + try do + {:ok, result} = Abacus.eval(cl) + {result, nil} + rescue + _ -> {nil, "cl: invalid calc expression"} + end + {:error, _} -> {nil, "cl: invalid value"} + end + + {deg, comment, auto_set, beer_id} = case Util.float_paparse(deg) do + {deg, _} -> {deg, comment, Keyword.get(options, :auto_set, false), nil} + :error -> + beername = if(comment, do: "#{deg} #{comment}", else: deg) + case Untappd.search_beer(beername, limit: 1) do + {:ok, %{"response" => %{"beers" => %{"count" => count, "items" => [%{"beer" => beer, "brewery" => brewery} | _]}}}} -> + {Map.get(beer, "beer_abv"), "#{Map.get(brewery, "brewery_name")}: #{Map.get(beer, "beer_name")}", true, Map.get(beer, "bid")} + _ -> + {deg, "could not find beer", false, nil} + end + end + + cond do + cl == nil -> m.replyfun.(cl_extra) + deg == nil -> m.replyfun.(comment) + cl >= 500 || deg >= 100 -> Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.drink_toohuge") + cl == 0 || deg == 0 -> Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.drink_zero") + cl < 0 || deg < 0 -> Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.drink_negative") + true -> + points = Alcool.units(cl, deg) + now = m.at || DateTime.utc_now() + |> DateTime.to_unix(:millisecond) + user_meta = get_user_meta(state, m.account.id) + name = "#{cl}cl #{deg}°" + old_stats = get_full_statistics(state, m.account.id) + meta = %{} + meta = Map.put(meta, "timestamp", now) + meta = Map.put(meta, "weight", user_meta.weight) + meta = Map.put(meta, "sex", user_meta.sex) + :ok = :dets.insert(state.dets, {m.account.id, now, points, if(old_stats, do: old_stats.active, else: 0), cl, deg, name, comment, meta}) + true = :ets.insert(state.ets, {{m.account.id, now}, points, if(old_stats, do: old_stats.active, else: 0),cl, deg, name, comment, meta}) + #sante = @santai |> Enum.map(fn(s) -> String.trim(String.upcase(s)) end) |> Enum.shuffle() |> Enum.random() + sante = Nola.IRC.TxtPlugin.random("alcoolog.santai") + k = if user_meta.sex, do: 0.7, else: 0.6 + weight = user_meta.weight + peak = Float.round((10*points||0.0)/(k*weight), 4) + stats = get_full_statistics(state, m.account.id) + sober_add = if old_stats && Map.get(old_stats || %{}, :sober_in) do + mins = round(stats.sober_in - old_stats.sober_in) + " [+#{mins}m]" + else + "" + end + nonow = DateTime.utc_now() + sober = nonow |> DateTime.add(round(stats.sober_in*60), :second) + |> Timex.Timezone.convert("Europe/Paris") + at = if nonow.day == sober.day do + {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "aujourd'hui {h24}:{m}", "fr") + detail + else + {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "{WDfull} {h24}:{m}", "fr") + detail + end + + up = if stats.active_drinks > 1 do + " " <> Enum.join(for(_ <- 1..stats.active_drinks, do: "▲")) <> "" + else + "" + end + + since_str = if stats.since && stats.since_min > 180 do + "(depuis: #{stats.since_s}) " + else + "" + end + + msg = fn(nick, extra) -> + "#{sante} #{nick} #{extra}#{up} #{format_points(points)} @#{stats.active}g/l [+#{peak} g/l]" + <> " (15m: #{stats.active15m}, 30m: #{stats.active30m}, 1h: #{stats.active1h}) #{since_str}(sobriété #{at} (dans #{stats.sober_in_s})#{sober_add}) !" + <> " (aujourd'hui #{stats.daily_volumes} points - #{stats.daily_gl} g/l)" + end + + meta = if beer_id do + Map.put(meta, "untappd:beer_id", beer_id) + else + meta + end + + if beer_id do + spawn(fn() -> + case Untappd.maybe_checkin(m.account, beer_id) do + {:ok, body} -> + badges = get_in(body, ["badges", "items"]) + if badges != [] do + badges_s = Enum.map(badges, fn(badge) -> Map.get(badge, "badge_name") end) + |> Enum.filter(fn(b) -> b end) + |> Enum.intersperse(", ") + |> Enum.join("") + badge = if(length(badges) > 1, do: "badges", else: "badge") + m.replyfun.("\\O/ Unlocked untappd #{badge}: #{badges_s}") + end + :ok + {:error, {:http_error, error}} when is_integer(error) -> m.replyfun.("Checkin to Untappd failed: #{to_string(error)}") + {:error, {:http_error, error}} -> m.replyfun.("Checkin to Untappd failed: #{inspect error}") + _ -> :error + end + end) + end + + local_extra = if auto_set do + if comment do + " #{comment} (#{cl}cl @ #{deg}°)" + else + "#{cl}cl @ #{deg}°" + end + else + "" + end + m.replyfun.(msg.(m.sender.nick, local_extra)) + notify = IRC.Membership.notify_channels(m.account) -- [{m.network,m.channel}] + for {net, chan} <- notify do + user = IRC.UserTrack.find_by_account(net, m.account) + nick = if(user, do: user.nick, else: m.account.name) + extra = " " <> present_type(name, comment) <> "" + IRC.Connection.broadcast_message(net, chan, msg.(nick, extra)) + end + + miss = cond do + points <= 0.6 -> :small + stats.active30m >= 2.9 && stats.active30m < 3 -> :miss3 + stats.active30m >= 1.9 && stats.active30m < 2 -> :miss2 + stats.active30m >= 0.9 && stats.active30m < 1 -> :miss1 + stats.active30m >= 0.45 && stats.active30m < 0.5 -> :miss05 + stats.active30m >= 0.20 && stats.active30m < 0.20 -> :miss025 + stats.active30m >= 3 && stats.active1h < 3.15 -> :small3 + stats.active30m >= 2 && stats.active1h < 2.15 -> :small2 + stats.active30m >= 1.5 && stats.active1h < 1.5 -> :small15 + stats.active30m >= 1 && stats.active1h < 1.15 -> :small1 + stats.active30m >= 0.5 && stats.active1h <= 0.51 -> :small05 + stats.active30m >= 0.25 && stats.active30m <= 0.255 -> :small025 + true -> nil + end + + if miss do + miss = Nola.IRC.TxtPlugin.random("alcoolog.#{to_string(miss)}") + if miss do + for {net, chan} <- IRC.Membership.notify_channels(m.account) do + user = IRC.UserTrack.find_by_account(net, m.account) + nick = if(user, do: user.nick, else: m.account.name) + IRC.Connection.broadcast_message(net, chan, "#{nick}: #{miss}") + end + end + end + end + end + + def handle_info({:irc, :trigger, "santai", m = %IRC.Message{trigger: %IRC.Trigger{args: _, type: :bang}}}, state) do + m.replyfun.("!santai [commentaire]") + {:noreply, state} + end + + def get_all_stats() do + IRC.Account.all_accounts() + |> Enum.map(fn(account) -> {account.id, get_full_statistics(account.id)} end) + |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) + |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) + end + + def get_channel_statistics(account, network, nil) do + IRC.Membership.expanded_members_or_friends(account, network, nil) + |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(account.id)} end) + |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) + |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) + end + + def get_channel_statistics(_, network, channel), do: get_channel_statistics(network, channel) + + def get_channel_statistics(network, channel) do + IRC.Membership.expanded_members(network, channel) + |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(account.id)} end) + |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) + |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) + end + + @spec since() :: %{IRC.Account.id() => DateTime.t()} + @doc "Returns the last time the user was at 0 g/l" + def since() do + :ets.foldr(fn({{acct, timestamp_or_date}, _vol, current, _cl, _deg, _name, _comment, _m}, acc) -> + if !Map.get(acc, acct) && current == 0 do + date = Util.to_date_time(timestamp_or_date) + Map.put(acc, acct, date) + else + acc + end + end, %{}, __MODULE__.ETS) + end + + def get_full_statistics(nick) do + get_full_statistics(data_state(), nick) + end + + defp get_full_statistics(state, nick) do + case get_statistics_for_nick(state, nick) do + {count, {_, last_at, last_points, last_active, last_cl, last_deg, last_type, last_descr, _meta}} -> + {active, active_drinks} = current_alcohol_level(state, nick) + {_, m30} = alcohol_level_rising(state, nick) + {rising, m15} = alcohol_level_rising(state, nick, 15) + {_, m5} = alcohol_level_rising(state, nick, 5) + {_, h1} = alcohol_level_rising(state, nick, 60) + + trend = if rising do + "▲" + else + "▼" + end + user_state = cond do + active <= 0.0 -> :sober + active <= 0.25 -> :low + active <= 0.50 -> :legal + active <= 1.0 -> :legalhigh + active <= 2.5 -> :high + active < 3 -> :toohigh + true -> :sick + end + + rising_file_key = if rising, do: "_rising", else: "" + txt_file = "alcoolog." <> "user_" <> to_string(user_state) <> rising_file_key + user_status = Nola.IRC.TxtPlugin.random(txt_file) + + meta = get_user_meta(state, nick) + minutes_til_sober = h1/((meta.loss_factor/100)/60) + minutes_til_sober = cond do + active < 0 -> 0 + m15 < 0 -> 15 + m30 < 0 -> 30 + h1 < 0 -> 60 + minutes_til_sober > 0 -> + Float.round(minutes_til_sober+60) + true -> 0 + end + + duration = Timex.Duration.from_minutes(minutes_til_sober) + sober_in_s = if minutes_til_sober > 0 do + Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized) + else + nil + end + + since = if active > 0 do + since() + |> Map.get(nick) + end + + since_diff = if since, do: Timex.diff(DateTime.utc_now(), since, :minutes) + since_duration = if since, do: Timex.Duration.from_minutes(since_diff) + since_s = if since, do: Timex.Format.Duration.Formatter.lformat(since_duration, "fr", :humanized) + + {total_volumes, total_gl} = user_stats(state, nick) + + + %{active: active, last_at: last_at, last_cl: last_cl, last_deg: last_deg, last_points: last_points, last_type: last_type, last_descr: last_descr, + trend_symbol: trend, + active5m: m5, active15m: m15, active30m: m30, active1h: h1, + rising: rising, + active_drinks: active_drinks, + user_status: user_status, + daily_gl: total_gl, daily_volumes: total_volumes, + sober_in: minutes_til_sober, sober_in_s: sober_in_s, + since: since, since_min: since_diff, since_s: since_s, + } + _ -> + nil + end + end + + def handle_info({:irc, :trigger, "sobre", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :dot}}}, state) do + nicks = IRC.Membership.expanded_members_or_friends(m.account, m.network, m.channel) + |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(state, account.id)} end) + |> Enum.filter(fn({_nick, status}) -> status && status.sober_in && status.sober_in > 0 end) + |> Enum.sort_by(fn({_, status}) -> status.sober_in end, & Enum.map(fn({nick, stats}) -> + now = DateTime.utc_now() + sober = now |> DateTime.add(round(stats.sober_in*60), :second) + |> Timex.Timezone.convert("Europe/Paris") + at = if now.day == sober.day do + {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "aujourd'hui {h24}:{m}", "fr") + detail + else + {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "{WDfull} {h24}:{m}", "fr") + detail + end + "#{nick} sobre #{at} (dans #{stats.sober_in_s})" + end) + |> Enum.intersperse(", ") + |> Enum.join("") + |> (fn(line) -> + case line do + "" -> "tout le monde est sobre......." + line -> line + end + end).() + |> m.replyfun.() + {:noreply, state} + end + + def handle_info({:irc, :trigger, "sobre", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do + account = case args do + [nick] -> IRC.Account.find_always_by_nick(m.network, m.channel, nick) + [] -> m.account + end + + if account do + user = IRC.UserTrack.find_by_account(m.network, account) + nick = if(user, do: user.nick, else: account.name) + stats = get_full_statistics(state, account.id) + if stats && stats.sober_in > 0 do + now = DateTime.utc_now() + sober = now |> DateTime.add(round(stats.sober_in*60), :second) + |> Timex.Timezone.convert("Europe/Paris") + at = if now.day == sober.day do + {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "aujourd'hui {h24}:{m}", "fr") + detail + else + {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "{WDfull} {h24}:{m}", "fr") + detail + end + m.replyfun.("#{nick} sera sobre #{at} (dans #{stats.sober_in_s})!") + else + m.replyfun.("#{nick} est déjà sobre. aidez le !") + end + else + m.replyfun.("inconnu") + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :dot}}}, state) do + nicks = IRC.Membership.expanded_members_or_friends(m.account, m.network, m.channel) + |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(state, account.id)} end) + |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) + |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) + |> Enum.map(fn({nick, status}) -> + trend_symbol = if status.active_drinks > 1 do + Enum.join(for(_ <- 1..status.active_drinks, do: status.trend_symbol)) + else + status.trend_symbol + end + since_str = if status.since_min > 180 do + "depuis: #{status.since_s} | " + else + "" + end + "#{nick} #{status.user_status} #{trend_symbol} #{Float.round(status.active, 4)} g/l [#{since_str}sobre dans: #{status.sober_in_s}]" + end) + |> Enum.intersperse(", ") + |> Enum.join("") + + msg = if nicks == "" do + "wtf?!?! personne n'a bu!" + else + nicks + end + + m.replyfun.(msg) + {:noreply, state} + end + + def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [time], type: :dot}}}, state) do + time = case time do + "semaine" -> 7 + string -> + case Integer.parse(string) do + {time, "j"} -> time + {time, "J"} -> time + _ -> nil + end + end + + if time do + aday = time*((24 * 60)*60) + now = DateTime.utc_now() + before = now + |> DateTime.add(-aday, :second) + |> DateTime.to_unix(:millisecond) + over_time_stats(before, time, m, state) + else + m.replyfun.(".alcooolisme semaine|Xj") + end + {:noreply, state} + end + + def user_over_time(account, count) do + user_over_time(data_state(), account, count) + end + + def user_over_time(state, account, count) do + delay = count*((24 * 60)*60) + now = DateTime.utc_now() + before = DateTime.utc_now() + |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) + |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) + |> DateTime.to_unix(:millisecond) + #[ +# {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, +# [{:andalso, {:==, :"$1", :"$1"}, {:<, :"$2", {:const, 3000}}}], [:lol]} + #] + match = [{{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, + [{:andalso, {:>, :"$2", {:const, before}}, {:==, :"$1", {:const, account.id}}}], [:"$_"]} + ] + :ets.select(state.ets, match) + |> Enum.reduce(Map.new, fn({{_, ts}, vol, _, _, _, _, _, _}, acc) -> + date = DateTime.from_unix!(ts, :millisecond) + |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) + + date = if date.hour <= 8 do + DateTime.add(date, -(60*(60*(date.hour+1))), :second, Tzdata.TimeZoneDatabase) + else + date + end + |> DateTime.to_date() + + Map.put(acc, date, Map.get(acc, date, 0) + vol) + end) + end + + def user_over_time_gl(account, count) do + state = data_state() + meta = get_user_meta(state, account.id) + delay = count*((24 * 60)*60) + now = DateTime.utc_now() + before = DateTime.utc_now() + |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) + |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) + |> DateTime.to_unix(:millisecond) + #[ +# {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, +# [{:andalso, {:==, :"$1", :"$1"}, {:<, :"$2", {:const, 3000}}}], [:lol]} + #] + match = [{{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, + [{:andalso, {:>, :"$2", {:const, before}}, {:==, :"$1", {:const, account.id}}}], [:"$_"]} + ] + :ets.select(state.ets, match) + |> Enum.reduce(Map.new, fn({{_, ts}, vol, _, _, _, _, _, _}, acc) -> + date = DateTime.from_unix!(ts, :millisecond) + |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) + + date = if date.hour <= 8 do + DateTime.add(date, -(60*(60*(date.hour+1))), :second, Tzdata.TimeZoneDatabase) + else + date + end + |> DateTime.to_date() + weight = meta.weight + k = if meta.sex, do: 0.7, else: 0.6 + gl = (10*vol)/(k*weight) + + Map.put(acc, date, Map.get(acc, date, 0) + gl) + end) + end + + + + defp over_time_stats(before, j, m, state) do + #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _, _, _, _}) when date > before -> obj end) + match = [{{{:_, :"$1"}, :_, :_, :_, :_, :_, :_, :_}, + [{:>, :"$1", {:const, before}}], [:"$_"]} + ] + # tuple ets: {{nick, date}, volumes, current, nom, commentaire} + members = IRC.Membership.members_or_friends(m.account, m.network, m.channel) + drinks = :ets.select(state.ets, match) + |> Enum.filter(fn({{account, _}, _, _, _, _, _, _, _}) -> Enum.member?(members, account) end) + |> Enum.sort_by(fn({{_, ts}, _, _, _, _, _, _, _}) -> ts end, &>/2) + + top = Enum.reduce(drinks, %{}, fn({{nick, _}, vol, _, _, _, _, _, _}, acc) -> + all = Map.get(acc, nick, 0) + Map.put(acc, nick, all + vol) + end) + |> Enum.sort_by(fn({_nick, count}) -> count end, &>/2) + |> Enum.map(fn({nick, count}) -> + account = IRC.Account.get(nick) + user = IRC.UserTrack.find_by_account(m.network, account) + nick = if(user, do: user.nick, else: account.name) + "#{nick}: #{Float.round(count, 4)}" + end) + |> Enum.intersperse(", ") + + m.replyfun.("sur #{j} jours: #{top}") + {:noreply, state} + end + + def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :plus}}}, state) do + meta = get_user_meta(state, m.account.id) + hf = if meta.sex, do: "h", else: "f" + m.replyfun.("+alcoolisme sexe: #{hf} poids: #{meta.weight} facteur de perte: #{meta.loss_factor}") + {:noreply, state} + end + + def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [h, weight | rest], type: :plus}}}, state) do + h = case h do + "h" -> true + "f" -> false + _ -> nil + end + + weight = case Util.float_paparse(weight) do + {weight, _} -> weight + _ -> nil + end + + {factor} = case rest do + [factor] -> + case Util.float_paparse(factor) do + {float, _} -> {float} + _ -> {@default_user_meta.loss_factor} + end + _ -> {@default_user_meta.loss_factor} + end + + if h == nil || weight == nil do + m.replyfun.("paramètres invalides") + else + old_meta = get_user_meta(state, m.account.id) + meta = Map.merge(@default_user_meta, %{sex: h, weight: weight, loss_factor: factor}) + put_user_meta(state, m.account.id, meta) + cond do + old_meta.weight < meta.weight -> + Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.fatter") + old_meta.weight == meta.weight -> + m.replyfun.("aucun changement!") + true -> + Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.thinner") + end + end + + {:noreply, state} + end + + def handle_info({:irc, :trigger, "santai", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :minus}}}, state) do + case get_statistics_for_nick(state, m.account.id) do + {_, obj = {_, date, points, _last_active, _cl, _deg, type, descr, _meta}} -> + :dets.delete_object(state.dets, obj) + :ets.delete(state.ets, {m.account.id, date}) + m.replyfun.("supprimé: #{m.sender.nick} #{points} #{type} #{descr}") + Nola.IRC.TxtPlugin.reply_random(m, "alcoolog.delete") + notify = IRC.Membership.notify_channels(m.account) -- [{m.network,m.channel}] + for {net, chan} <- notify do + user = IRC.UserTrack.find_by_account(net, m.account) + nick = if(user, do: user.nick, else: m.account.name) + IRC.Connection.broadcast_message(net, chan, "#{nick} -santai #{points} #{type} #{descr}") + end + {:noreply, state} + _ -> + {:noreply, state} + end + end + + + def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do + {account, duration} = case args do + [nick | rest] -> {IRC.Account.find_always_by_nick(m.network, m.channel, nick), rest} + [] -> {m.account, []} + end + if account do + duration = case duration do + ["semaine"] -> 7 + [j] -> + case Integer.parse(j) do + {j, "j"} -> j + _ -> nil + end + _ -> nil + end + user = IRC.UserTrack.find_by_account(m.network, account) + nick = if(user, do: user.nick, else: account.name) + if duration do + if duration > 90 do + m.replyfun.("trop gros, ça rentrera pas") + else + # duration stats + stats = user_over_time(state, account, duration) + |> Enum.sort_by(fn({k,_v}) -> k end, {:asc, Date}) + |> Enum.map(fn({date, count}) -> + "#{date.day}: #{Float.round(count, 2)}" + end) + |> Enum.intersperse(", ") + |> Enum.join("") + + if stats == "" do + m.replyfun.("alcoolisme a zéro sur #{duration}j :/") + else + m.replyfun.("alcoolisme de #{nick}, #{duration} derniers jours: #{stats}") + end + end + else + if stats = get_full_statistics(state, account.id) do + trend_symbol = if stats.active_drinks > 1 do + Enum.join(for(_ <- 1..stats.active_drinks, do: stats.trend_symbol)) + else + stats.trend_symbol + end + # TODO: Lookup nick for account_id + msg = "#{nick} #{stats.user_status} " + <> (if stats.active > 0 || stats.active15m > 0 || stats.active30m > 0 || stats.active1h > 0, do: ": #{trend_symbol} #{Float.round(stats.active, 4)}g/l ", else: "") + <> (if stats.active30m > 0 || stats.active1h > 0, do: "(15m: #{stats.active15m}, 30m: #{stats.active30m}, 1h: #{stats.active1h}) ", else: "") + <> (if stats.sober_in > 0, do: "— Sobre dans #{stats.sober_in_s} ", else: "") + <> (if stats.since && stats.since_min > 180, do: "— Paitai depuis #{stats.since_s} ", else: "") + <> "— Dernier verre: #{present_type(stats.last_type, stats.last_descr)} [#{Float.round(stats.last_points+0.0, 4)}] " + <> "#{format_duration_from_now(stats.last_at)} " + <> (if stats.daily_volumes > 0, do: "— Aujourd'hui: #{stats.daily_volumes} #{stats.daily_gl}g/l", else: "") + + m.replyfun.(msg) + else + m.replyfun.("honteux mais #{nick} n'a pas l'air alcoolique du tout. /kick") + end + end + else + m.replyfun.("je ne connais pas cet utilisateur") + end + {:noreply, state} + end + + + # Account merge + def handle_info({:account_change, old_id, new_id}, state) do + spec = [{{:"$1", :_, :_, :_, :_, :_, :_, :_, :_}, [{:==, :"$1", {:const, old_id}}], [:"$_"]}] + Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> + Logger.debug("alcolog/account_change:: merging #{old_id} -> #{new_id}") + rename_object_owner(table, state.ets, obj, old_id, new_id) + end) + case :dets.lookup(state.meta, {:meta, old_id}) do + [{_, meta}] -> + :dets.delete(state.meta, {:meta, old_id}) + :dets.insert(state.meta, {{:meta, new_id}, meta}) + _ -> + :ok + end + {:noreply, state} + end + + def terminate(_, state) do + for dets <- [state.dets, state.meta] do + :dets.sync(dets) + :dets.close(dets) + end + end + + defp rename_object_owner(table, ets, object = {old_id, date, volume, current, cl, deg, name, comment, meta}, old_id, new_id) do + :dets.delete_object(table, object) + :ets.delete(ets, {old_id, date}) + :dets.insert(table, {new_id, date, volume, current, cl, deg, name, comment, meta}) + :ets.insert(ets, {{new_id, date}, volume, current, cl, deg, name, comment, meta}) + end + + # Account: move from nick to account id + def handle_info({:accounts, accounts}, state) do + #for x={:account, _, _, _, _} <- accounts, do: handle_info(x, state) + #{:noreply, state} + mapping = Enum.reduce(accounts, Map.new, fn({:account, _net, _chan, nick, account_id}, acc) -> + Map.put(acc, String.downcase(nick), account_id) + end) + spec = [{{:"$1", :_, :_, :_, :_, :_, :_, :_, :_}, [], [:"$_"]}] + Logger.debug("accounts:: mappings #{inspect mapping}") + Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj = {nick, _date, _vol, _cur, _cl, _deg, _name, _comment, _meta}) -> + #Logger.debug("accounts:: item #{inspect(obj)}") + if new_id = Map.get(mapping, nick) do + Logger.debug("alcolog/accounts:: merging #{nick} -> #{new_id}") + rename_object_owner(table, state.ets, obj, nick, new_id) + end + end) + {:noreply, state} + end + + def handle_info({:account, _net, _chan, nick, account_id}, state) do + nick = String.downcase(nick) + spec = [{{:"$1", :_, :_, :_, :_, :_, :_, :_, :_}, [{:==, :"$1", {:const, nick}}], [:"$_"]}] + Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> + Logger.debug("alcoolog/account:: merging #{nick} -> #{account_id}") + rename_object_owner(table, state.ets, obj, nick, account_id) + end) + case :dets.lookup(state.meta, {:meta, nick}) do + [{_, meta}] -> + :dets.delete(state.meta, {:meta, nick}) + :dets.insert(state.meta, {{:meta, account_id}, meta}) + _ -> + :ok + end + {:noreply, state} + end + + def handle_info(t, state) do + Logger.debug("AlcoologPlugin: unhandled info #{inspect t}") + {:noreply, state} + end + + def nick_history(account) do + spec = [ + {{{:"$1", :_}, :_, :_, :_, :_, :_, :_, :_}, + [{:==, :"$1", {:const, account.id}}], + [:"$_"]} + ] + :ets.select(data_state().ets, spec) + end + + defp get_statistics_for_nick(state, account_id) do + qvc = :dets.lookup(state.dets, account_id) + |> Enum.sort_by(fn({_, ts, _, _, _, _, _, _, _}) -> ts end, & acc + (points||0) end) + last = List.last(qvc) || nil + {count, last} + end + + def present_type(type, descr) when descr in [nil, ""], do: "#{type}" + def present_type(type, description), do: "#{type} (#{description})" + + def format_points(int) when is_integer(int) and int > 0 do + "+#{Integer.to_string(int)}" + end + def format_points(int) when is_integer(int) and int < 0 do + Integer.to_string(int) + end + def format_points(int) when is_float(int) and int > 0 do + "+#{Float.to_string(Float.round(int,4))}" + end + def format_points(int) when is_float(int) and int < 0 do + Float.to_string(Float.round(int,4)) + end + def format_points(0), do: "0" + def format_points(0.0), do: "0" + + defp format_relative_timestamp(timestamp) do + alias Timex.Format.DateTime.Formatters + alias Timex.Timezone + date = timestamp + |> DateTime.from_unix!(:millisecond) + |> Timezone.convert("Europe/Paris") + + {:ok, relative} = Formatters.Relative.relative_to(date, Timex.now("Europe/Paris"), "{relative}", "fr") + {:ok, detail} = Formatters.Default.lformat(date, " ({h24}:{m})", "fr") + + relative <> detail + end + + defp put_user_meta(state, account_id, meta) do + :dets.insert(state.meta, {{:meta, account_id}, meta}) + :ok + end + + defp get_user_meta(%{meta: meta}, account_id) do + case :dets.lookup(meta, {:meta, account_id}) do + [{{:meta, _}, meta}] -> + Map.merge(@default_user_meta, meta) + _ -> + @default_user_meta + end + end + # Calcul g/l actuel: + # 1. load user meta + # 2. foldr ets + # for each object + # get_current_alcohol + # ((object g/l) - 0,15/l/60)* minutes_since_drink + # if minutes_since_drink < 10, reduce g/l (?!) + # acc + current_alcohol + # stop folding when ? + # + + def user_stats(account) do + user_stats(data_state(), account.id) + end + + defp user_stats(state = %{ets: ets}, account_id) do + meta = get_user_meta(state, account_id) + aday = (10 * 60)*60 + now = DateTime.utc_now() + before = now + |> DateTime.add(-aday, :second) + |> DateTime.to_unix(:millisecond) + #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end) + match = [ + {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, + [ + {:>, :"$2", {:const, before}}, + {:"=:=", {:const, account_id}, :"$1"} + ], [:"$_"]} + ] + # tuple ets: {{nick, date}, volumes, current, nom, commentaire} + drinks = :ets.select(ets, match) + # {date, single_peak} + total_volume = Enum.reduce(drinks, 0.0, fn({{_, date}, volume, _, _, _, _, _, _}, acc) -> + acc + volume + end) + k = if meta.sex, do: 0.7, else: 0.6 + weight = meta.weight + gl = (10*total_volume)/(k*weight) + {Float.round(total_volume + 0.0, 4), Float.round(gl + 0.0, 4)} + end + + defp alcohol_level_rising(state, account_id, minutes \\ 30) do + {now, _} = current_alcohol_level(state, account_id) + soon_date = DateTime.utc_now + |> DateTime.add(minutes*60, :second) + {soon, _} = current_alcohol_level(state, account_id, soon_date) + soon = cond do + soon < 0 -> 0.0 + true -> soon + end + #IO.puts "soon #{soon_date} - #{inspect soon} #{inspect now}" + {soon > now, Float.round(soon+0.0, 4)} + end + + defp current_alcohol_level(state = %{ets: ets}, account_id, now \\ nil) do + meta = get_user_meta(state, account_id) + aday = ((24*7) * 60)*60 + now = if now do + now + else + DateTime.utc_now() + end + before = now + |> DateTime.add(-aday, :second) + |> DateTime.to_unix(:millisecond) + #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end) + match = [ + {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, + [ + {:>, :"$2", {:const, before}}, + {:"=:=", {:const, account_id}, :"$1"} + ], [:"$_"]} + ] + # tuple ets: {{nick, date}, volumes, current, nom, commentaire} + drinks = :ets.select(ets, match) + |> Enum.sort_by(fn({{_, date}, _, _, _, _, _, _, _}) -> date end, & + k = if meta.sex, do: 0.7, else: 0.6 + weight = meta.weight + peak = (10*volume)/(k*weight) + date = case date do + ts when is_integer(ts) -> DateTime.from_unix!(ts, :millisecond) + date = %NaiveDateTime{} -> DateTime.from_naive!(date, "Etc/UTC") + date = %DateTime{} -> date + end + last_at = last_at || date + mins_since = round(DateTime.diff(now, date)/60.0) + #IO.puts "Drink: #{inspect({date, volume})} - mins since: #{inspect mins_since} - last drink at #{inspect last_at}" + # Apply loss since `last_at` on `all` + # + all = if last_at do + mins_since_last = round(DateTime.diff(date, last_at)/60.0) + loss = ((meta.loss_factor/100)/60)*(mins_since_last) + #IO.puts "Applying last drink loss: from #{all}, loss of #{inspect loss} (mins since #{inspect mins_since_first})" + cond do + (all-loss) > 0 -> all - loss + true -> 0.0 + end + else + all + end + #IO.puts "Applying last drink current before drink: #{inspect all}" + if mins_since < 30 do + per_min = (peak)/30.0 + current = (per_min*mins_since) + #IO.puts "Applying current drink 30m: from #{peak}, loss of #{inspect per_min}/min (mins since #{inspect mins_since})" + {all + current, date, [{date, current} | acc], active_drinks + 1} + else + {all + peak, date, [{date, peak} | acc], active_drinks} + end + end) + #IO.puts "last drink #{inspect last_drink_at}" + mins_since_last = if last_drink_at do + round(DateTime.diff(now, last_drink_at)/60.0) + else + 0 + end + # Si on a déjà bu y'a déjà moins 15 minutes (big up le binge drinking), on applique plus de perte + level = if mins_since_last > 15 do + loss = ((meta.loss_factor/100)/60)*(mins_since_last) + Float.round(all - loss, 4) + else + all + end + #IO.puts "\n LEVEL #{inspect level}\n\n\n\n" + cond do + level < 0 -> {0.0, 0} + true -> {level, active_drinks} + end + end + + defp format_duration_from_now(date, with_detail \\ true) do + date = if is_integer(date) do + date = DateTime.from_unix!(date, :millisecond) + |> Timex.Timezone.convert("Europe/Paris") + else + Util.to_naive_date_time(date) + end + now = DateTime.utc_now() + |> Timex.Timezone.convert("Europe/Paris") + {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(date, "({h24}:{m})", "fr") + + mins_since = round(DateTime.diff(now, date)/60.0) + if ago = format_minute_duration(mins_since) do + word = if mins_since > 0 do + "il y a " + else + "dans " + end + word <> ago <> if(with_detail, do: " #{detail}", else: "") + else + "maintenant #{detail}" + end + end + + defp format_minute_duration(minutes) do + sober_in_s = if (minutes != 0) do + duration = Timex.Duration.from_minutes(minutes) + Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized) + else + nil + end + end + +end diff --git a/lib/nola_plugins/alcoolog_announcer_plugin.ex b/lib/nola_plugins/alcoolog_announcer_plugin.ex new file mode 100644 index 0000000..f90dc42 --- /dev/null +++ b/lib/nola_plugins/alcoolog_announcer_plugin.ex @@ -0,0 +1,272 @@ +defmodule Nola.IRC.AlcoologAnnouncerPlugin do + require Logger + + @moduledoc """ + Annonce changements d'alcoolog + """ + + @channel "#dmz" + + @seconds 30 + + @apero [ + "C'EST L'HEURE DE L'APÉRRROOOOOOOO !!", + "SAAAAANNNNNNNTTTTTTTTAAAAAAAAIIIIIIIIIIIIIIIIIIIIII", + "APÉRO ? APÉRO !", + {:timed, ["/!\\ à vos tires bouchons…", "… débouchez …", "… BUVEZ! SANTAIII!"]}, + "/!\\ ALERTE APÉRO /!\\", + "CED !!! VASE DE ROUGE !", + "DIDI UN PETIT RICARD™??!", + "ALLEZ GUIGUI UNE PETITE BIERE ?", + {:timed, ["/!\\ à vos tires bouchons…", "… débouchez …", "… BUVEZ! SANTAIII!"]}, + "APPPPAIIIRRREAAUUUUUUUUUUU" + ] + + def irc_doc, do: nil + + def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) + + def log(account) do + dets_filename = (Nola.data_path() <> "/" <> "alcoologlog.dets") |> String.to_charlist + {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) + from = ~U[2020-08-23 19:41:40.524154Z] + to = ~U[2020-08-24 19:41:40.524154Z] + select = [ + {{:"$1", :"$2", :_}, + [ + {:andalso, + {:andalso, {:==, :"$1", {:const, account.id}}, + {:>, :"$2", {:const, DateTime.to_unix(from)}}}, + {:<, :"$2", {:const, DateTime.to_unix(to)}}} + ], [:"$_"]} + ] + res = :dets.select(dets, select) + :dets.close(dets) + res + end + + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "account", []) + stats = get_stats() + Process.send_after(self(), :stats, :timer.seconds(30)) + dets_filename = (Nola.data_path() <> "/" <> "alcoologlog.dets") |> String.to_charlist + {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) + ets = nil # :ets.new(__MODULE__.ETS, [:ordered_set, :named_table, :protected, {:read_concurrency, true}]) + #:ok = Nola.IRC.SettingPlugin.declare("alcoolog.alerts", __MODULE__, true, :boolean) + #:ok = Nola.IRC.SettingPlugin.declare("alcoolog.aperoalert", __MODULE__, true, :boolean) + # + {:ok, {stats, now(), dets, ets}}#, {:continue, :traverse}} + end + + def handle_continue(:traverse, state = {_, _, dets, ets}) do + traverse_fun = fn(obj, dets) -> + case obj do + {nick, %DateTime{} = dt, active} -> + :dets.delete_object(dets, obj) + :dets.insert(dets, {nick, DateTime.to_unix(dt), active}) + IO.puts("ok #{inspect obj}") + dets + {nick, ts, value} -> + :ets.insert(ets, { {nick, ts}, value }) + dets + end + end + :dets.foldl(traverse_fun, dets, dets) + :dets.sync(dets) + IO.puts("alcoolog announcer fixed") + {:noreply, state} + end + + def alcohol_reached(old, new, level) do + (old.active < level && new.active >= level) && (new.active5m >= level) + end + + def alcohol_below(old, new, level) do + (old.active > level && new.active <= level) && (new.active5m <= level) + end + + + def handle_info(:stats, {old_stats, old_now, dets, ets}) do + stats = get_stats() + now = now() + + if old_now.hour < 18 && now.hour == 18 do + apero = Enum.shuffle(@apero) + |> Enum.random() + + case apero do + {:timed, list} -> + spawn(fn() -> + for line <- list do + IRC.Connection.broadcast_message("evolu.net", "#dmz", line) + :timer.sleep(:timer.seconds(5)) + end + end) + string -> + IRC.Connection.broadcast_message("evolu.net", "#dmz", string) + end + + end + + #IO.puts "newstats #{inspect stats}" + events = for {acct, old} <- old_stats do + new = Map.get(stats, acct, nil) + #IO.puts "#{acct}: #{inspect(old)} -> #{inspect(new)}" + + now = DateTime.to_unix(DateTime.utc_now()) + if new && new[:active] do + :dets.insert(dets, {acct, now, new[:active]}) + :ets.insert(ets, {{acct, now}, new[:active]}) + else + :dets.insert(dets, {acct, now, 0.0}) + :ets.insert(ets, {{acct, now}, new[:active]}) + end + + event = cond do + old == nil -> nil + (old.active > 0) && (new == nil) -> :sober + new == nil -> nil + alcohol_reached(old, new, 0.5) -> :stopconduire + alcohol_reached(old, new, 1.0) -> :g1 + alcohol_reached(old, new, 2.0) -> :g2 + alcohol_reached(old, new, 3.0) -> :g3 + alcohol_reached(old, new, 4.0) -> :g4 + alcohol_reached(old, new, 5.0) -> :g5 + alcohol_reached(old, new, 6.0) -> :g6 + alcohol_reached(old, new, 7.0) -> :g7 + alcohol_reached(old, new, 10.0) -> :g10 + alcohol_reached(old, new, 13.74) -> :record + alcohol_below(old, new, 0.5) -> :conduire + alcohol_below(old, new, 1.0) -> :fini1g + alcohol_below(old, new, 2.0) -> :fini2g + alcohol_below(old, new, 3.0) -> :fini3g + alcohol_below(old, new, 4.0) -> :fini4g + (old.rising) && (!new.rising) -> :lowering + true -> nil + end + {acct, event} + end + + for {acct, event} <- events do + message = case event do + :g1 -> [ + "[vigicuite jaune] LE GRAMME! LE GRAMME O/", + "début de vigicuite jaune ! LE GRAMME ! \\O/", + "waiiiiiiii le grammmeee", + "bourraiiiiiiiiiiide 1 grammeeeeeeeeeee", + ] + :g2 -> [ + "[vigicuite orange] \\o_YAY 2 GRAMMES ! _o/", + "PAITAIIIIIIIIII DEUX GRAMMEESSSSSSSSSSSSSSSSS", + "bourrrrrraiiiiiiiiiiiiiiiide 2 grammeeeeeeeeeees", + ] + :g3 -> [ + "et un ! et deux ! et TROIS GRAMMEEESSSSSSS", + "[vigicuite rouge] _o/ BOURRAIIDDDEEEE 3 GRAMMESSSSSSSSS \\o/ \\o/" + ] + :g4 -> [ + "[vigicuite écarlate] et un, et deux, et trois, ET QUATRES GRAMMEESSSSSSSSSSSSSSSSSSSssssss" + ] + :g5 -> "[vigicuite écarlate+] PUTAIN 5 GRAMMES !" + :g6 -> "[vigicuite écarlate++] 6 grammes ? Vous pouvez joindre Alcool info service au 0 980 980 930" + :g7 -> "[vigicuite c'est la merde] 7 grammes. Le SAMU, c'est le 15." + :g10 -> "BORDLE 10 GRAMMES" + :record -> "RECORD DU MONDE BATTU ! >13.74g/l !!" + :fini1g -> [ + "fin d'alerte vigicuite jaune, passage en vert (<1g/l)", + "/!\\ alerte moins de 1g/l /!\\" + ] + :fini2g -> [ + "t'as moins de 2 g/l, faut se reprendre là [vigicuite jaune]" + ] + :fini3g -> [ + "fin d'alerte vigicuite rouge, passage en orange (<3g/l)" + ] + :fini4g -> [ + "fin d'alerte vigicuite écarlate, passage en rouge (<4g/l)" + ] + :lowering -> [ + "attention ça baisse!", + "tu vas quand même pas en rester là ?", + "IL FAUT CONTINUER À BOIRE !", + "t'abandonnes déjà ?", + "!santai ?", + "faut pas en rester là", + "il faut se resservir", + "coucou faut reboire", + "encore un petit verre ?", + "abwaaaaaaaaaaaaarrrrrrrrrrrrrr", + "taux d'alcoolémie en chute ! agissez avant qu'il soit trop tard!", + "ÇA BAISSE !!" + ] + :stopconduire -> [ + "0.5g! bientot le gramme?", + "tu peux plus prendre la route... mais... tu peux prendre la route du gramme! !santai !", + "fini la conduite!", + "0.5! continues faut pas en rester là!", + "beau début, continues !", + "ça monte! 0.5g/l!" + ] + :conduire -> [ + "tu peux conduire, ou recommencer à boire! niveau critique!", + "!santai ?", + "tu peux reprendre la route, ou reprendre la route du gramme..", + "attention, niveau critique!", + "il faut boire !!", + "trop de sang dans ton alcool, c'est mauvais pour la santé", + "faut pas en rester là !", + ] + :sober -> [ + "sobre…", + "/!\\ alerte sobriété /!\\", + "... sobre?!?!", + "sobre :(", + "attention, t'es sobre :/", + "danger, alcoolémie à 0.0 !", + "sobre! c'était bien on recommence quand ?", + "sobre ? Faut recommencer...", + "T'es sobre. Ne te laisses pas abattre- ton caviste peut aider.", + "Vous êtes sobre ? Ceci n'est pas une fatalité - resservez vous vite !" + ] + _ -> nil + end + message = case message do + m when is_binary(m) -> m + m when is_list(m) -> m |> Enum.shuffle() |> Enum.random() + nil -> nil + end + if message do + #IO.puts("#{acct}: #{message}") + account = IRC.Account.get(acct) + for {net, chan} <- IRC.Membership.notify_channels(account) do + user = IRC.UserTrack.find_by_account(net, account) + nick = if(user, do: user.nick, else: account.name) + IRC.Connection.broadcast_message(net, chan, "#{nick}: #{message}") + end + end + end + + timer() + + #IO.puts "tick stats ok" + {:noreply, {stats,now,dets,ets}} + end + + def handle_info(_, state) do + {:noreply, state} + end + + defp now() do + DateTime.utc_now() + |> Timex.Timezone.convert("Europe/Paris") + end + + defp get_stats() do + Enum.into(Nola.IRC.AlcoologPlugin.get_all_stats(), %{}) + end + + defp timer() do + Process.send_after(self(), :stats, :timer.seconds(@seconds)) + end + +end diff --git a/lib/nola_plugins/base_plugin.ex b/lib/nola_plugins/base_plugin.ex new file mode 100644 index 0000000..a2b9ffb --- /dev/null +++ b/lib/nola_plugins/base_plugin.ex @@ -0,0 +1,131 @@ +defmodule Nola.IRC.BasePlugin do + + def irc_doc, do: nil + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init([]) do + regopts = [plugin: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "trigger:version", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:help", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:liquidrender", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:plugin", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:plugins", regopts) + {:ok, nil} + end + + def handle_info({:irc, :trigger, "plugins", msg = %{trigger: %{type: :bang, args: []}}}, _) do + enabled_string = IRC.Plugin.enabled() + |> Enum.map(fn(mod) -> + mod + |> Macro.underscore() + |> String.split("/", parts: :infinity) + |> List.last() + |> String.replace("_plugin", "") + |> Enum.sort() + end) + |> Enum.join(", ") + msg.replyfun.("Enabled plugins: #{enabled_string}") + {:noreply, nil} + end + + def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :query, args: [plugin]}} = m}, _) do + module = Module.concat([Nola.IRC, Macro.camelize(plugin<>"_plugin")]) + with true <- Code.ensure_loaded?(module), + pid when is_pid(pid) <- GenServer.whereis(module) + do + m.replyfun.("loaded, active: #{inspect(pid)}") + else + false -> m.replyfun.("not loaded") + nil -> + msg = case IRC.Plugin.get(module) do + :disabled -> "disabled" + {_, false, _} -> "disabled" + _ -> "not active" + end + m.replyfun.(msg) + end + {:noreply, nil} + end + + def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :plus, args: [plugin]}} = m}, _) do + module = Module.concat([Nola.IRC, Macro.camelize(plugin<>"_plugin")]) + with true <- Code.ensure_loaded?(module), + IRC.Plugin.switch(module, true), + {:ok, pid} <- IRC.Plugin.start(module) + do + m.replyfun.("started: #{inspect(pid)}") + else + false -> m.replyfun.("not loaded") + :ignore -> m.replyfun.("disabled or throttled") + {:error, _} -> m.replyfun.("start error") + end + {:noreply, nil} + end + + def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :tilde, args: [plugin]}} = m}, _) do + module = Module.concat([Nola.IRC, Macro.camelize(plugin<>"_plugin")]) + with true <- Code.ensure_loaded?(module), + pid when is_pid(pid) <- GenServer.whereis(module), + :ok <- GenServer.stop(pid), + {:ok, pid} <- IRC.Plugin.start(module) + do + m.replyfun.("restarted: #{inspect(pid)}") + else + false -> m.replyfun.("not loaded") + nil -> m.replyfun.("not active") + end + {:noreply, nil} + end + + + def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :minus, args: [plugin]}} = m}, _) do + module = Module.concat([Nola.IRC, Macro.camelize(plugin<>"_plugin")]) + with true <- Code.ensure_loaded?(module), + pid when is_pid(pid) <- GenServer.whereis(module), + :ok <- GenServer.stop(pid) + do + IRC.Plugin.switch(module, false) + m.replyfun.("stopped: #{inspect(pid)}") + else + false -> m.replyfun.("not loaded") + nil -> m.replyfun.("not active") + end + {:noreply, nil} + end + + def handle_info({:irc, :trigger, "liquidrender", m = %{trigger: %{args: args}}}, _) do + template = Enum.join(args, " ") + m.replyfun.(Tmpl.render(template, m)) + {:noreply, nil} + end + + def handle_info({:irc, :trigger, "help", m = %{trigger: %{type: :bang}}}, _) do + url = NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel)) + m.replyfun.("-> #{url}") + {:noreply, nil} + end + + def handle_info({:irc, :trigger, "version", message = %{trigger: %{type: :bang}}}, _) do + {:ok, vsn} = :application.get_key(:nola, :vsn) + ver = List.to_string(vsn) + url = NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :index) + elixir_ver = Application.started_applications() |> List.keyfind(:elixir, 0) |> elem(2) |> to_string() + otp_ver = :erlang.system_info(:system_version) |> to_string() |> String.trim() + system = :erlang.system_info(:system_architecture) |> to_string() + message.replyfun.([ + <<"🤖 I am a robot running", 2, "beautte, version #{ver}", 2, " — source: #{Nola.source_url()}">>, + "🦾 Elixir #{elixir_ver} #{otp_ver} on #{system}", + "👷‍♀️ Owner: href ", + "🌍 Web interface: #{url}" + ]) + {:noreply, nil} + end + + def handle_info(msg, _) do + {:noreply, nil} + end + +end diff --git a/lib/nola_plugins/bourosama_plugin.ex b/lib/nola_plugins/bourosama_plugin.ex new file mode 100644 index 0000000..dd05144 --- /dev/null +++ b/lib/nola_plugins/bourosama_plugin.ex @@ -0,0 +1,58 @@ +defmodule Nola.IRC.BoursoramaPlugin do + + def irc_doc() do + """ + # bourses + + Un peu comme [finance](#finance), mais en un peu mieux, et un peu moins bien. + + Source: [boursorama.com](https://boursorama.com) + + * **!caca40** affiche l'état du cac40 + """ + end + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + @cac40_url "https://www.boursorama.com/bourse/actions/palmares/france/?france_filter%5Bmarket%5D=1rPCAC&france_filter%5Bsector%5D=&france_filter%5Bvariation%5D=50002&france_filter%5Bperiod%5D=1&france_filter%5Bfilter%5D=" + + def init(_) do + regopts = [plugin: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "trigger:cac40", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:caca40", regopts) + {:ok, nil} + end + + def handle_info({:irc, :trigger, cac, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang}}}, state) when cac in ["cac40", "caca40"] do + case HTTPoison.get(@cac40_url, [], []) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + html = Floki.parse(body) + board = Floki.find(body, "div.c-tradingboard") + + cac40 = Floki.find(board, ".c-tradingboard__main > .c-tradingboard__infos") + instrument = Floki.find(cac40, ".c-instrument") + last = Floki.find(instrument, "span[data-ist-last]") + |> Floki.text() + |> String.replace(" ", "") + variation = Floki.find(instrument, "span[data-ist-variation]") + |> Floki.text() + + sign = case variation do + "-"<>_ -> "▼" + "+" -> "▲" + _ -> "" + end + + m.replyfun.("caca40: #{sign} #{variation} #{last}") + + {:error, %HTTPoison.Response{status_code: code}} -> + m.replyfun.("caca40: erreur http #{code}") + + _ -> + m.replyfun.("caca40: erreur http") + end + end + +end diff --git a/lib/nola_plugins/buffer_plugin.ex b/lib/nola_plugins/buffer_plugin.ex new file mode 100644 index 0000000..eece34e --- /dev/null +++ b/lib/nola_plugins/buffer_plugin.ex @@ -0,0 +1,44 @@ +defmodule Nola.IRC.BufferPlugin do + @table __MODULE__.ETS + def irc_doc, do: nil + + def table(), do: @table + + def select_buffer(network, channel, limit \\ 50) do + import Ex2ms + spec = fun do {{n, c, _}, m} when n == ^network and (c == ^channel or is_nil(c)) -> m end + :ets.select(@table, spec, limit) + end + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init(_) do + for e <- ~w(messages triggers events outputs) do + {:ok, _} = Registry.register(IRC.PubSub, e, plugin: __MODULE__) + end + {:ok, :ets.new(@table, [:named_table, :ordered_set, :protected])} + end + + def handle_info({:irc, :trigger, _, message}, ets), do: handle_message(message, ets) + def handle_info({:irc, :text, message}, ets), do: handle_message(message, ets) + def handle_info({:irc, :event, event}, ets), do: handle_message(event, ets) + + defp handle_message(message = %{network: network}, ets) do + key = {network, Map.get(message, :channel), ts(message.at)} + :ets.insert(ets, {key, message}) + {:noreply, ets} + end + + defp ts(nil), do: ts(NaiveDateTime.utc_now()) + + defp ts(naive = %NaiveDateTime{}) do + ts = naive + |> DateTime.from_naive!("Etc/UTC") + |> DateTime.to_unix() + + -ts + end + +end diff --git a/lib/nola_plugins/calc_plugin.ex b/lib/nola_plugins/calc_plugin.ex new file mode 100644 index 0000000..264370c --- /dev/null +++ b/lib/nola_plugins/calc_plugin.ex @@ -0,0 +1,37 @@ +defmodule Nola.IRC.CalcPlugin do + @moduledoc """ + # calc + + * **!calc ``**: évalue l'expression mathématique ``. + """ + + def irc_doc, do: @moduledoc + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:calc", [plugin: __MODULE__]) + {:ok, nil} + end + + def handle_info({:irc, :trigger, "calc", message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: expr_list}}}, state) do + expr = Enum.join(expr_list, " ") + result = try do + case Abacus.eval(expr) do + {:ok, result} -> result + error -> inspect(error) + end + rescue + error -> if(error[:message], do: "#{error.message}", else: "erreur") + end + message.replyfun.("#{message.sender.nick}: #{expr} = #{result}") + {:noreply, state} + end + + def handle_info(msg, state) do + {:noreply, state} + end + +end diff --git a/lib/nola_plugins/coronavirus_plugin.ex b/lib/nola_plugins/coronavirus_plugin.ex new file mode 100644 index 0000000..d04d8f9 --- /dev/null +++ b/lib/nola_plugins/coronavirus_plugin.ex @@ -0,0 +1,172 @@ +defmodule Nola.IRC.CoronavirusPlugin do + require Logger + NimbleCSV.define(CovidCsv, separator: ",", escape: "\"") + @moduledoc """ + # Corona Virus + + Données de [Johns Hopkins University](https://github.com/CSSEGISandData/COVID-19) et mises à jour a peu près tous les jours. + + * `!coronavirus [France | Country]`: :-) + * `!coronavirus`: top 10 confirmés et non guéris + * `!coronavirus confirmés`: top 10 confirmés + * `!coronavirus morts`: top 10 morts + * `!coronavirus soignés`: top 10 soignés + """ + def irc_doc, do: @moduledoc + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:coronavirus", [plugin: __MODULE__]) + {:ok, nil, {:continue, :init}} + :ignore + end + + def handle_continue(:init, _) do + date = Date.add(Date.utc_today(), -2) + {data, _} = fetch_data(%{}, date) + {data, next} = fetch_data(data) + :timer.send_after(next, :update) + {:noreply, %{data: data}} + end + + def handle_info(:update, state) do + {data, next} = fetch_data(state.data) + :timer.send_after(next, :update) + {:noreply, %{data: data}} + end + + def handle_info({:irc, :trigger, "coronavirus", m = %IRC.Message{trigger: %{type: :bang, args: args}}}, state) when args in [ + [], ["morts"], ["confirmés"], ["soignés"], ["malades"], ["n"], ["nmorts"], ["nsoignés"], ["nconfirmés"]] do + {field, name} = case args do + ["confirmés"] -> {:confirmed, "confirmés"} + ["morts"] -> {:deaths, "morts"} + ["soignés"] -> {:recovered, "soignés"} + ["nmorts"] -> {:new_deaths, "nouveaux morts"} + ["nconfirmés"] -> {:new_confirmed, "nouveaux confirmés"} + ["n"] -> {:new_current, "nouveaux malades"} + ["nsoignés"] -> {:new_recovered, "nouveaux soignés"} + _ -> {:current, "malades"} + end + IO.puts("FIELD #{inspect field}") + field_evol = String.to_atom("new_#{field}") + sorted = state.data + |> Enum.filter(fn({_, %{region: region}}) -> region == true end) + |> Enum.map(fn({location, data}) -> {location, Map.get(data, field, 0), Map.get(data, field_evol, 0)} end) + |> Enum.sort_by(fn({_,count,_}) -> count end, &>=/2) + |> Enum.take(10) + |> Enum.with_index() + |> Enum.map(fn({{location, count, evol}, index}) -> + ev = if String.starts_with?(name, "nouveaux") do + "" + else + " (#{Util.plusminus(evol)})" + end + "##{index+1}: #{location} #{count}#{ev}" + end) + |> Enum.intersperse(" - ") + |> Enum.join() + m.replyfun.("CORONAVIRUS TOP10 #{name}: " <> sorted) + {:noreply, state} + end + + def handle_info({:irc, :trigger, "coronavirus", m = %IRC.Message{trigger: %{type: :bang, args: location}}}, state) do + location = Enum.join(location, " ") |> String.downcase() + if data = Map.get(state.data, location) do + m.replyfun.("coronavirus: #{location}: " + <> "#{data.current} malades (#{Util.plusminus(data.new_current)}), " + <> "#{data.confirmed} confirmés (#{Util.plusminus(data.new_confirmed)}), " + <> "#{data.deaths} morts (#{Util.plusminus(data.new_deaths)}), " + <> "#{data.recovered} soignés (#{Util.plusminus(data.new_recovered)}) (@ #{data.update})") + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, "coronavirus", m = %IRC.Message{trigger: %{type: :query, args: location}}}, state) do + m.replyfun.("https://github.com/CSSEGISandData/COVID-19") + {:noreply, state} + end + + # 1. Try to fetch data for today + # 2. Fetch yesterday if no results + defp fetch_data(current_data, date \\ nil) do + now = Date.utc_today() + url = fn(date) -> + "https://github.com/CSSEGISandData/COVID-19/raw/master/csse_covid_19_data/csse_covid_19_daily_reports/#{date}.csv" + end + request_date = date || now + Logger.debug("Coronavirus check date: #{inspect request_date}") + {:ok, date_s} = Timex.format({request_date.year, request_date.month, request_date.day}, "%m-%d-%Y", :strftime) + cur_url = url.(date_s) + Logger.debug "Fetching URL #{cur_url}" + case HTTPoison.get(cur_url, [], follow_redirect: true) do + {:ok, %HTTPoison.Response{status_code: 200, body: csv}} -> + # Parse CSV update data + data = csv + |> CovidCsv.parse_string() + |> Enum.reduce(%{}, fn(line, acc) -> + case line do + # FIPS,Admin2,Province_State,Country_Region,Last_Update,Lat,Long_,Confirmed,Deaths,Recovered,Active,Combined_Key + #0FIPS,Admin2,Province_State,Country_Region,Last_Update,Lat,Long_,Confirmed,Deaths,Recovered,Active,Combined_Key,Incidence_Rate,Case-Fatality_Ratio + [_, _, state, region, update, _lat, _lng, confirmed, deaths, recovered, _active, _combined_key, _incidence_rate, _fatality_ratio] -> + state = String.downcase(state) + region = String.downcase(region) + confirmed = String.to_integer(confirmed) + deaths = String.to_integer(deaths) + recovered = String.to_integer(recovered) + + current = (confirmed - recovered) - deaths + + entry = %{update: update, confirmed: confirmed, deaths: deaths, recovered: recovered, current: current, region: region} + + region_entry = Map.get(acc, region, %{update: nil, confirmed: 0, deaths: 0, recovered: 0, current: 0}) + region_entry = %{ + update: region_entry.update || update, + confirmed: region_entry.confirmed + confirmed, + deaths: region_entry.deaths + deaths, + current: region_entry.current + current, + recovered: region_entry.recovered + recovered, + region: true + } + + changes = if old = Map.get(current_data, region) do + %{ + new_confirmed: region_entry.confirmed - old.confirmed, + new_current: region_entry.current - old.current, + new_deaths: region_entry.deaths - old.deaths, + new_recovered: region_entry.recovered - old.recovered, + } + else + %{new_confirmed: 0, new_current: 0, new_deaths: 0, new_recovered: 0} + end + + region_entry = Map.merge(region_entry, changes) + + acc = Map.put(acc, region, region_entry) + + acc = if state && state != "" do + Map.put(acc, state, entry) + else + acc + end + + other -> + Logger.info("Coronavirus line failed: #{inspect line}") + acc + end + end) + Logger.info "Updated coronavirus database" + {data, :timer.minutes(60)} + {:ok, %HTTPoison.Response{status_code: 404}} -> + Logger.debug "Corona 404 #{cur_url}" + date = Date.add(date || now, -1) + fetch_data(current_data, date) + other -> + Logger.error "Coronavirus: Update failed #{inspect other}" + {current_data, :timer.minutes(5)} + end + end + +end diff --git a/lib/nola_plugins/correction_plugin.ex b/lib/nola_plugins/correction_plugin.ex new file mode 100644 index 0000000..5f9b278 --- /dev/null +++ b/lib/nola_plugins/correction_plugin.ex @@ -0,0 +1,59 @@ +defmodule Nola.IRC.CorrectionPlugin do + @moduledoc """ + # correction + + * `s/pattern/replace` replace `pattern` by `replace` in the last matching message + """ + + def irc_doc, do: @moduledoc + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "messages", [plugin: __MODULE__]) + {:ok, _} = Registry.register(IRC.PubSub, "triggers", [plugin: __MODULE__]) + {:ok, %{}} + end + + # Trigger fallback + def handle_info({:irc, :trigger, _, m = %IRC.Message{}}, state) do + {:noreply, correction(m, state)} + end + + def handle_info({:irc, :text, m = %IRC.Message{}}, state) do + {:noreply, correction(m, state)} + end + + def correction(m, state) do + history = Map.get(state, key(m), []) + if String.starts_with?(m.text, "s/") do + case String.split(m.text, "/") do + ["s", match, replace | _] -> + case Regex.compile(match) do + {:ok, reg} -> + repl = Enum.find(history, fn(m) -> Regex.match?(reg, m.text) end) + if repl do + new_text = String.replace(repl.text, reg, replace) + m.replyfun.("correction: <#{repl.sender.nick}> #{new_text}") + end + _ -> + m.replyfun.("correction: invalid regex") + end + _ -> m.replyfun.("correction: invalid regex format") + end + state + else + history = if length(history) > 100 do + {_, history} = List.pop_at(history, 99) + [m | history] + else + [m | history] + end + Map.put(state, key(m), history) + end + end + + defp key(%{network: net, channel: chan}), do: "#{net}/#{chan}" + +end diff --git a/lib/nola_plugins/dice_plugin.ex b/lib/nola_plugins/dice_plugin.ex new file mode 100644 index 0000000..b5e7649 --- /dev/null +++ b/lib/nola_plugins/dice_plugin.ex @@ -0,0 +1,66 @@ +defmodule Nola.IRC.DicePlugin do + require Logger + + @moduledoc """ + # dice + + * **!dice `[1 | lancés]` `[6 | faces]`**: lance une ou plusieurs fois un dé de 6 ou autre faces + """ + + @default_faces 6 + @default_rolls 1 + @max_rolls 50 + + def short_irc_doc, do: "!dice (jeter un dé)" + defstruct client: nil, dets: nil + + def irc_doc, do: @moduledoc + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:dice", [plugin: __MODULE__]) + {:ok, %__MODULE__{}} + end + + def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :bang, args: args}}}, state) do + to_integer = fn(string, default) -> + case Integer.parse(string) do + {int, _} -> int + _ -> default + end + end + + {rolls, faces} = case args do + [] -> {@default_rolls, @default_faces} + [faces, rolls] -> {to_integer.(rolls, @default_rolls), to_integer.(faces, @default_faces)} + [rolls] -> {to_integer.(rolls, @default_rolls), @default_faces} + end + + roll(state, message, faces, rolls) + + {:noreply, state} + end + + def handle_info(info, state) do + {:noreply, state} + end + + defp roll(state, message, faces, 1) when faces > 0 do + random = :crypto.rand_uniform(1, faces+1) + message.replyfun.("#{message.sender.nick} dice: #{random}") + end + defp roll(state, message, faces, rolls) when faces > 0 and rolls > 0 and rolls <= @max_rolls do + {results, acc} = Enum.map_reduce(Range.new(1, rolls), 0, fn(i, acc) -> + random = :crypto.rand_uniform(1, faces+1) + {random, acc + random} + end) + results = Enum.join(results, "; ") + message.replyfun.("#{message.sender.nick} dice: [#{acc}] #{results}") + end + + defp roll(_, _, _, _, _), do: nil + +end diff --git a/lib/nola_plugins/finance_plugin.ex b/lib/nola_plugins/finance_plugin.ex new file mode 100644 index 0000000..16d06ee --- /dev/null +++ b/lib/nola_plugins/finance_plugin.ex @@ -0,0 +1,190 @@ +defmodule Nola.IRC.FinancePlugin do + require Logger + + @moduledoc """ + # finance + + Données de [alphavantage.co](https://alphavantage.co). + + ## forex / monnaies / crypto-monnaies + + * **`!forex [MONNAIE2]`**: taux de change entre deux monnaies. + * **`!forex `**: converti `montant` entre deux monnaies + * **`?currency `**: recherche une monnaie + + Utiliser le symbole des monnaies (EUR, USD, ...). + + ## bourses + + * **`!stocks `** + * **`?stocks `** cherche un symbole + + Pour les symboles non-US, ajouter le suffixe (RNO Paris: RNO.PAR). + + """ + + @currency_list "http://www.alphavantage.co/physical_currency_list/" + @crypto_list "http://www.alphavantage.co/digital_currency_list/" + + HTTPoison.start() + load_currency = fn(url) -> + resp = HTTPoison.get!(url) + resp.body + |> String.strip() + |> String.split("\n") + |> Enum.drop(1) + |> Enum.map(fn(line) -> + [symbol, name] = line + |> String.strip() + |> String.split(",", parts: 2) + {symbol, name} + end) + |> Enum.into(Map.new) + end + fiat = load_currency.(@currency_list) + crypto = load_currency.(@crypto_list) + @currencies Map.merge(fiat, crypto) + + def irc_doc, do: @moduledoc + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init([]) do + regopts = [plugin: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "trigger:forex", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:currency", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:stocks", regopts) + {:ok, nil} + end + + + def handle_info({:irc, :trigger, "stocks", message = %{trigger: %{type: :query, args: args = search}}}, state) do + search = Enum.join(search, "%20") + url = "https://www.alphavantage.co/query?function=SYMBOL_SEARCH&keywords=#{search}&apikey=#{api_key()}" + case HTTPoison.get(url) do + {:ok, %HTTPoison.Response{status_code: 200, body: data}} -> + data = Poison.decode!(data) + if error = Map.get(data, "Error Message") do + Logger.error("AlphaVantage API invalid request #{url} - #{inspect error}") + message.replyfun.("stocks: requête invalide") + else + items = for item <- Map.get(data, "bestMatches") do + symbol = Map.get(item, "1. symbol") + name = Map.get(item, "2. name") + type = Map.get(item, "3. type") + region = Map.get(item, "4. region") + currency = Map.get(item, "8. currency") + "#{symbol}: #{name} (#{region}; #{currency}; #{type})" + end + |> Enum.join(", ") + items = if items == "" do + "no results!" + else + items + end + message.replyfun.(items) + end + {:ok, resp = %HTTPoison.Response{status_code: code}} -> + Logger.error "AlphaVantage API error: #{code} #{url} - #{inspect resp}" + message.replyfun.("forex: erreur (api #{code})") + {:error, %HTTPoison.Error{reason: error}} -> + Logger.error "AlphaVantage HTTP error: #{inspect error}" + message.replyfun.("forex: erreur (http #{inspect error})") + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, "stocks", message = %{trigger: %{type: :bang, args: args = [symbol]}}}, state) do + url = "https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=#{symbol}&apikey=#{api_key()}" + case HTTPoison.get(url) do + {:ok, %HTTPoison.Response{status_code: 200, body: data}} -> + data = Poison.decode!(data) + if error = Map.get(data, "Error Message") do + Logger.error("AlphaVantage API invalid request #{url} - #{inspect error}") + message.replyfun.("stocks: requête invalide") + else + data = Map.get(data, "Global Quote") + open = Map.get(data, "02. open") + high = Map.get(data, "03. high") + low = Map.get(data, "04. low") + price = Map.get(data, "05. price") + volume = Map.get(data, "06. volume") + prev_close = Map.get(data, "08. previous close") + change = Map.get(data, "09. change") + change_pct = Map.get(data, "10. change percent") + + msg = "#{symbol}: #{price} #{change} [#{change_pct}] (high: #{high}, low: #{low}, open: #{open}, prev close: #{prev_close}) (volume: #{volume})" + message.replyfun.(msg) + end + {:ok, resp = %HTTPoison.Response{status_code: code}} -> + Logger.error "AlphaVantage API error: #{code} #{url} - #{inspect resp}" + message.replyfun.("stocks: erreur (api #{code})") + {:error, %HTTPoison.Error{reason: error}} -> + Logger.error "AlphaVantage HTTP error: #{inspect error}" + message.replyfun.("stocks: erreur (http #{inspect error})") + end + {:noreply, state} + end + + + def handle_info({:irc, :trigger, "forex", message = %{trigger: %{type: :bang, args: args = [_ | _]}}}, state) do + {amount, from, to} = case args do + [amount, from, to] -> + {amount, _} = Float.parse(amount) + {amount, from, to} + [from, to] -> + {1, from, to} + [from] -> + {1, from, "EUR"} + end + url = "https://www.alphavantage.co/query?function=CURRENCY_EXCHANGE_RATE&from_currency=#{from}&to_currency=#{to}&apikey=#{api_key()}" + case HTTPoison.get(url) do + {:ok, %HTTPoison.Response{status_code: 200, body: data}} -> + data = Poison.decode!(data) + if error = Map.get(data, "Error Message") do + Logger.error("AlphaVantage API invalid request #{url} - #{inspect error}") + message.replyfun.("forex: requête invalide") + else + data = Map.get(data, "Realtime Currency Exchange Rate") + from_name = Map.get(data, "2. From_Currency Name") + to_name = Map.get(data, "4. To_Currency Name") + rate = Map.get(data, "5. Exchange Rate") + {rate, _} = Float.parse(rate) + value = amount*rate + message.replyfun.("#{amount} #{from} (#{from_name}) -> #{value} #{to} (#{to_name}) (#{rate})") + end + {:ok, resp = %HTTPoison.Response{status_code: code}} -> + Logger.error "AlphaVantage API error: #{code} #{url} - #{inspect resp}" + message.replyfun.("forex: erreur (api #{code})") + {:error, %HTTPoison.Error{reason: error}} -> + Logger.error "AlphaVantage HTTP error: #{inspect error}" + message.replyfun.("forex: erreur (http #{inspect error})") + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, "currency", message = %{trigger: %{type: :query, args: args = search}}}, state) do + search = Enum.join(search, " ") + results = Enum.filter(@currencies, fn({symbol, name}) -> + String.contains?(String.downcase(name), String.downcase(search)) || String.contains?(String.downcase(symbol), String.downcase(search)) + end) + |> Enum.map(fn({symbol, name}) -> + "#{symbol}: #{name}" + end) + |> Enum.join(", ") + + if results == "" do + message.replyfun.("no results!") + else + message.replyfun.(results) + end + {:noreply, state} + end + + defp api_key() do + Application.get_env(:nola, :alphavantage, []) + |> Keyword.get(:api_key, "demo") + end + +end diff --git a/lib/nola_plugins/gpt_plugin.ex b/lib/nola_plugins/gpt_plugin.ex new file mode 100644 index 0000000..2c8f182 --- /dev/null +++ b/lib/nola_plugins/gpt_plugin.ex @@ -0,0 +1,259 @@ +defmodule Nola.IRC.GptPlugin do + require Logger + import Irc.Plugin.TempRef + + def irc_doc() do + """ + # OpenAI GPT + + Uses OpenAI's GPT-3 API to bring natural language prompts to your IRC channel. + + _prompts_ are pre-defined prompts and parameters defined in the bot' CouchDB. + + _Runs_ (results of the inference of a _prompt_) are also stored in CouchDB and + may be resumed. + + * **!gpt** list GPT prompts + * **!gpt `[prompt]` ``** run a prompt + * **+gpt `[short ref|run id]` ``** continue a prompt + * **?gpt offensive ``** is content offensive ? + * **?gpt show `[short ref|run id]`** run information and web link + * **?gpt `[prompt]`** prompt information and web link + """ + end + + @couch_db "bot-plugin-openai-prompts" + @couch_run_db "bot-plugin-gpt-history" + @trigger "gpt" + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + defstruct [:temprefs] + + def get_result(id) do + Couch.get(@couch_run_db, id) + end + + def get_prompt(id) do + Couch.get(@couch_db, id) + end + + def init(_) do + regopts = [plugin: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{@trigger}", regopts) + {:ok, %__MODULE__{temprefs: new_temp_refs()}} + end + + def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [prompt | args]}}}, state) do + case Couch.get(@couch_db, prompt) do + {:ok, prompt} -> {:noreply, prompt(m, prompt, Enum.join(args, " "), state)} + {:error, :not_found} -> + m.replyfun.("gpt: prompt '#{prompt}' does not exists") + {:noreply, state} + error -> + Logger.info("gpt: prompt load error: #{inspect error}") + m.replyfun.("gpt: database error") + {:noreply, state} + end + end + + def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: []}}}, state) do + case Couch.get(@couch_db, "_all_docs") do + {:ok, %{"rows" => []}} -> m.replyfun.("gpt: no prompts available") + {:ok, %{"rows" => prompts}} -> + prompts = prompts |> Enum.map(fn(prompt) -> Map.get(prompt, "id") end) |> Enum.join(", ") + m.replyfun.("gpt: prompts: #{prompts}") + error -> + Logger.info("gpt: prompt load error: #{inspect error}") + m.replyfun.("gpt: database error") + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :plus, args: [ref_or_id | args]}}}, state) do + id = lookup_temp_ref(ref_or_id, state.temprefs, ref_or_id) + case Couch.get(@couch_run_db, id) do + {:ok, run} -> + Logger.debug("+gpt run: #{inspect run}") + {:noreply, continue_prompt(m, run, Enum.join(args, " "), state)} + {:error, :not_found} -> + m.replyfun.("gpt: ref or id not found or expired: #{inspect ref_or_id} (if using short ref, try using full id)") + {:noreply, state} + error -> + Logger.info("+gpt: run load error: #{inspect error}") + m.replyfun.("gpt: database error") + {:noreply, state} + end + end + + def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :query, args: ["offensive" | text]}}}, state) do + text = Enum.join(text, " ") + {moderate?, moderation} = moderation(text, m.account.id) + reply = cond do + moderate? -> "⚠️ #{Enum.join(moderation, ", ")}" + !moderate? && moderation -> "👍" + !moderate? -> "☠️ error" + end + m.replyfun.(reply) + {:noreply, state} + end + + def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :query, args: ["show", ref_or_id]}}}, state) do + id = lookup_temp_ref(ref_or_id, state.temprefs, ref_or_id) + url = if m.channel do + NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :result, m.network, NolaWeb.format_chan(m.channel), id) + else + NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :result, id) + end + m.replyfun.("→ #{url}") + {:noreply, state} + end + + def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :query, args: [prompt]}}}, state) do + url = if m.channel do + NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :prompt, m.network, NolaWeb.format_chan(m.channel), prompt) + else + NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :prompt, prompt) + end + m.replyfun.("→ #{url}") + {:noreply, state} + end + + def handle_info(info, state) do + Logger.debug("gpt: unhandled info: #{inspect info}") + {:noreply, state} + end + + defp continue_prompt(msg, run, content, state) do + prompt_id = Map.get(run, "prompt_id") + prompt_rev = Map.get(run, "prompt_rev") + + original_prompt = case Couch.get(@couch_db, prompt_id, rev: prompt_rev) do + {:ok, prompt} -> prompt + _ -> nil + end + + if original_prompt do + continue_prompt = %{"_id" => prompt_id, + "_rev" => prompt_rev, + "type" => Map.get(original_prompt, "type"), + "parent_run_id" => Map.get(run, "_id"), + "openai_params" => Map.get(run, "request") |> Map.delete("prompt")} + + continue_prompt = if prompt_string = Map.get(original_prompt, "continue_prompt") do + full_text = get_in(run, ~w(request prompt)) <> "\n" <> Map.get(run, "response") + continue_prompt + |> Map.put("prompt", prompt_string) + |> Map.put("prompt_format", "liquid") + |> Map.put("prompt_liquid_variables", %{"previous" => full_text}) + else + prompt_content_tag = if content != "", do: " {{content}}", else: "" + string = get_in(run, ~w(request prompt)) <> "\n" <> Map.get(run, "response") <> prompt_content_tag + continue_prompt + |> Map.put("prompt", string) + |> Map.put("prompt_format", "liquid") + end + + prompt(msg, continue_prompt, content, state) + else + msg.replyfun.("gpt: cannot continue this prompt: original prompt not found #{prompt_id}@v#{prompt_rev}") + state + end + end + + defp prompt(msg, prompt = %{"type" => "completions", "prompt" => prompt_template}, content, state) do + Logger.debug("gpt_plugin:prompt/4 #{inspect prompt}") + prompt_text = case Map.get(prompt, "prompt_format", "liquid") do + "liquid" -> Tmpl.render(prompt_template, msg, Map.merge(Map.get(prompt, "prompt_liquid_variables", %{}), %{"content" => content})) + "norender" -> prompt_template + end + + args = Map.get(prompt, "openai_params") + |> Map.put("prompt", prompt_text) + |> Map.put("user", msg.account.id) + + {moderate?, moderation} = moderation(content, msg.account.id) + if moderate?, do: msg.replyfun.("⚠️ offensive input: #{Enum.join(moderation, ", ")}") + + Logger.debug("GPT: request #{inspect args}") + case OpenAi.post("/v1/completions", args) do + {:ok, %{"choices" => [%{"text" => text, "finish_reason" => finish_reason} | _], "usage" => usage, "id" => gpt_id, "created" => created}} -> + text = String.trim(text) + {o_moderate?, o_moderation} = moderation(text, msg.account.id) + if o_moderate?, do: msg.replyfun.("🚨 offensive output: #{Enum.join(o_moderation, ", ")}") + msg.replyfun.(text) + doc = %{"id" => FlakeId.get(), + "prompt_id" => Map.get(prompt, "_id"), + "prompt_rev" => Map.get(prompt, "_rev"), + "network" => msg.network, + "channel" => msg.channel, + "nick" => msg.sender.nick, + "account_id" => (if msg.account, do: msg.account.id), + "request" => args, + "response" => text, + "message_at" => msg.at, + "reply_at" => DateTime.utc_now(), + "gpt_id" => gpt_id, + "gpt_at" => created, + "gpt_usage" => usage, + "type" => "completions", + "parent_run_id" => Map.get(prompt, "parent_run_id"), + "moderation" => %{"input" => %{flagged: moderate?, categories: moderation}, + "output" => %{flagged: o_moderate?, categories: o_moderation} + } + } + Logger.debug("Saving result to couch: #{inspect doc}") + {id, ref, temprefs} = case Couch.post(@couch_run_db, doc) do + {:ok, id, _rev} -> + {ref, temprefs} = put_temp_ref(id, state.temprefs) + {id, ref, temprefs} + error -> + Logger.error("Failed to save to Couch: #{inspect error}") + {nil, nil, state.temprefs} + end + stop = cond do + finish_reason == "stop" -> "" + finish_reason == "length" -> " — truncated" + true -> " — #{finish_reason}" + end + ref_and_prefix = if Map.get(usage, "completion_tokens", 0) == 0 do + "GPT had nothing else to say :( ↪ #{ref || "✗"}" + else + " ↪ #{ref || "✗"}" + end + msg.replyfun.(ref_and_prefix <> + stop <> + " — #{Map.get(usage, "total_tokens", 0)}" <> + " (#{Map.get(usage, "prompt_tokens", 0)}/#{Map.get(usage, "completion_tokens", 0)}) tokens" <> + " — #{id || "save failed"}") + %__MODULE__{state | temprefs: temprefs} + {:error, atom} when is_atom(atom) -> + Logger.error("gpt error: #{inspect atom}") + msg.replyfun.("gpt: ☠️ #{to_string(atom)}") + state + error -> + Logger.error("gpt error: #{inspect error}") + msg.replyfun.("gpt: ☠️ ") + state + end + end + + defp moderation(content, user_id) do + case OpenAi.post("/v1/moderations", %{"input" => content, "user" => user_id}) do + {:ok, %{"results" => [%{"flagged" => true, "categories" => categories} | _]}} -> + cat = categories + |> Enum.filter(fn({_key, value}) -> value end) + |> Enum.map(fn({key, _}) -> key end) + {true, cat} + {:ok, moderation} -> + Logger.debug("gpt: moderation: not flagged, #{inspect moderation}") + {false, true} + error -> + Logger.error("gpt: moderation error: #{inspect error}") + {false, false} + end + end + +end diff --git a/lib/nola_plugins/kick_roulette_plugin.ex b/lib/nola_plugins/kick_roulette_plugin.ex new file mode 100644 index 0000000..55b7da4 --- /dev/null +++ b/lib/nola_plugins/kick_roulette_plugin.ex @@ -0,0 +1,32 @@ +defmodule Nola.IRC.KickRoulettePlugin do + @moduledoc """ + # kick roulette + + * **!kick**, tentez votre chance… + """ + + def irc_doc, do: @moduledoc + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:kick", [plugin: __MODULE__]) + {:ok, nil} + end + + def handle_info({:irc, :trigger, "kick", message = %{trigger: %{type: :bang, args: []}}}, _) do + if 5 == :crypto.rand_uniform(1, 6) do + spawn(fn() -> + :timer.sleep(:crypto.rand_uniform(200, 10_000)) + message.replyfun.({:kick, message.sender.nick, "perdu"}) + end) + end + {:noreply, nil} + end + + def handle_info(msg, _) do + {:noreply, nil} + end + +end diff --git a/lib/nola_plugins/last_fm_plugin.ex b/lib/nola_plugins/last_fm_plugin.ex new file mode 100644 index 0000000..03df675 --- /dev/null +++ b/lib/nola_plugins/last_fm_plugin.ex @@ -0,0 +1,187 @@ +defmodule Nola.IRC.LastFmPlugin do + require Logger + + @moduledoc """ + # last.fm + + * **!lastfm|np `[nick|username]`** + * **.lastfm|np** + * **+lastfm, -lastfm `; ?lastfm`** Configurer un nom d'utilisateur last.fm + """ + + @single_trigger ~w(lastfm np) + @pubsub_topics ~w(trigger:lastfm trigger:np) + + defstruct dets: nil + + def irc_doc, do: @moduledoc + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init([]) do + regopts = [type: __MODULE__] + for t <- @pubsub_topics, do: {:ok, _} = Registry.register(IRC.PubSub, t, type: __MODULE__) + dets_filename = (Nola.data_path() <> "/" <> "lastfm.dets") |> String.to_charlist + {:ok, dets} = :dets.open_file(dets_filename, []) + {:ok, %__MODULE__{dets: dets}} + end + + def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :plus, args: [username]}}}, state) do + username = String.strip(username) + :ok = :dets.insert(state.dets, {message.account.id, username}) + message.replyfun.("#{message.sender.nick}: nom d'utilisateur last.fm configuré: \"#{username}\".") + {:noreply, state} + end + + def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :minus, args: []}}}, state) do + text = case :dets.lookup(state.dets, message.account.id) do + [{_nick, _username}] -> + :dets.delete(state.dets, message.account.id) + message.replyfun.("#{message.sender.nick}: nom d'utilisateur last.fm enlevé.") + _ -> nil + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :query, args: []}}}, state) do + text = case :dets.lookup(state.dets, message.account.id) do + [{_nick, username}] -> + message.replyfun.("#{message.sender.nick}: #{username}.") + _ -> nil + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :bang, args: []}}}, state) do + irc_now_playing(message.account.id, message, state) + {:noreply, state} + end + + def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :bang, args: [nick_or_user]}}}, state) do + irc_now_playing(nick_or_user, message, state) + {:noreply, state} + end + + def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :dot}}}, state) do + members = IRC.Membership.members(message.network, message.channel) + foldfun = fn({nick, user}, acc) -> [{nick,user}|acc] end + usernames = :dets.foldl(foldfun, [], state.dets) + |> Enum.uniq() + |> Enum.filter(fn({acct,_}) -> Enum.member?(members, acct) end) + |> Enum.map(fn({_, u}) -> u end) + for u <- usernames, do: irc_now_playing(u, message, state) + {:noreply, state} + end + + def handle_info(info, state) do + {:noreply, state} + end + + def terminate(_reason, state) do + if state.dets do + :dets.sync(state.dets) + :dets.close(state.dets) + end + :ok + end + + defp irc_now_playing(nick_or_user, message, state) do + nick_or_user = String.strip(nick_or_user) + + id_or_user = if account = IRC.Account.get(nick_or_user) || IRC.Account.find_always_by_nick(message.network, message.channel, nick_or_user) do + account.id + else + nick_or_user + end + + username = case :dets.lookup(state.dets, id_or_user) do + [{_, username}] -> username + _ -> id_or_user + end + + case now_playing(username) do + {:error, text} when is_binary(text) -> + message.replyfun.(text) + {:ok, map} when is_map(map) -> + track = fetch_track(username, map) + text = format_now_playing(map, track) + user = if account = IRC.Account.get(id_or_user) do + user = IRC.UserTrack.find_by_account(message.network, account) + if(user, do: user.nick, else: account.name) + else + username + end + if user && text do + message.replyfun.("#{user} #{text}") + else + message.replyfun.("#{username}: pas de résultat") + end + other -> + message.replyfun.("erreur :(") + end + end + + defp now_playing(user) do + api = Application.get_env(:nola, :lastfm)[:api_key] + url = "http://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&format=json&limit=1&extended=1" <> "&api_key=" <> api <> "&user="<> user + case HTTPoison.get(url) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> Jason.decode(body) + {:ok, %HTTPoison.Response{status_code: 404}} -> {:error, "last.fm: utilisateur \"#{user}\" inexistant"} + {:ok, %HTTPoison.Response{status_code: code}} -> {:error, "last.fm: erreur #{to_string(code)}"} + error -> + Logger.error "Lastfm http error: #{inspect error}" + :error + end + end + defp fetch_track(user, %{"recenttracks" => %{"track" => [ t = %{"name" => name, "artist" => %{"name" => artist}} | _]}}) do + api = Application.get_env(:nola, :lastfm)[:api_key] + url = "http://ws.audioscrobbler.com/2.0/?method=track.getInfo&format=json" <> "&api_key=" <> api <> "&username="<> user <> "&artist="<>URI.encode(artist)<>"&track="<>URI.encode(name) + case HTTPoison.get(url) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + case Jason.decode(body) do + {:ok, body} -> body["track"] || %{} + _ -> %{} + end + error -> + Logger.error "Lastfm http error: #{inspect error}" + :error + end + end + + defp format_now_playing(%{"recenttracks" => %{"track" => [track = %{"@attr" => %{"nowplaying" => "true"}} | _]}}, et) do + format_track(true, track, et) + end + + defp format_now_playing(%{"recenttracks" => %{"track" => [track | _]}}, et) do + format_track(false, track, et) + end + + defp format_now_playing(%{"error" => err, "message" => message}, _) do + "last.fm error #{err}: #{message}" + end + + defp format_now_playing(miss) do + nil + end + + defp format_track(np, track, extended) do + artist = track["artist"]["name"] + album = if track["album"]["#text"], do: " (" <> track["album"]["#text"] <> ")", else: "" + name = track["name"] <> album + action = if np, do: "écoute ", else: "a écouté" + love = if track["loved"] != "0", do: "❤️" + count = if x = extended["userplaycount"], do: "x#{x} #{love}" + tags = (get_in(extended, ["toptags", "tag"]) || []) + |> Enum.map(fn(tag) -> tag["name"] end) + |> Enum.filter(& &1) + |> Enum.join(", ") + + [action, artist, name, count, tags, track["url"]] + |> Enum.filter(& &1) + |> Enum.map(&String.trim(&1)) + |> Enum.join(" - ") + end + +end diff --git a/lib/nola_plugins/link_plugin.ex b/lib/nola_plugins/link_plugin.ex new file mode 100644 index 0000000..dee78e8 --- /dev/null +++ b/lib/nola_plugins/link_plugin.ex @@ -0,0 +1,271 @@ +defmodule Nola.IRC.LinkPlugin do + @moduledoc """ + # Link Previewer + + An extensible link previewer for IRC. + + To extend the supported sites, create a new handler implementing the callbacks. + + See `link_plugin/` directory for examples. The first in list handler that returns true to the `match/2` callback will be used, + and if the handler returns `:error` or crashes, will fallback to the default preview. + + Unsupported websites will use the default link preview method, which is for html document the title, otherwise it'll use + the mimetype and size. + + ## Configuration: + + ``` + config :nola, Nola.IRC.LinkPlugin, + handlers: [ + Nola.IRC.LinkPlugin.Youtube: [ + invidious: true + ], + Nola.IRC.LinkPlugin.Twitter: [], + Nola.IRC.LinkPlugin.Imgur: [], + ] + ``` + + """ + + @ircdoc """ + # Link preview + + Previews links (just post a link!). + + Announces real URL after redirections and provides extended support for YouTube, Twitter and Imgur. + """ + def short_irc_doc, do: false + def irc_doc, do: @ircdoc + require Logger + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + @callback match(uri :: URI.t, options :: Keyword.t) :: {true, params :: Map.t} | false + @callback expand(uri :: URI.t, params :: Map.t, options :: Keyword.t) :: {:ok, lines :: [] | String.t} | :error + @callback post_match(uri :: URI.t, content_type :: binary, headers :: [], opts :: Keyword.t) :: {:body | :file, params :: Map.t} | false + @callback post_expand(uri :: URI.t, body :: binary() | Path.t, params :: Map.t, options :: Keyword.t) :: {:ok, lines :: [] | String.t} | :error + + @optional_callbacks [expand: 3, post_expand: 4] + + defstruct [:client] + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "messages", [plugin: __MODULE__]) + #{:ok, _} = Registry.register(IRC.PubSub, "messages:telegram", [plugin: __MODULE__]) + Logger.info("Link handler started") + {:ok, %__MODULE__{}} + end + + def handle_info({:irc, :text, message = %{text: text}}, state) do + String.split(text) + |> Enum.map(fn(word) -> + if String.starts_with?(word, "http://") || String.starts_with?(word, "https://") do + uri = URI.parse(word) + if uri.scheme && uri.host do + spawn(fn() -> + :timer.kill_after(:timer.seconds(30)) + case expand_link([uri]) do + {:ok, uris, text} -> + text = case uris do + [uri] -> text + [luri | _] -> + if luri.host == uri.host && luri.path == luri.path do + text + else + ["-> #{URI.to_string(luri)}", text] + end + end + if is_list(text) do + for line <- text, do: message.replyfun.(line) + else + message.replyfun.(text) + end + _ -> nil + end + end) + end + end + end) + {:noreply, state} + end + + def handle_info(msg, state) do + {:noreply, state} + end + + def terminate(_reason, state) do + :ok + end + + # 1. Match the first valid handler + # 2. Try to run the handler + # 3. If :error or crash, default link. + # If :skip, nothing + # 4. ? + + # Over five redirections: cancel. + def expand_link(acc = [_, _, _, _, _ | _]) do + {:ok, acc, "link redirects more than five times"} + end + + def expand_link(acc=[uri | _]) do + Logger.debug("link: expanding: #{inspect uri}") + handlers = Keyword.get(Application.get_env(:nola, __MODULE__, [handlers: []]), :handlers) + handler = Enum.reduce_while(handlers, nil, fn({module, opts}, acc) -> + Logger.debug("link: attempt expanding: #{inspect module} for #{inspect uri}") + module = Module.concat([module]) + case module.match(uri, opts) do + {true, params} -> {:halt, {module, params, opts}} + false -> {:cont, acc} + end + end) + run_expand(acc, handler) + end + + def run_expand(acc, nil) do + expand_default(acc) + end + + def run_expand(acc=[uri|_], {module, params, opts}) do + Logger.debug("link: expanding #{inspect uri} with #{inspect module}") + case module.expand(uri, params, opts) do + {:ok, data} -> {:ok, acc, data} + :error -> expand_default(acc) + :skip -> nil + end + rescue + e -> + Logger.error("link: rescued #{inspect uri} with #{inspect module}: #{inspect e}") + Logger.error(Exception.format(:error, e, __STACKTRACE__)) + expand_default(acc) + catch + e, b -> + Logger.error("link: catched #{inspect uri} with #{inspect module}: #{inspect {e, b}}") + expand_default(acc) + end + + defp get(url, headers \\ [], options \\ []) do + get_req(url, :hackney.get(url, headers, <<>>, options)) + end + + defp get_req(_, {:error, reason}) do + {:error, reason} + end + + defp get_req(url, {:ok, 200, headers, client}) do + headers = Enum.reduce(headers, %{}, fn({key, value}, acc) -> + Map.put(acc, String.downcase(key), value) + end) + content_type = Map.get(headers, "content-type", "application/octect-stream") + length = Map.get(headers, "content-length", "0") + {length, _} = Integer.parse(length) + + handlers = Keyword.get(Application.get_env(:nola, __MODULE__, [handlers: []]), :handlers) + handler = Enum.reduce_while(handlers, false, fn({module, opts}, acc) -> + module = Module.concat([module]) + try do + case module.post_match(url, content_type, headers, opts) do + {mode, params} when mode in [:body, :file] -> {:halt, {module, params, opts, mode}} + false -> {:cont, acc} + end + rescue + e -> + Logger.error(inspect(e)) + {:cont, false} + catch + e, b -> + Logger.error(inspect({b})) + {:cont, false} + end + end) + + cond do + handler != false and length <= 30_000_000 -> + case get_body(url, 30_000_000, client, handler, <<>>) do + {:ok, _} = ok -> ok + :error -> + {:ok, "file: #{content_type}, size: #{human_size(length)}"} + end + #String.starts_with?(content_type, "text/html") && length <= 30_000_000 -> + # get_body(url, 30_000_000, client, <<>>) + true -> + :hackney.close(client) + {:ok, "file: #{content_type}, size: #{human_size(length)}"} + end + end + + defp get_req(_, {:ok, redirect, headers, client}) when redirect in 300..399 do + headers = Enum.reduce(headers, %{}, fn({key, value}, acc) -> + Map.put(acc, String.downcase(key), value) + end) + location = Map.get(headers, "location") + + :hackney.close(client) + {:redirect, location} + end + + defp get_req(_, {:ok, status, headers, client}) do + :hackney.close(client) + {:error, status, headers} + end + + defp get_body(url, len, client, {handler, params, opts, mode} = h, acc) when len >= byte_size(acc) do + case :hackney.stream_body(client) do + {:ok, data} -> + get_body(url, len, client, h, << acc::binary, data::binary >>) + :done -> + body = case mode do + :body -> acc + :file -> + {:ok, tmpfile} = Plug.Upload.random_file("linkplugin") + File.write!(tmpfile, acc) + tmpfile + end + handler.post_expand(url, body, params, opts) + {:error, reason} -> + {:ok, "failed to fetch body: #{inspect reason}"} + end + end + + defp get_body(_, len, client, h, _acc) do + :hackney.close(client) + IO.inspect(h) + {:ok, "Error: file over 30"} + end + + def expand_default(acc = [uri = %URI{scheme: scheme} | _]) when scheme in ["http", "https"] do + Logger.debug("link: expanding #{uri} with default") + headers = [{"user-agent", "DmzBot (like TwitterBot)"}] + options = [follow_redirect: false, max_body_length: 30_000_000] + case get(URI.to_string(uri), headers, options) do + {:ok, text} -> + {:ok, acc, text} + {:redirect, link} -> + new_uri = URI.parse(link) + #new_uri = %URI{new_uri | scheme: scheme, authority: uri.authority, host: uri.host, port: uri.port} + expand_link([new_uri | acc]) + {:error, status, _headers} -> + text = Plug.Conn.Status.reason_phrase(status) + {:ok, acc, "Error: HTTP #{text} (#{status})"} + {:error, {:tls_alert, {:handshake_failure, err}}} -> + {:ok, acc, "TLS Error: #{to_string(err)}"} + {:error, reason} -> + {:ok, acc, "Error: #{to_string(reason)}"} + end + end + + # Unsupported scheme, came from a redirect. + def expand_default(acc = [uri | _]) do + {:ok, [uri], "-> #{URI.to_string(uri)}"} + end + + + defp human_size(bytes) do + bytes + |> FileSize.new(:b) + |> FileSize.scale() + |> FileSize.format() + end +end diff --git a/lib/nola_plugins/link_plugin/github.ex b/lib/nola_plugins/link_plugin/github.ex new file mode 100644 index 0000000..93e0892 --- /dev/null +++ b/lib/nola_plugins/link_plugin/github.ex @@ -0,0 +1,49 @@ +defmodule Nola.IRC.LinkPlugin.Github do + @behaviour Nola.IRC.LinkPlugin + + @impl true + def match(uri = %URI{host: "github.com", path: path}, _) do + case String.split(path, "/") do + ["", user, repo] -> + {true, %{user: user, repo: repo, path: "#{user}/#{repo}"}} + _ -> + false + end + end + + def match(_, _), do: false + + @impl true + def post_match(_, _, _, _), do: false + + @impl true + def expand(_uri, %{user: user, repo: repo}, _opts) do + case HTTPoison.get("https://api.github.com/repos/#{user}/#{repo}") do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + {:ok, json} = Jason.decode(body) + src = json["source"]["full_name"] + disabled = if(json["disabled"], do: " (disabled)", else: "") + archived = if(json["archived"], do: " (archived)", else: "") + fork = if src && src != json["full_name"] do + " (⑂ #{json["source"]["full_name"]})" + else + "" + end + start = "#{json["full_name"]}#{disabled}#{archived}#{fork} - #{json["description"]}" + tags = for(t <- json["topics"]||[], do: "##{t}") |> Enum.intersperse(", ") |> Enum.join("") + lang = if(json["language"], do: "#{json["language"]} - ", else: "") + issues = if(json["open_issues_count"], do: "#{json["open_issues_count"]} issues - ", else: "") + last_push = if at = json["pushed_at"] do + {:ok, date, _} = DateTime.from_iso8601(at) + " - last pushed #{DateTime.to_string(date)}" + else + "" + end + network = "#{lang}#{issues}#{json["stargazers_count"]} stars - #{json["subscribers_count"]} watchers - #{json["forks_count"]} forks#{last_push}" + {:ok, [start, tags, network]} + other -> + :error + end + end + +end diff --git a/lib/nola_plugins/link_plugin/html.ex b/lib/nola_plugins/link_plugin/html.ex new file mode 100644 index 0000000..56a8ceb --- /dev/null +++ b/lib/nola_plugins/link_plugin/html.ex @@ -0,0 +1,106 @@ +defmodule Nola.IRC.LinkPlugin.HTML do + @behaviour Nola.IRC.LinkPlugin + + @impl true + def match(_, _), do: false + + @impl true + def post_match(_url, "text/html"<>_, _header, _opts) do + {:body, nil} + end + def post_match(_, _, _, _), do: false + + @impl true + def post_expand(url, body, _params, _opts) do + html = Floki.parse(body) + title = collect_title(html) + opengraph = collect_open_graph(html) + itemprops = collect_itemprops(html) + text = if Map.has_key?(opengraph, "title") && Map.has_key?(opengraph, "description") do + sitename = if sn = Map.get(opengraph, "site_name") do + "#{sn}" + else + "" + end + paywall? = if Map.get(opengraph, "article:content_tier", Map.get(itemprops, "article:content_tier", "free")) == "free" do + "" + else + "[paywall] " + end + section = if section = Map.get(opengraph, "article:section", Map.get(itemprops, "article:section", nil)) do + ": #{section}" + else + "" + end + date = case DateTime.from_iso8601(Map.get(opengraph, "article:published_time", Map.get(itemprops, "article:published_time", ""))) do + {:ok, date, _} -> + "#{Timex.format!(date, "%d/%m/%y", :strftime)}. " + _ -> + "" + end + uri = URI.parse(url) + + prefix = "#{paywall?}#{Map.get(opengraph, "site_name", uri.host)}#{section}" + prefix = unless prefix == "" do + "#{prefix} — " + else + "" + end + [clean_text("#{prefix}#{Map.get(opengraph, "title")}")] ++ IRC.splitlong(clean_text("#{date}#{Map.get(opengraph, "description")}")) + else + clean_text(title) + end + {:ok, text} + end + + defp collect_title(html) do + case Floki.find(html, "title") do + [{"title", [], [title]} | _] -> + String.trim(title) + _ -> + nil + end + end + + defp collect_open_graph(html) do + Enum.reduce(Floki.find(html, "head meta"), %{}, fn(tag, acc) -> + case tag do + {"meta", values, []} -> + name = List.keyfind(values, "property", 0, {nil, nil}) |> elem(1) + content = List.keyfind(values, "content", 0, {nil, nil}) |> elem(1) + case name do + "og:" <> key -> + Map.put(acc, key, content) + "article:"<>_ -> + Map.put(acc, name, content) + _other -> acc + end + _other -> acc + end + end) + end + + defp collect_itemprops(html) do + Enum.reduce(Floki.find(html, "[itemprop]"), %{}, fn(tag, acc) -> + case tag do + {"meta", values, []} -> + name = List.keyfind(values, "itemprop", 0, {nil, nil}) |> elem(1) + content = List.keyfind(values, "content", 0, {nil, nil}) |> elem(1) + case name do + "article:" <> key -> + Map.put(acc, name, content) + _other -> acc + end + _other -> acc + end + end) + end + + defp clean_text(text) do + text + |> String.replace("\n", " ") + |> HtmlEntities.decode() + end + + +end diff --git a/lib/nola_plugins/link_plugin/imgur.ex b/lib/nola_plugins/link_plugin/imgur.ex new file mode 100644 index 0000000..5d74956 --- /dev/null +++ b/lib/nola_plugins/link_plugin/imgur.ex @@ -0,0 +1,96 @@ +defmodule Nola.IRC.LinkPlugin.Imgur do + @behaviour Nola.IRC.LinkPlugin + + @moduledoc """ + # Imgur link preview + + No options. + + Needs to have a Imgur API key configured: + + ``` + config :nola, :imgur, + client_id: "xxxxxxxx", + client_secret: "xxxxxxxxxxxxxxxxxxxx" + ``` + """ + + @impl true + def match(uri = %URI{host: "imgur.io"}, arg) do + match(%URI{uri | host: "imgur.com"}, arg) + end + def match(uri = %URI{host: "i.imgur.io"}, arg) do + match(%URI{uri | host: "i.imgur.com"}, arg) + end + def match(uri = %URI{host: "imgur.com", path: "/a/"<>album_id}, _) do + {true, %{album_id: album_id}} + end + def match(uri = %URI{host: "imgur.com", path: "/gallery/"<>album_id}, _) do + {true, %{album_id: album_id}} + end + def match(uri = %URI{host: "i.imgur.com", path: "/"<>image}, _) do + [hash, _] = String.split(image, ".", parts: 2) + {true, %{image_id: hash}} + end + def match(_, _), do: false + + @impl true + def post_match(_, _, _, _), do: false + + def expand(_uri, %{album_id: album_id}, opts) do + expand_imgur_album(album_id, opts) + end + + def expand(_uri, %{image_id: image_id}, opts) do + expand_imgur_image(image_id, opts) + end + + def expand_imgur_image(image_id, opts) do + client_id = Keyword.get(Application.get_env(:nola, :imgur, []), :client_id, "42") + headers = [{"Authorization", "Client-ID #{client_id}"}] + options = [] + case HTTPoison.get("https://api.imgur.com/3/image/#{image_id}", headers, options) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + {:ok, json} = Jason.decode(body) + data = json["data"] + title = String.slice(data["title"] || data["description"], 0, 180) + nsfw = if data["nsfw"], do: "(NSFW) - ", else: " " + height = Map.get(data, "height") + width = Map.get(data, "width") + size = Map.get(data, "size") + {:ok, "image, #{width}x#{height}, #{size} bytes #{nsfw}#{title}"} + other -> + :error + end + end + + def expand_imgur_album(album_id, opts) do + client_id = Keyword.get(Application.get_env(:nola, :imgur, []), :client_id, "42") + headers = [{"Authorization", "Client-ID #{client_id}"}] + options = [] + case HTTPoison.get("https://api.imgur.com/3/album/#{album_id}", headers, options) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + {:ok, json} = Jason.decode(body) + data = json["data"] + title = data["title"] + nsfw = data["nsfw"] + nsfw = if nsfw, do: "(NSFW) - ", else: "" + if data["images_count"] == 1 do + [image] = data["images"] + title = if title || data["title"] do + title = [title, data["title"]] |> Enum.filter(fn(x) -> x end) |> Enum.uniq() |> Enum.join(" — ") + "#{title} — " + else + "" + end + {:ok, "#{nsfw}#{title}#{image["link"]}"} + else + title = if title, do: title, else: "Untitled album" + {:ok, "#{nsfw}#{title} - #{data["images_count"]} images"} + end + other -> + :error + end + end + +end diff --git a/lib/nola_plugins/link_plugin/pdf.ex b/lib/nola_plugins/link_plugin/pdf.ex new file mode 100644 index 0000000..5f72ef5 --- /dev/null +++ b/lib/nola_plugins/link_plugin/pdf.ex @@ -0,0 +1,39 @@ +defmodule Nola.IRC.LinkPlugin.PDF do + require Logger + @behaviour Nola.IRC.LinkPlugin + + @impl true + def match(_, _), do: false + + @impl true + def post_match(_url, "application/pdf"<>_, _header, _opts) do + {:file, nil} + end + + def post_match(_, _, _, _), do: false + + @impl true + def post_expand(url, file, _, _) do + case System.cmd("pdftitle", ["-p", file]) do + {text, 0} -> + text = text + |> String.trim() + + if text == "" do + :error + else + basename = Path.basename(url, ".pdf") + text = "[#{basename}] " <> text + |> String.split("\n") + {:ok, text} + end + {_, 127} -> + Logger.error("dependency `pdftitle` is missing, please install it: `pip3 install pdftitle`.") + :error + {error, code} -> + Logger.warn("command `pdftitle` exited with status code #{code}:\n#{inspect error}") + :error + end + end + +end diff --git a/lib/nola_plugins/link_plugin/redacted.ex b/lib/nola_plugins/link_plugin/redacted.ex new file mode 100644 index 0000000..7a6229d --- /dev/null +++ b/lib/nola_plugins/link_plugin/redacted.ex @@ -0,0 +1,18 @@ +defmodule Nola.IRC.LinkPlugin.Redacted do + @behaviour Nola.IRC.LinkPlugin + + @impl true + def match(uri = %URI{host: "redacted.ch", path: "/torrent.php", query: query = "id="<>id}, _opts) do + %{"id" => id} = URI.decode_query(id) + {true, %{torrent: id}} + end + + def match(_, _), do: false + + @impl true + def post_match(_, _, _, _), do: false + + def expand(_uri, %{torrent: id}, _opts) do + end + +end diff --git a/lib/nola_plugins/link_plugin/reddit.ex b/lib/nola_plugins/link_plugin/reddit.ex new file mode 100644 index 0000000..79102e0 --- /dev/null +++ b/lib/nola_plugins/link_plugin/reddit.ex @@ -0,0 +1,119 @@ +defmodule Nola.IRC.LinkPlugin.Reddit do + @behaviour Nola.IRC.LinkPlugin + + @impl true + def match(uri = %URI{host: "reddit.com", path: path}, _) do + case String.split(path, "/") do + ["", "r", sub, "comments", post_id, _slug] -> + {true, %{mode: :post, path: path, sub: sub, post_id: post_id}} + ["", "r", sub, "comments", post_id, _slug, ""] -> + {true, %{mode: :post, path: path, sub: sub, post_id: post_id}} + ["", "r", sub, ""] -> + {true, %{mode: :sub, path: path, sub: sub}} + ["", "r", sub] -> + {true, %{mode: :sub, path: path, sub: sub}} +# ["", "u", user] -> +# {true, %{mode: :user, path: path, user: user}} + _ -> + false + end + end + + def match(uri = %URI{host: host, path: path}, opts) do + if String.ends_with?(host, ".reddit.com") do + match(%URI{uri | host: "reddit.com"}, opts) + else + false + end + end + + @impl true + def post_match(_, _, _, _), do: false + + @impl true + def expand(_, %{mode: :sub, sub: sub}, _opts) do + url = "https://api.reddit.com/r/#{sub}/about" + case HTTPoison.get(url) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + sr = Jason.decode!(body) + |> Map.get("data") + |> IO.inspect(limit: :infinity) + description = Map.get(sr, "public_description")||Map.get(sr, "description", "") + |> String.split("\n") + |> List.first() + name = if title = Map.get(sr, "title") do + Map.get(sr, "display_name_prefixed") <> ": " <> title + else + Map.get(sr, "display_name_prefixed") + end + nsfw = if Map.get(sr, "over18") do + "[NSFW] " + else + "" + end + quarantine = if Map.get(sr, "quarantine") do + "[Quarantined] " + else + "" + end + count = "#{Map.get(sr, "subscribers")} subscribers, #{Map.get(sr, "active_user_count")} active" + preview = "#{quarantine}#{nsfw}#{name} — #{description} (#{count})" + {:ok, preview} + _ -> + :error + end + end + + def expand(_uri, %{mode: :post, path: path, sub: sub, post_id: post_id}, _opts) do + case HTTPoison.get("https://api.reddit.com#{path}?sr_detail=true") do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + json = Jason.decode!(body) + op = List.first(json) + |> Map.get("data") + |> Map.get("children") + |> List.first() + |> Map.get("data") + |> IO.inspect(limit: :infinity) + sr = get_in(op, ["sr_detail", "display_name_prefixed"]) + {self?, url} = if Map.get(op, "selftext") == "" do + {false, Map.get(op, "url")} + else + {true, nil} + end + + self_str = if(self?, do: "text", else: url) + up = Map.get(op, "ups") + down = Map.get(op, "downs") + comments = Map.get(op, "num_comments") + nsfw = if Map.get(op, "over_18") do + "[NSFW] " + else + "" + end + state = cond do + Map.get(op, "hidden") -> "hidden" + Map.get(op, "archived") -> "archived" + Map.get(op, "locked") -> "locked" + Map.get(op, "quarantine") -> "quarantined" + Map.get(op, "removed_by") || Map.get(op, "removed_by_category") -> "removed" + Map.get(op, "banned_by") -> "banned" + Map.get(op, "pinned") -> "pinned" + Map.get(op, "stickied") -> "stickied" + true -> nil + end + flair = if flair = Map.get(op, "link_flair_text") do + "[#{flair}] " + else + "" + end + title = "#{nsfw}#{sr}: #{flair}#{Map.get(op, "title")}" + state_str = if(state, do: "#{state}, ") + content = "by u/#{Map.get(op, "author")} - #{state_str}#{up} up, #{down} down, #{comments} comments - #{self_str}" + + {:ok, [title, content]} + err -> + :error + end + end + +end diff --git a/lib/nola_plugins/link_plugin/twitter.ex b/lib/nola_plugins/link_plugin/twitter.ex new file mode 100644 index 0000000..640b193 --- /dev/null +++ b/lib/nola_plugins/link_plugin/twitter.ex @@ -0,0 +1,158 @@ +defmodule Nola.IRC.LinkPlugin.Twitter do + @behaviour Nola.IRC.LinkPlugin + + @moduledoc """ + # Twitter Link Preview + + Configuration: + + needs an API key and auth tokens: + + ``` + config :extwitter, :oauth, [ + consumer_key: "zzzzz", + consumer_secret: "xxxxxxx", + access_token: "yyyyyy", + access_token_secret: "ssshhhhhh" + ] + ``` + + options: + + * `expand_quoted`: Add the quoted tweet instead of its URL. Default: true. + """ + + def match(uri = %URI{host: twitter, path: path}, _opts) when twitter in ["twitter.com", "m.twitter.com", "mobile.twitter.com"] do + case String.split(path, "/", parts: 4) do + ["", _username, "status", status_id] -> + {status_id, _} = Integer.parse(status_id) + {true, %{status_id: status_id}} + _ -> false + end + end + + def match(_, _), do: false + + @impl true + def post_match(_, _, _, _), do: false + + def expand(_uri, %{status_id: status_id}, opts) do + expand_tweet(ExTwitter.show(status_id, tweet_mode: "extended"), opts) + end + + defp expand_tweet(nil, _opts) do + :error + end + + defp link_tweet(tweet_or_screen_id_tuple, opts, force_twitter_com \\ false) + + defp link_tweet({screen_name, id}, opts, force_twitter_com) do + path = "/#{screen_name}/status/#{id}" + nitter = Keyword.get(opts, :nitter) + host = if !force_twitter_com && nitter, do: nitter, else: "twitter.com" + "https://#{host}/#{screen_name}/status/#{id}" + end + + defp link_tweet(tweet, opts, force_twitter_com) do + link_tweet({tweet.user.screen_name, tweet.id}, opts, force_twitter_com) + end + + defp expand_tweet(tweet, opts) do + head = format_tweet_header(tweet, opts) + + # Format tweet text + text = expand_twitter_text(tweet, opts) + text = if tweet.quoted_status do + quote_url = link_tweet(tweet.quoted_status, opts, true) + String.replace(text, quote_url, "") + else + text + end + text = IRC.splitlong(text) + + reply_to = if tweet.in_reply_to_status_id do + reply_url = link_tweet({tweet.in_reply_to_screen_name, tweet.in_reply_to_status_id}, opts) + text = if tweet.in_reply_to_screen_name == tweet.user.screen_name, do: "continued from", else: "replying to" + <<3, 15, " ↪ ", text::binary, " ", reply_url::binary, 3>> + end + + quoted = if tweet.quoted_status do + full_text = tweet.quoted_status + |> expand_twitter_text(opts) + |> IRC.splitlong_with_prefix(">") + + head = format_tweet_header(tweet.quoted_status, opts, details: false, prefix: "↓ quoting") + + [head | full_text] + else + [] + end + + #<<2, "#{tweet.user.name} (@#{tweet.user.screen_name})", 2, " ", 3, 61, "#{foot} #{nitter_link}", 3>>, reply_to] ++ text ++ quoted + + text = [head, reply_to | text] ++ quoted + |> Enum.filter(& &1) + {:ok, text} + end + + defp expand_twitter_text(tweet, _opts) do + text = Enum.reduce(tweet.entities.urls, tweet.full_text, fn(entity, text) -> + String.replace(text, entity.url, entity.expanded_url) + end) + extended = tweet.extended_entities || %{media: []} + text = Enum.reduce(extended.media, text, fn(entity, text) -> + url = Enum.filter(extended.media, fn(e) -> entity.url == e.url end) + |> Enum.map(fn(e) -> + cond do + e.type == "video" -> e.expanded_url + true -> e.media_url_https + end + end) + |> Enum.join(" ") + String.replace(text, entity.url, url) + end) + |> HtmlEntities.decode() + end + + defp format_tweet_header(tweet, opts, format_opts \\ []) do + prefix = Keyword.get(format_opts, :prefix, nil) + details = Keyword.get(format_opts, :details, true) + + padded_prefix = if prefix, do: "#{prefix} ", else: "" + author = <> + + link = link_tweet(tweet, opts) + + {:ok, at} = Timex.parse(tweet.created_at, "%a %b %e %H:%M:%S %z %Y", :strftime) + {:ok, formatted_time} = Timex.format(at, "{relative}", :relative) + + nsfw = if tweet.possibly_sensitive, do: <<3, 52, "NSFW", 3>> + + rts = if tweet.retweet_count && tweet.retweet_count > 0, do: "#{tweet.retweet_count} RT" + likes = if tweet.favorite_count && tweet.favorite_count > 0, do: "#{tweet.favorite_count} ❤︎" + qrts = if tweet.quote_count && tweet.quote_count > 0, do: "#{tweet.quote_count} QRT" + replies = if tweet.reply_count && tweet.reply_count > 0, do: "#{tweet.reply_count} Reps" + + dmcad = if tweet.withheld_copyright, do: <<3, 52, "DMCA", 3>> + withheld_local = if tweet.withheld_in_countries && length(tweet.withheld_in_countries) > 0 do + "Withheld in #{length(tweet.withheld_in_countries)} countries" + end + + verified = if tweet.user.verified, do: <<3, 51, "✔", 3>> + + meta = if details do + [verified, nsfw, formatted_time, dmcad, withheld_local, rts, qrts, likes, replies] + else + [verified, nsfw, formatted_time, dmcad, withheld_local] + end + + meta = meta + |> Enum.filter(& &1) + |> Enum.join(" - ") + + meta = <<3, 15, meta::binary, " → #{link}", 3>> + + <> + end + +end diff --git a/lib/nola_plugins/link_plugin/youtube.ex b/lib/nola_plugins/link_plugin/youtube.ex new file mode 100644 index 0000000..f7c7541 --- /dev/null +++ b/lib/nola_plugins/link_plugin/youtube.ex @@ -0,0 +1,72 @@ +defmodule Nola.IRC.LinkPlugin.YouTube do + @behaviour Nola.IRC.LinkPlugin + + @moduledoc """ + # YouTube link preview + + needs an API key: + + ``` + config :nola, :youtube, + api_key: "xxxxxxxxxxxxx" + ``` + + options: + + * `invidious`: Add a link to invidious. + """ + + @impl true + def match(uri = %URI{host: yt, path: "/watch", query: "v="<>video_id}, _opts) when yt in ["youtube.com", "www.youtube.com"] do + {true, %{video_id: video_id}} + end + + def match(%URI{host: "youtu.be", path: "/"<>video_id}, _opts) do + {true, %{video_id: video_id}} + end + + def match(_, _), do: false + + @impl true + def post_match(_, _, _, _), do: false + + @impl true + def expand(uri, %{video_id: video_id}, opts) do + key = Application.get_env(:nola, :youtube)[:api_key] + params = %{ + "part" => "snippet,contentDetails,statistics", + "id" => video_id, + "key" => key + } + headers = [] + options = [params: params] + case HTTPoison.get("https://www.googleapis.com/youtube/v3/videos", [], options) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + case Jason.decode(body) do + {:ok, json} -> + item = List.first(json["items"]) + if item do + snippet = item["snippet"] + duration = item["contentDetails"]["duration"] |> String.replace("PT", "") |> String.downcase + date = snippet["publishedAt"] + |> DateTime.from_iso8601() + |> elem(1) + |> Timex.format("{relative}", :relative) + |> elem(1) + + line = if host = Keyword.get(opts, :invidious) do + ["-> https://#{host}/watch?v=#{video_id}"] + else + [] + end + {:ok, line ++ ["#{snippet["title"]}", "— #{duration} — uploaded by #{snippet["channelTitle"]} — #{date}" + <> " — #{item["statistics"]["viewCount"]} views, #{item["statistics"]["likeCount"]} likes"]} + else + :error + end + _ -> :error + end + end + end + +end diff --git a/lib/nola_plugins/logger_plugin.ex b/lib/nola_plugins/logger_plugin.ex new file mode 100644 index 0000000..b13f33a --- /dev/null +++ b/lib/nola_plugins/logger_plugin.ex @@ -0,0 +1,70 @@ +defmodule Nola.IRC.LoggerPlugin do + require Logger + + @couch_db "bot-logs" + + def irc_doc(), do: nil + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init([]) do + regopts = [plugin: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "triggers", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "irc:outputs", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "messages:private", regopts) + {:ok, nil} + end + + def handle_info({:irc, :trigger, _, m}, state) do + {:noreply, log(m, state)} + end + + def handle_info({:irc, :text, m}, state) do + {:noreply, log(m, state)} + end + + def handle_info(info, state) do + Logger.debug("logger_plugin: unhandled info: #{info}") + {:noreply, state} + end + + def log(entry, state) do + case Couch.post(@couch_db, format_to_db(entry)) do + {:ok, id, _rev} -> + Logger.debug("logger_plugin: saved: #{inspect id}") + state + error -> + Logger.error("logger_plugin: save failed: #{inspect error}") + end + rescue + e -> + Logger.error("logger_plugin: rescued processing for #{inspect entry}: #{inspect e}") + Logger.error(Exception.format(:error, e, __STACKTRACE__)) + state + catch + e, b -> + Logger.error("logger_plugin: catched processing for #{inspect entry}: #{inspect e}") + Logger.error(Exception.format(e, b, __STACKTRACE__)) + state + end + + def format_to_db(msg = %IRC.Message{id: id}) do + msg + |> Poison.encode!() + |> Map.drop("id") + + %{"_id" => id || FlakeId.get(), + "type" => "irc.message/v1", + "object" => msg} + end + + def format_to_db(anything) do + %{"_id" => FlakeId.get(), + "type" => "object", + "object" => anything} + end + +end diff --git a/lib/nola_plugins/outline_plugin.ex b/lib/nola_plugins/outline_plugin.ex new file mode 100644 index 0000000..820500e --- /dev/null +++ b/lib/nola_plugins/outline_plugin.ex @@ -0,0 +1,108 @@ +defmodule Nola.IRC.OutlinePlugin do + @moduledoc """ + # outline auto-link + + Envoie un lien vers Outline quand un lien est envoyé. + + * **!outline ``** crée un lien outline pour ``. + * **+outline ``** active outline pour ``. + * **-outline ``** désactive outline pour ``. + """ + def short_irc_doc, do: false + def irc_doc, do: @moduledoc + require Logger + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + defstruct [:file, :hosts] + + def init([]) do + regopts = [plugin: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "trigger:outline", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) + file = Path.join(Nola.data_path, "/outline.txt") + hosts = case File.read(file) do + {:error, :enoent} -> + [] + {:ok, lines} -> + String.split(lines, "\n", trim: true) + end + {:ok, %__MODULE__{file: file, hosts: hosts}} + end + + def handle_info({:irc, :trigger, "outline", message = %IRC.Message{trigger: %IRC.Trigger{type: :plus, args: [host]}}}, state) do + state = %{state | hosts: [host | state.hosts]} + save(state) + message.replyfun.("ok") + {:noreply, state} + end + + def handle_info({:irc, :trigger, "outline", message = %IRC.Message{trigger: %IRC.Trigger{type: :minus, args: [host]}}}, state) do + state = %{state | hosts: List.delete(state.hosts, host)} + save(state) + message.replyfun.("ok") + {:noreply, state} + end + + def handle_info({:irc, :trigger, "outline", message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [url]}}}, state) do + line = "-> #{outline(url)}" + message.replyfun.(line) + end + + def handle_info({:irc, :text, message = %IRC.Message{text: text}}, state) do + String.split(text) + |> Enum.map(fn(word) -> + if String.starts_with?(word, "http://") || String.starts_with?(word, "https://") do + uri = URI.parse(word) + if uri.scheme && uri.host do + if Enum.any?(state.hosts, fn(host) -> String.ends_with?(uri.host, host) end) do + outline_url = outline(word) + line = "-> #{outline_url}" + message.replyfun.(line) + end + end + end + end) + {:noreply, state} + end + + def handle_info(msg, state) do + {:noreply, state} + end + + def save(state = %{file: file, hosts: hosts}) do + string = Enum.join(hosts, "\n") + File.write(file, string) + end + + def outline(url) do + unexpanded = "https://outline.com/#{url}" + headers = [ + {"User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:77.0) Gecko/20100101 Firefox/77.0"}, + {"Accept", "*/*"}, + {"Accept-Language", "en-US,en;q=0.5"}, + {"Origin", "https://outline.com"}, + {"DNT", "1"}, + {"Referer", unexpanded}, + {"Pragma", "no-cache"}, + {"Cache-Control", "no-cache"} + ] + params = %{"source_url" => url} + case HTTPoison.get("https://api.outline.com/v3/parse_article", headers, params: params) do + {:ok, %HTTPoison.Response{status_code: 200, body: json}} -> + body = Poison.decode!(json) + if Map.get(body, "success") do + code = get_in(body, ["data", "short_code"]) + "https://outline.com/#{code}" + else + unexpanded + end + error -> + Logger.info("outline.com error: #{inspect error}") + unexpanded + end + end + +end diff --git a/lib/nola_plugins/preums_plugin.ex b/lib/nola_plugins/preums_plugin.ex new file mode 100644 index 0000000..f250e85 --- /dev/null +++ b/lib/nola_plugins/preums_plugin.ex @@ -0,0 +1,276 @@ +defmodule Nola.IRC.PreumsPlugin do + @moduledoc """ + # preums !!! + + * `!preums`: affiche le preums du jour + * `.preums`: stats des preums + """ + + # WIP Scores + # L'idée c'est de donner un score pour mettre un peu de challenge en pénalisant les preums faciles. + # + # Un preums ne vaut pas 1 point, mais plutôt 0.10 ou 0.05, et on arrondi au plus proche. C'est un jeu sur le long + # terme. Un gros bonus pourrait apporter beaucoup de points. + # + # Il faudrait ces données: + # - moyenne des preums + # - activité récente du channel et par nb actifs d'utilisateurs + # (aggréger memberships+usertrack last_active ?) + # (faire des stats d'activité habituelle (un peu a la pisg) ?) + # - preums consécutifs + # + # Malus: + # - est proche de la moyenne en faible activité + # - trop consécutif de l'utilisateur sauf si activité + # + # Bonus: + # - plus le preums est éloigné de la moyenne + # - après 18h double + # - plus l'activité est élévée, exponentiel selon la moyenne + # - derns entre 4 et 6 (pourrait être adapté selon les stats d'activité) + # + # WIP Badges: + # - derns + # - streaks + # - faciles + # - ? + + require Logger + + @perfects [~r/preum(s|)/i] + + # dets {{chan, day = {yyyy, mm, dd}}, nick, now, perfect?, text} + + def all(dets) do + :dets.foldl(fn(i, acc) -> [i|acc] end, [], dets) + end + + def all(dets, channel) do + fun = fn({{chan, date}, account_id, time, perfect, text}, acc) -> + if channel == chan do + [%{date: date, account_id: account_id, time: time, perfect: perfect, text: text} | acc] + else + acc + end + end + :dets.foldl(fun, [], dets) + end + + def topnicks(dets, channel, options \\ []) do + sort_elem = case Keyword.get(options, :sort_by, :score) do + :score -> 1 + :count -> 0 + end + + fun = fn(x = {{chan, date}, account_id, time, perfect, text}, acc) -> + if (channel == nil and chan) or (channel == chan) do + {count, points} = Map.get(acc, account_id, {0, 0}) + score = score(chan, account_id, time, perfect, text) + Map.put(acc, account_id, {count + 1, points + score}) + else + acc + end + end + :dets.foldl(fun, %{}, dets) + |> Enum.sort_by(fn({_account_id, value}) -> elem(value, sort_elem) end, &>=/2) + end + + def irc_doc, do: @moduledoc + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def dets do + (Nola.data_path() <> "/preums.dets") |> String.to_charlist() + end + + def init([]) do + regopts = [plugin: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "account", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "triggers", regopts) + {:ok, dets} = :dets.open_file(dets(), [{:repair, :force}]) + Util.ets_mutate_select_each(:dets, dets, [{:"$1", [], [:"$1"]}], fn(table, obj) -> + {key, nick, now, perfect, text} = obj + case key do + {{net, {bork,chan}}, date} -> + :dets.delete(table, key) + nick = if IRC.Account.get(nick) do + nick + else + if acct = IRC.Account.find_always_by_nick(net, nil, nick) do + acct.id + else + nick + end + end + :dets.insert(table, { { {net,chan}, date }, nick, now, perfect, text}) + {{_net, nil}, _} -> + :dets.delete(table, key) + {{net, chan}, date} -> + if !IRC.Account.get(nick) do + if acct = IRC.Account.find_always_by_nick(net, chan, nick) do + :dets.delete(table, key) + :dets.insert(table, { { {net,chan}, date }, acct.id, now, perfect, text}) + end + end + _ -> + Logger.debug("DID NOT FIX: #{inspect key}") + end + end) + {:ok, %{dets: dets}} + end + + # Latest + def handle_info({:irc, :trigger, "preums", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang}}}, state) do + channelkey = {m.network, m.channel} + state = handle_preums(m, state) + tz = timezone(channelkey) + {:ok, now} = DateTime.now(tz, Tzdata.TimeZoneDatabase) + date = {now.year, now.month, now.day} + key = {channelkey, date} + chan_cache = Map.get(state, channelkey, %{}) + item = if i = Map.get(chan_cache, date) do + i + else + case :dets.lookup(state.dets, key) do + [item = {^key, _account_id, _now, _perfect, _text}] -> item + _ -> nil + end + end + + if item do + {_, account_id, date, _perfect, text} = item + h = "#{date.hour}:#{date.minute}:#{date.second}" + account = IRC.Account.get(account_id) + user = IRC.UserTrack.find_by_account(m.network, account) + nick = if(user, do: user.nick, else: account.name) + m.replyfun.("preums: #{nick} à #{h}: “#{text}”") + end + {:noreply, state} + end + + # Stats + def handle_info({:irc, :trigger, "preums", m = %IRC.Message{trigger: %IRC.Trigger{type: :dot}}}, state) do + channel = {m.network, m.channel} + state = handle_preums(m, state) + top = topnicks(state.dets, channel, sort_by: :score) + |> Enum.map(fn({account_id, {count, score}}) -> + account = IRC.Account.get(account_id) + user = IRC.UserTrack.find_by_account(m.network, account) + nick = if(user, do: user.nick, else: account.name) + "#{nick}: #{score} (#{count})" + end) + |> Enum.intersperse(", ") + |> Enum.join("") + msg = unless top == "" do + "top preums: #{top}" + else + "vous êtes tous nuls" + end + m.replyfun.(msg) + {:noreply, state} + end + + # Help + def handle_info({:irc, :trigger, "preums", m = %IRC.Message{trigger: %IRC.Trigger{type: :query}}}, state) do + state = handle_preums(m, state) + msg = "!preums - preums du jour, .preums top preumseurs" + m.replymsg.(msg) + {:noreply, state} + end + + # Trigger fallback + def handle_info({:irc, :trigger, _, m = %IRC.Message{}}, state) do + state = handle_preums(m, state) + {:noreply, state} + end + + # Message fallback + def handle_info({:irc, :text, m = %IRC.Message{}}, state) do + {:noreply, handle_preums(m, state)} + end + + # Account + def handle_info({:account_change, old_id, new_id}, state) do + spec = [{{:_, :"$1", :_, :_, :_}, [{:==, :"$1", {:const, old_id}}], [:"$_"]}] + Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> + rename_object_owner(table, obj, new_id) + end) + {:noreply, state} + end + + # Account: move from nick to account id + # FIXME: Doesn't seem to work. + def handle_info({:accounts, accounts}, state) do + for x={:account, _net, _chan, _nick, _account_id} <- accounts do + handle_info(x, state) + end + {:noreply, state} + end + def handle_info({:account, _net, _chan, nick, account_id}, state) do + nick = String.downcase(nick) + spec = [{{:_, :"$1", :_, :_, :_}, [{:==, :"$1", {:const, nick}}], [:"$_"]}] + Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> + Logger.debug("account:: merging #{nick} -> #{account_id}") + rename_object_owner(table, obj, account_id) + end) + {:noreply, state} + end + + def handle_info(_, dets) do + {:noreply, dets} + end + + + defp rename_object_owner(table, object = {key, _, now, perfect, time}, new_id) do + :dets.delete_object(table, key) + :dets.insert(table, {key, new_id, now, perfect, time}) + end + + defp timezone(channel) do + env = Application.get_env(:nola, Nola.IRC.PreumsPlugin, []) + channels = Keyword.get(env, :channels, %{}) + channel_settings = Map.get(channels, channel, []) + default = Keyword.get(env, :default_tz, "Europe/Paris") + Keyword.get(channel_settings, :tz, default) || default + end + + defp handle_preums(%IRC.Message{channel: nil}, state) do + state + end + + defp handle_preums(m = %IRC.Message{text: text, sender: sender}, state) do + channel = {m.network, m.channel} + tz = timezone(channel) + {:ok, now} = DateTime.now(tz, Tzdata.TimeZoneDatabase) + date = {now.year, now.month, now.day} + key = {channel, date} + chan_cache = Map.get(state, channel, %{}) + unless i = Map.get(chan_cache, date) do + case :dets.lookup(state.dets, key) do + [item = {^key, _nick, _now, _perfect, _text}] -> + # Preums lost, but wasn't cached + Map.put(state, channel, %{date => item}) + [] -> + # Preums won! + perfect? = Enum.any?(@perfects, fn(perfect) -> Regex.match?(perfect, text) end) + item = {key, m.account.id, now, perfect?, text} + :dets.insert(state.dets, item) + :dets.sync(state.dets) + Map.put(state, channel, %{date => item}) + {:error, _} = error -> + Logger.error("#{__MODULE__} dets lookup failed: #{inspect error}") + state + end + else + state + end + end + + def score(_chan, _account, _time, _perfect, _text) do + 1 + end + + +end diff --git a/lib/nola_plugins/quatre_cent_vingt_plugin.ex b/lib/nola_plugins/quatre_cent_vingt_plugin.ex new file mode 100644 index 0000000..8953ea3 --- /dev/null +++ b/lib/nola_plugins/quatre_cent_vingt_plugin.ex @@ -0,0 +1,149 @@ +defmodule Nola.IRC.QuatreCentVingtPlugin do + require Logger + + @moduledoc """ + # 420 + + * **!420**: recorde un nouveau 420. + * **!420*x**: recorde un nouveau 420*x (*2 = 840, ...) (à vous de faire la multiplication). + * **!420 pseudo**: stats du pseudo. + """ + + @achievements %{ + 1 => ["[le premier… il faut bien commencer un jour]"], + 10 => ["T'en es seulement à 10 ? ╭∩╮(Ο_Ο)╭∩╮"], + 42 => ["Bravo, et est-ce que autant de pétards t'on aidés à trouver la Réponse ? ٩(- ̮̮̃-̃)۶ [42]"], + 100 => ["°º¤ø,¸¸,ø¤º°`°º¤ø,¸,ø¤°º¤ø,¸¸,ø¤º°`°º¤ø,¸ 100 °º¤ø,¸¸,ø¤º°`°º¤ø,¸,ø¤°º¤ø,¸¸,ø¤º°`°º¤ø,¸"], + 115 => [" ۜ\(סּںסּَ` )/ۜ 115!!"] + } + + @emojis [ + "\\o/", + "~o~", + "~~o∞~~", + "*\\o/*", + "**\\o/**", + "*ô*", + ] + + @coeffs Range.new(1, 100) + + def irc_doc, do: @moduledoc + + def start_link, do: GenServer.start_link(__MODULE__, [], name: __MODULE__) + + def init(_) do + for coeff <- @coeffs do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{420*coeff}", [plugin: __MODULE__]) + end + {:ok, _} = Registry.register(IRC.PubSub, "account", [plugin: __MODULE__]) + dets_filename = (Nola.data_path() <> "/420.dets") |> String.to_charlist + {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag},{:repair,:force}]) + {:ok, dets} + :ignore + end + + for coeff <- @coeffs do + qvc = to_string(420 * coeff) + def handle_info({:irc, :trigger, unquote(qvc), m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :bang}}}, dets) do + {count, last} = get_statistics_for_nick(dets, m.account.id) + count = count + unquote(coeff) + text = achievement_text(count) + now = DateTime.to_unix(DateTime.utc_now())-1 # this is ugly + for i <- Range.new(1, unquote(coeff)) do + :ok = :dets.insert(dets, {m.account.id, now+i}) + end + last_s = if last do + last_s = format_relative_timestamp(last) + " (le dernier était #{last_s})" + else + "" + end + m.replyfun.("#{m.sender.nick} 420 +#{unquote(coeff)} #{text}#{last_s}") + {:noreply, dets} + end + end + + def handle_info({:irc, :trigger, "420", m = %IRC.Message{trigger: %IRC.Trigger{args: [nick], type: :bang}}}, dets) do + account = IRC.Account.find_by_nick(m.network, nick) + if account do + text = case get_statistics_for_nick(dets, m.account.id) do + {0, _} -> "#{nick} n'a jamais !420 ... honte à lui." + {count, last} -> + last_s = format_relative_timestamp(last) + "#{nick} 420: total #{count}, le dernier #{last_s}" + end + m.replyfun.(text) + else + m.replyfun.("je connais pas de #{nick}") + end + {:noreply, dets} + end + + # Account + def handle_info({:account_change, old_id, new_id}, dets) do + spec = [{{:"$1", :_}, [{:==, :"$1", {:const, old_id}}], [:"$_"]}] + Util.ets_mutate_select_each(:dets, dets, spec, fn(table, obj) -> + rename_object_owner(table, obj, new_id) + end) + {:noreply, dets} + end + + # Account: move from nick to account id + def handle_info({:accounts, accounts}, dets) do + for x={:account, _net, _chan, _nick, _account_id} <- accounts do + handle_info(x, dets) + end + {:noreply, dets} + end + def handle_info({:account, _net, _chan, nick, account_id}, dets) do + nick = String.downcase(nick) + spec = [{{:"$1", :_}, [{:==, :"$1", {:const, nick}}], [:"$_"]}] + Util.ets_mutate_select_each(:dets, dets, spec, fn(table, obj) -> + Logger.debug("account:: merging #{nick} -> #{account_id}") + rename_object_owner(table, obj, account_id) + end) + {:noreply, dets} + end + + def handle_info(_, dets) do + {:noreply, dets} + end + + defp rename_object_owner(table, object = {_, at}, account_id) do + :dets.delete_object(table, object) + :dets.insert(table, {account_id, at}) + end + + + defp format_relative_timestamp(timestamp) do + alias Timex.Format.DateTime.Formatters + alias Timex.Timezone + date = timestamp + |> DateTime.from_unix! + |> Timezone.convert("Europe/Paris") + + {:ok, relative} = Formatters.Relative.relative_to(date, Timex.now("Europe/Paris"), "{relative}", "fr") + {:ok, detail} = Formatters.Default.lformat(date, " ({h24}:{m})", "fr") + + relative <> detail + end + + defp get_statistics_for_nick(dets, acct) do + qvc = :dets.lookup(dets, acct) |> Enum.sort + count = Enum.reduce(qvc, 0, fn(_, acc) -> acc + 1 end) + {_, last} = List.last(qvc) || {nil, nil} + {count, last} + end + + @achievements_keys Map.keys(@achievements) + defp achievement_text(count) when count in @achievements_keys do + Enum.random(Map.get(@achievements, count)) + end + + defp achievement_text(count) do + emoji = Enum.random(@emojis) + "#{emoji} [#{count}]" + end + +end diff --git a/lib/nola_plugins/radio_france_plugin.ex b/lib/nola_plugins/radio_france_plugin.ex new file mode 100644 index 0000000..c2e966f --- /dev/null +++ b/lib/nola_plugins/radio_france_plugin.ex @@ -0,0 +1,133 @@ +defmodule Nola.IRC.RadioFrancePlugin do + require Logger + + def irc_doc() do + """ + # radio france + + Qu'est ce qu'on écoute sur radio france ? + + * **!radiofrance `[station]`, !rf `[station]`** + * **!fip, !inter, !info, !bleu, !culture, !musique, !fip `[sous-station]`, !bleu `[région]`** + """ + end + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + @trigger "radiofrance" + @shortcuts ~w(fip inter info bleu culture musique) + + def init(_) do + regopts = [plugin: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "trigger:radiofrance", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:rf", regopts) + for s <- @shortcuts do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{s}", regopts) + end + {:ok, nil} + end + + def handle_info({:irc, :trigger, "rf", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang}}}, state) do + handle_info({:irc, :trigger, "radiofrance", m}, state) + end + + def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: []}}}, state) do + m.replyfun.("radiofrance: précisez la station!") + {:noreply, state} + end + + def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: args}}}, state) do + now(args_to_station(args), m) + {:noreply, state} + end + + def handle_info({:irc, :trigger, trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: args}}}, state) when trigger in @shortcuts do + now(args_to_station([trigger | args]), m) + {:noreply, state} + end + + defp args_to_station(args) do + args + |> Enum.map(&unalias/1) + |> Enum.map(&String.downcase/1) + |> Enum.join("_") + end + + def handle_info(info, state) do + Logger.debug("unhandled info: #{inspect info}") + {:noreply, state} + end + + defp now(station, m) when is_binary(station) do + case HTTPoison.get(np_url(station), [], []) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + json = Poison.decode!(body) + song? = !!get_in(json, ["now", "song"]) + station = reformat_station_name(get_in(json, ["now", "stationName"])) + now_title = get_in(json, ["now", "firstLine", "title"]) + now_subtitle = get_in(json, ["now", "secondLine", "title"]) + next_title = get_in(json, ["next", "firstLine", "title"]) + next_subtitle = get_in(json, ["next", "secondLine", "title"]) + next_song? = !!get_in(json, ["next", "song"]) + next_at = get_in(json, ["next", "startTime"]) + + now = format_title(song?, now_title, now_subtitle) + prefix = if song?, do: "🎶", else: "🎤" + m.replyfun.("#{prefix} #{station}: #{now}") + + next = format_title(song?, next_title, next_subtitle) + if next do + next_prefix = if next_at do + next_date = DateTime.from_unix!(next_at) + in_seconds = DateTime.diff(next_date, DateTime.utc_now()) + in_minutes = ceil(in_seconds / 60) + if in_minutes >= 5 do + if next_song?, do: "#{in_minutes}m 🔜", else: "dans #{in_minutes} minutes:" + else + if next_song?, do: "🔜", else: "suivi de:" + end + else + if next_song?, do: "🔜", else: "à suivre:" + end + m.replyfun.("#{next_prefix} #{next}") + end + + {:error, %HTTPoison.Response{status_code: 404}} -> + m.replyfun.("radiofrance: la radio \"#{station}\" n'existe pas") + + {:error, %HTTPoison.Response{status_code: code}} -> + m.replyfun.("radiofrance: erreur http #{code}") + + _ -> + m.replyfun.("radiofrance: ça n'a pas marché, rip") + end + end + + defp np_url(station), do: "https://www.radiofrance.fr/api/v2.0/stations/#{station}/live" + + defp unalias("inter"), do: "franceinter" + defp unalias("info"), do: "franceinfo" + defp unalias("bleu"), do: "francebleu" + defp unalias("culture"), do: "franceculture" + defp unalias("musique"), do: "francemusique" + defp unalias(station), do: station + + defp format_title(_, nil, nil) do + nil + end + defp format_title(true, title, artist) do + [artist, title] |> Enum.filter(& &1) |> Enum.join(" - ") + end + defp format_title(false, show, section) do + [show, section] |> Enum.filter(& &1) |> Enum.join(": ") + end + + defp reformat_station_name(station) do + station + |> String.replace("france", "france ") + |> String.replace("_", " ") + end + +end diff --git a/lib/nola_plugins/say_plugin.ex b/lib/nola_plugins/say_plugin.ex new file mode 100644 index 0000000..915b0f6 --- /dev/null +++ b/lib/nola_plugins/say_plugin.ex @@ -0,0 +1,73 @@ +defmodule Nola.IRC.SayPlugin do + + def irc_doc do + """ + # say + + Say something... + + * **!say `` ``** say something on `channel` + * **!asay `` ``** same but anonymously + + You must be a member of the channel. + """ + end + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init([]) do + regopts = [type: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "trigger:say", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:asay", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "messages:private", regopts) + {:ok, nil} + end + + def handle_info({:irc, :trigger, "say", m = %{trigger: %{type: :bang, args: [target | text]}}}, state) do + text = Enum.join(text, " ") + say_for(m.account, target, text, true) + {:noreply, state} + end + + def handle_info({:irc, :trigger, "asay", m = %{trigger: %{type: :bang, args: [target | text]}}}, state) do + text = Enum.join(text, " ") + say_for(m.account, target, text, false) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %{text: "say "<>rest}}, state) do + case String.split(rest, " ", parts: 2) do + [target, text] -> say_for(m.account, target, text, true) + _ -> nil + end + {:noreply, state} + end + + def handle_info({:irc, :text, m = %{text: "asay "<>rest}}, state) do + case String.split(rest, " ", parts: 2) do + [target, text] -> say_for(m.account, target, text, false) + _ -> nil + end + {:noreply, state} + end + + def handle_info(_, state) do + {:noreply, state} + end + + defp say_for(account, target, text, with_nick?) do + for {net, chan} <- IRC.Membership.of_account(account) do + chan2 = String.replace(chan, "#", "") + if (target == "#{net}/#{chan}" || target == "#{net}/#{chan2}" || target == chan || target == chan2) do + if with_nick? do + IRC.send_message_as(account, net, chan, text) + else + IRC.Connection.broadcast_message(net, chan, text) + end + end + end + end + +end diff --git a/lib/nola_plugins/script_plugin.ex b/lib/nola_plugins/script_plugin.ex new file mode 100644 index 0000000..94d4edf --- /dev/null +++ b/lib/nola_plugins/script_plugin.ex @@ -0,0 +1,42 @@ +defmodule Nola.IRC.ScriptPlugin do + require Logger + + @moduledoc """ + Allows to run outside scripts. Scripts are expected to be long running and receive/send data as JSON over stdin/stdout. + + """ + + @ircdoc """ + # script + + Allows to run an outside script. + + * **+script `` `[command]`** défini/lance un script + * **-script ``** arrête un script + * **-script del ``** supprime un script + """ + + def irc_doc, do: @ircdoc + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:script", [plugin: __MODULE__]) + dets_filename = (Nola.data_path() <> "/" <> "scripts.dets") |> String.to_charlist + {:ok, dets} = :dets.open_file(dets_filename, []) + {:ok, %{dets: dets}} + end + + def handle_info({:irc, :trigger, "script", m = %{trigger: %{type: :plus, args: [name | args]}}}, state) do + end + + def handle_info({:irc, :trigger, "script", m = %{trigger: %{type: :minus, args: args}}}, state) do + case args do + ["del", name] -> :ok #prout + [name] -> :ok#stop + end + end + +end diff --git a/lib/nola_plugins/seen_plugin.ex b/lib/nola_plugins/seen_plugin.ex new file mode 100644 index 0000000..2a4d0dd --- /dev/null +++ b/lib/nola_plugins/seen_plugin.ex @@ -0,0 +1,59 @@ +defmodule Nola.IRC.SeenPlugin do + @moduledoc """ + # seen + + * **!seen ``** + """ + + def irc_doc, do: @moduledoc + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init([]) do + regopts = [plugin: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "triggers", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) + dets_filename = (Nola.data_path() <> "/seen.dets") |> String.to_charlist() + {:ok, dets} = :dets.open_file(dets_filename, []) + {:ok, %{dets: dets}} + end + + def handle_info({:irc, :trigger, "seen", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [nick]}}}, state) do + witness(m, state) + m.replyfun.(last_seen(m.channel, nick, state)) + {:noreply, state} + end + + def handle_info({:irc, :trigger, _, m}, state) do + witness(m, state) + {:noreply, state} + end + + def handle_info({:irc, :text, m}, state) do + witness(m, state) + {:noreply, state} + end + + defp witness(%IRC.Message{channel: channel, text: text, sender: %{nick: nick}}, %{dets: dets}) do + :dets.insert(dets, {{channel, nick}, DateTime.utc_now(), text}) + :ok + end + + defp last_seen(channel, nick, %{dets: dets}) do + case :dets.lookup(dets, {channel, nick}) do + [{_, date, text}] -> + diff = round(DateTime.diff(DateTime.utc_now(), date)/60) + cond do + diff >= 30 -> + duration = Timex.Duration.from_minutes(diff) + format = Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized) + "#{nick} a parlé pour la dernière fois il y a #{format}: “#{text}”" + true -> "#{nick} est là..." + end + [] -> + "je ne connais pas de #{nick}" + end + end + +end diff --git a/lib/nola_plugins/sms_plugin.ex b/lib/nola_plugins/sms_plugin.ex new file mode 100644 index 0000000..d8f7387 --- /dev/null +++ b/lib/nola_plugins/sms_plugin.ex @@ -0,0 +1,165 @@ +defmodule Nola.IRC.SmsPlugin do + @moduledoc """ + ## sms + + * **!sms `` ``** envoie un SMS. + """ + def short_irc_doc, do: false + def irc_doc, do: @moduledoc + require Logger + + def incoming(from, "enable "<>key) do + key = String.trim(key) + account = IRC.Account.find_meta_account("sms-validation-code", String.downcase(key)) + if account do + net = IRC.Account.get_meta(account, "sms-validation-target") + IRC.Account.put_meta(account, "sms-number", from) + IRC.Account.delete_meta(account, "sms-validation-code") + IRC.Account.delete_meta(account, "sms-validation-number") + IRC.Account.delete_meta(account, "sms-validation-target") + IRC.Connection.broadcast_message(net, account, "SMS Number #{from} added!") + send_sms(from, "Yay! Number linked to account #{account.name}") + end + end + + def incoming(from, message) do + account = IRC.Account.find_meta_account("sms-number", from) + if account do + reply_fun = fn(text) -> + send_sms(from, text) + end + trigger_text = if Enum.any?(IRC.Connection.triggers(), fn({trigger, _}) -> String.starts_with?(message, trigger) end) do + message + else + "!"<>message + end + message = %IRC.Message{ + id: FlakeId.get(), + transport: :sms, + network: "sms", + channel: nil, + text: message, + account: account, + sender: %ExIRC.SenderInfo{nick: account.name}, + replyfun: reply_fun, + trigger: IRC.Connection.extract_trigger(trigger_text) + } + Logger.debug("converted sms to message: #{inspect message}") + IRC.Connection.publish(message, ["messages:sms"]) + message + end + end + + def my_number() do + Keyword.get(Application.get_env(:nola, :sms, []), :number, "+33000000000") + end + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def path() do + account = Keyword.get(Application.get_env(:nola, :sms), :account) + "https://eu.api.ovh.com/1.0/sms/#{account}" + end + + def path(rest) do + Path.join(path(), rest) + end + + def send_sms(number, text) do + url = path("/virtualNumbers/#{my_number()}/jobs") + body = %{ + "message" => text, + "receivers" => [number], + #"senderForResponse" => true, + #"noStopClause" => true, + "charset" => "UTF-8", + "coding" => "8bit" + } |> Poison.encode!() + headers = [{"content-type", "application/json"}] ++ sign("POST", url, body) + options = [] + case HTTPoison.post(url, body, headers, options) do + {:ok, %HTTPoison.Response{status_code: 200}} -> :ok + {:ok, %HTTPoison.Response{status_code: code} = resp} -> + Logger.error("SMS Error: #{inspect resp}") + {:error, code} + {:error, error} -> {:error, error} + end + end + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:sms", [plugin: __MODULE__]) + :ok = register_ovh_callback() + {:ok, %{}} + :ignore + end + + def handle_info({:irc, :trigger, "sms", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [nick | text]}}}, state) do + with \ + {:tree, false} <- {:tree, m.sender.nick == "Tree"}, + {_, %IRC.Account{} = account} <- {:account, IRC.Account.find_always_by_nick(m.network, m.channel, nick)}, + {_, number} when not is_nil(number) <- {:number, IRC.Account.get_meta(account, "sms-number")} + do + text = Enum.join(text, " ") + sender = if m.channel do + "#{m.channel} <#{m.sender.nick}> " + else + "<#{m.sender.nick}> " + end + case send_sms(number, sender<>text) do + :ok -> m.replyfun.("sent!") + {:error, error} -> m.replyfun.("not sent, error: #{inspect error}") + end + else + {:tree, _} -> m.replyfun.("Tree: va en enfer") + {:account, _} -> m.replyfun.("#{nick} not known") + {:number, _} -> m.replyfun.("#{nick} have not enabled sms") + end + {:noreply, state} + end + + def handle_info(msg, state) do + {:noreply, state} + end + + defp register_ovh_callback() do + url = path() + body = %{ + "callBack" =>NolaWeb.Router.Helpers.sms_url(NolaWeb.Endpoint, :ovh_callback), + "smsResponse" => %{ + "cgiUrl" => NolaWeb.Router.Helpers.sms_url(NolaWeb.Endpoint, :ovh_callback), + "responseType" => "cgi" + } + } |> Poison.encode!() + headers = [{"content-type", "application/json"}] ++ sign("PUT", url, body) + options = [] + case HTTPoison.put(url, body, headers, options) do + {:ok, %HTTPoison.Response{status_code: 200}} -> + :ok + error -> error + end + end + + defp sign(method, url, body) do + ts = DateTime.utc_now() |> DateTime.to_unix() + as = env(:app_secret) + ck = env(:consumer_key) + sign = Enum.join([as, ck, String.upcase(method), url, body, ts], "+") + sign_hex = :crypto.hash(:sha, sign) |> Base.encode16(case: :lower) + headers = [{"X-OVH-Application", env(:app_key)}, {"X-OVH-Timestamp", ts}, + {"X-OVH-Signature", "$1$"<>sign_hex}, {"X-Ovh-Consumer", ck}] + end + + def parse_number(num) do + {:error, :todo} + end + + defp env() do + Application.get_env(:nola, :sms) + end + + defp env(key) do + Keyword.get(env(), key) + end +end diff --git a/lib/nola_plugins/tell_plugin.ex b/lib/nola_plugins/tell_plugin.ex new file mode 100644 index 0000000..ecc98df --- /dev/null +++ b/lib/nola_plugins/tell_plugin.ex @@ -0,0 +1,106 @@ +defmodule Nola.IRC.TellPlugin do + use GenServer + + @moduledoc """ + # Tell + + * **!tell `` ``**: tell `message` to `nick` when they reconnect. + """ + + def irc_doc, do: @moduledoc + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def dets do + (Nola.data_path() <> "/tell.dets") |> String.to_charlist() + end + + def tell(m, target, message) do + GenServer.cast(__MODULE__, {:tell, m, target, message}) + end + + def init([]) do + regopts = [plugin: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "account", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:tell", regopts) + {:ok, dets} = :dets.open_file(dets(), [type: :bag]) + {:ok, %{dets: dets}} + end + + def handle_cast({:tell, m, target, message}, state) do + do_tell(state, m, target, message) + {:noreply, state} + end + + def handle_info({:irc, :trigger, "tell", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [target | message]}}}, state) do + do_tell(state, m, target, message) + {:noreply, state} + end + + def handle_info({:account, network, channel, nick, account_id}, state) do + messages = :dets.lookup(state.dets, {network, channel, account_id}) + if messages != [] do + strs = Enum.map(messages, fn({_, from, message, at}) -> + account = IRC.Account.get(from) + user = IRC.UserTrack.find_by_account(network, account) + fromnick = if user, do: user.nick, else: account.name + "#{nick}: <#{fromnick}> #{message}" + end) + Enum.each(strs, fn(s) -> IRC.Connection.broadcast_message(network, channel, s) end) + :dets.delete(state.dets, {network, channel, account_id}) + end + {:noreply, state} + end + + def handle_info({:account_change, old_id, new_id}, state) do + #:ets.fun2ms(fn({ {_net, _chan, target_id}, from_id, _, _} = obj) when (target_id == old_id) or (from_id == old_id) -> obj end) + spec = [{{{:"$1", :"$2", :"$3"}, :"$4", :_, :_}, [{:orelse, {:==, :"$3", {:const, old_id}}, {:==, :"$4", {:const, old_id}}}], [:"$_"]}] + Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> + case obj do + { {net, chan, ^old_id}, from_id, message, at } = obj -> + :dets.delete(obj) + :dets.insert(table, {{net, chan, new_id}, from_id, message, at}) + {key, ^old_id, message, at} = obj -> + :dets.delete(table, obj) + :dets.insert(table, {key, new_id, message, at}) + _ -> :ok + end + end) + {:noreply, state} + end + + + def handle_info(info, state) do + {:noreply, state} + end + + def terminate(_, state) do + :dets.close(state.dets) + :ok + end + + defp do_tell(state, m, nick_target, message) do + target = IRC.Account.find_always_by_nick(m.network, m.channel, nick_target) + message = Enum.join(message, " ") + with \ + {:target, %IRC.Account{} = target} <- {:target, target}, + {:same, false} <- {:same, target.id == m.account.id}, + target_user = IRC.UserTrack.find_by_account(m.network, target), + target_nick = if(target_user, do: target_user.nick, else: target.name), + present? = if(target_user, do: Map.has_key?(target_user.last_active, m.channel)), + {:absent, true, _} <- {:absent, !present?, target_nick}, + {:message, message} <- {:message, message} + do + obj = { {m.network, m.channel, target.id}, m.account.id, message, NaiveDateTime.utc_now()} + :dets.insert(state.dets, obj) + m.replyfun.("will tell to #{target_nick}") + else + {:same, _} -> m.replyfun.("are you so stupid that you need a bot to tell yourself things ?") + {:target, _} -> m.replyfun.("#{nick_target} unknown") + {:absent, _, nick} -> m.replyfun.("#{nick} is here, tell yourself!") + {:message, _} -> m.replyfun.("can't tell without a message") + end + end + +end diff --git a/lib/nola_plugins/txt_plugin.ex b/lib/nola_plugins/txt_plugin.ex new file mode 100644 index 0000000..cab912a --- /dev/null +++ b/lib/nola_plugins/txt_plugin.ex @@ -0,0 +1,556 @@ +defmodule Nola.IRC.TxtPlugin do + alias IRC.UserTrack + require Logger + + @moduledoc """ + # [txt]({{context_path}}/txt) + + * **.txt**: liste des fichiers et statistiques. + Les fichiers avec une `*` sont vérrouillés. + [Voir sur le web]({{context_path}}/txt). + + * **!txt**: lis aléatoirement une ligne dans tous les fichiers. + * **!txt ``**: recherche une ligne dans tous les fichiers. + + * **~txt**: essaie de générer une phrase (markov). + * **~txt ``**: essaie de générer une phrase commencant par ``. + + * **!`FICHIER`**: lis aléatoirement une ligne du fichier `FICHIER`. + * **!`FICHIER` ``**: lis la ligne `` du fichier `FICHIER`. + * **!`FICHIER` ``**: recherche une ligne contenant `` dans `FICHIER`. + + * **+txt `**: crée le fichier ``. + * **+`FICHIER` ``**: ajoute une ligne `` dans le fichier `FICHIER`. + * **-`FICHIER` ``**: supprime la ligne `` du fichier `FICHIER`. + + * **-txtrw, +txtrw**. op seulement. active/désactive le mode lecture seule. + * **+txtlock ``, -txtlock ``**. op seulement. active/désactive le verrouillage d'un fichier. + + Insérez `\\\\` pour faire un saut de ligne. + """ + + def short_irc_doc, do: "!txt https://sys.115ans.net/irc/txt " + def irc_doc, do: @moduledoc + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + defstruct triggers: %{}, rw: true, locks: nil, markov_handler: nil, markov: nil + + def random(file) do + GenServer.call(__MODULE__, {:random, file}) + end + + def reply_random(message, file) do + if line = random(file) do + line + |> format_line(nil, message) + |> message.replyfun.() + + line + end + end + + def init([]) do + dets_locks_filename = (Nola.data_path() <> "/" <> "txtlocks.dets") |> String.to_charlist + {:ok, locks} = :dets.open_file(dets_locks_filename, []) + markov_handler = Keyword.get(Application.get_env(:nola, __MODULE__, []), :markov_handler, Nola.IRC.TxtPlugin.Markov.Native) + {:ok, markov} = markov_handler.start_link() + {:ok, _} = Registry.register(IRC.PubSub, "triggers", [plugin: __MODULE__]) + {:ok, %__MODULE__{locks: locks, markov_handler: markov_handler, markov: markov, triggers: load()}} + end + + def handle_info({:received, "!reload", _, chan}, state) do + {:noreply, %__MODULE__{state | triggers: load()}} + end + + # + # ADMIN: RW/RO + # + + def handle_info({:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :plus}}}, state = %{rw: false}) do + if channel && UserTrack.operator?(msg.network, channel, msg.sender.nick) do + msg.replyfun.("txt: écriture réactivée") + {:noreply, %__MODULE__{state | rw: true}} + else + {:noreply, state} + end + end + + def handle_info({:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :minus}}}, state = %{rw: true}) do + if channel && UserTrack.operator?(msg.network, channel, msg.sender.nick) do + msg.replyfun.("txt: écriture désactivée") + {:noreply, %__MODULE__{state | rw: false}} + else + {:noreply, state} + end + end + + # + # ADMIN: LOCKS + # + + def handle_info({:irc, :trigger, "txtlock", msg = %{trigger: %{type: :plus, args: [trigger]}}}, state) do + with \ + {trigger, _} <- clean_trigger(trigger), + true <- UserTrack.operator?(msg.network, msg.channel, msg.sender.nick) + do + :dets.insert(state.locks, {trigger}) + msg.replyfun.("txt: #{trigger} verrouillé") + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, "txtlock", msg = %{trigger: %{type: :minus, args: [trigger]}}}, state) do + with \ + {trigger, _} <- clean_trigger(trigger), + true <- UserTrack.operator?(msg.network, msg.channel, msg.sender.nick), + true <- :dets.member(state.locks, trigger) + do + :dets.delete(state.locks, trigger) + msg.replyfun.("txt: #{trigger} déverrouillé") + end + {:noreply, state} + end + + # + # FILE LIST + # + + def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :dot}}}, state) do + map = Enum.map(state.triggers, fn({key, data}) -> + ignore? = String.contains?(key, ".") + locked? = case :dets.lookup(state.locks, key) do + [{trigger}] -> "*" + _ -> "" + end + + unless ignore?, do: "#{key}: #{to_string(Enum.count(data))}#{locked?}" + end) + |> Enum.filter(& &1) + total = Enum.reduce(state.triggers, 0, fn({_, data}, acc) -> + acc + Enum.count(data) + end) + detail = Enum.join(map, ", ") + total = ". total: #{Enum.count(state.triggers)} fichiers, #{to_string(total)} lignes. Détail: https://sys.115ans.net/irc/txt" + + ro = if !state.rw, do: " (lecture seule activée)", else: "" + + (detail<>total<>ro) + |> msg.replyfun.() + {:noreply, state} + end + + # + # GLOBAL: RANDOM + # + + def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :bang, args: []}}}, state) do + result = Enum.reduce(state.triggers, [], fn({trigger, data}, acc) -> + Enum.reduce(data, acc, fn({l, _}, acc) -> + [{trigger, l} | acc] + end) + end) + |> Enum.shuffle() + + if !Enum.empty?(result) do + {source, line} = Enum.random(result) + msg.replyfun.(format_line(line, "#{source}: ", msg)) + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :bang, args: args}}}, state) do + grep = Enum.join(args, " ") + |> String.downcase + |> :unicode.characters_to_nfd_binary() + + result = with_stateful_results(msg, {:bang,"txt",msg.network,msg.channel,grep}, fn() -> + Enum.reduce(state.triggers, [], fn({trigger, data}, acc) -> + if !String.contains?(trigger, ".") do + Enum.reduce(data, acc, fn({l, _}, acc) -> + [{trigger, l} | acc] + end) + else + acc + end + end) + |> Enum.filter(fn({_, line}) -> + line + |> String.downcase() + |> :unicode.characters_to_nfd_binary() + |> String.contains?(grep) + end) + |> Enum.shuffle() + end) + + if result do + {source, line} = result + msg.replyfun.(["#{source}: " | line]) + end + {:noreply, state} + end + + def with_stateful_results(msg, key, initfun) do + me = self() + scope = {msg.network, msg.channel || msg.sender.nick} + key = {__MODULE__, me, scope, key} + with_stateful_results(key, initfun) + end + + def with_stateful_results(key, initfun) do + pid = case :global.whereis_name(key) do + :undefined -> + start_stateful_results(key, initfun.()) + pid -> pid + end + if pid, do: wait_stateful_results(key, initfun, pid) + end + + def start_stateful_results(key, []) do + nil + end + + def start_stateful_results(key, list) do + me = self() + {pid, _} = spawn_monitor(fn() -> + Process.monitor(me) + stateful_results(me, list) + end) + :yes = :global.register_name(key, pid) + pid + end + + def wait_stateful_results(key, initfun, pid) do + send(pid, :get) + receive do + {:stateful_results, line} -> + line + {:DOWN, _ref, :process, ^pid, reason} -> + with_stateful_results(key, initfun) + after + 5000 -> + nil + end + end + + defp stateful_results(owner, []) do + send(owner, :empty) + :ok + end + + @stateful_results_expire :timer.minutes(30) + defp stateful_results(owner, [line | rest] = acc) do + receive do + :get -> + send(owner, {:stateful_results, line}) + stateful_results(owner, rest) + {:DOWN, _ref, :process, ^owner, _} -> + :ok + after + @stateful_results_expire -> :ok + end + end + + # + # GLOBAL: MARKOV + # + + def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :tilde, args: []}}}, state) do + case state.markov_handler.sentence(state.markov) do + {:ok, line} -> + msg.replyfun.(line) + error -> + Logger.error "Txt Markov error: "<>inspect error + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :tilde, args: complete}}}, state) do + complete = Enum.join(complete, " ") + case state.markov_handler.complete_sentence(complete, state.markov) do + {:ok, line} -> + msg.replyfun.(line) + error -> + Logger.error "Txt Markov error: "<>inspect error + end + {:noreply, state} + end + + # + # TXT CREATE + # + + def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :plus, args: [trigger]}}}, state) do + with \ + {trigger, _} <- clean_trigger(trigger), + true <- can_write?(state, msg, trigger), + :ok <- create_file(trigger) + do + msg.replyfun.("#{trigger}.txt créé. Ajouter: `+#{trigger} …` ; Lire: `!#{trigger}`") + {:noreply, %__MODULE__{state | triggers: load()}} + else + _ -> {:noreply, state} + end + end + + # + # TXT: RANDOM + # + + def handle_info({:irc, :trigger, trigger, m = %{trigger: %{type: :query, args: opts}}}, state) do + {trigger, _} = clean_trigger(trigger) + if Map.get(state.triggers, trigger) do + url = if m.channel do + NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :txt, m.network, NolaWeb.format_chan(m.channel), trigger) + else + NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :txt, trigger) + end + m.replyfun.("-> #{url}") + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :bang, args: opts}}}, state) do + {trigger, _} = clean_trigger(trigger) + line = get_random(msg, state.triggers, trigger, String.trim(Enum.join(opts, " "))) + if line do + msg.replyfun.(format_line(line, nil, msg)) + end + {:noreply, state} + end + + # + # TXT: ADD + # + + def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :plus, args: content}}}, state) do + with \ + true <- can_write?(state, msg, trigger), + {:ok, idx} <- add(state.triggers, msg.text) + do + msg.replyfun.("#{msg.sender.nick}: ajouté à #{trigger}. (#{idx})") + {:noreply, %__MODULE__{state | triggers: load()}} + else + {:error, {:jaro, string, idx}} -> + msg.replyfun.("#{msg.sender.nick}: doublon #{trigger}##{idx}: #{string}") + error -> + Logger.debug("txt add failed: #{inspect error}") + {:noreply, state} + end + end + + # + # TXT: DELETE + # + + def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :minus, args: [id]}}}, state) do + with \ + true <- can_write?(state, msg, trigger), + data <- Map.get(state.triggers, trigger), + {id, ""} <- Integer.parse(id), + {text, _id} <- Enum.find(data, fn({_, idx}) -> id-1 == idx end) + do + data = data |> Enum.into(Map.new) + data = Map.delete(data, text) + msg.replyfun.("#{msg.sender.nick}: #{trigger}.txt##{id} supprimée: #{text}") + dump(trigger, data) + {:noreply, %__MODULE__{state | triggers: load()}} + else + _ -> + {:noreply, state} + end + end + + def handle_info(:reload_markov, state=%__MODULE__{triggers: triggers, markov: markov}) do + state.markov_handler.reload(state.triggers, state.markov) + {:noreply, state} + end + + def handle_info(msg, state) do + {:noreply, state} + end + + def handle_call({:random, file}, _from, state) do + random = get_random(nil, state.triggers, file, []) + {:reply, random, state} + end + + def terminate(_reason, state) do + if state.locks do + :dets.sync(state.locks) + :dets.close(state.locks) + end + :ok + end + + # Load/Reloads text files from disk + defp load() do + triggers = Path.wildcard(directory() <> "/*.txt") + |> Enum.reduce(%{}, fn(path, m) -> + file = Path.basename(path) + key = String.replace(file, ".txt", "") + data = directory() <> file + |> File.read! + |> String.split("\n") + |> Enum.reject(fn(line) -> + cond do + line == "" -> true + !line -> true + true -> false + end + end) + |> Enum.with_index + Map.put(m, key, data) + end) + |> Enum.sort + |> Enum.into(Map.new) + + send(self(), :reload_markov) + triggers + end + + defp dump(trigger, data) do + data = data + |> Enum.sort_by(fn({_, idx}) -> idx end) + |> Enum.map(fn({text, _}) -> text end) + |> Enum.join("\n") + File.write!(directory() <> "/" <> trigger <> ".txt", data<>"\n", []) + end + + defp get_random(msg, triggers, trigger, []) do + if data = Map.get(triggers, trigger) do + {data, _idx} = Enum.random(data) + data + else + nil + end + end + + defp get_random(msg, triggers, trigger, opt) do + arg = case Integer.parse(opt) do + {pos, ""} -> {:index, pos} + {_pos, _some_string} -> {:grep, opt} + _error -> {:grep, opt} + end + get_with_param(msg, triggers, trigger, arg) + end + + defp get_with_param(msg, triggers, trigger, {:index, pos}) do + data = Map.get(triggers, trigger, %{}) + case Enum.find(data, fn({_, index}) -> index+1 == pos end) do + {text, _} -> text + _ -> nil + end + end + + defp get_with_param(msg, triggers, trigger, {:grep, query}) do + out = with_stateful_results(msg, {:grep, trigger, query}, fn() -> + data = Map.get(triggers, trigger, %{}) + regex = Regex.compile!("#{query}", "i") + Enum.filter(data, fn({txt, _}) -> Regex.match?(regex, txt) end) + |> Enum.map(fn({txt, _}) -> txt end) + |> Enum.shuffle() + end) + if out, do: out + end + + defp create_file(name) do + File.touch!(directory() <> "/" <> name <> ".txt") + :ok + end + + defp add(triggers, trigger_and_content) do + case String.split(trigger_and_content, " ", parts: 2) do + [trigger, content] -> + {trigger, _} = clean_trigger(trigger) + + + if Map.has_key?(triggers, trigger) do + jaro = Enum.find(triggers[trigger], fn({string, idx}) -> String.jaro_distance(content, string) > 0.9 end) + + if jaro do + {string, idx} = jaro + {:error, {:jaro, string, idx}} + else + File.write!(directory() <> "/" <> trigger <> ".txt", content<>"\n", [:append]) + idx = Enum.count(triggers[trigger])+1 + {:ok, idx} + end + else + {:error, :notxt} + end + _ -> {:error, :badarg} + end + end + + # fixme: this is definitely the ugliest thing i've ever done + defp clean_trigger(trigger) do + [trigger | opts] = trigger + |> String.strip + |> String.split(" ", parts: 2) + + trigger = trigger + |> String.downcase + |> :unicode.characters_to_nfd_binary() + |> String.replace(~r/[^a-z0-9._]/, "") + |> String.trim(".") + |> String.trim("_") + + {trigger, opts} + end + + def format_line(line, prefix, msg) do + prefix = unless(prefix, do: "", else: prefix) + prefix <> line + |> String.split("\\\\") + |> Enum.map(fn(line) -> + String.split(line, "\\\\\\\\") + end) + |> List.flatten() + |> Enum.map(fn(line) -> + String.trim(line) + |> Tmpl.render(msg) + end) + end + + def directory() do + Application.get_env(:nola, :data_path) <> "/irc.txt/" + end + + defp can_write?(%{rw: rw?, locks: locks}, msg = %{channel: nil, sender: sender}, trigger) do + admin? = IRC.admin?(sender) + locked? = case :dets.lookup(locks, trigger) do + [{trigger}] -> true + _ -> false + end + unlocked? = if rw? == false, do: false, else: !locked? + + can? = unlocked? || admin? + + if !can? do + reason = if !rw?, do: "lecture seule", else: "fichier vérrouillé" + msg.replyfun.("#{sender.nick}: permission refusée (#{reason})") + end + can? + end + + defp can_write?(state = %__MODULE__{rw: rw?, locks: locks}, msg = %{channel: channel, sender: sender}, trigger) do + admin? = IRC.admin?(sender) + operator? = IRC.UserTrack.operator?(msg.network, channel, sender.nick) + locked? = case :dets.lookup(locks, trigger) do + [{trigger}] -> true + _ -> false + end + unlocked? = if rw? == false, do: false, else: !locked? + can? = admin? || operator? || unlocked? + + if !can? do + reason = if !rw?, do: "lecture seule", else: "fichier vérrouillé" + msg.replyfun.("#{sender.nick}: permission refusée (#{reason})") + end + can? + end + +end diff --git a/lib/nola_plugins/txt_plugin/markov.ex b/lib/nola_plugins/txt_plugin/markov.ex new file mode 100644 index 0000000..2e30dfa --- /dev/null +++ b/lib/nola_plugins/txt_plugin/markov.ex @@ -0,0 +1,9 @@ +defmodule Nola.IRC.TxtPlugin.Markov do + + @type state :: any() + @callback start_link() :: {:ok, state()} + @callback reload(content :: Map.t, state()) :: any() + @callback sentence(state()) :: {:ok, String.t} | {:error, String.t} + @callback complete_sentence(state()) :: {:ok, String.t} | {:error, String.t} + +end diff --git a/lib/nola_plugins/txt_plugin/markov_native.ex b/lib/nola_plugins/txt_plugin/markov_native.ex new file mode 100644 index 0000000..4c403c2 --- /dev/null +++ b/lib/nola_plugins/txt_plugin/markov_native.ex @@ -0,0 +1,33 @@ +defmodule Nola.IRC.TxtPlugin.MarkovNative do + @behaviour Nola.IRC.TxtPlugin.Markov + + def start_link() do + ExChain.MarkovModel.start_link() + end + + def reload(data, markov) do + data = data + |> Enum.map(fn({_, data}) -> + for {line, _idx} <- data, do: line + end) + |> List.flatten + + ExChain.MarkovModel.populate_model(markov, data) + :ok + end + + def sentence(markov) do + case ExChain.SentenceGenerator.create_filtered_sentence(markov) do + {:ok, line, _, _} -> {:ok, line} + error -> error + end + end + + def complete_sentence(sentence, markov) do + case ExChain.SentenceGenerator.complete_sentence(markov, sentence) do + {line, _} -> {:ok, line} + error -> error + end + end + +end diff --git a/lib/nola_plugins/txt_plugin/markov_py_markovify.ex b/lib/nola_plugins/txt_plugin/markov_py_markovify.ex new file mode 100644 index 0000000..b610ea8 --- /dev/null +++ b/lib/nola_plugins/txt_plugin/markov_py_markovify.ex @@ -0,0 +1,39 @@ +defmodule Nola.IRC.TxtPlugin.MarkovPyMarkovify do + + def start_link() do + {:ok, nil} + end + + def reload(_data, _markov) do + :ok + end + + def sentence(_) do + {:ok, run()} + end + + def complete_sentence(sentence, _) do + {:ok, run([sentence])} + end + + defp run(args \\ []) do + {binary, script} = script() + args = [script, Path.expand(Nola.IRC.TxtPlugin.directory()) | args] + IO.puts "Args #{inspect args}" + case MuonTrap.cmd(binary, args) do + {response, 0} -> response + {response, code} -> "error #{code}: #{response}" + end + end + + defp script() do + default_script = to_string(:code.priv_dir(:nola)) <> "/irc/txt/markovify.py" + env = Application.get_env(:nola, Nola.IRC.TxtPlugin, []) + |> Keyword.get(:py_markovify, []) + + {Keyword.get(env, :python, "python3"), Keyword.get(env, :script, default_script)} + end + + + +end diff --git a/lib/nola_plugins/untappd_plugin.ex b/lib/nola_plugins/untappd_plugin.ex new file mode 100644 index 0000000..50b0c4d --- /dev/null +++ b/lib/nola_plugins/untappd_plugin.ex @@ -0,0 +1,66 @@ +defmodule Nola.IRC.UntappdPlugin do + + def irc_doc() do + """ + # [Untappd](https://untappd.com) + + * `!beer ` Information about the first beer matching `` + * `?beer ` List the 10 firsts beer matching `` + + _Note_: The best way to search is always "Brewery Name + Beer Name", such as "Dogfish 60 Minute". + + Link your Untappd account to the bot (for automated checkins on [alcoolog](#alcoolog), ...) with the `enable-untappd` command, in private. + """ + end + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:beer", [plugin: __MODULE__]) + {:ok, %{}} + end + + def handle_info({:irc, :trigger, _, m = %IRC.Message{trigger: %{type: :bang, args: args}}}, state) do + case Untappd.search_beer(Enum.join(args, " "), limit: 1) do + {:ok, %{"response" => %{"beers" => %{"count" => count, "items" => [result | _]}}}} -> + %{"beer" => beer, "brewery" => brewery} = result + description = Map.get(beer, "beer_description") + |> String.replace("\n", " ") + |> String.replace("\r", " ") + |> String.trim() + beer_s = "#{Map.get(brewery, "brewery_name")}: #{Map.get(beer, "beer_name")} - #{Map.get(beer, "beer_abv")}°" + city = get_in(brewery, ["location", "brewery_city"]) + location = [Map.get(brewery, "brewery_type"), city, Map.get(brewery, "country_name")] + |> Enum.filter(fn(x) -> x end) + |> Enum.join(", ") + extra = "#{Map.get(beer, "beer_style")} - IBU: #{Map.get(beer, "beer_ibu")} - #{location}" + m.replyfun.([beer_s, extra, description]) + err -> + m.replyfun.("Error") + end + {:noreply, state} + end + + + def handle_info({:irc, :trigger, _, m = %IRC.Message{trigger: %{type: :query, args: args}}}, state) do + case Untappd.search_beer(Enum.join(args, " ")) do + {:ok, %{"response" => %{"beers" => %{"count" => count, "items" => results}}}} -> + beers = for %{"beer" => beer, "brewery" => brewery} <- results do + "#{Map.get(brewery, "brewery_name")}: #{Map.get(beer, "beer_name")} - #{Map.get(beer, "beer_abv")}°" + end + |> Enum.intersperse(", ") + |> Enum.join("") + m.replyfun.("#{count}. #{beers}") + err -> + m.replyfun.("Error") + end + {:noreply, state} + end + + def handle_info(info, state) do + {:noreply, state} + end + +end diff --git a/lib/nola_plugins/user_mention_plugin.ex b/lib/nola_plugins/user_mention_plugin.ex new file mode 100644 index 0000000..eb230fd --- /dev/null +++ b/lib/nola_plugins/user_mention_plugin.ex @@ -0,0 +1,52 @@ +defmodule Nola.IRC.UserMentionPlugin do + @moduledoc """ + # mention + + * **@`` ``**: notifie si possible le nick immédiatement via Telegram, SMS, ou équivalent à `!tell`. + """ + + require Logger + + def short_irc_doc, do: false + def irc_doc, do: @moduledoc + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "triggers", plugin: __MODULE__) + {:ok, nil} + end + + def handle_info({:irc, :trigger, nick, message = %IRC.Message{sender: sender, account: account, network: network, channel: channel, trigger: %IRC.Trigger{type: :at, args: content}}}, state) do + nick = nick + |> String.trim(":") + |> String.trim(",") + target = IRC.Account.find_always_by_nick(network, channel, nick) + if target do + telegram = IRC.Account.get_meta(target, "telegram-id") + sms = IRC.Account.get_meta(target, "sms-number") + text = "#{channel} <#{sender.nick}> #{Enum.join(content, " ")}" + + cond do + telegram -> + Nola.Telegram.send_message(telegram, "`#{channel}` <**#{sender.nick}**> #{Enum.join(content, " ")}") + sms -> + case Nola.IRC.SmsPlugin.send_sms(sms, text) do + {:error, code} -> message.replyfun("#{sender.nick}: erreur #{code} (sms)") + end + true -> + Nola.IRC.TellPlugin.tell(message, nick, content) + end + else + message.replyfun.("#{nick} m'est inconnu") + end + {:noreply, state} + end + + def handle_info(_, state) do + {:noreply, state} + end + +end diff --git a/lib/nola_plugins/wikipedia_plugin.ex b/lib/nola_plugins/wikipedia_plugin.ex new file mode 100644 index 0000000..3202e13 --- /dev/null +++ b/lib/nola_plugins/wikipedia_plugin.ex @@ -0,0 +1,90 @@ +defmodule Nola.IRC.WikipediaPlugin do + require Logger + + @moduledoc """ + # wikipédia + + * **!wp ``**: retourne le premier résultat de la `` Wikipedia + * **!wp**: un article Wikipédia au hasard + """ + + def irc_doc, do: @moduledoc + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:wp", [plugin: __MODULE__]) + {:ok, nil} + end + + def handle_info({:irc, :trigger, _, message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: []}}}, state) do + irc_random(message) + {:noreply, state} + end + def handle_info({:irc, :trigger, _, message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: args}}}, state) do + irc_search(Enum.join(args, " "), message) + {:noreply, state} + end + + def handle_info(info, state) do + {:noreply, state} + end + + defp irc_search("", message), do: irc_random(message) + defp irc_search(query, message) do + params = %{ + "action" => "query", + "list" => "search", + "srsearch" => String.strip(query), + "srlimit" => 1, + } + case query_wikipedia(params) do + {:ok, %{"query" => %{"search" => [item | _]}}} -> + title = item["title"] + url = "https://fr.wikipedia.org/wiki/" <> String.replace(title, " ", "_") + msg = "Wikipédia: #{title} — #{url}" + message.replyfun.(msg) + _ -> + nil + end + end + + defp irc_random(message) do + params = %{ + "action" => "query", + "generator" => "random", + "grnnamespace" => 0, + "prop" => "info" + } + case query_wikipedia(params) do + {:ok, %{"query" => %{"pages" => map = %{}}}} -> + [{_, item}] = Map.to_list(map) + title = item["title"] + url = "https://fr.wikipedia.org/wiki/" <> String.replace(title, " ", "_") + msg = "Wikipédia: #{title} — #{url}" + message.replyfun.(msg) + _ -> + nil + end + end + + defp query_wikipedia(params) do + url = "https://fr.wikipedia.org/w/api.php" + params = params + |> Map.put("format", "json") + |> Map.put("utf8", "") + + case HTTPoison.get(url, [], params: params) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> Jason.decode(body) + {:ok, %HTTPoison.Response{status_code: 400, body: body}} -> + Logger.error "Wikipedia HTTP 400: #{inspect body}" + {:error, "http 400"} + error -> + Logger.error "Wikipedia http error: #{inspect error}" + {:error, "http client error"} + end + end + +end diff --git a/lib/nola_plugins/wolfram_alpha_plugin.ex b/lib/nola_plugins/wolfram_alpha_plugin.ex new file mode 100644 index 0000000..6ee06f0 --- /dev/null +++ b/lib/nola_plugins/wolfram_alpha_plugin.ex @@ -0,0 +1,47 @@ +defmodule Nola.IRC.WolframAlphaPlugin do + use GenServer + require Logger + + @moduledoc """ + # wolfram alpha + + * **`!wa `** lance `` sur WolframAlpha + """ + + def irc_doc, do: @moduledoc + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:wa", [plugin: __MODULE__]) + {:ok, nil} + end + + def handle_info({:irc, :trigger, _, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: query}}}, state) do + query = Enum.join(query, " ") + params = %{ + "appid" => Keyword.get(Application.get_env(:nola, :wolframalpha, []), :app_id, "NO_APP_ID"), + "units" => "metric", + "i" => query + } + url = "https://www.wolframalpha.com/input/?i=" <> URI.encode(query) + case HTTPoison.get("http://api.wolframalpha.com/v1/result", [], [params: params]) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + m.replyfun.(["#{query} -> #{body}", url]) + {:ok, %HTTPoison.Response{status_code: code, body: body}} -> + error = case {code, body} do + {501, b} -> "input invalide: #{body}" + {code, error} -> "erreur #{code}: #{body || ""}" + end + m.replyfun.("wa: #{error}") + {:error, %HTTPoison.Error{reason: reason}} -> + m.replyfun.("wa: erreur http: #{to_string(reason)}") + _ -> + m.replyfun.("wa: erreur http") + end + {:noreply, state} + end + +end diff --git a/lib/nola_plugins/youtube_plugin.ex b/lib/nola_plugins/youtube_plugin.ex new file mode 100644 index 0000000..fb9bea2 --- /dev/null +++ b/lib/nola_plugins/youtube_plugin.ex @@ -0,0 +1,104 @@ +defmodule Nola.IRC.YouTubePlugin do + require Logger + + @moduledoc """ + # youtube + + * **!yt ``**, !youtube ``: retourne le premier résultat de la `` YouTube + """ + + defstruct client: nil + + def irc_doc, do: @moduledoc + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init([]) do + for t <- ["trigger:yt", "trigger:youtube"], do: {:ok, _} = Registry.register(IRC.PubSub, t, [plugin: __MODULE__]) + {:ok, %__MODULE__{}} + end + + def handle_info({:irc, :trigger, _, message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: args}}}, state) do + irc_search(Enum.join(args, " "), message) + {:noreply, state} + end + + def handle_info(info, state) do + {:noreply, state} + end + + defp irc_search(query, message) do + case search(query) do + {:ok, %{"items" => [item | _]}} -> + url = "https://youtube.com/watch?v=" <> item["id"] + snippet = item["snippet"] + duration = item["contentDetails"]["duration"] |> String.replace("PT", "") |> String.downcase + date = snippet["publishedAt"] + |> DateTime.from_iso8601() + |> elem(1) + |> Timex.format("{relative}", :relative) + |> elem(1) + + info_line = "— #{duration} — uploaded by #{snippet["channelTitle"]} — #{date}" + <> " — #{item["statistics"]["viewCount"]} views, #{item["statistics"]["likeCount"]} likes," + <> " #{item["statistics"]["dislikeCount"]} dislikes" + message.replyfun.("#{snippet["title"]} — #{url}") + message.replyfun.(info_line) + {:error, error} -> + message.replyfun.("Erreur YouTube: "<>error) + _ -> + nil + end + end + + defp search(query) do + query = query + |> String.strip + key = Application.get_env(:nola, :youtube)[:api_key] + params = %{ + "key" => key, + "maxResults" => 1, + "part" => "id", + "safeSearch" => "none", + "type" => "video", + "q" => query, + } + url = "https://www.googleapis.com/youtube/v3/search" + case HTTPoison.get(url, [], params: params) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + {:ok, json} = Jason.decode(body) + item = List.first(json["items"]) + if item do + video_id = item["id"]["videoId"] + params = %{ + "part" => "snippet,contentDetails,statistics", + "id" => video_id, + "key" => key + } + headers = [] + options = [params: params] + case HTTPoison.get("https://www.googleapis.com/youtube/v3/videos", [], options) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + Jason.decode(body) + {:ok, %HTTPoison.Response{status_code: code, body: body}} -> + Logger.error "YouTube HTTP #{code}: #{inspect body}" + {:error, "http #{code}"} + error -> + Logger.error "YouTube http error: #{inspect error}" + :error + end + else + :error + end + {:ok, %HTTPoison.Response{status_code: code, body: body}} -> + Logger.error "YouTube HTTP #{code}: #{inspect body}" + {:error, "http #{code}"} + error -> + Logger.error "YouTube http error: #{inspect error}" + :error + end + end + +end diff --git a/lib/nola_telegram/room.ex b/lib/nola_telegram/room.ex new file mode 100644 index 0000000..ca8a437 --- /dev/null +++ b/lib/nola_telegram/room.ex @@ -0,0 +1,188 @@ +defmodule Nola.TelegramRoom do + require Logger + @behaviour Telegram.ChatBot + alias Telegram.Api + + @couch "bot-telegram-rooms" + + def rooms(), do: rooms(:with_docs) + + @spec rooms(:with_docs | :ids) :: [Map.t | integer( )] + def rooms(:with_docs) do + case Couch.get(@couch, :all_docs, include_docs: true) do + {:ok, %{"rows" => rows}} -> {:ok, for(%{"doc" => doc} <- rows, do: doc)} + error = {:error, _} -> error + end + end + + def rooms(:ids) do + case Couch.get(@couch, :all_docs) do + {:ok, %{"rows" => rows}} -> {:ok, for(%{"id" => id} <- rows, do: id)} + error = {:error, _} -> error + end + end + + def room(id, opts \\ []) do + Couch.get(@couch, id, opts) + end + + # TODO: Create couch + def setup() do + :ok + end + + def after_start() do + for id <- room(:ids), do: Telegram.Bot.ChatBot.Chat.Session.Supervisor.start_child(Nola.Telegram, Integer.parse(id) |> elem(0)) + end + + @impl Telegram.ChatBot + def init(id) when is_integer(id) and id < 0 do + token = Keyword.get(Application.get_env(:nola, :telegram, []), :key) + {:ok, chat} = Api.request(token, "getChat", chat_id: id) + Logger.metadata(transport: :telegram, id: id, telegram_room_id: id) + tg_room = case room(id) do + {:ok, tg_room = %{"network" => _net, "channel" => _chan}} -> tg_room + {:error, :not_found} -> + [net, chan] = String.split(chat["title"], "/", parts: 2) + {net, chan} = case IRC.Connection.get_network(net, chan) do + %IRC.Connection{} -> {net, chan} + _ -> {nil, nil} + end + {:ok, _id, _rev} = Couch.post(@couch, %{"_id" => id, "network" => net, "channel" => nil}) + {:ok, tg_room} = room(id) + tg_room + end + %{"network" => net, "channel" => chan} = tg_room + Logger.info("Starting ChatBot for room #{id} \"#{chat["title"]}\" #{inspect tg_room}") + irc_plumbed = if net && chan do + {:ok, _} = Registry.register(IRC.PubSub, "#{net}/#{chan}:messages", plugin: __MODULE__) + {:ok, _} = Registry.register(IRC.PubSub, "#{net}/#{chan}:triggers", plugin: __MODULE__) + {:ok, _} = Registry.register(IRC.PubSub, "#{net}/#{chan}:outputs", plugin: __MODULE__) + true + else + Logger.warn("Did not found telegram match for #{id} \"#{chat["title"]}\"") + false + end + {:ok, %{id: id, net: net, chan: chan, irc: irc_plumbed}} + end + + def init(id) do + Logger.error("telegram_room: bad id (not room id)", transport: :telegram, id: id, telegram_room_id: id) + :ignoree + end + + defp find_or_create_meta_account(from = %{"id" => user_id}, state) do + if account = IRC.Account.find_meta_account("telegram-id", user_id) do + account + else + first_name = Map.get(from, "first_name") + last_name = Map.get(from, "last_name") + name = [first_name, last_name] + |> Enum.filter(& &1) + |> Enum.join(" ") + + username = Map.get(from, "username", first_name) + + account = username + |> IRC.Account.new_account() + |> IRC.Account.update_account_name(name) + |> IRC.Account.put_meta("telegram-id", user_id) + + Logger.info("telegram_room: created account #{account.id} for telegram user #{user_id}") + account + end + end + + def handle_update(%{"message" => %{"from" => from = %{"id" => user_id}, "text" => text}}, _token, state) do + account = find_or_create_meta_account(from, state) + connection = IRC.Connection.get_network(state.net) + IRC.send_message_as(account, state.net, state.chan, text, true) + {:ok, state} + end + + def handle_update(data = %{"message" => %{"from" => from = %{"id" => user_id}, "location" => %{"latitude" => lat, "longitude" => lon}}}, _token, state) do + account = find_or_create_meta_account(from, state) + connection = IRC.Connection.get_network(state.net) + IRC.send_message_as(account, state.net, state.chan, "@ #{lat}, #{lon}", true) + {:ok, state} + end + + for type <- ~w(photo voice video document animation) do + def handle_update(data = %{"message" => %{unquote(type) => _}}, token, state) do + upload(unquote(type), data, token, state) + end + end + + def handle_update(update, token, state) do + {:ok, state} + end + + def handle_info({:irc, _, _, message}, state) do + handle_info({:irc, nil, message}, state) + end + + def handle_info({:irc, _, message = %IRC.Message{sender: %{nick: nick}, text: text}}, state) do + if Map.get(message.meta, :from) == self() do + else + body = if Map.get(message.meta, :self), do: text, else: "<#{nick}> #{text}" + Nola.Telegram.send_message(state.id, body) + end + {:ok, state} + end + + def handle_info(info, state) do + Logger.info("UNhandled #{inspect info}") + {:ok, state} + end + + defp upload(_type, %{"message" => m = %{"chat" => %{"id" => chat_id}, "from" => from = %{"id" => user_id}}}, token, state) do + account = find_or_create_meta_account(from, state) + if account do + {content, type} = cond do + m["photo"] -> {m["photo"], "photo"} + m["voice"] -> {m["voice"], "voice message"} + m["video"] -> {m["video"], "video"} + m["document"] -> {m["document"], "file"} + m["animation"] -> {m["animation"], "gif"} + end + + file = if is_list(content) && Enum.count(content) > 1 do + Enum.sort_by(content, fn(p) -> p["file_size"] end, &>=/2) + |> List.first() + else + content + end + + file_id = file["file_id"] + file_unique_id = file["file_unique_id"] + text = if(m["caption"], do: m["caption"] <> " ", else: "") + + spawn(fn() -> + with \ + {:ok, file} <- Telegram.Api.request(token, "getFile", file_id: file_id), + path = "https://api.telegram.org/file/bot#{token}/#{file["file_path"]}", + {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.get(path), + <> = body, + {:ok, magic} <- GenMagic.Pool.perform(Nola.GenMagic, {:bytes, smol_body}), + bucket = Application.get_env(:nola, :s3, []) |> Keyword.get(:bucket), + ext = Path.extname(file["file_path"]), + s3path = "#{account.id}/#{file_unique_id}#{ext}", + s3req = ExAws.S3.put_object(bucket, s3path, body, acl: :public_read, content_type: magic.mime_type), + {:ok, _} <- ExAws.request(s3req) + do + path = NolaWeb.Router.Helpers.url(NolaWeb.Endpoint) <> "/files/#{s3path}" + txt = "#{type}: #{text}#{path}" + connection = IRC.Connection.get_network(state.net) + IRC.send_message_as(account, state.net, state.chan, txt, true) + else + error -> + Telegram.Api.request(token, "sendMessage", chat_id: chat_id, text: "File upload failed, sorry.") + Logger.error("Failed upload from Telegram: #{inspect error}") + end + end) + + {:ok, state} + end + end + +end diff --git a/lib/nola_telegram/telegram.ex b/lib/nola_telegram/telegram.ex new file mode 100644 index 0000000..1c6a9a9 --- /dev/null +++ b/lib/nola_telegram/telegram.ex @@ -0,0 +1,233 @@ +defmodule Nola.Telegram do + require Logger + @behaviour Telegram.ChatBot + + def my_path() do + "https://t.me/beauttebot" + end + + def send_message(id, text, md2 \\ false) do + md = if md2, do: "MarkdownV2", else: "Markdown" + token = Keyword.get(Application.get_env(:nola, :telegram, []), :key) + Telegram.Bot.ChatBot.Chat.Session.Supervisor.start_child(Nola.Telegram, id) + Telegram.Api.request(token, "sendMessage", chat_id: id, text: text, parse_mode: "Markdown") + end + + @impl Telegram.ChatBot + def init(chat_id) when chat_id < 0 do + {:ok, state} = Nola.TelegramRoom.init(chat_id) + {:ok, %{room_state: state}} + end + def init(chat_id) do + Logger.info("Telegram session starting: #{chat_id}") + account = IRC.Account.find_meta_account("telegram-id", chat_id) + account_id = if account, do: account.id + {:ok, %{account: account_id}} + end + + @impl Telegram.ChatBot + def handle_update(update, token, %{room_state: room_state}) do + {:ok, room_state} = Nola.TelegramRoom.handle_update(update, token, room_state) + {:ok, %{room_state: room_state}} + end + + def handle_update(%{"message" => m = %{"chat" => %{"type" => "private"}, "text" => text = "/start"<>_}}, _token, state) do + text = "*Welcome to beautte!*\n\nQuery the bot on IRC and say \"enable-telegram\" to continue." + send_message(m["chat"]["id"], text) + {:ok, %{account: nil}} + end + + def handle_update(%{"message" => m = %{"chat" => %{"type" => "private"}, "text" => text = "/enable"<>_}}, _token, state) do + key = case String.split(text, " ") do + ["/enable", key | _] -> key + _ -> "nil" + end + + #Handled message "1247435154:AAGnSSCnySn0RuVxy_SUcDEoOX_rbF6vdq0" %{"message" => + # %{"chat" => %{"first_name" => "J", "id" => 2075406, "type" => "private", "username" => "ahref"}, + # "date" => 1591027272, "entities" => + # [%{"length" => 7, "offset" => 0, "type" => "bot_command"}], + # "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"}, + # "message_id" => 11, "text" => "/enable salope"}, "update_id" => 764148578} + account = IRC.Account.find_meta_account("telegram-validation-code", String.downcase(key)) + text = if account do + net = IRC.Account.get_meta(account, "telegram-validation-target") + IRC.Account.put_meta(account, "telegram-id", m["chat"]["id"]) + IRC.Account.put_meta(account, "telegram-username", m["chat"]["username"]) + IRC.Account.put_meta(account, "telegram-username", m["chat"]["username"]) + IRC.Account.delete_meta(account, "telegram-validation-code") + IRC.Account.delete_meta(account, "telegram-validation-target") + IRC.Connection.broadcast_message(net, account, "Telegram #{m["chat"]["username"]} account added!") + "Yay! Linked to account **#{account.name}**." + else + "Token invalid" + end + send_message(m["chat"]["id"], text) + {:ok, %{account: account.id}} + end + + #[debug] Unhandled update: %{"message" => + # %{"chat" => %{"first_name" => "J", "id" => 2075406, "type" => "private", "username" => "ahref"}, + # "date" => 1591096015, + # "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"}, + # "message_id" => 29, + # "photo" => [ + # %{"file_id" => "AgACAgQAAxkBAAMdXtYyz4RQqLcpOlR6xKK3w3NayHAAAnCzMRuL4bFSgl_cTXMl4m5G_T0kXQADAQADAgADbQADZVMBAAEaBA", + # "file_size" => 9544, "file_unique_id" => "AQADRv09JF0AA2VTAQAB", "height" => 95, "width" => 320}, + # %{"file_id" => "AgACAgQAAxkBAAMdXtYyz4RQqLcpOlR6xKK3w3NayHAAAnCzMRuL4bFSgl_cTXMl4m5G_T0kXQADAQADAgADeAADZFMBAAEaBA", + # "file_size" => 21420, "file_unique_id" => "AQADRv09JF0AA2RTAQAB", "height" => 148, "width" => 501}]}, + # "update_id" => 218161546} + + for type <- ~w(photo voice video document animation) do + def handle_update(data = %{"message" => %{unquote(type) => _}}, token, state) do + start_upload(unquote(type), data, token, state) + end + end + + #[debug] Unhandled update: %{"callback_query" => + # %{ + # "chat_instance" => "-7948978714441865930", "data" => "evolu.net/#dmz", + # "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"}, + # "id" => "8913804780149600", + # "message" => %{"chat" => %{"first_name" => "J", "id" => 2075406, "type" => "private", "username" => "ahref"}, + # "date" => 1591098553, "from" => %{"first_name" => "devbeautte", "id" => 1293058838, "is_bot" => true, "username" => "devbeauttebot"}, + # "message_id" => 62, + # "reply_markup" => %{"inline_keyboard" => [[%{"callback_data" => "random/#", "text" => "random/#"}, + # %{"callback_data" => "evolu.net/#dmz", "text" => "evolu.net/#dmz"}]]}, + # "text" => "Where should I send the file?"} + # } + # , "update_id" => 218161568} + + #def handle_update(t, %{"callback_query" => cb = %{"data" => "resend", "id" => id, "message" => m = %{"message_id" => m_id, "chat" => %{"id" => chat_id}, "reply_to_message" => op}}}) do + #end + + def handle_update(%{"callback_query" => cb = %{"data" => "start-upload:"<>target, "id" => id, "message" => m = %{"message_id" => m_id, "chat" => %{"id" => chat_id}, "reply_to_message" => op}}}, t, state) do + account = IRC.Account.find_meta_account("telegram-id", chat_id) + if account do + target = case String.split(target, "/") do + ["everywhere"] -> IRC.Membership.of_account(account) + [net, chan] -> [{net, chan}] + end + Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "Processing...", reply_markup: %{}) + + {content, type} = cond do + op["photo"] -> {op["photo"], ""} + op["voice"] -> {op["voice"], " a voice message"} + op["video"] -> {op["video"], ""} + op["document"] -> {op["document"], ""} + op["animation"] -> {op["animation"], ""} + end + + file = if is_list(content) && Enum.count(content) > 1 do + Enum.sort_by(content, fn(p) -> p["file_size"] end, &>=/2) + |> List.first() + else + content + end + file_id = file["file_id"] + file_unique_id = file["file_unique_id"] + text = if(op["caption"], do: ": "<> op["caption"] <> "", else: "") + resend = %{"inline_keyboard" => [ [%{"text" => "re-share", "callback_data" => "resend"}] ]} + spawn(fn() -> + with \ + {:ok, file} <- Telegram.Api.request(t, "getFile", file_id: file_id), + path = "https://api.telegram.org/file/bot#{t}/#{file["file_path"]}", + {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.get(path), + <> = body, + {:ok, magic} <- GenMagic.Pool.perform(Nola.GenMagic, {:bytes, smol_body}), + bucket = Application.get_env(:nola, :s3, []) |> Keyword.get(:bucket), + ext = Path.extname(file["file_path"]), + s3path = "#{account.id}/#{file_unique_id}#{ext}", + Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "*Uploading...*", reply_markup: %{}, parse_mode: "MarkdownV2"), + s3req = ExAws.S3.put_object(bucket, s3path, body, acl: :public_read, content_type: magic.mime_type), + {:ok, _} <- ExAws.request(s3req) + do + path = NolaWeb.Router.Helpers.url(NolaWeb.Endpoint) <> "/files/#{s3path}" + sent = for {net, chan} <- target do + txt = "sent#{type}#{text} #{path}" + IRC.send_message_as(account, net, chan, txt) + "#{net}/#{chan}" + end + if caption = op["caption"], do: as_irc_message(chat_id, caption, account) + text = "Sent on " <> Enum.join(sent, ", ") <> " !" + Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "_Sent!_", reply_markup: %{}, parse_mode: "MarkdownV2") + else + error -> + Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "Something failed.", reply_markup: %{}, parse_mode: "MarkdownV2") + Logger.error("Failed upload from Telegram: #{inspect error}") + end + end) + end + {:ok, state} + end + + def handle_update(%{"message" => m = %{"chat" => %{"id" => id, "type" => "private"}, "text" => text}}, _, state) do + account = IRC.Account.find_meta_account("telegram-id", id) + if account do + as_irc_message(id, text, account) + end + {:ok, state} + end + + def handle_update(m, _, state) do + Logger.debug("Unhandled update: #{inspect m}") + {:ok, state} + end + + @impl Telegram.ChatBot + def handle_info(info, %{room_state: room_state}) do + {:ok, room_state} = Nola.TelegramRoom.handle_info(info, room_state) + {:ok, %{room_state: room_state}} + end + + def handle_info(_info, state) do + {:ok, state} + end + + defp as_irc_message(id, text, account) do + reply_fun = fn(text) -> send_message(id, text) end + trigger_text = cond do + String.starts_with?(text, "/") -> + "/"<>text = text + "!"<>text + Enum.any?(IRC.Connection.triggers(), fn({trigger, _}) -> String.starts_with?(text, trigger) end) -> + text + true -> + "!"<>text + end + message = %IRC.Message{ + id: FlakeId.get(), + transport: :telegram, + network: "telegram", + channel: nil, + text: text, + account: account, + sender: %ExIRC.SenderInfo{nick: account.name}, + replyfun: reply_fun, + trigger: IRC.Connection.extract_trigger(trigger_text), + at: nil + } + IRC.Connection.publish(message, ["messages:private", "messages:telegram", "telegram/#{account.id}:messages"]) + message + end + + defp start_upload(_type, %{"message" => m = %{"chat" => %{"id" => id, "type" => "private"}}}, token, state) do + account = IRC.Account.find_meta_account("telegram-id", id) + if account do + text = if(m["text"], do: m["text"], else: nil) + targets = IRC.Membership.of_account(account) + |> Enum.map(fn({net, chan}) -> "#{net}/#{chan}" end) + |> Enum.map(fn(i) -> %{"text" => i, "callback_data" => "start-upload:#{i}"} end) + kb = if Enum.count(targets) > 1 do + [%{"text" => "everywhere", "callback_data" => "start-upload:everywhere"}] ++ targets + else + targets + end + |> Enum.chunk_every(2) + keyboard = %{"inline_keyboard" => kb} + Telegram.Api.request(token, "sendMessage", chat_id: id, text: "Where should I send this file?", reply_markup: keyboard, reply_to_message_id: m["message_id"], parse_mode: "MarkdownV2") + end + {:ok, state} + end + +end diff --git a/lib/nola_web/channels/user_socket.ex b/lib/nola_web/channels/user_socket.ex new file mode 100644 index 0000000..eadd4e0 --- /dev/null +++ b/lib/nola_web/channels/user_socket.ex @@ -0,0 +1,37 @@ +defmodule NolaWeb.UserSocket do + use Phoenix.Socket + + ## Channels + # channel "room:*", NolaWeb.RoomChannel + + ## Transports + #transport :websocket, Phoenix.Transports.WebSocket + # transport :longpoll, Phoenix.Transports.LongPoll + + # Socket params are passed from the client and can + # be used to verify and authenticate a user. After + # verification, you can put default assigns into + # the socket that will be set for all channels, ie + # + # {:ok, assign(socket, :user_id, verified_user_id)} + # + # To deny connection, return `:error`. + # + # See `Phoenix.Token` documentation for examples in + # performing token verification on connect. + def connect(_params, socket) do + {:ok, socket} + end + + # Socket id's are topics that allow you to identify all sockets for a given user: + # + # def id(socket), do: "user_socket:#{socket.assigns.user_id}" + # + # Would allow you to broadcast a "disconnect" event and terminate + # all active sockets and channels for a given user: + # + # NolaWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) + # + # Returning `nil` makes this socket anonymous. + def id(_socket), do: nil +end diff --git a/lib/nola_web/components/component.ex b/lib/nola_web/components/component.ex new file mode 100644 index 0000000..fff8263 --- /dev/null +++ b/lib/nola_web/components/component.ex @@ -0,0 +1,44 @@ +defmodule NolaWeb.Component do + use Phoenix.Component + + @date_time_default_format "%F %H:%M" + @date_time_formats %{"time-24-with-seconds" => "%H:%M:%S"} + def naive_date_time_utc(assigns = %{at: nil}) do + "" + end + + def naive_date_time_utc(assigns = %{format: format}) do + assigns = assign(assigns, :format, Map.get(@date_time_formats, format, format)) + ~H""" + + """ + end + def naive_date_time_utc(assigns) do + naive_date_time_utc(assign(assigns, :format, "%F %H:%M")) + end + def get_luxon_format("%H:%M:%S"), do: "TIME_24_WITH_SECONDS" + + def nick(assigns = %{self: false}) do + ~H""" + + <%= @nick %> + + """ + end + + def nick(assigns = %{self: true}) do + ~H""" + + You + + """ + end + + +end diff --git a/lib/nola_web/components/event_component.ex b/lib/nola_web/components/event_component.ex new file mode 100644 index 0000000..8af3c67 --- /dev/null +++ b/lib/nola_web/components/event_component.ex @@ -0,0 +1,43 @@ +defmodule NolaWeb.EventComponent do + use Phoenix.Component + + def content(assigns = %{event: %{type: :day_changed}}) do + ~H""" + Day changed: + <%= Date.to_string(@date) %> + """ + end + + def content(assigns = %{event: %{type: :quit}}) do + ~H""" + + has quit: + <%= @reason %> + """ + end + + def content(assigns = %{event: %{type: :part}}) do + ~H""" + + has left: + <%= @reason %> + """ + end + + def content(assigns = %{event: %{type: :nick}}) do + ~H""" + <%= @old_nick %> + is now known as + + """ + end + + def content(assigns = %{event: %{type: :join}}) do + ~H""" + + joined + """ + end + + +end diff --git a/lib/nola_web/components/message_component.ex b/lib/nola_web/components/message_component.ex new file mode 100644 index 0000000..5d0386b --- /dev/null +++ b/lib/nola_web/components/message_component.ex @@ -0,0 +1,12 @@ +defmodule NolaWeb.MessageComponent do + use Phoenix.Component + + def content(assigns) do + ~H""" + +
<%= @message.sender.nick %>
+
<%= @text %>
+ """ + end + +end diff --git a/lib/nola_web/context_plug.ex b/lib/nola_web/context_plug.ex new file mode 100644 index 0000000..ebededa --- /dev/null +++ b/lib/nola_web/context_plug.ex @@ -0,0 +1,92 @@ +defmodule NolaWeb.ContextPlug do + import Plug.Conn + import Phoenix.Controller + + def init(opts \\ []) do + opts || [] + end + + def get_account(conn) do + cond do + get_session(conn, :account) -> get_session(conn, :account) + get_session(conn, :oidc_id) -> if account = IRC.Account.find_meta_account("identity-id", get_session(conn, :oidc_id)), do: account.id + true -> nil + end + end + + def call(conn, opts) do + account = with \ + {:account, account_id} when is_binary(account_id) <- {:account, get_account(conn)}, + {:account, account} when not is_nil(account) <- {:account, IRC.Account.get(account_id)} + do + account + else + _ -> nil + end + + network = Map.get(conn.params, "network") + network = if network == "-", do: nil, else: network + + oidc_account = IRC.Account.find_meta_account("identity-id", get_session(conn, :oidc_id)) + + conns = IRC.Connection.get_network(network) + chan = if c = Map.get(conn.params, "chan") do + NolaWeb.reformat_chan(c) + end + chan_conn = IRC.Connection.get_network(network, chan) + + memberships = if account do + IRC.Membership.of_account(account) + end + + auth_required = cond do + Keyword.get(opts, :restrict) == :public -> false + account == nil -> true + network == nil -> false + Keyword.get(opts, :restrict) == :logged_in -> false + network && chan -> + !Enum.member?(memberships, {network, chan}) + network -> + !Enum.any?(memberships, fn({n, _}) -> n == network end) + end + + bot = cond do + network && chan && chan_conn -> chan_conn.nick + network && conns -> conns.nick + true -> nil + end + + + cond do + account && auth_required -> + conn + |> put_status(404) + |> text("Page not found") + |> halt() + auth_required -> + conn + |> put_status(403) + |> render(NolaWeb.AlcoologView, "auth.html", bot: bot, no_header: true, network: network) + |> halt() + (network && !conns) -> + conn + |> put_status(404) + |> text("Page not found") + |> halt() + (chan && !chan_conn) -> + conn + |> put_status(404) + |> text("Page not found") + |> halt() + true -> + conn = conn + |> assign(:network, network) + |> assign(:chan, chan) + |> assign(:bot, bot) + |> assign(:account, account) + |> assign(:oidc_account, oidc_account) + |> assign(:memberships, memberships) + end + end + +end diff --git a/lib/nola_web/controllers/alcoolog_controller.ex b/lib/nola_web/controllers/alcoolog_controller.ex new file mode 100644 index 0000000..3081762 --- /dev/null +++ b/lib/nola_web/controllers/alcoolog_controller.ex @@ -0,0 +1,323 @@ +defmodule NolaWeb.AlcoologController do + use NolaWeb, :controller + require Logger + + plug NolaWeb.ContextPlug when action not in [:token] + plug NolaWeb.ContextPlug, [restrict: :public] when action in [:token] + + def token(conn, %{"token" => token}) do + case Nola.Token.lookup(token) do + {:ok, {:alcoolog, :index, network, channel}} -> index(conn, nil, network, channel) + err -> + Logger.debug("AlcoologControler: token #{inspect err} invalid") + conn + |> put_status(404) + |> text("Page not found") + end + end + + def nick(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do + profile_account = IRC.Account.find_always_by_nick(network, nick, nick) + days = String.to_integer(Map.get(params, "days", "180")) + friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) + if friend? do + stats = Nola.IRC.AlcoologPlugin.get_full_statistics(profile_account.id) + history = for {{nick, ts}, points, active, cl, deg, type, descr, meta} <- Nola.IRC.AlcoologPlugin.nick_history(profile_account) do + %{ + at: ts |> DateTime.from_unix!(:millisecond), + points: points, + active: active, + cl: cl, + deg: deg, + type: type, + description: descr, + meta: meta + } + end + history = Enum.sort(history, &(DateTime.compare(&1.at, &2.at) != :lt)) + |> IO.inspect() + conn + |> assign(:title, "alcoolog #{nick}") + |> render("user.html", network: network, profile: profile_account, days: days, nick: nick, history: history, stats: stats) + else + conn + |> put_status(404) + |> text("Page not found") + end + end + + def nick_stats_json(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do + profile_account = IRC.Account.find_always_by_nick(network, nick, nick) + friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) + if friend? do + stats = Nola.IRC.AlcoologPlugin.get_full_statistics(profile_account.id) + + conn + |> put_resp_content_type("application/json") + |> text(Jason.encode!(stats)) + else + conn + |> put_status(404) + |> json([]) + end + end + + def nick_gls_json(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do + profile_account = IRC.Account.find_always_by_nick(network, nick, nick) + friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) + count = String.to_integer(Map.get(params, "days", "180")) + if friend? do + data = Nola.IRC.AlcoologPlugin.user_over_time_gl(profile_account, count) + delay = count*((24 * 60)*60) + now = DateTime.utc_now() + start_date = DateTime.utc_now() + |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) + |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) + |> DateTime.to_date() + |> Date.to_erl() + filled = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) + |> Enum.to_list + |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) + |> Enum.map(&Date.from_erl!(&1)) + |> Enum.map(fn(date) -> + %{date: date, gls: Map.get(data, date, 0)} + end) + |> Enum.sort(&(Date.compare(&1.date, &2.date) != :gt)) + + conn + |> put_resp_content_type("application/json") + |> text(Jason.encode!(filled)) + else + conn + |> put_status(404) + |> json([]) + end + end + + + + def nick_volumes_json(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do + profile_account = IRC.Account.find_always_by_nick(network, nick, nick) + friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) + count = String.to_integer(Map.get(params, "days", "180")) + if friend? do + data = Nola.IRC.AlcoologPlugin.user_over_time(profile_account, count) + delay = count*((24 * 60)*60) + now = DateTime.utc_now() + start_date = DateTime.utc_now() + |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) + |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) + |> DateTime.to_date() + |> Date.to_erl() + filled = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) + |> Enum.to_list + |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) + |> Enum.map(&Date.from_erl!(&1)) + |> Enum.map(fn(date) -> + %{date: date, volumes: Map.get(data, date, 0)} + end) + |> Enum.sort(&(Date.compare(&1.date, &2.date) != :gt)) + + conn + |> put_resp_content_type("application/json") + |> text(Jason.encode!(filled)) + else + conn + |> put_status(404) + |> json([]) + end + end + + def nick_log_json(conn = %{assigns: %{account: account}}, %{"network" => network, "nick" => nick}) do + profile_account = IRC.Account.find_always_by_nick(network, nick, nick) + friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) + if friend? do + history = for {{nick, ts}, points, active, cl, deg, type, descr, meta} <- Nola.IRC.AlcoologPlugin.nick_history(profile_account) do + %{ + at: ts |> DateTime.from_unix!(:millisecond) |> DateTime.to_iso8601(), + points: points, + active: active, + cl: cl, + deg: deg, + type: type, + description: descr, + meta: meta + } + end + last = List.last(history) + {_, active} = Nola.IRC.AlcoologPlugin.user_stats(profile_account) + last = %{last | active: active, at: DateTime.utc_now() |> DateTime.to_iso8601()} + history = history ++ [last] + + conn + |> put_resp_content_type("application/json") + |> text(Jason.encode!(history)) + else + conn + |> put_status(404) + |> json([]) + end + end + + def nick_history_json(conn = %{assigns: %{account: account}}, %{"network" => network, "nick" => nick}) do + profile_account = IRC.Account.find_always_by_nick(network, nick, nick) + friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id) + if friend? do + history = for {_, date, value} <- Nola.IRC.AlcoologAnnouncerPlugin.log(profile_account) do + %{date: DateTime.to_iso8601(date), value: value} + end + conn + |> put_resp_content_type("application/json") + |> text(Jason.encode!(history)) + else + conn + |> put_status(404) + |> json([]) + end + end + + def index(conn = %{assigns: %{account: account}}, %{"network" => network, "chan" => channel}) do + index(conn, account, network, NolaWeb.reformat_chan(channel)) + end + + def index(conn = %{assigns: %{account: account}}, _) do + index(conn, account, nil, nil) + end + + #def index(conn, params) do + # network = Map.get(params, "network") + # chan = if c = Map.get(params, "chan") do + # NolaWeb.reformat_chan(c) + # end + # irc_conn = if network do + # IRC.Connection.get_network(network, chan) + # end + # bot = if(irc_conn, do: irc_conn.nick)# + # + # conn + # |> put_status(403) + # |> render("auth.html", network: network, channel: chan, irc_conn: conn, bot: bot) + #end + + def index(conn, account, network, channel) do + aday = ((24 * 60)*60) + now = DateTime.utc_now() + before7 = now + |> DateTime.add(-(7*aday), :second) + |> DateTime.to_unix(:millisecond) + before15 = now + |> DateTime.add(-(15*aday), :second) + |> DateTime.to_unix(:millisecond) + before31 = now + |> DateTime.add(-(31*aday), :second) + |> DateTime.to_unix(:millisecond) + #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end) + match = [ + {{{:_, :"$1"}, :_, :_, :_, :_, :_, :_, :_}, + [ + {:>, :"$1", {:const, before15}}, + ], [:"$_"]} + ] + + # tuple ets: {{nick, date}, volumes, current, nom, commentaire} + members = IRC.Membership.expanded_members_or_friends(account, network, channel) + members_ids = Enum.map(members, fn({account, _, nick}) -> account.id end) + member_names = Enum.reduce(members, %{}, fn({account, _, nick}, acc) -> Map.put(acc, account.id, nick) end) + drinks = :ets.select(Nola.IRC.AlcoologPlugin.ETS, match) + |> Enum.filter(fn({{account, _}, _vol, _cur, _cl, _deg, _name, _cmt, _meta}) -> Enum.member?(members_ids, account) end) + |> Enum.map(fn({{account, _}, _, _, _, _, _, _, _} = object) -> {object, Map.get(member_names, account)} end) + |> Enum.sort_by(fn({{{_, ts}, _, _, _, _, _, _, _}, _}) -> ts end, &>/2) + + stats = Nola.IRC.AlcoologPlugin.get_channel_statistics(account, network, channel) + + top = Enum.reduce(drinks, %{}, fn({{{account_id, _}, vol, _, _, _, _, _, _}, _}, acc) -> + nick = Map.get(member_names, account_id) + all = Map.get(acc, nick, 0) + Map.put(acc, nick, all + vol) + end) + |> Enum.sort_by(fn({_nick, count}) -> count end, &>/2) + # {date, single_peak} + # + conn + |> assign(:title, "alcoolog") + |> render("index.html", network: network, channel: channel, drinks: drinks, top: top, stats: stats) + end + + def index_gls_json(conn = %{assigns: %{account: account}}, %{"network" => network, "chan" => channel}) do + count = 30 + channel = NolaWeb.reformat_chan(channel) + members = IRC.Membership.expanded_members_or_friends(account, network, channel) + members_ids = Enum.map(members, fn({account, _, nick}) -> account.id end) + member_names = Enum.reduce(members, %{}, fn({account, _, nick}, acc) -> Map.put(acc, account.id, nick) end) + delay = count*((24 * 60)*60) + now = DateTime.utc_now() + start_date = DateTime.utc_now() + |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) + |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) + |> DateTime.to_date() + |> Date.to_erl() + filled = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) + |> Enum.to_list + |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) + |> Enum.map(&Date.from_erl!(&1)) + |> Enum.map(fn(date) -> + {date, (for {a, _, _} <- members, into: Map.new, do: {Map.get(member_names, a.id, a.id), 0})} + end) + |> Enum.into(Map.new) + + gls = Enum.reduce(members, filled, fn({account, _, _}, gls) -> + Enum.reduce(Nola.IRC.AlcoologPlugin.user_over_time_gl(account, count), gls, fn({date, gl}, gls) -> + u = Map.get(gls, date, %{}) + |> Map.put(Map.get(member_names, account.id, account.id), gl) + Map.put(gls, date, u) + end) + end) + + dates = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) + |> Enum.to_list + |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) + |> Enum.map(&Date.from_erl!(&1)) + + filled2 = Enum.map(member_names, fn({_, name}) -> + history = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) + |> Enum.to_list + |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) + |> Enum.map(&Date.from_erl!(&1)) + |> Enum.map(fn(date) -> + get_in(gls, [date, name]) #%{date: date, gl: get_in(gls, [date, name])} + end) + if Enum.all?(history, fn(x) -> x == 0 end) do + nil + else + %{name: name, history: history} + end + end) + |> Enum.filter(fn(x) -> x end) + + conn + |> put_resp_content_type("application/json") + |> text(Jason.encode!(%{labels: dates, data: filled2})) + end + + def minisync(conn, %{"user_id" => user_id, "key" => key, "value" => value}) do + account = IRC.Account.get(user_id) + if account do + ds = Nola.IRC.AlcoologPlugin.data_state() + meta = Nola.IRC.AlcoologPlugin.get_user_meta(ds, account.id) + case Float.parse(value) do + {val, _} -> + new_meta = Map.put(meta, String.to_existing_atom(key), val) + Nola.IRC.AlcoologPlugin.put_user_meta(ds, account.id, new_meta) + _ -> + conn + |> put_status(:unprocessable_entity) + |> text("invalid value") + end + else + conn + |> put_status(:not_found) + |> text("not found") + end + end + +end diff --git a/lib/nola_web/controllers/gpt_controller.ex b/lib/nola_web/controllers/gpt_controller.ex new file mode 100644 index 0000000..038b235 --- /dev/null +++ b/lib/nola_web/controllers/gpt_controller.ex @@ -0,0 +1,33 @@ +defmodule NolaWeb.GptController do + use NolaWeb, :controller + require Logger + + plug NolaWeb.ContextPlug + + def result(conn, params = %{"id" => result_id}) do + case Nola.IRC.GptPlugin.get_result(result_id) do + {:ok, result} -> + network = Map.get(params, "network") + channel = if c = Map.get(params, "chan"), do: NolaWeb.reformat_chan(c) + render(conn, "result.html", network: network, channel: channel, result: result) + {:error, :not_found} -> + conn + |> put_status(404) + |> text("Page not found") + end + end + + def prompt(conn, params = %{"id" => prompt_id}) do + case Nola.IRC.GptPlugin.get_prompt(prompt_id) do + {:ok, prompt} -> + network = Map.get(params, "network") + channel = if c = Map.get(params, "chan"), do: NolaWeb.reformat_chan(c) + render(conn, "prompt.html", network: network, channel: channel, prompt: prompt) + {:error, :not_found} -> + conn + |> put_status(404) + |> text("Page not found") + end + end + +end diff --git a/lib/nola_web/controllers/icecast_see_controller.ex b/lib/nola_web/controllers/icecast_see_controller.ex new file mode 100644 index 0000000..877ad4e --- /dev/null +++ b/lib/nola_web/controllers/icecast_see_controller.ex @@ -0,0 +1,41 @@ +defmodule NolaWeb.IcecastSseController do + use NolaWeb, :controller + require Logger + + @ping_interval 20_000 + + def sse(conn, _params) do + conn + |> put_resp_header("X-Accel-Buffering", "no") + |> put_resp_header("content-type", "text/event-stream") + |> send_chunked(200) + |> subscribe + |> send_sse_message("ping", "ping") + |> send_sse_message("icecast", Nola.IcecastAgent.get) + |> sse_loop + end + + def subscribe(conn) do + :timer.send_interval(@ping_interval, {:event, :ping}) + {:ok, _} = Registry.register(Nola.BroadcastRegistry, "icecast", []) + conn + end + + def sse_loop(conn) do + {type, event} = receive do + {:event, :ping} -> {"ping", "ping"} + {:icecast, stats} -> {"icecast", stats} + end + + conn + |> send_sse_message(type, event) + |> sse_loop() + end + + defp send_sse_message(conn, type, data) do + json = Jason.encode!(%{type => data}) + {:ok, conn} = chunk(conn, "event: #{type}\ndata: #{json}\n\n") + conn + end + +end diff --git a/lib/nola_web/controllers/irc_auth_sse_controller.ex b/lib/nola_web/controllers/irc_auth_sse_controller.ex new file mode 100644 index 0000000..62ee2b5 --- /dev/null +++ b/lib/nola_web/controllers/irc_auth_sse_controller.ex @@ -0,0 +1,66 @@ +defmodule NolaWeb.IrcAuthSseController do + use NolaWeb, :controller + require Logger + + @ping_interval 20_000 + @expire_delay :timer.minutes(3) + + def sse(conn, params) do + perks = if uri = Map.get(params, "redirect_to") do + {:redirect, uri} + else + nil + end + token = String.downcase(EntropyString.random_string(65)) + conn + |> assign(:token, token) + |> assign(:perks, perks) + |> put_resp_header("X-Accel-Buffering", "no") + |> put_resp_header("content-type", "text/event-stream") + |> send_chunked(200) + |> subscribe() + |> send_sse_message("token", token) + |> sse_loop + end + + def subscribe(conn) do + :timer.send_interval(@ping_interval, {:event, :ping}) + :timer.send_after(@expire_delay, {:event, :expire}) + {:ok, _} = Registry.register(IRC.PubSub, "messages:private", []) + conn + end + + def sse_loop(conn) do + {type, event, exit} = receive do + {:event, :ping} -> {"ping", "ping", false} + {:event, :expire} -> {"expire", "expire", true} + {:irc, :text, %{account: account, text: token} = m} -> + if String.downcase(String.trim(token)) == conn.assigns.token do + path = Nola.AuthToken.new_path(account.id, conn.assigns.perks) + m.replyfun.("ok!") + {"authenticated", path, true} + else + {nil, nil, false} + end + _ -> {nil, nil, false} + end + + conn = if type do + send_sse_message(conn, type, event) + else + conn + end + + if exit do + conn + else + sse_loop(conn) + end + end + + defp send_sse_message(conn, type, data) do + {:ok, conn} = chunk(conn, "event: #{type}\ndata: #{data}\n\n") + conn + end + +end diff --git a/lib/nola_web/controllers/irc_controller.ex b/lib/nola_web/controllers/irc_controller.ex new file mode 100644 index 0000000..c617e78 --- /dev/null +++ b/lib/nola_web/controllers/irc_controller.ex @@ -0,0 +1,101 @@ +defmodule NolaWeb.IrcController do + use NolaWeb, :controller + + plug NolaWeb.ContextPlug + + def index(conn, params) do + network = Map.get(params, "network") + channel = if c = Map.get(params, "chan"), do: NolaWeb.reformat_chan(c) + commands = for mod <- Enum.uniq([IRC.Account.AccountPlugin] ++ IRC.Plugin.enabled()) do + if is_atom(mod) do + identifier = Module.split(mod) |> List.last |> String.replace("Plugin", "") |> Macro.underscore + {identifier, mod.irc_doc()} + end + end + |> Enum.filter(& &1) + |> Enum.filter(fn({_, doc}) -> doc end) + members = cond do + network && channel -> Enum.map(IRC.UserTrack.channel(network, channel), fn(tuple) -> IRC.UserTrack.User.from_tuple(tuple) end) + true -> + IRC.Membership.of_account(conn.assigns.account) + end + render conn, "index.html", network: network, commands: commands, channel: channel, members: members + end + + def txt(conn, %{"name" => name}) do + if String.contains?(name, ".txt") do + name = String.replace(name, ".txt", "") + data = data() + if Map.has_key?(data, name) do + lines = Enum.join(data[name], "\n") + text(conn, lines) + else + conn + |> put_status(404) + |> text("Not found") + end + else + do_txt(conn, name) + end + end + def txt(conn, _), do: do_txt(conn, nil) + + + defp do_txt(conn, nil) do + doc = Nola.IRC.TxtPlugin.irc_doc() + data = data() + main = Enum.filter(data, fn({trigger, _}) -> !String.contains?(trigger, ".") end) |> Enum.into(Map.new) + system = Enum.filter(data, fn({trigger, _}) -> String.contains?(trigger, ".") end) |> Enum.into(Map.new) + lines = Enum.reduce(main, 0, fn({_, lines}, acc) -> acc + Enum.count(lines) end) + conn + |> assign(:title, "txt") + |> render("txts.html", data: main, doc: doc, files: Enum.count(main), lines: lines, system: system) + end + + defp do_txt(conn, txt) do + data = data() + base_url = cond do + conn.assigns[:chan] -> "/#{conn.assigns.network}/#{NolaWeb.format_chan(conn.assigns.chan)}" + true -> "/-" + end + if lines = Map.get(data, txt) do + lines = Enum.map(lines, fn(line) -> + line + |> String.split("\\\\") + |> Enum.intersperse(Phoenix.HTML.Tag.tag(:br)) + end) + conn + |> assign(:breadcrumbs, [{"txt", "#{base_url}/txt"}]) + |> assign(:title, "#{txt}.txt") + |> render("txt.html", name: txt, data: lines, doc: nil) + else + conn + |> put_status(404) + |> text("Not found") + end + end + + defp data() do + dir = Application.get_env(:nola, :data_path) <> "/irc.txt/" + Path.wildcard(dir <> "/*.txt") + |> Enum.reduce(%{}, fn(path, m) -> + path = String.split(path, "/") + file = List.last(path) + key = String.replace(file, ".txt", "") + data = dir <> file + |> File.read! + |> String.split("\n") + |> Enum.reject(fn(line) -> + cond do + line == "" -> true + !line -> true + true -> false + end + end) + Map.put(m, key, data) + end) + |> Enum.sort + |> Enum.into(Map.new) + end + +end diff --git a/lib/nola_web/controllers/network_controller.ex b/lib/nola_web/controllers/network_controller.ex new file mode 100644 index 0000000..800294f --- /dev/null +++ b/lib/nola_web/controllers/network_controller.ex @@ -0,0 +1,11 @@ +defmodule NolaWeb.NetworkController do + use NolaWeb, :controller + plug NolaWeb.ContextPlug + + def index(conn, %{"network" => network}) do + conn + |> assign(:title, network) + |> render("index.html") + end + +end diff --git a/lib/nola_web/controllers/open_id_controller.ex b/lib/nola_web/controllers/open_id_controller.ex new file mode 100644 index 0000000..d3fef5d --- /dev/null +++ b/lib/nola_web/controllers/open_id_controller.ex @@ -0,0 +1,64 @@ +defmodule NolaWeb.OpenIdController do + use NolaWeb, :controller + plug NolaWeb.ContextPlug, restrict: :public + require Logger + + def login(conn, _) do + url = OAuth2.Client.authorize_url!(new_client(), scope: "openid", state: Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)) + redirect(conn, external: url) + end + + def callback(conn, %{"error" => error_code, "error_description" => error}) do + Logger.warn("OpenId error: #{error_code} #{error}") + render(conn, "error.html", error: error) + end + + def callback(conn, %{"code" => code, "state" => state}) do + with \ + client = %{token: %OAuth2.AccessToken{access_token: json}} = OAuth2.Client.get_token!(new_client(), state: state, code: code), + {:ok, %{"access_token" => token}} <- Jason.decode(json), + client = %OAuth2.Client{client | token: %OAuth2.AccessToken{access_token: token}}, + {:ok, %OAuth2.Response{body: body}} <- OAuth2.Client.get(client, "/userinfo"), + {:ok, %{"sub" => id, "preferred_username" => username}} <- Jason.decode(body) + do + if account = conn.assigns.account do + if !IRC.Account.get_meta(account, "identity-id") do # XXX: And oidc id not linked yet + IRC.Account.put_meta(account, "identity-id", id) + end + IRC.Account.put_meta(account, "identity-username", username) + conn + else + conn + end + + conn + |> put_session(:oidc_id, id) + |> put_flash(:info, "Logged in!") + |> redirect(to: Routes.path(conn, "/")) + else + {:error, %OAuth2.Response{status_code: 401}} -> + Logger.error("OpenID: Unauthorized token") + render(conn, "error.html", error: "The token is invalid.") + {:error, %OAuth2.Error{reason: reason}} -> + Logger.error("Error: #{inspect reason}") + render(conn, "error.html", error: reason) + end + end + + def callback(conn, _params) do + render(conn, "error.html", error: "Unspecified error.") + end + + defp new_client() do + config = Application.get_env(:nola, :oidc) + OAuth2.Client.new([ + strategy: OAuth2.Strategy.AuthCode, + client_id: config[:client_id], + client_secret: config[:client_secret], + site: config[:base_url], + authorize_url: config[:authorize_url], + token_url: config[:token_url], + redirect_uri: Routes.open_id_url(NolaWeb.Endpoint, :callback) + ]) + end +end diff --git a/lib/nola_web/controllers/page_controller.ex b/lib/nola_web/controllers/page_controller.ex new file mode 100644 index 0000000..2ac4d0a --- /dev/null +++ b/lib/nola_web/controllers/page_controller.ex @@ -0,0 +1,53 @@ +defmodule NolaWeb.PageController do + use NolaWeb, :controller + + plug NolaWeb.ContextPlug when action not in [:token] + plug NolaWeb.ContextPlug, [restrict: :public] when action in [:token] + + def token(conn, %{"token" => token}) do + with \ + {:ok, account, perks} <- Nola.AuthToken.lookup(token) + do + IO.puts("Authenticated account #{inspect account}") + conn = put_session(conn, :account, account) + case perks do + nil -> redirect(conn, to: "/") + {:redirect, path} -> redirect(conn, to: path) + {:external_redirect, url} -> redirect(conn, external: url) + end + else + z -> + IO.inspect(z) + text(conn, "Error: invalid or expired token") + end + end + + def index(conn = %{assigns: %{account: account}}, _) do + memberships = IRC.Membership.of_account(account) + users = IRC.UserTrack.find_by_account(account) + metas = IRC.Account.get_all_meta(account) + predicates = IRC.Account.get_predicates(account) + conn + |> assign(:title, account.name) + |> render("user.html", users: users, memberships: memberships, metas: metas, predicates: predicates) + end + + def irc(conn, _) do + bot_helps = for mod <- Nola.IRC.env(:handlers) do + mod.irc_doc() + end + render conn, "irc.html", bot_helps: bot_helps + end + + def authenticate(conn, _) do + with \ + {:account, account_id} when is_binary(account_id) <- {:account, get_session(conn, :account)}, + {:account, account} when not is_nil(account) <- {:account, IRC.Account.get(account_id)} + do + assign(conn, :account, account) + else + _ -> conn + end + end + +end diff --git a/lib/nola_web/controllers/sms_controller.ex b/lib/nola_web/controllers/sms_controller.ex new file mode 100644 index 0000000..575655c --- /dev/null +++ b/lib/nola_web/controllers/sms_controller.ex @@ -0,0 +1,10 @@ +defmodule NolaWeb.SmsController do + use NolaWeb, :controller + require Logger + + def ovh_callback(conn, %{"senderid" => from, "message" => message}) do + spawn(fn() -> Nola.IRC.SmsPlugin.incoming(from, String.trim(message)) end) + text(conn, "") + end + +end diff --git a/lib/nola_web/controllers/untappd_controller.ex b/lib/nola_web/controllers/untappd_controller.ex new file mode 100644 index 0000000..d3a540d --- /dev/null +++ b/lib/nola_web/controllers/untappd_controller.ex @@ -0,0 +1,18 @@ +defmodule NolaWeb.UntappdController do + use NolaWeb, :controller + + def callback(conn, %{"code" => code}) do + with \ + {:account, account_id} when is_binary(account_id) <- {:account, get_session(conn, :account)}, + {:account, account} when not is_nil(account) <- {:account, IRC.Account.get(account_id)}, + {:ok, auth_token} <- Untappd.auth_callback(code) + do + IRC.Account.put_meta(account, "untappd-token", auth_token) + text(conn, "OK!") + else + {:account, _} -> text(conn, "Error: account not found") + :error -> text(conn, "Error: untappd authentication failed") + end + end + +end diff --git a/lib/nola_web/endpoint.ex b/lib/nola_web/endpoint.ex new file mode 100644 index 0000000..a401f54 --- /dev/null +++ b/lib/nola_web/endpoint.ex @@ -0,0 +1,62 @@ +defmodule NolaWeb.Endpoint do + use Sentry.PlugCapture + use Phoenix.Endpoint, otp_app: :nola + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phoenix.digest + # when deploying your static files in production. + plug Plug.Static, + at: "/", from: :nola, gzip: false, + only: ~w(assets css js fonts images favicon.ico robots.txt) + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if 42==43 && code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + end + + plug Plug.RequestId + plug Plug.Logger + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Jason + + plug Sentry.PlugContext + plug Plug.MethodOverride + plug Plug.Head + + @session_options [store: :cookie, + key: "_nola_key", + signing_salt: "+p7K3wrj"] + + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]] + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + plug Plug.Session, @session_options + + plug NolaWeb.Router + + @doc """ + Callback invoked for dynamically configuring the endpoint. + + It receives the endpoint configuration and checks if + configuration should be loaded from the system environment. + """ + def init(_key, config) do + if config[:load_from_system_env] do + port = System.get_env("PORT") || raise "expected the PORT environment variable to be set" + {:ok, Keyword.put(config, :http, [:inet6, port: port])} + else + {:ok, config} + end + end +end diff --git a/lib/nola_web/gettext.ex b/lib/nola_web/gettext.ex new file mode 100644 index 0000000..a43cb0d --- /dev/null +++ b/lib/nola_web/gettext.ex @@ -0,0 +1,24 @@ +defmodule NolaWeb.Gettext do + @moduledoc """ + A module providing Internationalization with a gettext-based API. + + By using [Gettext](https://hexdocs.pm/gettext), + your module gains a set of macros for translations, for example: + + import NolaWeb.Gettext + + # Simple translation + gettext "Here is the string to translate" + + # Plural translation + ngettext "Here is the string to translate", + "Here are the strings to translate", + 3 + + # Domain-based translation + dgettext "errors", "Here is the error message to translate" + + See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. + """ + use Gettext, otp_app: :nola +end diff --git a/lib/nola_web/live/chat_live.ex b/lib/nola_web/live/chat_live.ex new file mode 100644 index 0000000..276b362 --- /dev/null +++ b/lib/nola_web/live/chat_live.ex @@ -0,0 +1,120 @@ +defmodule NolaWeb.ChatLive do + use Phoenix.LiveView + use Phoenix.HTML + require Logger + + def mount(%{"network" => network, "chan" => chan}, %{"account" => account_id}, socket) do + chan = NolaWeb.reformat_chan(chan) + connection = IRC.Connection.get_network(network, chan) + account = IRC.Account.get(account_id) + membership = IRC.Membership.of_account(IRC.Account.get("DRgpD4fLf8PDJMLp8Dtb")) + if account && connection && Enum.member?(membership, {connection.network, chan}) do + {:ok, _} = Registry.register(IRC.PubSub, "#{connection.network}:events", plugin: __MODULE__) + for t <- ["messages", "triggers", "outputs", "events"] do + {:ok, _} = Registry.register(IRC.PubSub, "#{connection.network}/#{chan}:#{t}", plugin: __MODULE__) + end + + IRC.PuppetConnection.start(account, connection) + + users = IRC.UserTrack.channel(connection.network, chan) + |> Enum.map(fn(tuple) -> IRC.UserTrack.User.from_tuple(tuple) end) + |> Enum.reduce(Map.new, fn(user = %{id: id}, acc) -> + Map.put(acc, id, user) + end) + + backlog = case Nola.IRC.BufferPlugin.select_buffer(connection.network, chan) do + {backlog, _} -> + {backlog, _} = Enum.reduce(backlog, {backlog, nil}, &reduce_contextual_event/2) + Enum.reverse(backlog) + _ -> [] + end + + socket = socket + |> assign(:connection_id, connection.id) + |> assign(:network, connection.network) + |> assign(:chan, chan) + |> assign(:title, "live") + |> assign(:channel, chan) + |> assign(:account_id, account.id) + |> assign(:backlog, backlog) + |> assign(:users, users) + |> assign(:counter, 0) + + {:ok, socket} + else + {:ok, redirect(socket, to: "/")} + end + end + + def handle_event("send", %{"message" => %{"text" => text}}, socket) do + account = IRC.Account.get(socket.assigns.account_id) + IRC.send_message_as(account, socket.assigns.network, socket.assigns.channel, text, true) + {:noreply, assign(socket, :counter, socket.assigns.counter + 1)} + end + + def handle_info({:irc, :event, event = %{type: :join, user_id: id}}, socket) do + if user = IRC.UserTrack.lookup(id) do + socket = socket + |> assign(:users, Map.put(socket.assigns.users, id, user)) + |> append_to_backlog(event) + {:noreply, socket} + else + {:noreply, socket} + end + end + + def handle_info({:irc, :event, event = %{type: :nick, user_id: id, nick: nick}}, socket) do + socket = socket + |> assign(:users, update_in(socket.assigns.users, [id, :nick], nick)) + |> append_to_backlog(event) + {:noreply, socket} + end + + def handle_info({:irc, :event, event = %{type: :quit, user_id: id}}, socket) do + socket = socket + |> assign(:users, Map.delete(socket.assigns.users, id)) + |> append_to_backlog(event) + {:noreply, socket} + end + + def handle_info({:irc, :event, event = %{type: :part, user_id: id}}, socket) do + socket = socket + |> assign(:users, Map.delete(socket.assigns.users, id)) + |> append_to_backlog(event) + {:noreply, socket} + end + + def handle_info({:irc, :trigger, _, message}, socket) do + handle_info({:irc, nil, message}, socket) + end + + def handle_info({:irc, :text, message}, socket) do + IO.inspect({:live_message, message}) + socket = socket + |> append_to_backlog(message) + {:noreply, socket} + end + + def handle_info(info, socket) do + Logger.debug("Unhandled info: #{inspect info}") + {:noreply, socket} + end + + defp append_to_backlog(socket, line) do + {add, _} = reduce_contextual_event(line, {[], List.last(socket.assigns.backlog)}) + assign(socket, :backlog, socket.assigns.backlog ++ add) + end + + defp reduce_contextual_event(line, {acc, nil}) do + {[line | acc], line} + end + defp reduce_contextual_event(line, {acc, last}) do + if NaiveDateTime.to_date(last.at) != NaiveDateTime.to_date(line.at) do + {[%{type: :day_changed, date: NaiveDateTime.to_date(line.at), at: nil}, line | acc], line} + else + {[line | acc], line} + end + + end + +end diff --git a/lib/nola_web/live/chat_live.html.heex b/lib/nola_web/live/chat_live.html.heex new file mode 100644 index 0000000..29cd6a1 --- /dev/null +++ b/lib/nola_web/live/chat_live.html.heex @@ -0,0 +1,91 @@ +
+ +
+
+

+ <%= @network %> + <%= @chan %> +

+ +
+
+ +
+ +
+

+ Disconnected :'( +

+

+ Oh no error >:( +

+ +
    + <%= for message <- @backlog do %> + <%= if is_map(message) && Map.get(message, :__struct__) == IRC.Message do %> +
  • + +
  • + <% end %> + + <%= if is_binary(message) do %> +
  • <%= message %>
  • + <% end %> + + <%= if is_map(message) && Map.get(message, :type) do %> +
  • + + * * * + + + +
  • + <% end %> + <% end %> +
+
+ + + +
+ + <.form let={f} id={"form-#{@counter}"} for={:message} phx-submit="send" class="w-full px-4 pt-4"> +
+
+ <%= text_input f, :text, class: "focus:ring-indigo-500 focus:border-indigo-500 block w-full border rounded-md pl-4 sm:text-sm border-gray-300", autofocus: true, 'phx-hook': "AutoFocus", autocomplete: "off", placeholder: "Don't be shy, say something…" %> + <%= submit content_tag(:span, "Send"), class: "-ml-px relative inline-flex items-center space-x-2 px-4 py-2 border border-gray-300 text-sm font-medium rounded-r-md text-gray-700 bg-gray-50 hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500"%> +
+
+ +
diff --git a/lib/nola_web/router.ex b/lib/nola_web/router.ex new file mode 100644 index 0000000..5658fda --- /dev/null +++ b/lib/nola_web/router.ex @@ -0,0 +1,85 @@ +defmodule NolaWeb.Router do + use NolaWeb, :router + + pipeline :browser do + plug :accepts, ["html", "txt"] + plug :fetch_session + plug :fetch_flash + plug :fetch_live_flash + plug :protect_from_forgery + plug :put_secure_browser_headers + plug :put_root_layout, {NolaWeb.LayoutView, :root} + end + + pipeline :api do + plug :accepts, ["json", "sse"] + end + + pipeline :matrix_app_service do + plug :accepts, ["json"] + plug Nola.Matrix.Plug.Auth + plug Nola.Matrix.Plug.SetConfig + end + + scope "/api", NolaWeb do + pipe_through :api + get "/irc-auth.sse", IrcAuthSseController, :sse + post "/sms/callback/Ovh", SmsController, :ovh_callback, as: :sms + end + + scope "/", NolaWeb do + pipe_through :browser + get "/", PageController, :index + + get "/login/irc/:token", PageController, :token, as: :login + get "/login/oidc", OpenIdController, :login + get "/login/oidc/callback", OpenIdController, :callback + + get "/api/untappd/callback", UntappdController, :callback, as: :untappd_callback + + get "/-", IrcController, :index + get "/-/txt", IrcController, :txt + get "/-/txt/:name", IrcController, :txt + get "/-/gpt/prompt/:id", GptController, :task + get "/-/gpt/result/:id", GptController, :result + + get "/-/alcoolog", AlcoologController, :index + get "/-/alcoolog/~/:account_name", AlcoologController, :index + get "/:network", NetworkController, :index + get "/:network/~:nick/alcoolog", AlcoologController, :nick + get "/:network/~:nick/alcoolog/log.json", AlcoologController, :nick_log_json + get "/:network/~:nick/alcoolog/gls.json", AlcoologController, :nick_gls_json + get "/:network/~:nick/alcoolog/volumes.json", AlcoologController, :nick_volumes_json + get "/:network/~:nick/alcoolog/history.json", AlcoologController, :nick_history_json + get "/:network/~:nick/alcoolog/stats.json", AlcoologController, :nick_stats_json + get "/:network/:chan/alcoolog", AlcoologController, :index + get "/:network/:chan/alcoolog/gls.json", AlcoologController, :index_gls_json + get "/:network/:chan/gpt/prompt/:id", GptController, :task + get "/:network/:chan/gpt/result/:id", GptController, :result + put "/api/alcoolog/minisync/:user_id/meta/:key", AlcoologController, :minisync_put_meta + + get "/:network/:chan", IrcController, :index + live "/:network/:chan/live", ChatLive + get "/:network/:chan/txt", IrcController, :txt + get "/:network/:chan/txt/:name", IrcController, :txt + get "/:network/:channel/preums", IrcController, :preums + get "/:network/:chan/alcoolog/t/:token", AlcoologController, :token + end + + scope "/_matrix/:appservice", MatrixAppServiceWeb.V1, as: :matrix do + pipe_through :matrix_app_service + + put "/transactions/:txn_id", TransactionController, :push + + get "/users/:user_id", UserController, :query + get "/rooms/*room_alias", RoomController, :query + + get "/thirdparty/protocol/:protocol", ThirdPartyController, :query_protocol + get "/thirdparty/user/:protocol", ThirdPartyController, :query_users + get "/thirdparty/location/:protocol", ThirdPartyController, :query_locations + get "/thirdparty/location", ThirdPartyController, :query_location_by_alias + get "/thirdparty/user", ThirdPartyController, :query_user_by_id + end + + +end diff --git a/lib/nola_web/templates/alcoolog/auth.html.eex b/lib/nola_web/templates/alcoolog/auth.html.eex new file mode 100644 index 0000000..6e5cedc --- /dev/null +++ b/lib/nola_web/templates/alcoolog/auth.html.eex @@ -0,0 +1,43 @@ +
+

authentication

+
+ <%= link("connect using random.sh", to: "/login/oidc", class: "inline-block font-medium underline") %> +
+
+ +
+

+ <%= if @bot, do: "Send this to #{@bot} on #{@network}:", else: "Find your bot nickname and send:" %>

+ /msg <%= @bot || "the-bot-nickname" %> web +

+ ... then come back to this address. +

+
+ + diff --git a/lib/nola_web/templates/alcoolog/index.html.eex b/lib/nola_web/templates/alcoolog/index.html.eex new file mode 100644 index 0000000..5a5423a --- /dev/null +++ b/lib/nola_web/templates/alcoolog/index.html.eex @@ -0,0 +1,205 @@ + + +<%= if @stats == [] do %> +
+
+
+ + + +
+
+

+ CATASTROPHE! Personne n'a bu!!!! +

+
+
+
+<% end %> + +
    + <%= for {nick, status} <- @stats do %> +
  • +
    +
    +
    +

    <%= link nick, to: alcoolog_path(@conn, :nick, @network, nick) %>

    + <% rising_class = if status.rising, do: "teal", else: "red" %> + + <%= status.trend_symbol %> <%= Float.round(status.active, 4) %> g/l + +
    +

    + <%= status.last_cl %>cl @ <%= status.last_deg %>° + <%= if status.last_descr && status.last_descr != "" do %> +
    <%= status.last_descr %> + <% end %> +
    <%= NolaWeb.LayoutView.format_time(status.last_at) %> +

    + +

    +
    + — sobre dans: <%= status.sober_in_s %>
    + <%= if status.since do %> + — depuis: <%= status.since_s %>
    + <% end %> + + — 15m: <%= status.active15m %> g/l - 30m: <%= status.active30m %> g/l - 1h: <%= status.active1h %> g/l
    + — aujourd'hui: <%= status.daily_volumes %> points, <%= status.daily_gl %> g/l +
    +

    + + +
    +
    +
  • + <% end %> +
+ +<%= if @stats == %{} do %> +
+
+
+ + + +
+
+

+ ENCORE PIRE! Aucune boisson enregistrée! +

+
+
+
+<% else %> + + + +

Classement 15 jours

+ +
    + <%= for {{nick, count}, rank} <- Enum.with_index(@top) do %> + <% rank = rank + 1 %> + <% trophy = rank <= 3 %> + <% {colour, text} = case rank do +1 -> {"yellow-500", "font-semibold text-base"} +2 -> {"gray-500", "font-medium text-base"} +3 -> {"orange-300", "font-medium text-base"} +_ -> {"gray-300", ""} + end %> +
  • + <%= if trophy do %> + + + + <% end %> + #<%= rank %>: <%= link nick, to: alcoolog_path(@conn, :nick, @network, nick) %> +
    + <%= Float.round(count, 4) %> +
  • + <% end %> +
+ +

Historique

+
+
+
+ + + + + + + + + + + <%= for {{{{account, date}, points, _active, cl, deg, nom, comment, _meta}, nick}, index} <- Enum.with_index(@drinks) do %> + <% class = if(Integer.is_even(index), do: "bg-gray-50", else: "bg-white") %> + <% date = DateTime.from_unix!(date, :millisecond) %> + + + + + + + <% end %> + +
+ date + + nick + +   + +   +
+ <%= NolaWeb.LayoutView.format_time(date, false) %> + + <%= link nick, to: alcoolog_path(@conn, :nick, @network, nick) %> + <%= cl %>cl <%= deg %>°<%= comment||"" %>
+
+
+
+<% end %> + +<%= if @conn.assigns.account && (@network || @channel) do %> + <%= link("alcoolog global", to: alcoolog_path(@conn, :index)) %> +<% end %> + + + + + + + + diff --git a/lib/nola_web/templates/alcoolog/user.html.eex b/lib/nola_web/templates/alcoolog/user.html.eex new file mode 100644 index 0000000..d7f716b --- /dev/null +++ b/lib/nola_web/templates/alcoolog/user.html.eex @@ -0,0 +1,170 @@ +<%= if @stats.active > 0 do %> +

+ <% rising_class = if @stats.rising, do: "teal", else: "red" %> + + <%= @stats.trend_symbol %> <%= Float.round(@stats.active, 4) %> g/l + + + <%= @stats.last_cl %>cl @ <%= @stats.last_deg %>° + + <%= if @stats.last_descr && @stats.last_descr != "" do %> + <%= @stats.last_descr %> + <% end %> + + + <%= NolaWeb.LayoutView.format_time(@stats.last_at) %> + +

+ +

+ a commencé il y a <%= @stats.since_s %> + — + sobre dans <%= @stats.sober_in_s %> +

+<% else %> +

+ est sobre! + +

+ dernier verre + <%= @stats.last_cl %>cl @ <%= @stats.last_deg %>° + + <%= if @stats.last_descr && @stats.last_descr != "" do %> + <%= @stats.last_descr %> + <% end %> + + + <%= NolaWeb.LayoutView.format_time(@stats.last_at) %> +

+<% end %> + + + + + +

Historique

+
+
+
+ + + + + + + + + + <%= for {%{at: date, cl: cl, deg: deg, description: comment}, index} <- Enum.with_index(@history) do %> + <% class = if(Integer.is_even(index), do: "bg-gray-50", else: "bg-white") %> + + + + + + <% end %> + +
+ date + +   + +   +
+ <%= NolaWeb.LayoutView.format_time(date, false) %> + <%= cl %>cl <%= deg %>°<%= comment||"" %>
+
+
+
+ +
+ + + + + + + + diff --git a/lib/nola_web/templates/irc/index.html.eex b/lib/nola_web/templates/irc/index.html.eex new file mode 100644 index 0000000..182624d --- /dev/null +++ b/lib/nola_web/templates/irc/index.html.eex @@ -0,0 +1,44 @@ + + +<%= if @members != [] do %> +
    + <%= for user <- @members do %>
  • <%= user.nick %>
  • <% end %> +
+<% end %> + +
+ <%= for {identifier, help} <- @commands do %> + <%= if help do %> +
+
<%= NolaWeb.LayoutView.liquid_markdown(@conn, help) %>
+
+ <% end %> + <% end %> +
+ +


+ +

+ + Légende:
+ entre < >: argument obligatoire,
+ entre [ ]: argument optionel; [1 | ]: argument optionel avec valeur par défaut. +
+

+ +


+ +

+ + running beautte version <%= Nola.version() %> — git + +

diff --git a/lib/nola_web/templates/irc/txt.html.eex b/lib/nola_web/templates/irc/txt.html.eex new file mode 100644 index 0000000..fd4ea00 --- /dev/null +++ b/lib/nola_web/templates/irc/txt.html.eex @@ -0,0 +1,27 @@ + + +
    + <%= for {txt, id} <- Enum.with_index(@data) do %> +
  1. + + <%= txt %> + # +
  2. + <% end %> +
+ +

+ télécharger au format texte +

diff --git a/lib/nola_web/templates/irc/txts.html.eex b/lib/nola_web/templates/irc/txts.html.eex new file mode 100644 index 0000000..aff0c5d --- /dev/null +++ b/lib/nola_web/templates/irc/txts.html.eex @@ -0,0 +1,49 @@ +
+

+ <%= @lines %> lignes dans <%= @files %> fichiers + Aide + Fichiers système +

+
    + <%= for {txt, data} <- @data do %> + <% base_url = cond do + @conn.assigns[:chan] -> "/#{@conn.assigns.network}/#{NolaWeb.format_chan(@conn.assigns.chan)}" + true -> "/-" + end %> +
  • +
    +
    + <%= txt %> +

    <%= Enum.count(data) %> lignes

    +
    +
    +
  • + <% end %> +
+
+ +
<%= NolaWeb.LayoutView.liquid_markdown(@conn, @doc) %>
+ +
+

+ Fichiers système +

+ +
    + <%= for {txt, data} <- @system do %> + <% base_url = cond do + @conn.assigns[:chan] -> "/#{@conn.assigns.network}/#{NolaWeb.format_chan(@conn.assigns.chan)}" + true -> "/-" + end %> +
  • +
    +
    + <%= txt %> +

    <%= Enum.count(data) %> lignes

    +
    +
    +
  • + <% end %> +
+ +
diff --git a/lib/nola_web/templates/layout/app.html.eex b/lib/nola_web/templates/layout/app.html.eex new file mode 100644 index 0000000..c774369 --- /dev/null +++ b/lib/nola_web/templates/layout/app.html.eex @@ -0,0 +1,126 @@ +
+
+ +
+
+

+ <%= if n = @conn.assigns[:network] do %><%= n %> › <% end %> + <%= if c = @conn.assigns[:chan] do %><%= c %> › <% end %> + <%= for({name, href} <- Enum.uniq(@conn.assigns[:breadcrumbs]||[]), do: [link(name, to: href), raw(" › ")]) %> + <%= @conn.assigns[:title] %> +

+
+
+
+ +
+
+ +
+ <%= @inner_content %> +
+ +
+
+
diff --git a/lib/nola_web/templates/layout/root.html.leex b/lib/nola_web/templates/layout/root.html.leex new file mode 100644 index 0000000..6a48506 --- /dev/null +++ b/lib/nola_web/templates/layout/root.html.leex @@ -0,0 +1,18 @@ + + + + <%= page_title(@conn) %> + + + + + + <%= Map.get(assigns, :title, "") %> + "> + <%= csrf_meta_tag() %> + + + + <%= @inner_content %> + + diff --git a/lib/nola_web/templates/network/index.html.eex b/lib/nola_web/templates/network/index.html.eex new file mode 100644 index 0000000..fc024dd --- /dev/null +++ b/lib/nola_web/templates/network/index.html.eex @@ -0,0 +1 @@ +pouet diff --git a/lib/nola_web/templates/open_id/error.html.eex b/lib/nola_web/templates/open_id/error.html.eex new file mode 100644 index 0000000..d1b35b9 --- /dev/null +++ b/lib/nola_web/templates/open_id/error.html.eex @@ -0,0 +1,3 @@ +

OpenID authentication error

+ +

<%= @error %>

diff --git a/lib/nola_web/templates/page/api.html.eex b/lib/nola_web/templates/page/api.html.eex new file mode 100644 index 0000000..03dfa6b --- /dev/null +++ b/lib/nola_web/templates/page/api.html.eex @@ -0,0 +1,35 @@ +

sys.115ans.net/api

+ +

Icecast Status

+ +

GET /api/icecast.json

+ +

+ Content-Type: application/json +

+ +

+{
+  "np": String,
+  "genre": null | String,
+  "live": false | true
+}
+
+ +

GET /api/icecast.sse

+

+ Content-Type: text/event-stream +

+ +

+ Stream of: +

+ +
    +
  • icecast events (same format as /api/icecast.json)
  • +
  • ping events (to keep-alive connection. You can safely ignore them)
  • +
+

+ On client connection, the server sends the latest icecast status known. +

+ diff --git a/lib/nola_web/templates/page/index.html.eex b/lib/nola_web/templates/page/index.html.eex new file mode 100644 index 0000000..1b8519a --- /dev/null +++ b/lib/nola_web/templates/page/index.html.eex @@ -0,0 +1 @@ +

vOv

diff --git a/lib/nola_web/templates/page/irc.html.eex b/lib/nola_web/templates/page/irc.html.eex new file mode 100644 index 0000000..f6598ee --- /dev/null +++ b/lib/nola_web/templates/page/irc.html.eex @@ -0,0 +1,19 @@ +

bot `115ans

+ +

Si vous cherchez l'IRC c'est par là.

+ + + +
+ <%= for help <- @bot_helps do %> +
<%= help |> Earmark.as_html! |> raw() %>
+ <% end %> +
+ diff --git a/lib/nola_web/templates/page/user.html.eex b/lib/nola_web/templates/page/user.html.eex new file mode 100644 index 0000000..de9f718 --- /dev/null +++ b/lib/nola_web/templates/page/user.html.eex @@ -0,0 +1,43 @@ +
+
    +
  • <%= link("Help", to: "/-") %>
  • + <%= unless List.keyfind(@metas, "identity-id", 0) do %> +
  • <%= link("Connect with random.sh", to: Routes.open_id_path(@conn, :login)) %>
  • + <% end %> +
+ +

channels

+
    + <%= for {net, channel} <- @memberships do %> +
  • + <% url = NolaWeb.Router.Helpers.irc_path(NolaWeb.Endpoint, :index, net, NolaWeb.format_chan(channel)) %> + <%= link([net, ": ", content_tag(:strong, channel)], to: url) %> +
  • + <% end %> +
+ +

connections

+
    + <%= for user <- @users do %> +
  • + <%= user.network %>: <%= user.nick %>!<%= user.username %>@<%= user.host %> <%= user.realname %>
    + <%= Enum.join(Enum.intersperse(Enum.map(user.privileges, fn({c, _}) -> c end), ", ")) %> +
  • + <% end %> +
+ +

account

+
    +
  • account-id: <%= @conn.assigns.account.id %>
  • + <%= for {k, v} <- @metas do %> +
  • <%= k %>: <%= to_string(v) %>
  • + <% end %> +
+ +irc auths: +
    + <%= for {net, {predicate, v}} <- @predicates do %> +
  • <%= net %>: <%= to_string(predicate) %>, <%= v %>
  • + <% end %> +
+
diff --git a/lib/nola_web/templates/page/widget.html.eex b/lib/nola_web/templates/page/widget.html.eex new file mode 100644 index 0000000..65853b3 --- /dev/null +++ b/lib/nola_web/templates/page/widget.html.eex @@ -0,0 +1,20 @@ + + + + + + +"> + + + + +
"> +
+
+
<%= @icecast.genre %>
+
<%= @icecast.np %>
+
+
+ + diff --git a/lib/nola_web/views/alcoolog_view.ex b/lib/nola_web/views/alcoolog_view.ex new file mode 100644 index 0000000..ad52472 --- /dev/null +++ b/lib/nola_web/views/alcoolog_view.ex @@ -0,0 +1,6 @@ +defmodule NolaWeb.AlcoologView do + use NolaWeb, :view + require Integer + +end + diff --git a/lib/nola_web/views/error_helpers.ex b/lib/nola_web/views/error_helpers.ex new file mode 100644 index 0000000..25214bd --- /dev/null +++ b/lib/nola_web/views/error_helpers.ex @@ -0,0 +1,40 @@ +defmodule NolaWeb.ErrorHelpers do + @moduledoc """ + Conveniences for translating and building error messages. + """ + + use Phoenix.HTML + + @doc """ + Generates tag for inlined form input errors. + """ + def error_tag(form, field) do + Enum.map(Keyword.get_values(form.errors, field), fn (error) -> + content_tag :span, translate_error(error), class: "help-block" + end) + end + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # Because error messages were defined within Ecto, we must + # call the Gettext module passing our Gettext backend. We + # also use the "errors" domain as translations are placed + # in the errors.po file. + # Ecto will pass the :count keyword if the error message is + # meant to be pluralized. + # On your own code and templates, depending on whether you + # need the message to be pluralized or not, this could be + # written simply as: + # + # dngettext "errors", "1 file", "%{count} files", count + # dgettext "errors", "is invalid" + # + if count = opts[:count] do + Gettext.dngettext(NolaWeb.Gettext, "errors", msg, msg, count, opts) + else + Gettext.dgettext(NolaWeb.Gettext, "errors", msg, opts) + end + end +end diff --git a/lib/nola_web/views/error_view.ex b/lib/nola_web/views/error_view.ex new file mode 100644 index 0000000..5cad939 --- /dev/null +++ b/lib/nola_web/views/error_view.ex @@ -0,0 +1,17 @@ +defmodule NolaWeb.ErrorView do + use NolaWeb, :view + + def render("404.html", _assigns) do + "Page not found" + end + + def render("500.html", _assigns) do + "Internal server error" + end + + # In case no render clause matches or no + # template is found, let's render it as 500 + def template_not_found(_template, assigns) do + render "500.html", assigns + end +end diff --git a/lib/nola_web/views/irc_view.ex b/lib/nola_web/views/irc_view.ex new file mode 100644 index 0000000..331d91f --- /dev/null +++ b/lib/nola_web/views/irc_view.ex @@ -0,0 +1,3 @@ +defmodule NolaWeb.IrcView do + use NolaWeb, :view +end diff --git a/lib/nola_web/views/layout_view.ex b/lib/nola_web/views/layout_view.ex new file mode 100644 index 0000000..2bffc6f --- /dev/null +++ b/lib/nola_web/views/layout_view.ex @@ -0,0 +1,81 @@ +defmodule NolaWeb.LayoutView do + use NolaWeb, :view + + def liquid_markdown(conn, text) do + context_path = cond do + conn.assigns[:chan] -> "/#{conn.assigns[:network]}/#{NolaWeb.format_chan(conn.assigns[:chan])}" + conn.assigns[:network] -> "/#{conn.assigns[:network]}/-" + true -> "/-" + end + + {:ok, ast} = Liquex.parse(text) + context = Liquex.Context.new(%{ + "context_path" => context_path + }) + {content, _} = Liquex.render(ast, context) + content + |> to_string() + |> Earmark.as_html!() + |> raw() + end + + def page_title(conn) do + target = cond do + conn.assigns[:chan] -> + "#{conn.assigns.chan} @ #{conn.assigns.network}" + conn.assigns[:network] -> conn.assigns.network + true -> Keyword.get(Nola.name()) + end + + breadcrumb_title = Enum.map(Map.get(conn.assigns, :breadcrumbs)||[], fn({title, _href}) -> title end) + + title = [conn.assigns[:title], breadcrumb_title, target] + |> List.flatten() + |> Enum.uniq() + |> Enum.filter(fn(x) -> x end) + |> Enum.intersperse(" / ") + |> Enum.join() + + content_tag(:title, title) + end + + def format_time(date, with_relative \\ true) do + alias Timex.Format.DateTime.Formatters + alias Timex.Timezone + date = if is_integer(date) do + date + |> DateTime.from_unix!(:millisecond) + |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) + else + date + |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) + end + + now = DateTime.now!("Europe/Paris", Tzdata.TimeZoneDatabase) + + now_week = Timex.iso_week(now) + date_week = Timex.iso_week(date) + + {y, w} = now_week + now_last_week = {y, w-1} + now_last_roll = 7-Timex.days_to_beginning_of_week(now) + + date_date = DateTime.to_date(date) + now_date = DateTime.to_date(date) + + format = cond do + date.year != now.year -> "{D}/{M}/{YYYY} {h24}:{m}" + date_date == now_date -> "{h24}:{m}" + (now_week == date_week) || (date_week == now_last_week && (Date.day_of_week(date) >= now_last_roll)) -> "{WDfull} {h24}:{m}" + (now.year == date.year && now.month == date.month) -> "{WDfull} {D} {h24}:{m}" + true -> "{WDfull} {D} {M} {h24}:{m}" + end + + {:ok, relative} = Formatters.Relative.relative_to(date, Timex.now("Europe/Paris"), "{relative}", "fr") + {:ok, full} = Formatters.Default.lformat(date, "{WDfull} {D} {YYYY} {h24}:{m}", "fr") #"{h24}:{m} {WDfull} {D}", "fr") + {:ok, detail} = Formatters.Default.lformat(date, format, "fr") #"{h24}:{m} {WDfull} {D}", "fr") + + content_tag(:time, if(with_relative, do: relative, else: detail), [title: full]) + end + +end diff --git a/lib/nola_web/views/network_view.ex b/lib/nola_web/views/network_view.ex new file mode 100644 index 0000000..7a24db1 --- /dev/null +++ b/lib/nola_web/views/network_view.ex @@ -0,0 +1,4 @@ +defmodule NolaWeb.NetworkView do + use NolaWeb, :view + +end diff --git a/lib/nola_web/views/open_id_view.ex b/lib/nola_web/views/open_id_view.ex new file mode 100644 index 0000000..bd8089b --- /dev/null +++ b/lib/nola_web/views/open_id_view.ex @@ -0,0 +1,4 @@ +defmodule NolaWeb.OpenIdView do + use NolaWeb, :view + +end diff --git a/lib/nola_web/views/page_view.ex b/lib/nola_web/views/page_view.ex new file mode 100644 index 0000000..1bfaadd --- /dev/null +++ b/lib/nola_web/views/page_view.ex @@ -0,0 +1,3 @@ +defmodule NolaWeb.PageView do + use NolaWeb, :view +end diff --git a/lib/nola_web/web.ex b/lib/nola_web/web.ex new file mode 100644 index 0000000..906e961 --- /dev/null +++ b/lib/nola_web/web.ex @@ -0,0 +1,99 @@ +defmodule NolaWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, views, channels and so on. + + This can be used in your application as: + + use NolaWeb, :controller + use NolaWeb, :view + + The definitions below will be executed for every view, + controller, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define any helper function in modules + and import those modules here. + """ + + def format_chan("##") do + "♯♯" + end + + def format_chan("#") do + "♯" + end + + def format_chan("#"<>chan) do + chan + end + + def format_chan(chan = "!"<>_), do: chan + + def reformat_chan("♯") do + "#" + end + def reformat_chan("♯♯") do + "##" + end + def reformat_chan(chan = "!"<>_), do: chan + + def reformat_chan(chan) do + "#"<>chan + end + + def controller do + quote do + use Phoenix.Controller, namespace: NolaWeb + import Plug.Conn + import NolaWeb.Router.Helpers + import NolaWeb.Gettext + alias NolaWeb.Router.Helpers, as: Routes + end + end + + def view do + quote do + use Phoenix.View, root: "lib/nola_web/templates", + namespace: NolaWeb + + # Import convenience functions from controllers + import Phoenix.Controller, only: [get_flash: 2, view_module: 1] + + # Use all HTML functionality (forms, tags, etc) + use Phoenix.HTML + + import NolaWeb.Router.Helpers + import NolaWeb.ErrorHelpers + import NolaWeb.Gettext + + import Phoenix.LiveView.Helpers + + alias NolaWeb.Router.Helpers, as: Routes + end + end + + def router do + quote do + use Phoenix.Router + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def channel do + quote do + use Phoenix.Channel + import NolaWeb.Gettext + end + end + + @doc """ + When used, dispatch to the appropriate controller/view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/test/lsg_web/controllers/page_controller_test.exs b/test/lsg_web/controllers/page_controller_test.exs deleted file mode 100644 index 7550a24..0000000 --- a/test/lsg_web/controllers/page_controller_test.exs +++ /dev/null @@ -1,8 +0,0 @@ -defmodule NolaWeb.PageControllerTest do - use NolaWeb.ConnCase - - test "GET /", %{conn: conn} do - conn = get conn, "/" - assert html_response(conn, 200) =~ "Welcome to Phoenix!" - end -end diff --git a/test/lsg_web/views/error_view_test.exs b/test/lsg_web/views/error_view_test.exs deleted file mode 100644 index b67e3df..0000000 --- a/test/lsg_web/views/error_view_test.exs +++ /dev/null @@ -1,21 +0,0 @@ -defmodule NolaWeb.ErrorViewTest do - use NolaWeb.ConnCase, async: true - - # Bring render/3 and render_to_string/3 for testing custom views - import Phoenix.View - - test "renders 404.html" do - assert render_to_string(NolaWeb.ErrorView, "404.html", []) == - "Page not found" - end - - test "render 500.html" do - assert render_to_string(NolaWeb.ErrorView, "500.html", []) == - "Internal server error" - end - - test "render any other" do - assert render_to_string(NolaWeb.ErrorView, "505.html", []) == - "Internal server error" - end -end diff --git a/test/lsg_web/views/layout_view_test.exs b/test/lsg_web/views/layout_view_test.exs deleted file mode 100644 index 51289f7..0000000 --- a/test/lsg_web/views/layout_view_test.exs +++ /dev/null @@ -1,3 +0,0 @@ -defmodule NolaWeb.LayoutViewTest do - use NolaWeb.ConnCase, async: true -end diff --git a/test/lsg_web/views/page_view_test.exs b/test/lsg_web/views/page_view_test.exs deleted file mode 100644 index c0674a3..0000000 --- a/test/lsg_web/views/page_view_test.exs +++ /dev/null @@ -1,3 +0,0 @@ -defmodule NolaWeb.PageViewTest do - use NolaWeb.ConnCase, async: true -end diff --git a/test/nola_web/controllers/page_controller_test.exs b/test/nola_web/controllers/page_controller_test.exs new file mode 100644 index 0000000..7550a24 --- /dev/null +++ b/test/nola_web/controllers/page_controller_test.exs @@ -0,0 +1,8 @@ +defmodule NolaWeb.PageControllerTest do + use NolaWeb.ConnCase + + test "GET /", %{conn: conn} do + conn = get conn, "/" + assert html_response(conn, 200) =~ "Welcome to Phoenix!" + end +end diff --git a/test/nola_web/views/error_view_test.exs b/test/nola_web/views/error_view_test.exs new file mode 100644 index 0000000..b67e3df --- /dev/null +++ b/test/nola_web/views/error_view_test.exs @@ -0,0 +1,21 @@ +defmodule NolaWeb.ErrorViewTest do + use NolaWeb.ConnCase, async: true + + # Bring render/3 and render_to_string/3 for testing custom views + import Phoenix.View + + test "renders 404.html" do + assert render_to_string(NolaWeb.ErrorView, "404.html", []) == + "Page not found" + end + + test "render 500.html" do + assert render_to_string(NolaWeb.ErrorView, "500.html", []) == + "Internal server error" + end + + test "render any other" do + assert render_to_string(NolaWeb.ErrorView, "505.html", []) == + "Internal server error" + end +end diff --git a/test/nola_web/views/layout_view_test.exs b/test/nola_web/views/layout_view_test.exs new file mode 100644 index 0000000..51289f7 --- /dev/null +++ b/test/nola_web/views/layout_view_test.exs @@ -0,0 +1,3 @@ +defmodule NolaWeb.LayoutViewTest do + use NolaWeb.ConnCase, async: true +end diff --git a/test/nola_web/views/page_view_test.exs b/test/nola_web/views/page_view_test.exs new file mode 100644 index 0000000..c0674a3 --- /dev/null +++ b/test/nola_web/views/page_view_test.exs @@ -0,0 +1,3 @@ +defmodule NolaWeb.PageViewTest do + use NolaWeb.ConnCase, async: true +end -- cgit v1.2.3