diff --git a/.gitignore b/.gitignore index 1f4280195ad..abffa258c7d 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ log/ !/plugins/poll/ !/plugins/discourse-details/ !/plugins/discourse-nginx-performance-report +!/plugins/discourse-narrative-bot /plugins/*/auto_generated/ /spec/fixtures/plugins/my_plugin/auto_generated diff --git a/plugins/discourse-narrative-bot/assets/javascripts/initializers/new-user-narrative.js.es6 b/plugins/discourse-narrative-bot/assets/javascripts/initializers/new-user-narrative.js.es6 new file mode 100644 index 00000000000..40d727b6ae6 --- /dev/null +++ b/plugins/discourse-narrative-bot/assets/javascripts/initializers/new-user-narrative.js.es6 @@ -0,0 +1,39 @@ +import { withPluginApi } from 'discourse/lib/plugin-api'; + +function initialize(api) { + const messageBus = api.container.lookup('message-bus:main'); + const currentUser = api.getCurrentUser(); + const appEvents = api.container.lookup('app-events:main'); + const SiteHeaderComponent = api.container.lookupFactory('component:site-header'); + + SiteHeaderComponent.reopen({ + didInsertElement() { + this._super(); + this.dispatch('header:search-context-trigger', 'header'); + } + }); + + api.attachWidgetAction('header', 'headerSearchContextTrigger', function() { + if (this.site.mobileView) { + this.state.skipSearchContext = false; + } else { + this.state.contextEnabled = true; + this.state.searchContextType = 'topic'; + } + }); + + if (messageBus && currentUser) { + messageBus.subscribe(`/new_user_narrative/tutorial_search`, () => { + appEvents.trigger('header:search-context-trigger'); + }); + } +} + +export default { + name: "new-user-narratve", + + initialize(container) { + const siteSettings = container.lookup('site-settings:main'); + if (siteSettings.discourse_narrative_bot_enabled) withPluginApi('0.5', initialize); + } +}; diff --git a/plugins/discourse-narrative-bot/config/locales/client.de.yml b/plugins/discourse-narrative-bot/config/locales/client.de.yml new file mode 100644 index 00000000000..1c33c43dd0d --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.de.yml @@ -0,0 +1,6 @@ +de: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "Starte bei allen neuen Benutzern das „Tutorial für neue Benutzer“" + welcome_message: "Sende allen neuen Benutzern eine Willkommensnachricht mit einer Kurzanleitung" diff --git a/plugins/discourse-narrative-bot/config/locales/client.en.yml b/plugins/discourse-narrative-bot/config/locales/client.en.yml new file mode 100644 index 00000000000..912895794d3 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.en.yml @@ -0,0 +1,6 @@ +en: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "Start the new user tutorial for all new users" + welcome_message: "Send all new users a welcome message with a quick start guide" diff --git a/plugins/discourse-narrative-bot/config/locales/client.es.yml b/plugins/discourse-narrative-bot/config/locales/client.es.yml new file mode 100644 index 00000000000..d67b6b1842b --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.es.yml @@ -0,0 +1,6 @@ +es: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "Comenzar el tutorial de nuevos usuarios para todos los nuevos usuarios" + welcome_message: "Enviar a todos los nuevos usuarios un mensaje de bienvenida con la guía de comienzo rápida" diff --git a/plugins/discourse-narrative-bot/config/locales/client.fi.yml b/plugins/discourse-narrative-bot/config/locales/client.fi.yml new file mode 100644 index 00000000000..b27b70792f8 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.fi.yml @@ -0,0 +1,6 @@ +fi: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "Aloita opastuskurssi kaikkien uusien käyttäjien kanssa" + welcome_message: "Lähetä kaikille uusille käyttäjille tervetuloviesti, jossa on pikaopas palstan käyttöön" diff --git a/plugins/discourse-narrative-bot/config/locales/server.de.yml b/plugins/discourse-narrative-bot/config/locales/server.de.yml new file mode 100644 index 00000000000..bc12850e639 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.de.yml @@ -0,0 +1,422 @@ +de: + site_settings: + discourse_narrative_bot_enabled: 'Discourse Narrative Bot aktivieren' + disable_discourse_narrative_bot_welcome_post: "Die vom Discourse Narrative Bot gesendete Willkommensnachricht deaktivieren" + discourse_narrative_bot_ignored_usernames: "Benutzernamen, die vom Discourse Narrative Bot ignoriert werden sollen" + discourse_narrative_bot_disable_public_replies: "Öffentliche Antworten von Discourse Narrative Bot deaktivieren" + discourse_narrative_bot_welcome_post_type: "Die Art der Willkommensnachricht, die vom Discourse Narrative Bot versendet werden soll." + badges: + certified: + name: Zertifiziert + description: "Hat das Tutorial für neue Benutzer abgeschlossen" + long_description: | + Das Abzeichen wird verliehen, wenn das interaktive Tutorial für neue Benutzer erfolgreich abgeschlossen wurde. Du hast die Grundlagen für Diskussionen erlernt und bist nun zertifiziert. + licensed: + name: Lizenziert + description: "Hat das Tutorial für fortgeschrittene Benutzer abgeschlossen" + long_description: | + Das Abzeichen wird verliehen, wenn das interaktive Tutorial für fortgeschrittene Benutzer erfolgreich abgeschlossen wurde. Du beherrscht die fortgeschrittenen Werkzeuge für Diskussionen erlernt und besitzt nun die Lizenz zum Diskutieren. + discourse_narrative_bot: + bio: "Hallo! Ich bin keine reale Person. Ich bin ein Bot, der dir etwas über diese Website beibringen kann. Schick mir eine Nachricht oder erwähne irgendwo **`@%{discobot_username}`**, um mit mir zu interagieren." + timeout: + message: |- + Hallo @%{username}! Ich wollte mich nur wieder einmal melden, weil ich schon länger nichts von dir gehört habe. + + - Um fortzusetzen, antworte mir jederzeit. + + - Wenn du diesen Schritt überspringen möchtest, antworte mit `%{skip_trigger}`. + + - Um von vorne zu beginnen, antworte mit `%{reset_trigger}`. + + Wenn du mir lieber nicht schreiben möchtest, ist das auch okay. Ich bin ein Roboter. Du wirst meine Gefühle nicht verletzen. :sob: + dice: + trigger: "würfeln" + invalid: |- + Es tut mir leid, aber das Rollen dieser Kombination von Würfeln ist mathematisch unmöglich. :confounded: + not_enough_dice: |- + Ich habe nur %{num_of_dice} Würfel. [Beschämend](http://www.therobotsvoice.com/2009/04/the_10_most_shameful_rpg_dice.php), ich weiß! + out_of_range: |- + Wusstest du, dass ein fairer Würfel aus [maximal 120 Seiten](http://www.spiegel.de/wissenschaft/mensch/d120-groesster-wuerfel-der-welt-hat-120-seiten-a-1099170.html) besteht? + results: |- + > :game_die: %{results} + quote: + trigger: "Zitat" + '1': + quote: "In der Mitte von Schwierigkeiten liegen die Möglichkeiten." + author: "Albert Einstein" + '2': + quote: "Sei du selbst die Veränderung, die du dir wünschst für diese Welt." + author: "Mahatma Gandhi" + '3': + quote: "Weine nicht, weil es vorbei ist. Lächle, weil es passiert ist." + author: "Dr. Seuss" + '4': + quote: "Gut ist man nur bedient, wenn man sich selbst bedient." + author: "Charles-Guillaume Étienne" + '5': + quote: "Glaube daran, dass du etwas kannst und du hast es schon halb geschafft." + author: "Theodore Roosevelt" + '6': + quote: "Das Leben ist wie eine Schachtel Pralinen. Man weiß nie, was man kriegt." + author: "Forrest Gump" + '7': + quote: "Das ist ein kleiner Schritt für einen Menschen, ein riesiger Sprung für die Menschheit." + author: "Neil Armstrong" + '8': + quote: "Tue jeden Tag etwas, wovor du Angst hast." + author: "Eleanor Roosevelt" + '9': + quote: "Fehler sind immer zu verzeihen, wenn man den Mut hat, diese auch zuzugeben." + author: "Bruce Lee" + '10': + quote: "Was der Verstand eines Menschen begreifen und glauben kann, kann er erreichen." + author: "Napoleon Hill" + results: |- + > :left_speech_bubble: _%{quote}_ — %{author} + magic_8_ball: + trigger: 'Vorhersage' + answers: + '1': "Es ist sicher" + '2': "Es ist entschieden so" + '3': "Ohne einen Zweifel" + '4': "Definitiv ja" + '5': "Darauf kannst du dich verlassen" + '6': "Wie ich es sehe, ja" + '7': "Höchstwahrscheinlich" + '8': "Gute Aussichten" + '9': "Ja" + '10': "Zeichen deuten auf ja" + '11': "Antwort unklar, versuch's nochmal" + '12': "Frag später nochmal" + '13': "Ich sags dir jetzt lieber nicht" + '14': "Kann es jetzt nicht vorhersagen" + '15': "Konzentriere dich und frag nochmal" + '16': "Warts erst mal ab." + '17': "Meine Antwort ist nein" + '18': "Meine Quellen sagen nein" + '19': "Nicht so gute Aussichten" + '20': "Sehr zweifelhaft" + result: |- + > :crystal_ball: %{result} + track_selector: + reset_trigger: 'starte' + skip_trigger: 'überspringen' + help_trigger: 'Hilfe anzeigen' + random_mention: + reply: |- + Hallo! Um herauszufinden, was ich kann, schreibe `@%{discobot_username} %{help_trigger}`. + tracks: |- + Ich weiß derzeit, wie man die folgenden Dinge macht: + + `@%{discobot_username} %{reset_trigger} %{default_track} + > Startet eine der folgenden interaktiven Tutorials: %{tracks}. + bot_actions: |- + `@%{discobot_username} %{dice_trigger} 2d6` + > :game_die: 3, 6 + + `@%{discobot_username} %{quote_trigger}` + > :left_speech_bubble: _Wie angenehm ist es doch, freundlich zu sein! Ein gutes Wort entschlüpft wie ein wohliger Seufzer._ — Bertolt Brecht + + `@%{discobot_username} %{magic_8_ball_trigger}` + > :crystal_ball: Darauf kannst du dich verlassen + do_not_understand: + first_response: |- + Danke für die Antwort! + + Weil ich ein schlecht programmierter Bot bin, habe ich das leider nicht ganz verstanden. :frowning: + track_response: Du kannst es nochmal versuchen, oder, wenn du diesen Schritt überspringen möchtest, antworte mit `%{skip_trigger}`. Falls du von vorne beginnen möchtest, antworte mit `%{reset_trigger}`. + second_response: |- + Es tut mir leid, aber ich verstehe es noch immer nicht. :anguished: + + Ich bin nur ein Bot. Schau dir [unsere Kontaktinformationen](/about) an, falls du dich mit einer echten Person unterhalten möchtest. + + In der Zwischenzeit lass ich dich erst mal in Ruhe. + new_user_narrative: + reset_trigger: "neuer Benutzer" + cert_title: "In Anerkennung deines erfolgreichen Abschlusses eines Tutorials für neue Benutzer" + hello: + title: ":robot: Grüß dich!" + message: |- + Willkommen bei %{title}. Danke, dass du beigetreten bist. + + - Ich bin nur ein Roboter, aber [unser freundliches Team](/about) ist auch da um zu helfen, wenn du eine Person erreichen möchtest. + + - Aus Sicherheitsgründen begrenzen wir vorübergehend, was neue Benutzer tun können. Du wirst neue Fähigkeiten (und [Abzeichen](/badges)) erhalten, während wir uns kennenlernen. + + - Wir glauben an [zivilisiertes Community-Verhalten](/guidelines) zu allen Zeiten. + onebox: + instructions: |- + Als nächstes: Kannst du einen dieser Links mit mir teilen? Antworte mit **einem Link auf einer eigenen Zeile**, und er wird automatisch in eine hübsche, kurze Inhaltsangabe erweitert. + + Um einen Link zu kopieren, tippe und halte auf mobilen Geräten, oder klicke rechts mit deiner Maus: + + - https://de.wikipedia.org/wiki/Antiwitz + - https://de.wikipedia.org/wiki/Tetraphobie + - https://de.wikipedia.org/wiki/Beghilos + reply: |- + Cool! Dies wird mit den meisten Links funktionieren. Denk immer daran, dass der Link _ganz allein_ auf einer Zeile stehen muss; ohne etwas davor oder dahinter. + not_found: |- + Entschuldige bitte, ich konnte keinen Link in deiner Antwort finden! :cry: + + Kannst du versuchen, den folgenden Link auf einer eigenen Zeile in deine nächste Antwort einzufügen? + + - https://de.wikipedia.org/wiki/Hauskatze + images: + instructions: |- + Hier ist ein Bild von einem Einhorn: + + + + Wenn du es magst (und wer tut das nicht!), leg los und drücke die Like-Schaltfläche :heart: unter diesem Beitrag, um es mich wissen zu lassen. + + Kannst du **mit einem Bild antworten?** Egal welches Bild! Ziehe das Bild in den Browser, drücke die Upload-Schaltfläche, oder kopiere es in die Zwischenablage und füge es ein. + reply: |- + Hübsches Bild – ich habe die Like-Schaltfläche :heart: gedrückt, um dich wissen zu lassen, wie sehr es mir gefällt :heart_eyes: + like_not_found: |- + Hast du vergessen meinen [Beitrag](%{url}) mit einem Like :heart: zu markieren? :crying_cat_face: + not_found: |- + Sieht so aus, als hättest du kein Bild hochgeladen. Deshalb habe ich ein Bild ausgesucht, von dem ich _sicher_ bin, dass es dir gefällt. + + `%{image_url}` + + Versuche dieses als nächstes hochzuladen, oder füge den Link auf einer eigenen Zeile ein! + formatting: + instructions: |- + Kannst du einige Wörter in deiner Antwort **fett** oder _kursiv_ markieren? + + - schreibe `**fett**` oder `_kursiv_` + + - oder, drücke die F- oder K-Schaltflächen im Editor + reply: |- + Großartige Leistung! HTML und BBCode funktionieren auch zur Formatierung – um mehr zu erfahren, [probiere dieses Tutorial aus](http://commonmark.org/help) :nerd: + not_found: |- + Ach, ich habe keine Formatierung in deiner Antwort gefunden. :pencil2: + + Kannst du es nochmal versuchen? Verwende die F- (Fett) oder K-Schaltfläche (Kursiv) im Editor, wenn du nicht weiter weißt. + quoting: + instructions: |- + Kannst du versuchen, mich zu zitieren, wenn du antwortest, sodass ich genau weiß, auf welchen Teil du antwortest? + + > Wenn das Kaffee ist, bringe mir bitte Tee; aber wenn das Tee ist, bringe mir bitte Kaffee. + > + > Eine Vorteil von Selbstgesprächen ist, dass du weißt, dass dir wenigstens irgendjemand zuhört. + > + > Manche Menschen können gut mit Worten umgehen, und andere Menschen… ähm, nun ja, nicht so gut. + + Wähle ein beliebiges ↑ Zitat aus, das du bevorzugst, und drücke dann die **Zitat**-Schaltfläche, die über deiner Auswahl erscheint – oder die **Antworten**-Schaltfläche am Ende dieses Beitrags. + + Unter dem Zitat, schreibe ein oder zwei Worte dazu, warum du dieses Zitat gewählt hast, denn das würde mich interessieren :thinking: + reply: |- + Gute Arbeit, du hast mein Lieblingszitat ausgewählt! :left_speech_bubble: + not_found: |- + Hmm, es sieht so aus als hättest du mich in deinem Beitrag nicht zitiert? + + Das Auswählen von beliebigem Text in meinem Beitrag lässt die **Zitat**-Schaltfläche erscheinen. Und das Drücken von **Antworten** mit einem beliebigen ausgewählten Text wird auch funktionieren! Kannst du es nochmal versuchen? + bookmark: + instructions: |- + Wenn du mehr lernen möchtest, wähle unterhalb aus und **füge ein Lesezeichen zu dieser Nachricht hinzu**. Wenn du dies tust, könnte es in der Zukunft ein :gift: geben! + reply: |- + Hervorragend! Jetzt kannst du jederzeit über [den Lesezeichen-Reiter in deinem Profil](%{profile_page_url}/activity/bookmarks) zu unserer Unterhaltung zurückkehren. Gehe dazu einfach auf dein Profilbild oben rechts ↗. + not_found: |- + Oh weh, ich sehe keine Lesezeichen in diesem Thema. Hast du die Lesezeichen-Schaltfläche unter jedem Beitrag gefunden? Verwende die „Mehr anzeigen“-Schaltfläche , um bei Bedarf weitere Aktionen anzuzeigen. + emoji: + instructions: |- + Du hast vielleicht bemerkt, dass ich kleine Bilder in meinen Antworten verwendet habe :blue_car::dash:, die [Emoji](https://de.wikipedia.org/wiki/Emoji) heißen. Kannst du in deiner Antwort **ein Emoji hinzufügen**? Jedes der folgenden wird funktionieren: + + - Gib `:) ;) :D :P :O` ein + + - Gib einen Doppelpunkt : gefolgt vom Emoji-Namen ein `:tada:` + + - Drücke die Emoji-Schaltfläche im Editor oder auf der Tastatur deines mobilen Geräts. + reply: |- + Das ist :sparkles: _emojitastisch!_ :sparkles: + not_found: |- + Hoppla, ich sehe kein Emoji in deinem Beitrag? Oh nein! :sob: + + Versuche einen Doppelpunkt : einzugeben, um damit die Emoji-Auswahl zu öffnen. Dann gib die ersten Buchstaben vom gesuchten Emoji ein; wie zum Beispiel `:bird:` + + Oder drücke die Emoji-Schaltfläche im Editor. + + (Auf einem mobilen Gerät kannst du Emojis auch direkt über deine Tastatur eingeben.) + mention: + instructions: |- + Manchmal möchtest du vielleicht die Aufmerksamkeit einer Person haben, auch wenn du ihr nicht direkt antwortest. Gib `@` ein und vervollständige dann ihren Benutzernamen, um sie zu erwähnen. + + Kannst du **`@%{discobot_username}`** in deiner Antwort erwähnen? + reply: |- + _Hat jemand meinen Namen gesagt!?_ :raised_hand: Ich glaube du warst das! :wave: Nun, hier bin ich! Danke, dass du mich erwähnt hast. :ok_hand: + not_found: |- + Ich sehe meinen Namen darin nicht :frowning: Kannst du nochmal versuchen, mich als `@%{discobot_username}` zu erwähnen? + + (Und ja, mein Benutzername wird wirklich _disco_ geschrieben. Genau wie die Tanzmusik aus den 1970er Jahren. Ich [liebe das Nachtleben!](https://www.youtube.com/watch?v=B_wGI3_sGf8) :dancer:) + flag: + instructions: |- + Wir mögen freundliche Diskussionen, und wir benötigen deine Hilfe, damit [alles zivilisiert bleibt](%{guidelines_url}). Wenn du ein Problem bemerkst, melde dies bitte und lass es diskret den Autor oder [unser hilfreiches Team](%{about_url}) wissen. + + > :imp: Ich habe dir hier etwas Böses geschrieben + + Ich denke, du weißt was zu tun ist. Zögere nicht und **melde diesen Beitrag** als unangemessen! + reply: |- + [Unser Team](/groups/staff) wird diskret über deine Meldung informiert. Wenn genügend Community-Mitglieder einen Beitrag melden, wird er als Vorsichtsmaßnahme automatisch versteckt. (Weil ich nicht wirklich einen bösen Beitrag geschrieben habe :angel:, habe ich mir erlaubt, die Meldung fürs Erste wieder zu löschen.) + not_found: |- + Oh nein, mein böser Beitrag wurde nicht gemeldet. :worried: Kannst du ihn als unangemessen **melden** ? Vergiss nicht, die „Mehr anzeigen“-Schaltfläche zu verwenden, damit für jeden Beitrag weitere Aktionen sichtbar werden. + search: + instructions: |- + _Psst_… ich habe in diesem Thema eine Überraschung versteckt. Wenn du für eine Herausforderung zu haben bist, dann **wähle das Such-Symbol** oben rechts ↗ aus und suche danach. + + Versuche in diesem Thema nach dem Begriff „Capy​bara“ zu suchen. + hidden_message: |- + Wie konntest du das Capybara übersehen? :wink: + + + + Hast du bemerkt, dass du jetzt zurück am Anfang bist? Füttere dieses arme, hungrige Capybara, indem du **mit dem `:herb:`-Emoji antwortest** und du wirst automatisch zum Ende gebracht. + reply: |- + Juhu! Du hast es gefunden :tada: + + - Zum Suchen nach weiteren Details gehe zur [Erweiterten Suche](%{search_url}). + + - Um in einer langen Diskussion an eine beliebige Position zu springen, probiere die Zeitleiste rechts neben dem Thema aus. Auf mobilen Geräten findest du die Zeitleiste rechts unten. + + - Wenn du eine physische Tastatur verwendest, gib ? ein, um eine nützliche Übersicht über Tastenkombinationen anzuzeigen. + not_found: |- + Hm… es sieht so aus, als hättest du Probleme. Tut mir leid. Hast du nach dem Begriff **capy​bara** gesucht ? + end: + message: |- + Danke, dass du mir treu geblieben bist, @%{username}! Ich habe dies für dich gemacht. Ich denke, du hast es verdient: + + %{certificate} + + Das ist erstmal alles für den Augenblick! Aber schau dir doch [**unsere neuesten Diskussionsthemen**](/latest) oder [**Diskussionskategorien**](/categories) an. :sunglasses: + + (Wenn du wieder mit mir sprechen möchtest, um noch mehr zu lernen, schreibe mir einfach eine Nachricht oder erwähne `@%{discobot_username}`!) + certificate: + alt: 'Urkunde' + advanced_user_narrative: + reset_trigger: 'erfahrener Benutzer' + cert_title: "In Anerkennung des erfolgreichen Abschlusses des Tutorials für fortgeschrittene Benutzer" + title: ':arrow_up: Fortgeschrittene Benutzerfunktionen' + start_message: |- + @%{username}, als fortgeschrittener Benutzer hast du dir sicherlich schon die [Benutzer-Einstellungen](/my/preferences) angesehen, oder? Dort gibt es eine Vielzahl an Möglichkeiten, um alles an deine Bedürfnisse anzupassen. Unter anderem kannst du ein helles oder dunkles Design auswählen. + + Aber ich schweife ab. Lass uns beginnen! + edit: + bot_created_post_raw: "@%{discobot_username} ist bei weitem der coolste Bot, den ich kenne :wink:" + instructions: |- + Jeder macht Fehler. Aber keine Sorge, du kannst deine Beiträge immer bearbeiten, um sie zu korrigieren! + + Kannst du beginnen, indem du den Beitrag **bearbeitest**, den ich gerade in deinem Namen erstellt habe? + not_found: |- + Es sieht so aus, als solltest du den [Beitrag](%{url}) noch bearbeiten, den ich für dich erstellt habe. Kannst du es nochmal versuchen? + + Verwende das -Symbol, um den Editor zu öffnen. + reply: |- + Gut gemacht! + + Beachte, dass Änderungen, die nach 5 Minuten gemacht werden, als Überarbeitungen für jeden sichtbar sind. Die Anzahl der Überarbeitungen wird, neben einem kleinen Bleistift-Symbol, am Beitrag rechts oben angezeigt. + delete: + instructions: |- + Wenn du einen von dir erstellten Beitrag zurückziehen möchtest, dann kannst du ihn löschen. + + Leg los und **lösche* einen deiner Beiträge in dieser Unterhaltung, indem du die **Löschen**-Aktion verwendest. Lösche aber nicht den ersten Beitrag! + not_found: |- + Ich sehe noch keine gelöschten Beiträge? Denk daran, dass die „Mehr anzeigen“-Schaltfläche die Löschen-Schaltfläche sichtbar werden lässt. + reply: |- + Oha! :boom: + + Um die Kontinuität deiner Diskussionen zu bewahren, werden Löschungen nicht sofort durchgeführt. Gelöschte Beiträge werden erst nach einer gewissen Zeit entfernt. + recover: + deleted_post_raw: 'Warum hat @%{discobot_username} meinen Beitrag gelöscht? :anguished:' + instructions: |- + Oh nein! Es sieht so aus, als hätte ich versehentlich einen neuen Beitrag gelöscht, den ich gerade für dich erstellt hatte. + + Kannst du mir einen Gefallen tun und ihn **wiederherstellen**? + not_found: |- + Hast du Schwierigkeiten? Denk daran, dass die „Mehr anzeigen“-Schaltfläche die Wiederherstellen-Schaltfläche sichtbar werden lässt. + reply: |- + Puh, das war knapp! Danke, dass du das korrigiert hast :wink: + + Bitte beachte, dass du nur 24 Stunden hast, um einen Beitrag wiederherzustellen. + category_hashtag: + instructions: |- + Wusstest du, dass du auf Kategorien und Schlagwörter in deinen Beiträgen verweisen kannst? Zum Beispiel, hast du die Kategorie %{category} schon gesehen? + + Gib `#` in der Mitte eines Satzes ein und wähle eine Kategorie oder ein Schlagwort aus. + not_found: |- + Hmm, ich sehe darin nirgendwo eine Kategorie. Beachte, dass `#` nicht das erste Zeichen sein darf. Kannst du dies in deine nächste Antwort kopieren? + + ```text + Ich erstelle einen Kategorie-Link per # + ``` + reply: |- + Hervorragend! Denk daran, dass dies sowohl für Kategorien _als auch_ für Schlagwörter funktioniert, sofern Schlagwörter aktiviert sind. + change_topic_notification_level: + instructions: |- + Jedes Thema hat eine Benachrichtigungsstufe. Es beginnt bei „Normal“, was bedeutet, dass du normalerweise nur benachrichtigt wirst, wenn dich jemand direkt anspricht. + + Standardmäßig ist die Benachrichtigungsstufe für Nachrichten auf die höchste Stufe „Beobachten“ gesetzt, was bedeutet, dass du über jede Antwort informiert wirst. Aber du kannst die Benachrichtigungsstufe für _jedes_ Thema auf „Beobachten“, „Verfolgen“ oder „Stummgeschaltet“ setzen. + + Lass uns versuchen, die Benachrichtigungsstufe für dieses Thema zu ändern. Am Ende des Themas wirst du eine Schaltfläche finden, die „Beobachten“ anzeigt. Kannst du die Benachrichtigungsstufe auf **Verfolgen** ändern? + not_found: |- + Es sieht so aus, als würdest du dieses Thema noch beobachten! :eyes: Falls du Probleme hast es zu finden: Die Schaltfläche für die Benachrichtigungsstufe befindet sich am Ende des Themas. + reply: |- + Fantastische Arbeit! Ich hoffe, dass du das Thema stumm geschaltet hast, denn ich kann manchmal ein bisschen gesprächig sein :grin:. + + Beachte, dass die Benachrichtigungsstufe automatisch auf „Verfolgen“ geändert wird, wenn du auf ein Thema antwortest oder ein Thema länger als ein paar Minuten liest. Du kannst dies in [deinen Benutzer-Einstellungen](/my/preferences) ändern. + poll: + instructions: |- + Wusstest du, dass du eine Umfrage zu einem beliebigen Beitrag hinzufügen kannst? Versuche dazu das Zahnrad im Editor zu verwenden und wähle **Umfrage erstellen** aus. + not_found: |- + Hoppla! Da war keine Umfrage in deiner Antwort. + + Verwende das Zahnrad im Editor, oder kopiere diese Umfrage und füge sie in deine nächste Antwort ein: + + ```text + [poll] + * :cat: + * :dog: + [/poll] + ``` + reply: |- + Hey, schöne Umfrage! Wie mache ich mich als Lehrer? + + [poll] + * :+1: + * :-1: + [/poll] + details: + instructions: |- + Manchmal möchtest du vielleicht in deinen Antworten **Details ausblenden**: + + - Wenn du Handlungen eines Films oder einer TV-Serie diskutierst, die als Spoiler betrachtet werden könnten. + + - Wenn dein Beitrag viele optionalen Details benötigt, die überwältigend sein könnten, wenn man sie alle auf einmal liest. + + [details=Wähle dies aus, um zu sehen, wie es funktioniert!] + 1. Wähle das Zahnrad im Editor aus. + 2. Wähle „Details ausblenden“ aus. + 3. Bearbeite die Zusammenfassung der Details und deinen Inhalt. + [/details] + + Kannst du das Zahnrad im Editor benutzen und einen Details-Abschnitt in deiner nächsten Antwort einfügen? + not_found: |- + Hast du Schwierigkeiten, den Details-Abschnitt zu erstellen? Versuche Folgendes in deine nächste Antwort aufzunehmen: + + ```text + [details=Wähle mich aus, um die Details zu sehen] + Hier sind die Details + [/details] + ``` + reply: |- + Großartige Arbeit — deine Aufmerksamkeit fürs _Detail_ ist bewundernswert! + end: + message: |- + Du hast dir in der Tat den Weg hierdurch gebahnt wie ein _fortgeschrittener Benutzer_ :bow: + + %{certificate} + + Das ist alles, was ich für dich habe. + + Bis bald! Wenn du wieder mit mir sprechen möchtest, schicke mir jederzeit eine Nachricht :sunglasses: + certificate: + alt: 'Urkunde für fortgeschrittene Benutzer' diff --git a/plugins/discourse-narrative-bot/config/locales/server.en.yml b/plugins/discourse-narrative-bot/config/locales/server.en.yml new file mode 100644 index 00000000000..b717f23b3a0 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.en.yml @@ -0,0 +1,454 @@ +en: + site_settings: + discourse_narrative_bot_enabled: 'Enable Discourse Narrative Bot' + disable_discourse_narrative_bot_welcome_post: "Disable the welcome post by Discourse Narrative Bot" + discourse_narrative_bot_ignored_usernames: "Usernames that the Discourse Narrative Bot should ignore" + discourse_narrative_bot_disable_public_replies: "Disable public replies by Discourse Narrative Bot" + discourse_narrative_bot_welcome_post_type: "Type of welcome post that the Discourse Narrative Bot should send out" + discouse_narrative_bot_welcome_post_delay: "Wait (n) seconds before sending the Discourse Narrative Bot welcome post." + + badges: + certified: + name: Certified + description: "Completed our new user tutorial" + long_description: | + This badge is granted upon successful completion of the interactive new user tutorial. You’ve taken the initiative to learn the basic tools of discussion, and now you're certified! + licensed: + name: Licensed + description: "Completed our advanced user tutorial" + long_description: | + This badge is granted upon successful completion of the interactive advanced user tutorial. You’ve mastered the advanced tools of discussion — and now you’re fully licensed! + + discourse_narrative_bot: + bio: "Hi, I’m not a real person. I’m a bot that can teach you about this site. To interact with me, send me a message or mention **`@%{discobot_username}`** anywhere." + + timeout: + message: |- + Hey @%{username}, just checking in because I haven’t heard from you in a while. + + - To continue, reply to me any time. + + - If you’d like to skip this step, say `%{skip_trigger}`. + + - To start over, say `%{reset_trigger}`. + + If you’d rather not, that’s OK too. I’m a robot. You won’t hurt my feelings. :sob: + + dice: + trigger: "roll" + invalid: |- + I’m sorry, it is mathematically impossible to roll that combination of dice. :confounded: + not_enough_dice: |- + I only have %{num_of_dice} dice. [Shameful](http://www.therobotsvoice.com/2009/04/the_10_most_shameful_rpg_dice.php), I know! + out_of_range: |- + Did you know that [the maximum number of sides](https://www.wired.com/2016/05/mathematical-challenge-of-designing-the-worlds-most-complex-120-sided-dice) for a mathematically fair die is 120? + results: |- + > :game_die: %{results} + + quote: + trigger: "quote" + "1": + quote: "In the middle of every difficulty lies opportunity" + author: "Albert Einstein" + "2": + quote: "You must be the change you wish to see in the world." + author: "Mahatma Gandhi" + "3": + quote: "Don’t cry because it’s over, smile because it happened." + author: "Dr Seuss" + "4": + quote: "If you want something done right, do it yourself." + author: "Charles-Guillaume Étienne" + "5": + quote: "Believe you can and you’re halfway there." + author: "Theodore Roosevelt" + "6": + quote: "Life is like a box of chocolates. You never know what you’re gonna get." + author: "Forrest Gump’s Mom" + "7": + quote: "That’s one small step for a man, a giant leap for mankind." + author: "Neil Armstrong" + "8": + quote: "Do one thing every day that scares you." + author: "Eleanor Roosevelt" + "9": + quote: "Mistakes are always forgivable, if one has the courage to admit them." + author: "Bruce Lee" + "10": + quote: "Whatever the mind of man can conceive and believe, it can achieve." + author: "Napoleon Hill" + results: |- + > :left_speech_bubble: _%{quote}_ — %{author} + + magic_8_ball: + trigger: 'fortune' + answers: + "1": "It is certain" + "2": "It is decidedly so" + "3": "Without a doubt" + "4": "Yes definitely" + "5": "You may rely on it" + "6": "As I see it, yes" + "7": "Most likely" + "8": "Outlook good" + "9": "Yes" + "10": "Signs point to yes" + "11": "Reply hazy try again" + "12": "Ask again later" + "13": "Better not tell you now" + "14": "Cannot predict now" + "15": "Concentrate and ask again" + "16": "Don't count on it" + "17": "My reply is no" + "18": "My sources say no" + "19": "Outlook not so good" + "20": "Very doubtful" + result: |- + > :crystal_ball: %{result} + + track_selector: + reset_trigger: 'start' + skip_trigger: 'skip' + help_trigger: 'display help' + + random_mention: + reply: |- + Hi! To find out what I can do, say `@%{discobot_username} %{help_trigger}`. + tracks: |- + I currently know how to do the following things: + + `@%{discobot_username} %{reset_trigger} %{default_track}` + > Starts one of the following interactive narratives: %{tracks}. + bot_actions: |- + `@%{discobot_username} %{dice_trigger} 2d6` + > :game_die: 3, 6 + + `@%{discobot_username} %{quote_trigger}` + > :left_speech_bubble: _Carry out a random act of kindness, with no expectation of reward, safe in the knowledge that one day someone might do the same for you_ — Princess Diana + + `@%{discobot_username} %{magic_8_ball_trigger}` + > :crystal_ball: You may rely on it + + do_not_understand: + first_response: |- + Hey, thanks for the reply! + + Unfortunately, as a poorly programmed bot, I can’t quite understand that one. :frowning: + track_response: + You can try again, or if you’d like to skip this step, say `%{skip_trigger}`. Otherwise, to start over, say `%{reset_trigger}`. + second_response: |- + Aw, sorry. I’m still not getting it. :anguished: + + I’m just a bot, but if you’d like to reach a real person, see [our contact page](/about). + + In the meantime, I’ll stay out of your way. + + new_user_narrative: + reset_trigger: "new user" + cert_title: "In recognition of successful completion of the new user tutorial" + + hello: + title: ":robot: Greetings!" + message: |- + Thanks for joining %{title}, and welcome! + + - I’m only a robot, but [our friendly staff](/about) are also here to help if you need to reach a person. + + - For safety reasons, we temporarily limit what new users can do. You’ll gain new abilities (and [badges](/badges)) as we get to know you. + + - We believe in [civilized community behavior](/guidelines) at all times. + + onebox: + instructions: |- + Next, can you share one of these links with me? Reply with **a link on a line by itself**, and it’ll automatically expand to include a nifty summary. + + To copy a link, tap and hold on mobile, or right click your mouse: + + - https://en.wikipedia.org/wiki/Inherently_funny_word + - https://en.wikipedia.org/wiki/Death_by_coconut + - https://en.wikipedia.org/wiki/Calculator_spelling + reply: |- + Cool! This will work for most links. Remember, it must be on a line _all by itself_, with nothing else in front, or behind. + not_found: |- + Sorry, I couldn’t find the link in your reply! :cry: + + Can you try adding the following link, on its own line, in your next reply? + + - https://en.wikipedia.org/wiki/Exotic_Shorthair + + images: + instructions: |- + Here’s a picture of a unicorn: + + + + If you like it (and who wouldn’t!) go ahead and press the like :heart: button below this post to let me know. + + Can you **reply with a picture?** Any picture will do! Drag and drop, press the upload button, or even copy and paste it in. + reply: |- + Nifty picture – I pressed the like :heart: button to let you know how much I appreciated it :heart_eyes: + like_not_found: |- + Did you forget to like :heart: my [post?](%{url}) :crying_cat_face: + not_found: |- + Looks like you didn’t upload an image so I’ve choosen a picture that I’m _sure_ you will enjoy. + + `%{image_url}` + + Try uploading that one next, or pasting the link in on a line by itself! + + formatting: + instructions: |- + Can you make some words **bold** or _italic_ in your reply? + + - type `**bold**` or `_italic_` + + - or, push the B or I buttons in the editor + + reply: |- + Great job! HTML and BBCode also work for formatting – to learn more, [try this tutorial](http://commonmark.org/help) :nerd: + not_found: |- + Aww, I didn’t find any formatting in your reply. :pencil2: + + Can you try again? Use the B bold or I italic buttons in the editor if you get stuck. + + quoting: + instructions: |- + Can you try quoting me when you reply, so I know exactly which part you’re replying to? + + > If this is coffee, please bring me some tea; but if this is tea, please bring me some coffee. + > + > One advantage of talking to yourself is that you know at least somebody’s listening. + > + > Some people have a way with words, and other people… oh, uh, not have way. + + Select the text of whichever ↑ quote you prefer, and then press the **Quote** button that pops up over your selection – or the **Reply** button at the bottom of this post. + + Below the quote, type a word or two about why you picked that one, because I’m curious :thinking: + reply: |- + Nice work, you picked my favorite quote! :left_speech_bubble: + not_found: |- + Hmm it looks like you didn’t quote me in your reply? + + Selecting any text in my post will bring up the **Quote** button. And pressing **Reply** with any text selected will work, too! Can you try again? + + bookmark: + instructions: |- + If you’d like to learn more, select below and **bookmark this private message**. If you do, there may be a :gift: in your future! + reply: |- + Excellent! Now you can easily find your way back to our private conversation any time, right from [the bookmarks tab on your profile](%{profile_page_url}/activity/bookmarks). Just select your profile picture at the upper right ↗ + not_found: |- + Uh oh, I don’t see any bookmarks in this topic. Did you find the bookmark under each post? Use the show more to reveal additional actions if needed. + + emoji: + instructions: |- + You may have seen me use little pictures in my replies :blue_car::dash: those are called [emoji](https://en.wikipedia.org/wiki/Emoji). Can you **add an emoji** to your reply? Any of these will work: + + - Type `:) ;) :D :P :O` + + - Type colon : then complete the emoji name `:tada:` + + - Press the emoji button in the editor, or on your mobile keyboard + reply: |- + That’s :sparkles: _emojitastic!_ :sparkles: + not_found: |- + Oops, I don’t see any Emoji in your reply? Oh no! :sob: + + Try typing a colon : to bring up the emoji picker, then type the first few letters of what you want, such as `:bird:` + + Or, press the emoji button in the editor. + + (If you are on a mobile device, you can also enter Emoji directly from your keyboard, too.) + + mention: + instructions: |- + Sometimes you might want to get a person’s attention, even if you aren’t replying to them directly. Type `@` then complete their user name to mention them. + + Can you mention **`@%{discobot_username}`** in your reply? + reply: |- + _Did someone say my name!?_ :raised_hand: I believe you did! :wave: Well, here I am! Thanks for mentioning me. :ok_hand: + not_found: |- + I don’t see my name in there anywhere :frowning: Can you try mentioning me as `@%{discobot_username}` again? + + (And yes, my user name is spelled _disco_, as in the 1970s dance craze. I do [love the nightlife!](https://www.youtube.com/watch?v=B_wGI3_sGf8) :dancer:) + + flag: + instructions: |- + We like our discussions friendly, and we need your help to [keep things civilized](%{guidelines_url}). If you see a problem, please flag to privately let the author, or [our helpful staff](%{about_url}), know about it. + + > :imp: I wrote something nasty here + + I guess you know what to do. Go ahead and **flag this post** as inappropriate! + reply: |- + [Our staff](/groups/staff) will be privately notified about your flag. If enough community members flag a post, it will also be automatically hidden as a precaution. (Since I didn’t actually write a nasty post :angel:, I’ve gone ahead and removed the flag for now.) + not_found: |- + Oh no, my nasty post hasn’t been flagged yet. :worried: Can you flag it as inappropriate using the **flag** ? Don’t forget to use the show more button to reveal more actions for each post. + + search: + instructions: |- + _psst_ … I’ve hidden a surprise in this topic. If you’re up for the challenge, **select the search icon** at the top right ↗ to search for it. + + Try searching for the term "capy​bara" in this topic + hidden_message: |- + How did you miss this capybara? :wink: + + + + Did you notice you’re now back at the beginning? Feed this poor hungry capybara by **replying with the `:herb:` emoji** and you’ll be automatically taken to the end. + reply: |- + Yay you found it :tada: + + - For more detailed searches, head over to the [full search page](%{search_url}). + + - To jump anywhere in a long discussion, try the topic timeline controls on the right (and bottom, on mobile). + + - If you have a physical :keyboard:, press ? to view our handy keyboard shortcuts. + not_found: |- + Hmm… looks like you might be having trouble. Sorry about that. Did you search for the term **capy​bara**? + + end: + message: |- + Thanks for sticking with me @%{username}! I made this for you, I think you’ve earned it: + + %{certificate} + + That’s all for now! Check out [**our latest discussion topics**](/latest) or [**discussion categories**](/categories). :sunglasses: + + (If you’d like to speak with me again to learn more, just message or mention `@%{discobot_username}` any time!) + + certificate: + alt: 'Certificate of Achievement' + + advanced_user_narrative: + reset_trigger: 'advanced user' + cert_title: "In recognition of successful completion of the advanced user tutorial" + title: ':arrow_up: Advanced user features' + start_message: |- + As an _advanced_ user, have you visited [your preferences page](/my/preferences) yet @%{username}? There are lots of ways to customize your experience, such as selecting a dark or light theme. + + But I digress, let’s begin! + + edit: + bot_created_post_raw: "@%{discobot_username} is, by far, the coolest bot I know :wink:" + instructions: |- + Everyone makes mistakes. But don’t worry, you can always edit your posts to fix them! + + Can you begin by **editing** the post I just created on your behalf? + not_found: |- + It looks like you’ve yet to edit the [post](%{url}) I created for you. Can you try again? + + Use the icon to bring up the editor. + reply: |- + Great work! + + Note that edits made after 5 minutes will show up as public edit revisions, and a little pencil icon will appear at the upper right with the revision count. + + delete: + instructions: |- + If you’d like to withdraw a post you made, you can delete it. + + Go ahead and **delete** any of your posts above by using the **delete** action. Don’t delete the first post, though! + not_found: |- + I don’t see any deleted posts yet? Remember show more will reveal delete. + reply: |- + Whoa! :boom: + + To preserve continuity of discussions, deletes aren’t immediate, so the post will be removed after some time. + + recover: + deleted_post_raw: 'Why did @%{discobot_username} delete my post? :anguished:' + instructions: |- + Oh no! It looks like I accidentally deleted a new post that I just created for you. + + Can you do me a favor and **undelete** it? + not_found: |- + Having trouble? Remember show more will reveal undelete. + reply: |- + Phew, that was a close one! Thanks for fixing that :wink: + + Do note that you only have 24 hours to undelete a post. + + category_hashtag: + instructions: |- + Did you know that you can refer to categories and tags in your post? For example, have you seen the %{category} category? + + Type `#` in the middle of a sentence and select any category or tag. + not_found: |- + Hmm, I don’t see a category in there anywhere. Note that `#` can't be the first character. Can you copy this in your next reply? + + ```text + I can create a category link via # + ``` + reply: |- + Excellent! Remember this works for both categories _and_ tags, if tags are enabled. + + change_topic_notification_level: + instructions: |- + Every topic has a notification level. It starts at 'normal', which means you’ll normally only be notified when someone is speaking directly to you. + + By default, the notification level for a private message is set to the highest level of 'watching', which means you you’ll be notified of every new reply. But you can override the notification level for _any_ topic to 'watch', 'tracking' or 'muted'. + + Let’s try changing the notification level for this topic. At the bottom of the topic, you’ll find a button which shows that you’re **watching** this topic. Can you change the notification level to **tracking**? + not_found: |- + It looks like you’re still watching :eyes: this topic! If you’re having trouble finding it, the notification level button is located at the bottom of the topic. + reply: |- + Awesome work! I hope you didn’t mute this topic since I can be a little talkative at times :grin:. + + Note that when you reply to a topic, or read a topic for more than a few minutes, it is automatically set to a notification level of 'tracking'. You can change this in [your user preferences](/my/preferences). + + poll: + instructions: |- + Did you know you can add a poll to any post? Try using the gear in the editor to **build a poll**. + not_found: |- + Whoops! There wasn’t any poll in your reply. + + Use the gear icon in the editor, or copy and paste this poll in your next reply: + + ```text + [poll] + * :cat: + * :dog: + [/poll] + ``` + reply: |- + Hey, nice poll! How’d I do in teaching you? + + [poll] + * :+1: + * :-1: + [/poll] + details: + instructions: |- + Sometimes you may wish to **hide details** in your replies: + + - When you’re discussing plot points of a movie or TV show that would be a considered a spoiler. + + - When your post needs lots of optional details that may be overwhelming when read all at once. + + [details=Select this to see how it works!] + 1. Select the gear in the editor. + 2. Select "Hide Details". + 3. Edit the details summary and add your content. + [/details] + + Can you use the gear in the editor to add a details section to your next reply? + not_found: |- + Having trouble creating a details widget? Try including the following in your next reply: + + ```text + [details=Select me for details] + Here are the details + [/details] + ``` + reply: |- + Great work — your attention to _detail_ is admirable! + end: + message: |- + You blazed through this like an _advanced user_ indeed :bow: + + %{certificate} + + That’s all I have for you. + + Bye for now! If you’d like to speak with me again, send me a message any time :sunglasses: + certificate: + alt: 'Advanced User Track Certificate of Achievement' diff --git a/plugins/discourse-narrative-bot/config/locales/server.es.yml b/plugins/discourse-narrative-bot/config/locales/server.es.yml new file mode 100644 index 00000000000..4a4ad507851 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.es.yml @@ -0,0 +1,431 @@ +es: + site_settings: + discourse_narrative_bot_enabled: 'Habilitar Discourse Narrative Bot' + disable_discourse_narrative_bot_welcome_post: "Deshabilitar el post de bienvenida por Discourse Narrative Bot" + discourse_narrative_bot_ignored_usernames: "Nombres de usuario que el Discourse Narrative Bot debe ignorar" + discourse_narrative_bot_disable_public_replies: "Deshabilitar respuestas públicas del Discourse Narrative Bot" + discourse_narrative_bot_welcome_post_type: "Tipo de post de bienvenida que el Discourse Narrative Bot debería enviar" + discouse_narrative_bot_welcome_post_delay: "Esperar (n) segundos antes de enviar el post de bienvenida del Discourse Narrative Bot." + + badges: + certified: + name: Certificado + description: "Ha completado nuestro tutorial de nuevo usuario" + long_description: | + Este distintivo se otorga a quienes han completado el tutorial de nuevo usuario. Has tomado la iniciativa para aprender las herramientas básicas para el debate, y ahora tú estás certificado! + licensed: + name: Licenciado + description: "Ha completado nuestro tutorial de usuario avanzado" + long_description: | + Este distintivo se otorga a quienes han completado el tutorial de usuario avanzado. Has dominado las herramientas avanzadas de discusión — y ahora tú eres un licenciado! + discourse_narrative_bot: + bio: "Hola, no soy una persona real. Soy un bot que puede enseñarte sobre este sitio. Para interactuar conmigo, me envías un mensaje o me mencionas **`@%{discobot_username}`** en cualquier lugar." + + timeout: + message: |- + Hey @%{username}, solo estoy verificando si estás porque no he oído de tí en un tiempo. + - Para continuar, responde en cualquier momento. + + - Si gustas de saltar este paso, dime `%{skip_trigger}`. + + - Para empezar de nuevo, dime `%{reset_trigger}`. + + Si prefieres no continuar, está OK también! Soy un robot. No herirás mis sentimientos. :sob: + + dice: + trigger: "tirar" + invalid: |- + Lo siento, es matemáticamente imposible lanzar esa combinación de dados. :confounded: + not_enough_dice: |- + Solo tengo %{num_of_dice} dados para jugar. Es vergonsozo lo sé! + out_of_range: |- + ¿Sabías que el máximo número de lados para que un dado, matemáticamente correcto, es de 120 lados? + results: |- + > :game_die: tirada de dados: %{results} + + quote: + trigger: "citar" + '1': + quote: "In the middle of every difficulty lies opportunity" + author: "Albert Einstein" + '2': + quote: "You must be the change you wish to see in the world." + author: "Mahatma Gandhi" + '3': + quote: "Don’t cry because it’s over, smile because it happened." + author: "Dr Seuss" + '4': + quote: "If you want something done right, do it yourself." + author: "Charles-Guillaume Étienne" + '5': + quote: "Believe you can and you’re halfway there." + author: "Theodore Roosevelt" + '6': + quote: "Life is like a box of chocolates. You never know what you’re gonna get." + author: "Forrest Gump’s Mom" + '7': + quote: "That’s one small step for a man, a giant leap for mankind." + author: "Neil Armstrong" + '8': + quote: "Do one thing every day that scares you." + author: "Eleanor Roosevelt" + '9': + quote: "Mistakes are always forgivable, if one has the courage to admit them." + author: "Bruce Lee" + '10': + quote: "Whatever the mind of man can conceive and believe, it can achieve." + author: "Napoleon Hill" + results: |- + > :left_speech_bubble: _%{quote}_ — %{author} + + magic_8_ball: + trigger: 'suerte' + answers: + '1': "Es cierto" + '2': "Es decididamente así" + '3': "Sin ninguna duda" + '4': "Definitivamente sí" + '5': "Puedes confiar en ello" + '6': "Como yo lo veo, sí" + '7': "Más probable" + '8': "Buena perspectiva" + '9': "Sí" + '10': "Las señales apuntan a que sí" + '11': "Respuesta confusa, intenta otra vez" + '12': "Pregunta de nuevo más tarde" + '13': "Mejor no te digo ahora" + '14': "No se puede predecir ahora" + '15': "Concentrate y pregunta de nuevo" + '16': "No cuentes con eso" + '17': "Mi respuesta es no" + '18': "Mis fuentes dicen no" + '19': "No son buenas las perspectivas" + '20': "Muy dudoso" + result: |- + > :crystal_ball: %{result} + + track_selector: + reset_trigger: 'empezar' + skip_trigger: 'saltar' + help_trigger: 'mostrar ayuda' + random_mention: + reply: |- + ¿Alguien me llamó? Averigua qué puedo hacer con `@%{discobot_username} %{help_trigger}`. + tracks: |- + Hola! Estoy conociendo cómo hacer las siguientes cosas: + + `@%{discobot_username} %{reset_trigger} %{default_track}` + > Inicia una de las siguientes narraciones interactivas: %{tracks}. + bot_actions: |- + `@%{discobot_username} %{dice_trigger} 2d6` + > :game_die: 3, 6 + + `@%{discobot_username} %{quote_trigger}` + > :left_speech_bubble: _Carry out a random act of kindness, with no expectation of reward, safe in the knowledge that one day someone might do the same for you_ — Princess Diana + + `@%{discobot_username} %{magic_8_ball_trigger}` + > :crystal_ball: No se puede predecir ahora + + do_not_understand: + first_response: |- + Hey, gracias por tu respuesta! + Lamentablemente, como un robot con programación muy pobre, no he podido entender lo que me has dicho. :frowning: + track_response: Puedes intentarlo de nuevo, o bien para saltar este paso, dime `%{skip_trigger}`. Para empezar de nuevo, dime `%{reset_trigger}`. + second_response: |- + Aw, lo siento. Sigo sin entenderlo. :anguished: + + Soy solo un robot, pero si quieres contactar a una persona real, mira [nuestra página de contacto](/about). + + Mientras tanto, me quedaré fuera de tu camino. + + new_user_narrative: + reset_trigger: "usuario nuevo" + cert_title: "En reconocimiento de la finalización exitosa del tutorial de usuario nuevo" + + hello: + title: ":robot: Saludos!" + message: |- + Gracias por unirte a %{title}, y bienvenido! + + - Soy solo un robot, pero [nuestro amigable staff](/about) está también aquí para ayudar si necesitas contactar a una persona. + + - Por razones de seguridad, nosotros temporalmente limitamos lo que los nuevos usuarios pueden hacer. Tú podrás ganar nuevas habilidades (y [distintivos](/badges)) cuando te vayamos conociendo. + + - Nosotros creemos en el [comportamiento de una comunidad civilizada](/guidelines) en todo momento. + + onebox: + instructions: |- + Ahora, tú puedes compartir uno de estos enlaces conmigo? Responde con **el enlace propiamente dicho**, y automáticamente se expandirá con un breve resumen. + Para copiar un enlace, toca y mantiene presionado en el móvil, o haz clic derecho con tu mouse: + + - https://en.wikipedia.org/wiki/Inherently_funny_word + - https://en.wikipedia.org/wiki/Death_by_coconut + - https://en.wikipedia.org/wiki/Calculator_spelling + reply: |- + Genial! Esto funcionará con la mayoría de los enlaces. Recuerda, deben ser escritos en la linea _todo el enlace_, con nada de texto (ni espacios) delante o detrás. + not_found: |- + Disculpa, no he podido encontrar el enlace en tu respuesta! :cry: + + Puedes intentar agregar el siguiente enlace, en una linea aparte, en tu siguiente respuesta? + + - https://en.wikipedia.org/wiki/Exotic_Shorthair + + images: + instructions: |- + Aquí hay una imagen de un unicornio: + + + + Si te gusta (a quién no!) por favor presiona sobre el botón :heart: "me gusta" que está debajo del post para hacerme saber que te gusta. + + Puedes **responder con una imagen?** Cualquier imagen funcionará! Arrastrá y soltá dentro del mensaje, o presioná el botón para "subir", o bien puedes copiar y pegar la imagen. + reply: |- + Elegante imagen – He presionado el botón :heart: "me gusta" para hacerte saber cuánto la aprecio :heart_eyes: + like_not_found: |- + Te olvidaste de dar "me gusta" :heart: a mi [post?](%{url}) :crying_cat_face: + not_found: |- + Parece que no subiste ninguna imagen, así que he elegido una imagen que estoy _seguro_ que disfrutarás. + `%{image_url}` + Trata de subir esta ahora, o bien copia el enlace de la imagen y pega el mismo en una única línea dentro del mensaje! + + formatting: + instructions: |- + ¿Puedes hacer algunas letras en **negrita** o _cursiva_ en tu respuesta? + - escribe `**negrita**` o `_cursiva_` + + - o, presiona los botones B o I en el editor + + reply: |- + Buen trabajo! HTML y BBCode también funcionan para darle formato al texto – aprende más en, [este tutorial](http://commonmark.org/help) :nerd: + not_found: |- + Aww, no encontré ningún texto con formato en tu respuesta. :pencil2: + + Puedes intentar de nuevo? Usa los botones B para negrita o I para cursiva en el editor si no recuerdas cómo formatear texto. + + quoting: + instructions: |- + ¿Puedes intentar citarme cuando me respondes, así sé exactamente qué parte estás respondiendome? + > Si esto es café, por favor trae un poco de té; pero si es té, por favor trae un poco de café. + > + > Una de las ventajas de hablarse a sí mismo, es que tú sabes que al menos alguien está oyendo. + > + > Algunas personas tienen un camino con las palabras, y otras personas ... oh, oh, no tienen camino. + Selecciona la parte del texto ↑ que prefieres citar, y luego presiona el botón **citar** que aparece sobre tu selección del texto – o presiona el botón **Responder** debajo de este post. + Debajo de la cita, escribe una palabra o dos sobre lo que escribí, porque soy curioso :thinking: + reply: |- + Buen trabajo, elegiste mi cita favorita! :left_speech_bubble: + not_found: |- + Hmm me parece que no me has citado en tu respuesta!? + Seleccionando cualquier texto de mi mensaje, aparecerá la palabra **Citar**, como si fuese un botón. Y si decides presionar **Responder** habiendo seleccionado el texto, funcionará igual! ¿Puedes intentarlo de nuevo? + + bookmark: + instructions: |- + Si quieres aprender más, selecciona debajo y **guarda como favorito este mensaje privado**. Si lo haces, quizás haya un :gift: en el futuro! + reply: |- + Excelente! Ahora puedes encontrar fácilmente las conversaciones privadas en cualquier momento, desde la [sección de marcadores de tu perfil](%{profile_page_url}/activity/bookmarks). Sólo debes seleccionar la foto de tu perfil en la parte superior derecha de la pantalla ↗ + not_found: |- + Oh oh, no veo ningún mensaje marcado como favorito en esta conversación. No encontraste cómo marcar el mensaje debajo del post? Prueba usando el "ver más" para ver las opciones, y ahí está el botón del marcador. + + emoji: + instructions: |- + Habrás notado que uso algunas imagenes pequeñas en mis respuestas por ejemplo: :blue_car::dash: éstos son denominados [emoji](https://es.wikipedia.org/wiki/Emoji). ¿Puedes **agregar un emoji** a tu respuesta? Cualquiera de éstos funcionarán: + + - Escribe `:) ;) :D :P :O` + + - Escribe primero `dos puntos`, así : luego completa con el nombre del emoji, ejemplo `:tada:` + + - Presiona el botón de los emojis en el editor, o a través del teclado de tu móvil. + reply: |- + Eso es :sparkles: _emojitastic!_ :sparkles: + not_found: |- + Oops, no veo ningún Emoji en tu respuesta? Oh no! :sob: + + Prueba escribiendo : y verás que aparece el seleccionador de Emojis, puedes elegir uno, o bien seguir escribiendo el nombre del Emoji que quieras, como `:bird:` + + O, presionar el botón de Emoji en el editor. + + (Si estás usando un celular, también puedes poner los Emojis (smiles, o caritas) desde el teclado de tu celular.) + + mention: + instructions: |- + A veces, quieres captar la atención de alguien en un debate, incluso aunque no le estés respondiendo a su mensaje directamente. Para ésto, escribe `@` seguido del nombre del usuario que quieres mencionar. + Puedes mencionar **`@%{discobot_username}`** en tu respuesta? + reply: |- + _Alguien dijo mi nombre!?_ :raised_hand: Creo que tú lo hiciste! :wave: Bien, aquí estoy! Gracias por mencionarme. :ok_hand: + not_found: |- + No veo que hayas dicho mi nombre en ningún lugar. :frowning: ¿Puedes intentar mencionarme como `@%{discobot_username}` de nuevo por favor? + + (Sí! mi nombre de usuario se deletrea _disco_, como el baile de los 70s. Me [encanta la vida nocturna!](https://www.youtube.com/watch?v=B_wGI3_sGf8) :dancer:) + + flag: + instructions: |- + Nos gustan nuestras conversaciones amistosas, y necesitamos tu ayuda para [mantener las cosas civilizadas](%{guidelines_url}). Si ves un problema, por favor marca con la bandera y de forma privada se le notificará al autor o bien se notificará al [staff](%{about_url}). + > :imp: Escribí algo desagradable aquí + + Supongo que sabes qué hacer. Sigue adelante y **reporta este mensaje** como inapropiado! + reply: |- + [Nuestro staff](/groups/staff) será notificado por privado sobre tu reporte. Si un número suficiente de miembros reportan un mensaje, será ocultado automáticamente como precaución. (Puesto que no escribí algo realmente desagradable :angel:, he quitado el reporte por ahora.) + not_found: |- + Oh no, mi mensaje desagradable aún no ha sido reportado! :worried: ¿Puedes marcar como inapropiado el mensaje con la **bandera** ? No te olvides de hacer clic en el botón para mostrar todas las opciones: y ver así más acciones por cada mensaje. + + search: + instructions: |- + _psst_ … Te he ocultado una sorpresa en este tema. Si aceptas el reto, **selecciona el icono de búsqueda** en la parte superior ↗ y busca lo siguiente. + + Prueba buscar el término "capy​bara" en este tema + hidden_message: |- + Cómo te perdiste este capybara? :wink: + + + + ¿Te fijaste en que estás de vuelta al principio? Alimenta este pobre hambriendo capybara **respondiendo con el emoji `:herb:`** y serás automáticamente enviado al final. + reply: |- + Yay lo encontraste! :tada: + + - Para búsquedas más detalladas, ir a la página de [busqueda completa](%{search_url}). + + - Para saltar donde quieras en una discusión larga, intente los controles de la línea de tiempo del tema a la derecha (o por debajo, en el móvil) + + - Si tienes un :keyboard: físico, presiona la tecla ? para ver nuestros prácticos atajos del teclado. + not_found: |- + Hmm… parece que podrías estar teniendo problemas. Lo siento acerca de ésto. Buscaste el término **capy​bara**? + + end: + message: |- + Gracias por quedarte conmigo @%{username}! Hice esto por tí, creo que te lo has ganado: + + %{certificate} + + ¡Eso es todo por ahora! Echa un vistazo a [**nuestros últimos temas de discusión**](/latest) o las [**categorías de debate**](/categories). :sunglasses: + + (Si deseas hablar conmigo de neuvo para aprender más, solo envia un mensaje o me mencionas `@%{discobot_username}` en cualquier momento!) + + certificate: + alt: 'Certificado de logro' + + advanced_user_narrative: + reset_trigger: 'usuario avanzado' + cert_title: "En reconocimiento de la finalización exitosa del tutorial de usuario avanzado" + title: ':arrow_up: Funciones avanzadas del usuario' + start_message: |- + Como un usuario _avanzado_, ¿no has visitado [tus preferencias](/my/preferences) aún @%{username}? Hay muchas formas de personalizar tu experiencia, por ejemplo podrías elegir un diseño oscuro. + ¡Pero yo divago, comencemos! + + edit: + bot_created_post_raw: "@%{discobot_username} es, por lejos, el mejor bot que conozco :wink:" + instructions: |- + Todos cometemos errores. Pero no te preocupes, siempre podrás editar tus mensajes para solucionarlos! + ¿Puedes empezar por **editar** el mensaje que acabo de crear en tu nombre? + not_found: |- + Parece que aún no has editado el [mensaje](%{url}) que creé por tí. ¿Puedes intentar de nuevo? + Usa el icono de lápiz para que aparezca el editor. + reply: |- + ¡Buen trabajo! + Tenga en cuenta que las ediciones realizadas después de 5 minutos aparecerán como revisiones de edición pública y aparecerá un pequeño ícono de lápiz en la parte superior derecha con el recuento de revisiones. + + delete: + instructions: |- + Si deseas retirar una publicación que hayas realizado, puedes eliminarla. + Vamos a **borrar** cualquier post que hayas escrito usando la acción para **borrar**. No vayas a querer borrar el primer post! + not_found: |- + ¿Todavía no veo ninguna publicación eliminada? Recuerda presionar para mostrar el botón y borrar. + reply: |- + Whoa! :boom: + + Para preservar la continuidad de las discusiones, las supresiones no son inmediatas, por lo que la publicación se eliminará después de algún tiempo. + + recover: + deleted_post_raw: 'Por qué @%{discobot_username} borró mi mensaje? :anguished:' + instructions: |- + Oh no! Parece que accidentalmente borré un nuevo mensaje que tú acababas de crear. + ¿Puedes hacerme el favor y hacer clic en para **recuperar** el mensaje borrado? + not_found: |- + ¿Teniendo problemas? Recuerda muestra más, y aparecerá el botón para recuperar. + reply: |- + Phew, ¡eso estuvo cerca! Gracias por solucionarlo :wink: + + Tenga en cuenta que sólo tiene 24 horas para recuperar una publicación. + + category_hashtag: + instructions: |- + ¿Sabía que puede hacer referencia a categorías y etiquetas en su publicación? Por ejemplo, has visto la %{category} categoría? + Escribe `#` en el medio de una oración y selecciona cualquier categoría o etiqueta. + not_found: |- + Hmm, no veo una categoría en ninguna parte. Nota que `#` no puede ser el primer carácter de la oración. ¿Puedes copiar esto en tu próxima respuesta? + + ```text + Yo puedo crear un enlace a la categoría con # + ``` + reply: |- + ¡Excelente! Recuerda que ésto funciona para ambos, categorías _y_ etiquetas, si las etiquetas están disponibles. + + change_topic_notification_level: + instructions: |- + Cada tema tiene un nivel de notificación. Comienza en 'normal', lo que significa que normalmente sólo se notificará cuando alguien está hablando directamente con usted. + De forma predeterminada, el nivel de notificación de un mensaje privado se establece en el nivel más alto de "observación", lo que significa que se le notificará cada nueva respuesta. Pero puede sobrescribir el nivel de notificación de _cualquier_ tema a 'vigilar', 'seguir' o 'silenciar'. + Intentemos cambiar el nivel de notificación de este tema. Al final del tema, encontrarás un botón que muestra que estás **vigilando** este tema. ¿Puedes cambiar el nivel de notificación a **seguir**? + not_found: |- + Parece que aún estás vigilando :eyes: este tema! Si tienes problemas para encontrar el botón del nivel de notificación, el mismo está debajo de todo el tema de debate. + reply: |- + ¡Impresionante trabajo! Espero que no silencies este tema ya que puedo ser un poco hablador a veces :grin:. + Tenga en cuenta que cuando responde a un tema o lee un tema durante más de unos minutos, se establece automáticamente en un nivel de notificación de "seguimiento". Puedes cambiar esto en [tus preferencias de usuario](/my/preferences). + + poll: + instructions: |- + ¿Sabes que puedes agregar una encuesta en cualquier mensaje? Intenta usando el botón de engranaje en el editor para **armar una encuesta**. + not_found: |- + Whoops! No hubo ninguna encuesta en tu respuesta. + + Usa el ícono de engranaje: en el editor, o bien copia y pega esta encuesta en tu próxima respuesta: + + ```text + [poll] + * :cat: + * :dog: + [/poll] + ``` + reply: |- + Hey, ¡hermosa encuesta! ¿Cómo te enseñé? + [poll] + * :+1: + * :-1: + [/poll] + + details: + instructions: |- + A veces, tú puedes decidir **ocultar detalles** en tus respuestas: + + - Cuando estás discutiendo puntos de trama de una película o programa de televisión que sería considerado un spoiler. + + - Cuando su puesto necesita un montón de detalles opcionales que pueden ser abrumador cuando se lee todo a la vez. + + [details=¡Clic aquí para ver cómo funciona!] + 1. Selecciona el botón de engranaje en el editor. + 2. Selecciona "Hide Details". + 3. Edita los detalles, y agrega el contenido que desees ocultar. + [/details] + + ¿Puedes usar el botón de engranaje en el editor para agregar datos ocultos en tu próxima respuesta? + not_found: |- + ¿Tiene problemas para crear un widget de detalles? Trate de incluir lo siguiente en su siguiente respuesta: + + ```text + [details=Seleccionar para ver detalles] + Aquí están los detalles + [/details] + ``` + reply: |- + ¡Gran trabajo — tu atención a los _detalles_ es admirable! + + end: + message: |- + Usted ha brillado a través de esto como un _usuario avanzado_ de hecho :bow: + + %{certificate} + + Eso es todo lo que tengo para ti. + + ¡Adiós por ahora! Si desea hablar conmigo de nuevo, envíeme un mensaje en cualquier momento :sunglasses: + + certificate: + alt: 'Advanced User Track Certificate of Achievement' diff --git a/plugins/discourse-narrative-bot/config/locales/server.fi.yml b/plugins/discourse-narrative-bot/config/locales/server.fi.yml new file mode 100644 index 00000000000..8ea4c130fe3 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.fi.yml @@ -0,0 +1,453 @@ +fi: + site_settings: + discourse_narrative_bot_enabled: 'Ota Discoursen opastava botti käyttöön' + disable_discourse_narrative_bot_welcome_post: "Estä bottia lähettämästä tervetuloviestejä" + discourse_narrative_bot_ignored_usernames: "Käyttäjänimet, jotka botin tulisi jättää huomiotta" + discourse_narrative_bot_disable_public_replies: "Estä bottia vastaamasta julkisesti ketjuihin" + discourse_narrative_bot_welcome_post_type: "Minkätyyppisen tervetuloviestin botti lähettää" + + badges: + certified: + name: Tutkinto plakkarissa + description: "Suoritti peruskurssin" + long_description: | + Tämä ansiomerkki myönnetään, kun suoritat menestyksellä vuorovaikutteisen palstan käytön peruskurssin. Otit tavoitteeksesi hallita tavallisimmat toiminnot, ja nyt sinulla on siitä todistus! + licensed: + name: Pätevyytensä osoittanut + description: "Suoritti jatkokurssin" + long_description: | + Tämä ansiomerkki myönnetään, kun suoritat menestyksellä vuorovaikutteisen palstan käytön jatkokurssin. Olet omaksunut edistyneemmätkin toiminnot, ja olet taitosi osoittanut. + + discourse_narrative_bot: + bio: "Moi! En ole ihminen. Olen botti, ja tarjoan opetusta sivuston käyttämisestä. Jos haluat jutella, lähetä yksityisviesti tai mainitse **`@%{discobot_username}`** missä vain." + + timeout: + message: |- + Hei @%{username}, kyselen kuulumisia, kun en ole kuullut sinusta hetkeen. + + - Voit jatkaa milloin vain vastaamalla minulle. + + - Voit hypätä tämän vaiheen yli sanomalla `@%{discobot_username} %{skip_trigger}`. + + - Voit aloittaa alusta sanomalla `@%{discobot_username} %{reset_trigger}`. + + Jos et viitsi, sekin on ihan okei. Olen robotti. Minulla ei ole tunteita. :sob: + + dice: + trigger: "heitä" + invalid: |- + Pahoittelen, mutta on matemaattinen mahdottomuus heittää noppia sillä tavalla kuin pyysit. :confounded: + not_enough_dice: |- + Minulla on vain %{num_of_dice} noppaa. [Häpeällistä](http://www.therobotsvoice.com/2009/04/the_10_most_shameful_rpg_dice.php), tiedän! + out_of_range: |- + Tiesitkö, että [suurin mahdollinen määrä sivuja](https://www.wired.com/2016/05/mathematical-challenge-of-designing-the-worlds-most-complex-120-sided-dice) matemaattisesti tasapuolisessa nopassa on 120? + results: |- + > :game_die: %{results} + + quote: + trigger: "sitaatti" + "1": + quote: "Tutki parhaita kirjailijoita siten avartaaksesi mielikuvituksesi piiriä." + author: "Aleksis Kivi" + "2": + quote: "On ihan mahdollista, että todellisuus on olemassa." + author: "Esko Valtaoja" + "3": + quote: "Jokainen syy, joka estää kuntoilun, on tekosyy." + author: "Urho Kekkonen" + "4": + quote: "Älkää kiinnittäkö huomiota mitä kriitikot sanovat, kriitikoille ei ole koskaan pystytetty yhtään patsasta." + author: "Jean Sibelius" + "5": + quote: "Kahdesta vaihtoehdosta koetan valita aina sen, joka pelottaa enemmän." + author: "Jouko Turkka" + "6": + quote: "Jos Suomessa juodaankin jokunen Koskenkorva liikaa, on siihen hyvä syy: paljon muuta tekemistä ei löydy." + author: Linus Torvalds + "7": + quote: "Kaikki lähtee siitä kun sä opit syömään lihapullat haarukalla. Silloin sä osaat tehdä mitä tahansa." + author: "Matti Nykänen" + "8": + quote: "Käsitykseni on, että se, mitä Suomen kansa tällä hetkellä ennen kaikkea kaipaa, on läheistä, asiallisuuteen pohjautuvaa ymmärtämystä eri yhteiskuntalukkien, eri kieliryhmien ja eri puolueiden kesken. Se takaa jatkuvan yhteiskunnallisen rauhan ja siitä riippuu, missä määrin tämä kansa kykenee vapauttansa ja itsenäisyytensä lujittamaan ja tarvittaessa puolustamaan." + author: "Lauri Kristian Relander" + "9": + quote: "Naisen työala tulevaisuudessa on laaja, hänen tehtävänsä tärkeä. Mitä vuosisadat, vuosituhannet ovat rikkoneet ja laiminlyöneet, se kaikki tulee hänen korjata ja parantaa." + author: "Minna Canth" + "10": + quote: "Joskus pitää laittaa viatonkin vankilaan. Joskus täytyy poliisin laittaa viaton lusimaan. Arvovalta säilyy. Pelote säilyy. Aina siellä muutama viaton istuu, viaton pulmunen istuu siellä." + author: "Rauno Repomies televisiosarjassa Pasila" + results: |- + > :left_speech_bubble: _%{quote}_ — %{author} + + magic_8_ball: + trigger: 'ennustus' + answers: + "1": "Se on varmaakin varmempaa" + "2": "Niin on tapahtuva" + "3": "Epäilemättä" + "4": "Kyllä, ehdottomasti" + "5": "Voit laskea sen varaan" + "6": "Näkemykseni on, että kyllä" + "7": "Todennäköisesti" + "8": "Siltä näyttäisi" + "9": "Kyllä" + "10": "Merkit viittaavat siihen suuntaan" + "11": "Näky epäselvä, yritä uudelleen" + "12": "Palaa myöhemmin asiaan" + "13": "Parempi, etten kerro vielä" + "14": "En pysty sanomaan juuri nyt" + "15": "Keskity ja kyse sitten uudelleen" + "16": "Älä laske sen varaan" + "17": "Vastaukseni on ei" + "18": "Lähteeni kertovat, että ei" + "19": "Ei siltä vaikuta" + "20": "Epäilen sitä vahvasti" + result: |- + > :crystal_ball: %{result} + + track_selector: + random_mention: + reset_trigger: 'aloita' + skip_trigger: 'ohita' + help_trigger: 'apua' + + reply: |- + Moi! Jos haluat tietää mitä osaan, sano `@%{discobot_username} %{help_trigger}`. + tracks: |- + Osaan nykyisellään seuraavat jutut: + + `@%{discobot_username} %{reset_trigger} %{default_track}` + > Aloittaa yhden seuraavista opastetuista kierroksista: %{tracks}. + bot_actions: |- + `@%{discobot_username} %{dice_trigger} 2d6` + > :game_die: dice roll: 3, 6 + + `@%{discobot_username} %{quote_trigger}` + > :left_speech_bubble: _Carry out a random act of kindness, with no expectation of reward, safe in the knowledge that one day someone might do the same for you_ — Prinsessa Diana + + `@%{discobot_username} %{magic_8_ball_trigger}` + > :crystal_ball: En pysty sanomaan juuri nyt + + do_not_understand: + first_response: |- + Hei, kiitos kun vastasit! + + Valitettavasti olen huonosti ohjelmoitu botti, enkä aivan ymmärtänyt mitä tarkoitit. :frowning: + track_response: + Voit yrittää uudelleen, tai jos mieluummin hyppäät vaiheen yli, sano `@%{discobot_username} %{skip_trigger}`. Tai jos haluat aloittaa alusta, sano `@%{discobot_username} %{reset_trigger}`. + second_response: |- + Voi ei, en vieläkään tajua. :anguished: + + Olen kuitenkin vain botti. Jos haluat tavoittaa aidon ihmisen, katsopa [yhteystietoja tältä sivulta](/about). + + En häiritse sinua enempää nyt. + + new_user_narrative: + reset_trigger: "peruskurssi" + cert_title: "Tunnustuksena palstan käytön peruskurssin menestyksekkäästä suorittamisesta" + + hello: + title: ":robot: Tervehdys!" + message: |- + Kiitos kun liityit sivustollemme %{title}. Tervetuloa! + + - Olen vain robotti, mutta [avulias henkilökunta](/about) auttaa myös mielellään, jos sinulla on asiaa heille. + + - Turvallisuussyistä uusi käyttäjä ei voi tehdä ihan kaikkea. Saat uusia toimintoja käyttöösi (ja [ansiomerkkejä](/badges)) kun tutustumme sinuun. + + - Vannomme [sivistyneen yhteisökäyttäytymisen](/guidelines) nimeen kaikissa tilanteissa. + + onebox: + instructions: |- + Voisitko nyt jakaa jonkun seuraavista linkeistä minulle? Laita vastaukseesi **linkin osoite yksin omalle rivilleen**, jolloin siitä muotoillaan automaattisesti kätevä tiivistelmälaatikko. + + Kopioi linkki mobiililaitteella koskettamalla sitä hetken aikaa, tai klikkaa sitä hiiresi oikealla painikkeella: + + - https://en.wikipedia.org/wiki/Inherently_funny_word + - https://en.wikipedia.org/wiki/Death_by_coconut + - https://en.wikipedia.org/wiki/Calculator_spelling + reply: |- + Siistiä! Suurin osa linkeistä käyttäytyy samoin. Muista, että sen tulee olla yksin omalla rivillään; mitään ei saa olla rivillä ennen sitä tai sen jälkeen. + not_found: |- + Voi ei, en löytänyt linkkiä vastauksestasi! :cry: + + Kokeilepa lisätä tämä linkki seuraavaan vastaukseesi, omalle rivilleen. + + - https://fi.wikipedia.org/wiki/Eksoottinen_lyhytkarva + + images: + instructions: |- + Tämä on kuva yksisarvisesta: + + + + Jos pidit siitä (kukapa ei pitäisi!), kerro siitä minulle painamalla tykkää-nappia :heart: tämän viestin alla. + + Osaatkohan **vastata kuvalla?** Ihan mikä tahansa kuva kelpaa! Raahaa ja pudota, paina liitä-nappia tai vaikkapa kopioi ja liitä. Keinot ovat monet! + reply: |- + Loistava kuva – Painoin tykkää-nappia :heart:, jotta tiedät että pidin siitä kovasti! :heart_eyes: + like_not_found: |- + Unohditko tykätä :heart: [viestistäni?](%{url}) :crying_cat_face: + not_found: |- + Näyttää ettet laittanut kuvaa, joten valitsin kuvan josta _takuulla_ pidät. + + `%{image_url}` + + Kokeilepa liittää tämä kuva tai liittää linkin osoite omalle rivilleen! + + formatting: + instructions: |- + **Lihavoi** tai **kursivoi** sanoja seuraavasta vastauksestasi. + + - kirjoita `**lihavoitua**` tai `_kursivoitua_` + + - tai käytä viestieditorin B- tai I-painiketta + + reply: |- + Hyvin tehty! HTML ja BBCode käyvät myös muotoiluun. Jos on puhtia, [tästä johdannosta](http://commonmark.org/help) opit lisää. :nerd: + not_found: |- + Pahus, en näe muotoilua viestissäsi. :pencil2: + + Kokeilisitko uudelleen? Käytä editorin B-lihavointipainiketta tai I-kursivointipainiketta, jos muuten ei meinaa onnistua. + + quoting: + instructions: |- + Voisitko lainata minua kun vastaat, jotta tiedän mihin kohtaan viittaat? + + > Jos tässä on kahvia, tuo minulle teetä. Jos tässä on teetä, tuo minulle kahvia. + > + > Hyvä puoli itsekseen juttelussa on, että voit olla varma, että joku kuuntelee. + > + > Jotkut ovat briljantteja sanankäyttäjiä ja toiset... öö... tuota... eivät ole. + + Maalaa valitsemasi sitaatin ↑ teksti ja paina sitten Lainaa-painiketta, joka ilmestyy valintasi yhteyteen. Lainaa-napin sijasta voit myös painaa Vastaa-nappia tämän viestin alla. + + Kuvaile parilla sanalla sitaatin perään, miksi valitsit juuri sen, sillä olen kovin utelias. :thinking: + reply: |- + Satuit valitsemaan suosikkisitaattini! :left_speech_bubble: + not_found: |- + Hmm, minusta tuntuu ettet lainannut tekstiäni vastaukseesi? + + **Lainaa**-painike ilmestyy, kun maalaat viestini tekstiä. **Vastaa**-painike käy yhtä hyvin, jos sinulla on tekstiä maalattuna. Kokeilisitko uudelleen? + + bookmark: + instructions: |- + Jos haluat oppia lisää, valitse tämän alta ja **lisää tämä yksityisviestiketju kirjanmerkkeihisi**. Jos teet niin, sinua voi odottaa :gift: tulevaisuudessa! + reply: |- + Erinomaista! Löydät yksityiskeskustelumme nyt helposti koska vain [profiilisi kirjanmerkkivälilehdeltä](%{profile_page_url}/activity/bookmarks). Klikkaat vain profiilikuvaasi oikeassa yläkulmasssa ↗ + not_found: |- + Tuota noin, et ole mielestäni kirjanmerkinnyt tämän ketjun viestejä. Löysithän kirjanmerkkikuvakkeen kunkin viestin alta? Voi olla, että sinun täytyy klikata -kuvaketta, jotta näet sen ja muita toimintoja. + + emoji: + instructions: |- + Tapaan käyttää pieniä kuvia :blue_car::dash: viesteissäni; ne ovat [emojeja](https://fi.wikipedia.org/wiki/Emoji). **Laitapa joku emoji** vastausviestiisi. On useita tapoja: + + - Näppäile `:) ;) :D :P :O` + + - Laita kaksoispiste : ja sen perään emojin nimi `:tada:` + + - Paina editorin tai mobiililaitteesi näppäimistön emoji-kuvaketta . + reply: |- + :sparkles: _Emojinomaista!_ :sparkles: + not_found: |- + Oho, et tainnut laittaa emojia viestiisi. Nyyh! :sob: + + Saat emojivalitsimen esiin näppäilemällä kaksoispisteen :. Ala kirjoittaa sen perään englanniksi millaisen emojin haluat - esimerkiksi lintu on `:bird:` + + Voit myös painaa editorin emojikuvaketta . + + (Mobiililaitteella voit lisätä emojin suoraan näppäimistöltäsikin.) + + mention: + instructions: |- + Voit haluta huomiota joltakulta, vaikket edes olisi vastaamassa hänelle suoraan. Mainitse hänet näppäilemällä `@` ja sen perään käyttäjänimi kokonaisuudessaan. + + Mainitsepa minut **`@%{discobot_username}`** seuraavassa vastauksessasi. + reply: |- + _Kutsuiko joku minua!?_ :raised_hand: Sinäkö se olit? :wave: Tässäpä minä! Kiitos kun mainitsit minut. :ok_hand: + not_found: |- + En näe nimeäni. :frowning: Yrittäisitkö nimeni `@%{discobot_username}` mainitsemista vielä kerran? + + (Huomaa, että nimessäni on kansainvälisessä hengessä _disco_, ihan kuin siinä 1970-luvun tanssityylissä. [Rakastan yöelämää!](https://www.youtube.com/watch?v=B_wGI3_sGf8) :dancer:) + + flag: + instructions: |- + Pidämme ystävällishenkisistä keskusteluista, ja tarvitsemme apuasi, jotta [pysytään asialinjalla](%{guidelines_url}). Kun näet ongelman, kerro siitä kirjoittajalle tai [avuliaalle henkilökunnalle](%{about_url}) liputtamalla. + + > :imp: Kirjoitin tähän jotain tuhmaa. + + Tiedät mitä tehdä. **Liputa tämä viesti** sopimattomaksi! + reply: |- + [Henkilökuntamme](/groups/staff) saa ei-julkisen ilmoituksen lipusta. Jos riittävän moni yhteisön jäsen liputtaa viestin, sekin riittää viestin automaattiseen piilottamiseen varotoimena. (Koska en oikeasti kirjoittanut mitään tuhmaa :angel:, menin ja poistin liputuksesi.) + not_found: |- + Oi voi, tuhmaa viestiäni ei ole vielä liputettu. :worried: Voitko liputtaa sen sopimattomaksi **lippupainikkeen** avulla ? Don’t forget to use the show more button to reveal more actions for each post. + + search: + instructions: |- + _psst_ … Tein pienen jekun tähän ketjuun. Jos olet valmis ottamaan haasteen vastaan, oikealla ylhäällä ↗ on **hakukuvake** , jolla voit yrittää löytää sen. + + Etsi hakusanaa "kapy​bara" tästä ketjusta + hidden_message: |- + Miten sinulta jäi tämä kapybara huomaamatta? :wink: + + + + Huomasitko, että palasit takaisin alkuun? Ruoki söpöä nälkäistä kapybaraa **vastaamalla `:herb:` -emojilla** niin sinut palautetaan automaattisesti takaisin loppuun. + reply: |- + Jes, löysit sen :tada: + + - Tarkempia hakuja voit tehdä [suurella hakusivulla](%{search_url}). + + - Hyppää mihin kohtaan haluat pitkää keskustelua oikealla olevalla aikajanan avulla (sijaitsee mobiilissa alhaalla). + + - Jos sinulla on fyysinen :keyboard:, tarkastele käteviä näppäinoikoteitä painamalla ?. + not_found: |- + Hm… sinulla vaikuttaa olevan vaikeuksia. Pahoitteluni. Haitko hakusanalla **kapy​bara**? + + end: + message: |- + Kiitos kun jaksoit loppuun asti @%{username}! Tein tämän sinulle, minusta olet ansainnut sen: + + %{certificate} + + Siinä kaikki tältä erää! Tsekkaa [**tuoreimmat ketjut**](/latest) tai tutki [**keskustelualueita**](/categories). :sunglasses: + + (Jos haluat vielä jutella kanssani ja oppia lisää, lähetä minulle `@%{discobot_username}` yksityisviesti tai mainitse minut koska vain!) + + certificate: + alt: 'Diplomi' + + advanced_user_narrative: + reset_trigger: 'jatkokurssi' + cert_title: "Tunnustuksena palstan käytön jatkokurssin menestyksekkäästä suorittamisesta" + title: ':arrow_up: Jatkokurssi' + start_message: |- + _Edistynyt_ käyttäjä kun olet, oletko @%{username} jo käynyt [käyttäasetussivulla](/my/preferences)? Siellä voit mukauttaa sivustoa makusi mukaan monilla tavoin, esimerkiksi valita tumman ja vaalean teeman väliltä. + + Vaan eipä harhauduta asiasta vaan aloitetaan! + + edit: + bot_created_post_raw: "@%{discobot_username} on ylivoimaisesti mahtavin botti, jonka tiedän. :wink:" + instructions: |- + Kaikki tekevät virheitä. Vaan älä huoli, voit muokata viestejäsi ja korjata erheesi! + + Aloita **muokkaamalla** viestiä, jonka juuri tein sinun nimissäsi. + not_found: |- + Näyttää, ettet vielä muokannut [viestiä](%{url}), jonka tein puolestasi. Yrittäisitkö uudelleen? + + Palaa viestieditoriin klikkaamalla -kuvaketta. + reply: |- + Hyvin tehty! + + Huomaa, että 5 minuutin jälkeen tehdyt muokkaukset näkyvät kaikille muokkaushistoriassa, joka ilmestyy viestin oikeaan yläkulmaan pienenä kynäsymbolina, jossa näkyy muokkausten määrä. + + delete: + instructions: |- + Jos haluat perua viestisi, voit poistaa sen. + + Anna mennä ja **poista** yltä mikä tahansa viestisi poista-toiminnon avulla. Älä kuitenkaan erehdy poistamaan ketjun ensimmäistä viestiä! + not_found: |- + Minusta viestejä ei vielä poistettu? Muista, että näytä lisää -kuvake paljastaa poista-kuvakkeen. + reply: |- + Vau! :boom: + + Keskusteluiden jatkuvuuden vuoksi viesti ei poistu kokonaan välittömästi, vaan se poistuu vasta jonkin ajan kuluttua. + + recover: + deleted_post_raw: 'Miksi @%{discobot_username} poisti viestini? :anguished:' + instructions: |- + Voi ei! Taisin vahingossa poistaa viestin, jonka tein sinun nimissäsi. + + Tekisitkö palveluksen ja **palauttaisit** sen? + not_found: |- + Onko ongelmia? Muista, että näytä lisää -kuvake paljastaa palauta-kuvakkeen. + reply: |- + Huh, sydäntä kylmäsi! Kiitos, kun korjasit mokani. :wink: + + Huomioi, että viestin voi palauttaa vain 24 tunnin sisällä sen poistamisesta. + + category_hashtag: + instructions: |- + Tiesitkö, että viesteissä voi viitata alueisiin ja tunnisteisiin? Esimerkiksi, oletko käynyt %{category}-alueella? + + Näppäile lauseen keskellä `#` ja valitse mieleinen alue tai tunniste. + not_found: |- + Höh, en näe viittauksia alueisiin. Huomioi, ettei `#` saa olla rivinsä ensimmäinen merkki. Kopioisitko tämän seuraavaan vastaukseesi? + + ```text + I can create a category link via # + ``` + reply: |- + Erinomaista! Muista, että tämä toimii alueiden lisäksi myös tunnisteille, jos ne ovat käytössä täällä. + + change_topic_notification_level: + instructions: |- + Sinulla on jokaiselle ketjulle ilmoitustaso. Aluksi se on 'tavallinen', jolloin saat tavalliseen tapaan ilmoituksen vain, jos joku puhuu nimenomaan sinulle. + + Oletuksena yksityisviestiketjujen ilmoitustaso on kaikkein korkein eli 'tarkkaillaan', jolloin saat ilmoituksen joka ikisestä viestistä. Voit kuitenkin valita _mille tahansa_ ketjulle ilmoitustason 'tarkkaillaan', 'seurataan' tai 'vaimennettu'. + + Kokeillaanpa vaihtaa tämän ketjun ilmoitustasoa. Ketjun alla on painike, joka näyttää sinun tarkkailevan tätä ketjua. Vaihdapa ilmoitustasoksi **seurataan**. + not_found: |- + Näytät yhä tarkkailevan :eyes: tätä ketjua! Jollet meinaa löytää sitä, ilmoitustasopainike sijaitsee ketjun alla. + reply: |- + Mahtavaa! Toivottavasti et kuitenkaan vaimentanut tätä ketjua, vaikka saatan välillä olla vähän liiankin puhelias. :grin:. + + Huomioi, että kun vastaat ketjuun tai luet sitä kauemmin kuin muutaman minuutin ajan, se saa automaattisesti ilmoitustasokseen 'seurataan'. Tätä voi säätää [käyttäjäasetuksissa](/my/preferences). + + poll: + instructions: |- + Tiesitkö, että viesteihin voi lisätä äänestyksiä? **Luo äänestys** valitsemalla viestieditorin hammasrataskuvake. + not_found: |- + Ups! Vastauksessasi ei ollut äänestystä. + + Käytä editorin hammasrataskuvaketta tai kopioi tämä äänestys ja liitä se seuraavaan vastaukseesi: + + ```text + [poll] + * :cat: + * :dog: + [/poll] + ``` + reply: |- + Hauska äänestys! Miten onnistuin opetuksessa? + + [poll] + * :+1: + * :-1: + [/poll] + details: + instructions: |- + Joskus voit haluta **piilottaa yksityiskohtia** vastauksissasi: + + - Kirjoitat elokuvasta tai TV-ohjelmasta jotakin, minkä voi kokea juonipaljastukseksi. + + - Viestissäsi on suuria määriä yksityiskohtaista tietoa, joka ei ole kaikille olennaista ja joka voisi uuvuttaa tai häiritä lukijaa. + + [details=Select this to see how it works!] + 1. Valitse editorin hammasrataskuvake. + 2. Valitse "Piilota yksityiskohdat". + 3. Muokkaa Yhteenveto-tekstiä, jonka taakse tiedot piilotetaan, ja lisää sisältö. + [/details] + + Piilottaisitko yksityiskohtia editorin hammasrataskuvakkeen avulla seuraavasta vastauksestasi? + not_found: |- + Onko ongelmia yksityiskohtatoiminnon kanssa? Kokeilepa laittaa tämä seuraavaan vastaukseesi: + + ```text + [details=Paina niin saat tarkempaa tietoa] + Yksityiskohtaista tietoa + [/details] + ``` + reply: |- + Hyvin tehty - panostustasi _yksityiskohtiin_ on pakko arvostaa! + end: + message: |- + Selvitit tämän kuin _edistynyt käyttäjä_ konsanaan! :bow: + + %{certificate} + + Siinä oli kaikki, mitä minulla on tarjota. + + Kuulemiin! Jos haluat vielä jutella kanssani, lähetä viesti milloin vain! :sunglasses: + certificate: + alt: 'Diplomi jatkokurssista' diff --git a/plugins/discourse-narrative-bot/config/locales/server.it.yml b/plugins/discourse-narrative-bot/config/locales/server.it.yml new file mode 100644 index 00000000000..9c6eb415bfd --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.it.yml @@ -0,0 +1,410 @@ +it: + site_settings: + discourse_narrative_bot_enabled: 'Abilita Discourse Narrative Bot' + disable_discourse_narrative_bot_welcome_post: "Disabilita il messaggio di benvenuto di Discourse Narrative Bot" + discourse_narrative_bot_ignored_usernames: "Gli username che Discourse Narrative Bot dovrebbe ignorare" + discourse_narrative_bot_disable_public_replies: "Disabilita le risposte pubbliche da Discourse Narrative Bot" + + badges: + certified: + name: Certificato + description: "Completato il tutorial per nuovi utenti" + long_description: | + Questo distintivo è assegnato al completamento del tutorial interattivo per nuovi utenti. Hai voluto imparare gli strumenti base di una discussione, e ora sei certificato! + licensed: + name: Diploma + description: "Completato il tutorial per utenti avanzati" + long_description: | + Questo distintivo è assegnato al completamento del tutorial interattivo per utenti avanzati. Hai imparato gli strumenti avanzati di discussione — e ora sei un diplomato! + discourse_narrative_bot: + bio: "Ciao, io non sono una persona reale. Io sono un bot che ti può insegnare ad usare questo sito. Per interagire con me inviami un messaggio o menziona**`@%{discobot_username}`** ovunque." + + timeout: + message: |- + Hey @%{username}, sto solo controllando perchè è da un po' che non ti sento. + - Per continuare, rispondimi in qualsiasi momento. + + - Se vuoi saltare questo passaggio, dimmi `%{skip_trigger}`. + + - Per ricominciare, dimmi `%{reset_trigger}`. + + Se preferisci non continuare, va bene lo stesso. Io sono un robot. Non ferirai i miei sentimenti. :sob: + + dice: + trigger: "tira" + invalid: |- + Mi dispiace, è matematicamente impossibile lanciare quella combinazione di dadi. :confounded: + not_enough_dice: |- + Ho solo %{num_of_dice} dadi. [Vergognoso](http://www.therobotsvoice.com/2009/04/the_10_most_shameful_rpg_dice.php), lo so! + out_of_range: |- + Lo sapevi che [il numero massimo di lati](https://www.wired.com/2016/05/mathematical-challenge-of-designing-the-worlds-most-complex-120-sided-dice) per essere matematicamente bilanciato è 120? + results: |- + > :game_die: %{results} + + quote: + trigger: "cita" + "1": + quote: "Nel mezzo delle difficoltà nascono le opportunità." + author: "Albert Einstein" + "2": + quote: "Siate il cambiamento che volete vedere nel mondo." + author: "Mahatma Gandhi" + "3": + quote: "Non piangere perchè è finita, sorridi perchè è successo." + author: "Dr Seuss" + "4": + quote: "Se vuoi fare bene una cosa, falla da solo." + author: "Charles-Guillaume Étienne" + "5": + quote: "Se credi che sia possibile sei già a metà strada." + author: "Theodore Roosevelt" + "6": + quote: "La vita è come una scatola di cioccolatini. Non sai mai quello che ti capita." + author: "Madre di Forrest Gump" + "7": + quote: "E' un piccolo passo per l'uomo ma un grande passo per l'umanità." + author: "Neil Armstrong" + "8": + quote: "Almeno una volta al giorno fai qualcosa che ti spaventa." + author: "Eleanor Roosevelt" + "9": + quote: "Gli errori sono sempre perdonabili, se si ha il coraggio di ammetterli." + author: "Bruce Lee" + "10": + quote: "Tutto ciò che la mente umana può concepire e credere, può essere realizzato." + author: "Napoleon Hill" + results: |- + > :left_speech_bubble: _%{quote}_ — %{author} + + magic_8_ball: + trigger: 'fortuna' + answers: + "1": "E' certo" + "2": "E' decisamente così" + "3": "Senza alcun dubbio" + "4": "Sì, senza dubbio" + "5": "Ci puoi contare" + "6": "Per quanto posso vedere, sì" + "7": "Molto probabilmente" + "8": "Le prospettive sono buone" + "9": "Sì" + "10": "I segni indicano di sì" + "11": "E' difficile rispondere, prova di nuovo" + "12": "Rifai la domanda più tardi" + "13": "Meglio non risponderti adesso" + "14": "Non posso predirlo ora" + "15": "Concentrati e rifai la domanda" + "16": "Non ci contare" + "17": "La mia risposta è no" + "18": "Le mie fonti dicono di no" + "19": "Le prospettive non sono buone" + "20": "Molto incerto" + result: |- + > :crystal_ball: %{result} + + track_selector: + reset_trigger: 'inizia' + skip_trigger: 'salta' + help_trigger: 'mostra la guida' + + random_mention: + reply: |- + Ciao! Per sapere cosa posso fare, dimmi `@%{discobot_username} %{help_trigger}`. + tracks: |- + Attualmente so fare le seguenti cose: + + `@%{discobot_username} %{reset_trigger} %{default_track}` + > Avvia una delle seguenti narrazioni interattive: %{tracks}. + bot_actions: |- + `@%{discobot_username} %{dice_trigger} 2d6` + > :game_die: 3, 6 + + `@%{discobot_username} %{quote_trigger}` + > :left_speech_bubble: _Fare un atto di gentilezza casuale, senza nessuna aspettativa di ricompensa, con la certezza che un giorno qualcuno potrebbe fare lo stesso per te_ — Principessa Diana + + `@%{discobot_username} %{magic_8_ball_trigger}` + > :crystal_ball: Ci puoi contare + + do_not_understand: + first_response: |- + Hey, grazie per la risposta! + Sfortunatamente, come un bot mal programmato, non posso capire la tua risposta. :frowning: + track_response: + Puoi provare di nuovo, o se vuoi saltare questo passaggio, dimmi `%{skip_trigger}`. Altrimenti per ricominciare, dimmi `%{reset_trigger}`. + second_response: |- + Aw, scusa. Non sto ancora capendo. :anguished: + + Sono solo un bot, ma se vuoi contattare una persona reale, guarda [la nostra pagina dei contatti](/about). + + Nel frattempo io me ne starò in disparte. + + new_user_narrative: + reset_trigger: "nuovo utente" + cert_title: "In riconoscimento del successo nel completamento del tutorial come nuovo utente" + + hello: + title: ":robot: Saluti!" + message: |- + Grazie per esserti unito a %{title}, e benvenuto! + - Io sono solo un robot, ma [il nostro amichevole staff](/about) è qui per aiutarti se hai bisogno di contattare una persona. + + - Per ragioni di sicurezza, abbiamo temporaneamente limitato ciò che i nuovi utenti possono fare. Potrai guadagnare nuove abilità (e [distintivi](/badges)) appena ti conosceremo meglio. + + - Noi crediamo da sempre in una [comunità dal comportamento civile](/guidelines). + + onebox: + instructions: |- + Adesso, puoi condividere uno di questi collegamenti con me? Rispondi con **un collegamento su una riga a sè stante**, e verrà automaticamente espanso per includere un bel sommario. + Per copiare un collegamento, toccalo e tienilo premuto su mobile, o fai click col tasto destro del mouse: + + - https://it.wikipedia.org/wiki/Italia + - https://it.wikipedia.org/wiki/Pagina_principale + - https://it.wikipedia.org/wiki/Traduzione + reply: |- + Fantastico! Questo funzionerà per la maggior parte dei collegamenti . Ricorda, deve essere su una linea _da solo_, con nient'altro davanti o dietro. + not_found: |- + Mi dispiace, non riesco a trovare il collegamento nella tua risposta! :cry: + + Puoi provare ad aggiungere il seguente collegamento, su una riga da solo, nella tua prossima risposta? + + - https://it.wikipedia.org/wiki/Exotic_Shorthair + + images: + instructions: |- + Ecco l'immagine di un unicorno: + + + + Se ti piace (e a chi non piacerebbe!) vai avanti e premi il pulsante mi piace :heart: sotto questo messaggio per farmelo sapere. + + Puoi **rispondere con un'immagine?** Qualsiasi immagine funzionerà! Trascinala e rilasciala, premi il pulsante carica, oppure copiala e incollala. + reply: |- + Bell'immagine – Ho premuto il pulsante mi piace :heart: per farti sapere quanto l'ho apprezzata :heart_eyes: + like_not_found: |- + Hai dimenticato di mettere mi piace :heart: al mio [messaggio?](%{url}) :crying_cat_face: + not_found: |- + Sembra che tu non abbia caricato un'immagine così ne ho scelta una io che sono _sicuro_ ti piacerà. + `%{image_url}` + Prova a caricare questa nel prossimo messaggio, o incolla il collegamento su una riga da solo! + formatting: + instructions: |- + Puoi scrivere alcune parole in **grassetto** o _italico_ nella tua risposta? + - digita `**grassetto**` o `_italico_` + + - oppure premi i pulsanti G o I sull'editor + + reply: |- + Ottimo lavoro! Anche HTML e BBCode funzionano per la formattazione – per saperne di più [prova questo tutorial](http://commonmark.org/help) :nerd: + not_found: |- + Aww, non ho trovato nessuna formattazione nella tua risposta. :pencil2: + + Puoi provare di nuovo? Usa i pulsanti G grassetto o I italico nell'editor se sei rimasto bloccato. + + quoting: + instructions: |- + Puoi provare a citarmi nella tua risposta, così saprò esattamente a quale parte del mio messaggio stai rispondendo? + > Se questo è caffè, per favore portami del thè; ma se questo è thè, per favore portami del caffè. + > + > Un vantaggio del parlare con sè stessi è che almeno sai che qualcuno ti sta ascoltando. + > + > Alcune persone si fanno strada con le parole e altre persone… oh, oh, non hanno una strada. + Seleziona il testo della citazione ↑ che preferisci, e poi premi il pulsante **Cita** che comparirà sopra il testo selezionato – o il pulsante **Rispondi** in fondo a questo messaggio. + Sotto la citazione, digita una o due parole sul motivo per cui hai scelto proprio quella perchè sono curioso :thinking: + reply: |- + Bel lavoro, hai scelto la mia citazione preferita! :left_speech_bubble: + not_found: |- + Hmm sembra che tu non mi abbia citato nella tua risposta! + Selezionare un qualsiasi testo del mio messaggio farà apparire il pulsante **Cita**. E anche premere **Rispondi** con qualsiasi testo selezionato funzionerà! Puoi provare di nuovo? + bookmark: + instructions: |- + Se vuoi saperne di più, seleziona qui sotto e per inserire **questo messaggio privato nei segnalibri**. Se lo farai, ci potrebbe essere un :gift: nel tuo futuro! + reply: |- + Eccellente! Ora potrai tornare facilmente a questa conversazione privata in ogni momento, proprio dalla [scheda segnalibri sul tuo profilo](%{profile_page_url}/activity/bookmarks). Basta selezionare l'immagine del tuo profilo in alto a destra ↗ + not_found: |- + Oh oh, non vedo nessun segnalibro in questo argomento. Hai trovato il pulsante segnalibro sotto ogni messaggio? Usa mostra altro per rivelare pulsanti aggiuntivi se necessario. + emoji: + instructions: |- + Puoi avermi visto utilizzare delle piccole immagini nelle mie risposte :blue_car::dash: che si chiamano [emoji](https://en.wikipedia.org/wiki/Emoji). Puoi **aggiungere una emoji** alla tua risposta? Uno qualsiasi di questi metodi funzionerà: + + - Digita `:) ;) :D :P :O` + + - Digita due punti : poi completa il nome della emoji `:tada:` + + - Premi il pulsante emoji nell'editor, o sulla tastiera mobile + reply: |- + Questo è :sparkles: _emojitastico!_ :sparkles: + not_found: |- + Oops, non vedo nessuna emoji nella tua risposta! Oh no! :sob: + + Prova digitando i due punti : per far apparire il selettore delle emoji, poi digita le prime lettere della emoji che vuoi, ad esempio `:bird:` + + Oppure premi il pulsante emoji nell'editor. + + (Se sei su un dispositivo mobile, puoi anche immettere l'emoji direttamente dalla tastiera.) + + mention: + instructions: |- + Qualche volta potresti volere l'attenzione di una persona, anche se non stai rispondendo direttamente a lei. Digita `@` poi completa il suo username per menzionarla. + Puoi menzionare **`@%{discobot_username}`** nella tua risposta? + reply: |- + _Qualcuno ha fatto il mio nome!?_ :raised_hand: Credo che sia stato tu! :wave: Bene, eccomi qui! Grazie per avermi menzionato. :ok_hand: + not_found: |- + Non vedo il mio nome da nessuna parte qui :frowning: Puoi provare a menzionarmi di nuovo come `@%{discobot_username}`? + + (E sì, il mio username inizia con _disco_, come nella mania dance degli anni 70. Io [amo la vita notturna!](https://www.youtube.com/watch?v=B_wGI3_sGf8) :dancer:) + + flag: + instructions: |- + Ci piacciono le discussioni amichevoli, e abbiamo bisogno del tuo aiuto per [mantenere le cose civilizzate](%{guidelines_url}). Se vedi un problema, per favore segnalalo privatamente per farlo sapere all'autore o [al nostro staff](%{about_url}). + > :imp: Ho scritto qualcosa di brutto qui + + Credo che tu sappia cosa fare. Vai avanti e **segnala questo messaggio** come inappropriato! + reply: |- + [Il nostro staff](/groups/staff) verrà notificato privatamente della tua segnalazione. Se abbastanza membri della comunità segnalano un messaggio, quest'ultimo verrà automaticamente nascosto per precauzione. (Dal momento che non ho scritto veramente qualcosa di brutto :angel:, ho rimosso la segnalazione per ora.) + not_found: |- + Oh no, il mio brutto messaggio non è ancora stato segnalato. :worried: Puoi segnalarlo come inappropriato usando il pulsante **segnala** ? Non dimenticarti di usare il pulsante mostra altro per rivelare altre azioni possibili su ogni messaggio. + + search: + instructions: |- + _psst_ … Ho nascosto una sorpresa in una delle mie risposte precedenti. Se sei pronto alla sfida, **seleziona l'icona cerca** in alto a destra ↗ per cercarla. + Prova a cercare il termine "capi​bara" in questo argomento + hidden_message: |- + Come hai fatto a perdere questo capibara? :wink: + + + + Hai notato che sei tornato all'inizio dell'argomento? Dai da mangiare a questo affamato capibara **rispondendo con l'emoji `:herb:`** e verrai automaticamente riportato alla fine. + reply: |- + Hey l'hai trovato :tada: + + - Per ricerche più dettagliate, vai alla [pagina di ricerca](%{search_url}). + + - Per saltare ovunque in una discussione lunga, prova i controlli temporali sulla destra (e in fondo, su mobile). + + - Se hai una :keyboard: fisica, premi ? per visualizzare delle comode scorciatoie da tastiera. + not_found: |- + Hmm… Sembra che tu abbia qualche problema. Ci dispiace. Hai cercato su il termine **capi​bara**? + end: + message: |- + Grazie per avermi seguito @%{username}! Ho fatto questo per te, penso che te lo sei guadagnato: + + %{certificate} + + E' tutto per ora! Controlla [**gli argomenti delle nostre ultime discussioni**](/latest) or [**le categorie di discussione**](/categories). :sunglasses: + + (Se vuoi parlare con me ancora per saperne di più, inviami un messaggio o menzionami `@%{discobot_username}` quando vuoi!) + + certificate: + alt: 'Attestato di Merito' + + advanced_user_narrative: + reset_trigger: 'utente avanzato' + cert_title: "In riconoscimento del completamento con successo del tutorial utente avanzato" + title: ':arrow_up: Funzioni utente avanzato' + start_message: |- + Come un utente avanzato, hai già visitato [la pagina delle opzioni sul tuo profilo](/my/preferences) @%{username}? Ci sono molti modi per personalizzare la tua esperienza, ad esempio selezionando un tema scuro o uno chiaro. + Ma sto divagando, cominciamo! + edit: + bot_created_post_raw: "@%{discobot_username} è, di gran lunga, il bot più interessante che conosco :wink:" + instructions: |- + Tutti fanno degli errori. Ma non preoccuparti, puoi sempre modificare i tuoi messaggi per sistemarli! + Puoi iniziare **modificando** il messaggio che ho appena creato al tuo posto? + not_found: |- + Sembra che tu non abbia ancora modificato il [messaggio](%{url}) che ho creato per te. Puoi provare di nuovo? + Usa l'icona per far apparire l'editor. + reply: |- + Ottimo lavoro! + Nota che le modifiche fatte dopo 5 minuti verranno mostrate come revisioni pubbliche, e una piccola icona a forma di matita apparirà in alto a destra sul messaggio con il conteggio delle modifiche fatte. + delete: + instructions: |- + Se lo desideri puoi eliminare un tuo messaggio, cancellandolo. + Vai avanti e **cancella** uno dei tuoi messaggi precedenti usando il pulsante **cancella**. Non cancellare il primo messaggio però! + not_found: |- + Non vedo ancora nessun messaggio cancellato! Ricorda di cliccare su mostra altro per rivelare il pulsante cancella. + reply: |- + Whoa! :boom: + + Per preservare la continuità delle discussioni, le cancellazioni non sono immediate, così i messaggi saranno rimossi dopo un certo periodo di tempo. + + recover: + deleted_post_raw: 'Perchè @%{discobot_username} ha cancellato il mio messaggio? :anguished:' + instructions: |- + Oh no! Sembra che io abbia accidentalmente cancellato un nuovo messaggio che avevo creato per te. + Puoi farmi un favore e **ripristinarlo**? + not_found: |- + Stai avendo problemi? Ricorda di cliccare su mostra altro per rivelare il pulsante ripristina. + reply: |- + Pfff, questa è fatta! Grazie per averlo ripristinato :wink: + + Ti faccio notare che hai solo 24 ore di tempo per ripristinare un messaggio. + + category_hashtag: + instructions: |- + Lo sapevi che è possibile fare riferimento a categorie ed etichette in un messaggio? Per esempio, hai visto la categoria %{category}? + Digita `#` nel mezzo di una frase e seleziona una categoria o un'etichetta. + not_found: |- + Hmm, non vedo nessuna categoria. Nota che `#` non può essere il primo carattere di una riga. Puoi copiare questo nella tua prossima risposta? + Posso creare un collegamento ad una categoria digitando # + reply: |- + Eccellente! Ricorda che questo funziona per le categorie _e_ le etichette, se le etichette sono abilitate. + change_topic_notification_level: + instructions: |- + Ogni argomento ha un livello di notifica. Parte da 'normale', il che significa che riceverai una notifica quando qualcuno parlerà direttamente con te. + Per impostazione predefinita, il livello di notifica per un messaggio privato è impostato al livello più alto 'in osservazione', il che significa che riceverai una notifica ad ogni nuovo messaggio. Ma puoi sovrascrivere il livello di notifica per _ogni_ argomento su 'in osservazione', 'seguito' o 'silenziato'. + Prova a modificare il livello di notifica per questo argomento. In fondo alla discussione, troverai un pulsante che ti mostrerà che questo argomento è **in osservazione** . Puoi modificare il livello di notifica a **seguito**? + not_found: |- + Sembra che stia ancora in osservazione :eyes: di questo argomento! Se hai problemi a trovarlo, il pulsante relativo al livello della notifica è situato in fondo a questo argomento. + reply: |- + Lavoro impressionante! Spero che non silenzi questo argomento dato che a volte posso essere un po' loquace :grin:. + Nota che quando rispondi ad un argomento, o leggi un argomento per più di qualche minuto, verrà automaticamente impostato il livello di notifica a 'seguito'. Puoi modificare queste impostazioni sulle [tue preferenze utente](/my/preferences). + poll: + instructions: |- + Lo sapevi che puoi aggiungere un sondaggio in qualsiasi messaggio? Prova a usare l'icona ingranaggio sull'editor per **costruire un sondaggio**. + not_found: |- + Whoops! Non c'è nessun sondaggio nella tua risposta. + Usa l'icona ingranaggio sull'editor, oppure copia e incolla questo sondaggio nella tua prossima risposta: + + [poll] + * :cat: + * :dog: + [/poll] + reply: |- + Hey, bel sondaggio! Sono un buon insegnante? + [poll] + * :+1: + * :-1: + [/poll] + details: + instructions: |- + Delle volte desidererai **nascondere dei dettagli** nelle tue risposte: + + - Quando stai discutendo alcuni punti della trama di un film o di uno show televisivo che potrebbero essere considerati spoiler. + + - Quando il tuo messaggio necessita di molti dettagli opzionali che possono essere di intralcio se letti tutti in una volta. + + [details=Seleziona questa opzione per vedere come funziona!] + 1. Seleziona l'icona ingranaggio sull'editor. + 2. Seleziona "Nascondi Dettagli". + 3. Modifica il sommario e aggiungi il tuo contenuto. + [/details] + + Puoi usare l'icona ingranaggio sull'editor per aggiungere una sezione con dei dettagli nella tua prossima risposta? + not_found: |- + Hai problemi a creare un widget con dei dettagli? Prova a includere quello che segue nella tua prossima risposta: + + [details=Selezionami per vedere i dettagli] + Qui ci sono i dettagli + [/details] + reply: |- + Ottimo lavoro — la tua attenzione per i _dettagli_ è ammirevole! + end: + message: |- + Hai affrontato tutto questo come un _utente avanzato_ infatti :bow: + + %{certificate} + + Questo è tutto quello che posso fare per te. + + Arrivederci per adesso! Se desideri parlare di nuovo con me mandami un messaggio in qualsiasi momento :sunglasses: + certificate: + alt: 'Attestato di Merito per Utente Avanzato' diff --git a/plugins/discourse-narrative-bot/config/settings.yml b/plugins/discourse-narrative-bot/config/settings.yml new file mode 100644 index 00000000000..e36e06f8a4d --- /dev/null +++ b/plugins/discourse-narrative-bot/config/settings.yml @@ -0,0 +1,23 @@ +plugins: + discourse_narrative_bot_enabled: + default: + default: true + test: false + client: true + disable_discourse_narrative_bot_welcome_post: + default: false + discourse_narrative_bot_welcome_post_type: + default: 'new_user_track' + enum: 'DiscourseNarrativeBot::WelcomePostTypeSiteSetting' + discourse_narrative_bot_welcome_post_delay: + default: 0 + discourse_narrative_bot_ignored_usernames: + default: 'discourse' + type: list + discourse_narrative_bot_disable_public_replies: + default: false + +uncategorized: + send_welcome_message: + default: false + hidden: true diff --git a/plugins/discourse-narrative-bot/db/fixtures/001_discobot.rb b/plugins/discourse-narrative-bot/db/fixtures/001_discobot.rb new file mode 100644 index 00000000000..c81ac87fc4a --- /dev/null +++ b/plugins/discourse-narrative-bot/db/fixtures/001_discobot.rb @@ -0,0 +1,43 @@ +discobot_username ='discobot' +user = User.find_by(id: -2) + +if !user + suggested_username = UserNameSuggester.suggest(discobot_username) + + User.seed do |u| + u.id = -2 + u.name = discobot_username + u.username = suggested_username + u.username_lower = suggested_username.downcase + u.email = "discobot_email" + u.password = SecureRandom.hex + u.active = true + u.approved = true + u.trust_level = TrustLevel[4] + end + + # TODO Pull the user avatar from that thread for now. In the future, pull it from a local file or from some central discobot repo. + if !Rails.env.test? + UserAvatar.import_url_for_user( + "https://cdn.discourse.org/dev/uploads/default/original/2X/e/edb63d57a720838a7ce6a68f02ba4618787f2299.png", + User.find(-2), + override_gravatar: true + ) + end +end + +bot = User.find(-2) +bot.update!(admin:true, moderator: false) + +bot.user_option.update!( + email_private_messages: false, + email_direct: false +) + +if !bot.user_profile.bio_raw + bot.user_profile.update!( + bio_raw: I18n.t('discourse_narrative_bot.bio', site_title: SiteSetting.title, discobot_username: bot.username) + ) +end + +Group.user_trust_level_change!(-2, TrustLevel[4]) diff --git a/plugins/discourse-narrative-bot/db/fixtures/002_badges.rb b/plugins/discourse-narrative-bot/db/fixtures/002_badges.rb new file mode 100644 index 00000000000..263e9dccb71 --- /dev/null +++ b/plugins/discourse-narrative-bot/db/fixtures/002_badges.rb @@ -0,0 +1,39 @@ +Badge + .where(name: 'Complete New User Track') + .update_all(name: DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME) + +Badge + .where(name: 'Complete Discobot Advanced User Track') + .update_all(name: DiscourseNarrativeBot::AdvancedUserNarrative::BADGE_NAME) + +new_user_narrative_badge = Badge.find_by(name: DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME) + +unless new_user_narrative_badge + new_user_narrative_badge = Badge.create!( + name: DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME, + badge_type_id: 3 + ) +end + +advanced_user_narrative_badge = Badge.find_by(name: DiscourseNarrativeBot::AdvancedUserNarrative::BADGE_NAME) + +unless advanced_user_narrative_badge + advanced_user_narrative_badge = Badge.create!( + name: DiscourseNarrativeBot::AdvancedUserNarrative::BADGE_NAME, + badge_type_id: 2 + ) +end + +badge_grouping = BadgeGrouping.find(1) + +[ + [new_user_narrative_badge, I18n.t('badges.certified.description')], + [advanced_user_narrative_badge, I18n.t('badges.licensed.description')] +].each do |badge, description| + + badge.update!( + badge_grouping: badge_grouping, + description: description, + system: true + ) +end diff --git a/plugins/discourse-narrative-bot/jobs/bot_input.rb b/plugins/discourse-narrative-bot/jobs/bot_input.rb new file mode 100644 index 00000000000..06ac33d7fcb --- /dev/null +++ b/plugins/discourse-narrative-bot/jobs/bot_input.rb @@ -0,0 +1,17 @@ +module Jobs + class BotInput < Jobs::Base + + sidekiq_options queue: 'critical', retry: false + + def execute(args) + return unless user = User.find_by(id: args[:user_id]) + + I18n.with_locale(user.effective_locale) do + ::DiscourseNarrativeBot::TrackSelector.new(args[:input].to_sym, user, + post_id: args[:post_id], + topic_id: args[:topic_id] + ).select + end + end + end +end diff --git a/plugins/discourse-narrative-bot/jobs/narrative_init.rb b/plugins/discourse-narrative-bot/jobs/narrative_init.rb new file mode 100644 index 00000000000..e2e9219bee3 --- /dev/null +++ b/plugins/discourse-narrative-bot/jobs/narrative_init.rb @@ -0,0 +1,13 @@ +module Jobs + class NarrativeInit < Jobs::Base + sidekiq_options queue: 'critical' + + def execute(args) + if user = User.find_by(id: args[:user_id]) + I18n.with_locale(user.effective_locale) do + args[:klass].constantize.new.input(:init, user) + end + end + end + end +end diff --git a/plugins/discourse-narrative-bot/jobs/narrative_timeout.rb b/plugins/discourse-narrative-bot/jobs/narrative_timeout.rb new file mode 100644 index 00000000000..0e8b5bc80be --- /dev/null +++ b/plugins/discourse-narrative-bot/jobs/narrative_timeout.rb @@ -0,0 +1,11 @@ +module Jobs + class NarrativeTimeout < Jobs::Base + def execute(args) + if user = User.find_by(id: args[:user_id]) + I18n.with_locale(user.effective_locale) do + args[:klass].constantize.new.notify_timeout(user) + end + end + end + end +end diff --git a/plugins/discourse-narrative-bot/jobs/onceoff/grant_badges.rb b/plugins/discourse-narrative-bot/jobs/onceoff/grant_badges.rb new file mode 100644 index 00000000000..bbc995fb3a6 --- /dev/null +++ b/plugins/discourse-narrative-bot/jobs/onceoff/grant_badges.rb @@ -0,0 +1,35 @@ +module Jobs + module DiscourseNarrativeBot + class GrantBadges < ::Jobs::Onceoff + def execute_onceoff(args) + new_user_track_badge = Badge.find_by( + name: ::DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME + ) + + advanced_user_track_badge = Badge.find_by( + name: ::DiscourseNarrativeBot::AdvancedUserNarrative::BADGE_NAME + ) + + PluginStoreRow.where( + plugin_name: ::DiscourseNarrativeBot::PLUGIN_NAME, + type_name: 'JSON' + ).find_each do |row| + + value = JSON.parse(row.value) + completed = value["completed"] + user = User.find_by(id: row.key) + + if user && completed + if completed.include?(::DiscourseNarrativeBot::NewUserNarrative.to_s) + BadgeGranter.grant(new_user_track_badge, user) + end + + if completed.include?(::DiscourseNarrativeBot::AdvancedUserNarrative.to_s) + BadgeGranter.grant(advanced_user_track_badge, user) + end + end + end + end + end + end +end diff --git a/plugins/discourse-narrative-bot/jobs/send_default_welcome_message.rb b/plugins/discourse-narrative-bot/jobs/send_default_welcome_message.rb new file mode 100644 index 00000000000..fcf3efcd5a2 --- /dev/null +++ b/plugins/discourse-narrative-bot/jobs/send_default_welcome_message.rb @@ -0,0 +1,25 @@ +module Jobs + class SendDefaultWelcomeMessage < Jobs::Base + def execute(args) + if user = User.find_by(id: args[:user_id]) + type = user.invited_by ? 'welcome_invite' : 'welcome_user' + params = SystemMessage.new(user).defaults + + title = I18n.t("system_messages.#{type}.subject_template", params) + raw = I18n.t("system_messages.#{type}.text_body_template", params) + discobot_user = User.find(-2) + + post = PostCreator.create!( + discobot_user, + title: title, + raw: raw, + archetype: Archetype.private_message, + target_usernames: user.username, + skip_validations: true + ) + + post.topic.update_status('closed', true, discobot_user) + end + end + end +end diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/actions.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/actions.rb new file mode 100644 index 00000000000..631ba918e7e --- /dev/null +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/actions.rb @@ -0,0 +1,85 @@ +module DiscourseNarrativeBot + module Actions + extend ActiveSupport::Concern + + included do + def self.discobot_user + @discobot ||= User.find(-2) + end + end + + private + + def reply_to(post, raw, opts = {}) + if post + default_opts = { + raw: raw, + topic_id: post.topic_id, + reply_to_post_number: post.post_number + } + + new_post = PostCreator.create!(self.class.discobot_user, default_opts.merge(opts)) + reset_rate_limits(post) if new_post + new_post + else + PostCreator.create!(self.class.discobot_user, { raw: raw }.merge(opts)) + end + end + + def reset_rate_limits(post) + user = post.user + data = DiscourseNarrativeBot::Store.get(user.id.to_s) + + return unless data + + key = "#{DiscourseNarrativeBot::PLUGIN_NAME}:reset-rate-limit:#{post.topic_id}:#{data['state']}" + + if !(count = $redis.get(key)) + count = 0 + + duration = + if user && user.new_user? + SiteSetting.rate_limit_new_user_create_post + else + SiteSetting.rate_limit_create_post + end + + $redis.setex(key, duration, count) + end + + if count.to_i < 2 + post.default_rate_limiter.rollback! + post.limit_posts_per_day&.rollback! + $redis.incr(key) + end + end + + def fake_delay + sleep(rand(2..3)) if Rails.env.production? + end + + def bot_mentioned?(post) + doc = Nokogiri::HTML.fragment(post.cooked) + + valid = false + + doc.css(".mention").each do |mention| + valid = true if mention.text == "@#{self.class.discobot_user.username}" + end + + valid + end + + def reply_to_bot_post?(post) + post&.reply_to_post && post.reply_to_post.user_id == -2 + end + + def pm_to_bot?(post) + topic = post.topic + return false if !topic + + topic.pm_with_non_human_user? && + topic.topic_allowed_users.where(user_id: -2).exists? + end + end +end diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/advanced_user_narrative.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/advanced_user_narrative.rb new file mode 100644 index 00000000000..5842410ad76 --- /dev/null +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/advanced_user_narrative.rb @@ -0,0 +1,384 @@ +module DiscourseNarrativeBot + class AdvancedUserNarrative < Base + I18N_KEY = "discourse_narrative_bot.advanced_user_narrative".freeze + BADGE_NAME = 'Licensed'.freeze + + TRANSITION_TABLE = { + begin: { + next_state: :tutorial_edit, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.edit.instructions") }, + init: { + action: :start_advanced_track + } + }, + + tutorial_edit: { + next_state: :tutorial_delete, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.delete.instructions") }, + edit: { + action: :reply_to_edit + }, + reply: { + next_state: :tutorial_edit, + action: :missing_edit + } + }, + + tutorial_delete: { + next_state: :tutorial_recover, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.recover.instructions") }, + delete: { + action: :reply_to_delete + }, + reply: { + next_state: :tutorial_delete, + action: :missing_delete + } + }, + + tutorial_recover: { + next_state: :tutorial_category_hashtag, + next_instructions: Proc.new do + category = Category.secured.last + slug = category.slug + + if parent_category = category.parent_category + slug = "#{parent_category.slug}#{CategoryHashtag::SEPARATOR}#{slug}" + end + + I18n.t("#{I18N_KEY}.category_hashtag.instructions", + category: "##{slug}" + ) + end, + recover: { + action: :reply_to_recover + }, + reply: { + next_state: :tutorial_recover, + action: :missing_recover + } + }, + + tutorial_category_hashtag: { + next_state: :tutorial_change_topic_notification_level, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.change_topic_notification_level.instructions") }, + reply: { + action: :reply_to_category_hashtag + } + }, + + tutorial_change_topic_notification_level: { + next_state: :tutorial_poll, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.poll.instructions") }, + topic_notification_level_changed: { + action: :reply_to_topic_notification_level_changed + }, + reply: { + next_state: :tutorial_notification_level, + action: :missing_topic_notification_level_change + } + }, + + tutorial_poll: { + next_state: :tutorial_details, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.details.instructions") }, + reply: { + action: :reply_to_poll + } + }, + + tutorial_details: { + next_state: :end, + reply: { + action: :reply_to_details + } + } + } + + def self.can_start?(user) + return true if user.staff? + user.badges.where(name: DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME).exists? + end + + def self.reset_trigger + I18n.t('discourse_narrative_bot.advanced_user_narrative.reset_trigger') + end + + def reset_bot(user, post) + if pm_to_bot?(post) + reset_data(user, { topic_id: post.topic_id }) + else + reset_data(user) + end + + Jobs.enqueue_in(2.seconds, :narrative_init, user_id: user.id, klass: self.class.to_s) + end + + private + + def init_tutorial_edit + data = get_data(@user) + + fake_delay + + post = PostCreator.create!(@user, { + raw: I18n.t( + "#{I18N_KEY}.edit.bot_created_post_raw", + discobot_username: self.class.discobot_user.username + ), + topic_id: data[:topic_id], + skip_bot: true + }) + + set_state_data(:post_id, post.id) + post + end + + def init_tutorial_recover + data = get_data(@user) + + post = PostCreator.create!(@user, { + raw: I18n.t( + "#{I18N_KEY}.recover.deleted_post_raw", + discobot_username: self.class.discobot_user.username + ), + topic_id: data[:topic_id], + skip_bot: true + }) + + set_state_data(:post_id, post.id) + PostDestroyer.new(@user, post, skip_bot: true).destroy + end + + def start_advanced_track + raw = I18n.t("#{I18N_KEY}.start_message", username: @user.username) + + raw = <<~RAW + #{raw} + + #{instance_eval(&@next_instructions)} + RAW + + opts = { + title: I18n.t("#{I18N_KEY}.title"), + target_usernames: @user.username, + archetype: Archetype.private_message + } + + if @post && + @post.archetype == Archetype.private_message && + @post.topic.topic_allowed_users.pluck(:user_id).include?(@user.id) + + opts = opts.merge(topic_id: @post.topic_id) + end + + if @data[:topic_id] + opts = opts.merge(topic_id: @data[:topic_id]) + end + post = reply_to(@post, raw, opts) + + @data[:topic_id] = post.topic_id + @data[:track] = self.class.to_s + post + end + + def reply_to_edit + return unless valid_topic?(@post.topic_id) + + fake_delay + + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.edit.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + reply_to(@post, raw) + end + + def missing_edit + post_id = get_state_data(:post_id) + return unless valid_topic?(@post.topic_id) && post_id != @post.id + + fake_delay + + unless @data[:attempted] + reply_to(@post, I18n.t("#{I18N_KEY}.edit.not_found", + url: Post.find_by(id: post_id).url + )) + end + + enqueue_timeout_job(@user) + false + end + + def reply_to_delete + return unless valid_topic?(@topic_id) + + fake_delay + + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.delete.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + PostCreator.create!(self.class.discobot_user, + raw: raw, + topic_id: @topic_id + ) + end + + def missing_delete + return unless valid_topic?(@post.topic_id) + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.delete.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + + def reply_to_recover + return unless valid_topic?(@post.topic_id) + + fake_delay + + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.recover.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + PostCreator.create!(self.class.discobot_user, + raw: raw, + topic_id: @post.topic_id + ) + end + + def missing_recover + return unless valid_topic?(@post.topic_id) && + post_id = get_state_data(:post_id) && @post.id != post_id + + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.recover.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + + def reply_to_category_hashtag + topic_id = @post.topic_id + return unless valid_topic?(topic_id) + + if Nokogiri::HTML.fragment(@post.cooked).css('.hashtag').size > 0 + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.category_hashtag.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + fake_delay + reply_to(@post, raw) + else + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.category_hashtag.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + end + + def missing_topic_notification_level_change + return unless valid_topic?(@post.topic_id) + + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.change_topic_notification_level.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + + def reply_to_topic_notification_level_changed + return unless valid_topic?(@topic_id) + + fake_delay + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.change_topic_notification_level.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + fake_delay + + post = PostCreator.create!(self.class.discobot_user, + raw: raw, + topic_id: @topic_id + ) + enqueue_timeout_job(@user) + post + end + + def reply_to_poll + topic_id = @post.topic_id + return unless valid_topic?(topic_id) + + if Nokogiri::HTML.fragment(@post.cooked).css(".poll").size > 0 + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.poll.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + fake_delay + reply_to(@post, raw) + else + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.poll.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + end + + def reply_to_details + topic_id = @post.topic_id + return unless valid_topic?(topic_id) + + fake_delay + + if Nokogiri::HTML.fragment(@post.cooked).css("details").size > 0 + reply_to(@post, I18n.t("#{I18N_KEY}.details.reply")) + else + reply_to(@post, I18n.t("#{I18N_KEY}.details.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + end + + def reply_to_wiki + topic_id = @post.topic_id + return unless valid_topic?(topic_id) + + fake_delay + + if @post.wiki + reply_to(@post, I18n.t("#{I18N_KEY}.wiki.reply")) + else + reply_to(@post, I18n.t("#{I18N_KEY}.wiki.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + end + + def end_reply + fake_delay + + reply_to(@post, I18n.t("#{I18N_KEY}.end.message", + certificate: certificate('advanced') + )) + end + + def synchronize(user) + if Rails.env.test? + yield + else + DistributedMutex.synchronize("advanced_user_narrative_#{user.id}") { yield } + end + end + end +end diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/base.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/base.rb new file mode 100644 index 00000000000..b454e826ccb --- /dev/null +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/base.rb @@ -0,0 +1,192 @@ +module DiscourseNarrativeBot + class Base + include Actions + + TIMEOUT_DURATION = 900 # 15 mins + + class InvalidTransitionError < StandardError; end + + def input(input, user, post: nil, topic_id: nil, skip: false) + new_post = nil + @post = post + @topic_id = topic_id + @skip = skip + + synchronize(user) do + @user = user + @data = get_data(user) || {} + @state = (@data[:state] && @data[:state].to_sym) || :begin + @input = input + opts = {} + + begin + opts = transition + rescue InvalidTransitionError + # For given input, no transition for current state + return + end + + next_state = opts[:next_state] + action = opts[:action] + + if next_instructions = opts[:next_instructions] + @next_instructions = next_instructions + end + + begin + old_data = @data.dup + new_post = (@skip && @state != :end) ? skip_tutorial(next_state) : self.send(action) + + if new_post + old_state = old_data[:state] + state_changed = (old_state.to_s != next_state.to_s) + clean_up_state(old_state) if state_changed + + @state = @data[:state] = next_state + @data[:last_post_id] = new_post.id + set_data(@user, @data) + + init_state(next_state) if state_changed + + if next_state == :end + end_reply + cancel_timeout_job(user) + + BadgeGranter.grant( + Badge.find_by(name: self.class::BADGE_NAME), + user + ) + + set_data(@user, + topic_id: new_post.topic_id, + state: :end, + track: self.class.to_s + ) + end + end + rescue => e + @data = old_data + set_data(@user, @data) + raise e + end + end + + new_post + end + + def reset_bot + not_implemented + end + + def set_data(user, value) + DiscourseNarrativeBot::Store.set(user.id, value) + end + + def get_data(user) + DiscourseNarrativeBot::Store.get(user.id) + end + + def notify_timeout(user) + @data = get_data(user) || {} + + if post = Post.find_by(id: @data[:last_post_id]) + reply_to(post, I18n.t("discourse_narrative_bot.timeout.message", + username: user.username, + skip_trigger: TrackSelector.skip_trigger, + reset_trigger: "#{TrackSelector.reset_trigger} #{self.class.reset_trigger}", + )) + end + end + + def certificate(type = nil) + options = { + user_id: @user.id, + date: Time.zone.now.strftime('%b %d %Y'), + host: Discourse.base_url, + format: :svg + } + + options.merge!(type: type) if type + src = DiscourseNarrativeBot::Engine.routes.url_helpers.certificate_url(options) + "#{I18n.t("#{self.class::I18N_KEY}.certificate.alt")}" + end + + protected + + def set_state_data(key, value) + @data[@state] ||= {} + @data[@state][key] = value + set_data(@user, @data) + end + + def get_state_data(key) + @data[@state] ||= {} + @data[@state][key] + end + + def reset_data(user, additional_data = {}) + old_data = get_data(user) + new_data = additional_data + set_data(user, new_data) + new_data + end + + def transition + options = self.class::TRANSITION_TABLE.fetch(@state).dup + input_options = options.fetch(@input) + options.merge!(input_options) unless @skip + options + rescue KeyError + raise InvalidTransitionError.new + end + + def skip_tutorial(next_state) + return unless valid_topic?(@post.topic_id) + + fake_delay + + if next_state != :end + reply = reply_to(@post, instance_eval(&@next_instructions)) + enqueue_timeout_job(@user) + reply + else + @post + end + end + + def valid_topic?(topic_id) + topic_id == @data[:topic_id] + end + + def cancel_timeout_job(user) + Jobs.cancel_scheduled_job(:narrative_timeout, user_id: user.id, klass: self.class.to_s) + end + + def enqueue_timeout_job(user) + return if Rails.env.test? + + cancel_timeout_job(user) + + Jobs.enqueue_in(TIMEOUT_DURATION, :narrative_timeout, + user_id: user.id, + klass: self.class.to_s + ) + end + + def not_implemented + raise 'Not implemented.' + end + + private + + def clean_up_state(state) + clean_up_method = "clean_up_#{state}" + self.send(clean_up_method) if self.class.private_method_defined?(clean_up_method) + end + + def init_state(state) + init_method = "init_#{state}" + self.send(init_method) if self.class.private_method_defined?(init_method) + end + end +end diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/certificate_generator.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/certificate_generator.rb new file mode 100644 index 00000000000..aed0874d2af --- /dev/null +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/certificate_generator.rb @@ -0,0 +1,582 @@ +module DiscourseNarrativeBot + class CertificateGenerator + def initialize(user, date) + @user = user + @date = I18n.l(Date.parse(date), format: :date_only) + end + + def new_user_track + width = 538.583 # Default width for the SVG + + svg = <<~SVG + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + test_cert + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #{I18n.t('discourse_narrative_bot.new_user_narrative.cert_title')} + + + #{NewUserNarrative.discobot_user.username} + + + #{@date} + + + + + + + + + #{name} + + #{logo_group(40, width, 280)} + + + + + + + + + + + + + + SVG + end + + def advanced_user_track + width = 722.8 # Default width for the SVG + + <<~SVG + + + + + + + + + + + + + + + + + + + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.cert_title')} + + + + + + + + + + + + + + #{@date} + + + + + + + + + #{AdvancedUserNarrative.discobot_user.username} + + + #{name} + + #{logo_group(55, width, 350)} + + + + + + + + SVG + end + + private + + def name + (@user.name && !@user.name.blank? ? @user.name : @user.username).titleize + end + + def logo_group(size, width, height) + begin + uri = URI(SiteSetting.logo_small_url) + + logo_uri = + if uri.host.blank? || uri.scheme.blank? + URI("#{Discourse.base_url}/#{uri.path}") + else + uri + end + + <<~URL + + + + URL + rescue URI::InvalidURIError + '' + end + end + + def avatar_url + UrlHelper.absolute(@user.avatar_template.gsub('{size}', '250')) + end + end +end diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/dice.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/dice.rb new file mode 100644 index 00000000000..de5cb4b377f --- /dev/null +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/dice.rb @@ -0,0 +1,32 @@ +module DiscourseNarrativeBot + class Dice + MAXIMUM_NUM_OF_DICE = 20 + MAXIMUM_RANGE_OF_DICE = 120 + + def self.roll(num_of_dice, range_of_dice) + if num_of_dice == 0 || range_of_dice == 0 + return I18n.t('discourse_narrative_bot.dice.invalid') + end + + output = '' + + if num_of_dice > MAXIMUM_NUM_OF_DICE + output << I18n.t('discourse_narrative_bot.dice.not_enough_dice', + num_of_dice: MAXIMUM_NUM_OF_DICE + ) + output << "\n\n" + num_of_dice = MAXIMUM_NUM_OF_DICE + end + + if range_of_dice > MAXIMUM_RANGE_OF_DICE + output << I18n.t('discourse_narrative_bot.dice.out_of_range') + output << "\n\n" + range_of_dice = MAXIMUM_RANGE_OF_DICE + end + + output << I18n.t('discourse_narrative_bot.dice.results', + results: num_of_dice.times.map { rand(1..range_of_dice) }.join(", ") + ) + end + end +end diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/magic_8_ball.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/magic_8_ball.rb new file mode 100644 index 00000000000..583dcfe3a1a --- /dev/null +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/magic_8_ball.rb @@ -0,0 +1,9 @@ +module DiscourseNarrativeBot + class Magic8Ball + def self.generate_answer + I18n.t("discourse_narrative_bot.magic_8_ball.result", result: I18n.t( + "discourse_narrative_bot.magic_8_ball.answers.#{rand(1..20)}" + )) + end + end +end diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/new_user_narrative.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/new_user_narrative.rb new file mode 100644 index 00000000000..1d1c451fd64 --- /dev/null +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/new_user_narrative.rb @@ -0,0 +1,519 @@ +require 'distributed_mutex' + +module DiscourseNarrativeBot + class NewUserNarrative < Base + I18N_KEY = "discourse_narrative_bot.new_user_narrative".freeze + BADGE_NAME = 'Certified'.freeze + + TRANSITION_TABLE = { + begin: { + init: { + next_state: :tutorial_bookmark, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.bookmark.instructions") }, + action: :say_hello + } + }, + + tutorial_bookmark: { + next_state: :tutorial_onebox, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.onebox.instructions") }, + + bookmark: { + action: :reply_to_bookmark + }, + + reply: { + next_state: :tutorial_bookmark, + action: :missing_bookmark + } + }, + + tutorial_onebox: { + next_state: :tutorial_emoji, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.emoji.instructions") }, + + reply: { + action: :reply_to_onebox + } + }, + + tutorial_emoji: { + next_state: :tutorial_mention, + next_instructions: Proc.new { + I18n.t("#{I18N_KEY}.mention.instructions", discobot_username: self.class.discobot_user.username) + }, + reply: { + action: :reply_to_emoji + } + }, + + tutorial_mention: { + next_state: :tutorial_formatting, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.formatting.instructions") }, + + reply: { + action: :reply_to_mention + } + }, + + tutorial_formatting: { + next_state: :tutorial_quote, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.quoting.instructions") }, + + reply: { + action: :reply_to_formatting + } + }, + + tutorial_quote: { + next_state: :tutorial_images, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.images.instructions") }, + + reply: { + action: :reply_to_quote + } + }, + + tutorial_images: { + next_state: :tutorial_flag, + next_instructions: Proc.new { + I18n.t("#{I18N_KEY}.flag.instructions", + guidelines_url: url_helpers(:guidelines_url), + about_url: url_helpers(:about_index_url)) + }, + reply: { + action: :reply_to_image + }, + like: { + action: :track_like + } + }, + + tutorial_flag: { + next_state: :tutorial_search, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.search.instructions") }, + flag: { + action: :reply_to_flag + }, + reply: { + next_state: :tutorial_flag, + action: :missing_flag + } + }, + + tutorial_search: { + next_state: :end, + reply: { + action: :reply_to_search + } + } + } + + SEARCH_ANSWER = ':herb:'.freeze + + def self.reset_trigger + I18n.t('discourse_narrative_bot.new_user_narrative.reset_trigger') + end + + def reset_bot(user, post) + if pm_to_bot?(post) + reset_data(user, { topic_id: post.topic_id }) + else + reset_data(user) + end + + Jobs.enqueue_in(2.seconds, :narrative_init, user_id: user.id, klass: self.class.to_s) + end + + private + + def synchronize(user) + if Rails.env.test? + yield + else + DistributedMutex.synchronize("new_user_narrative_#{user.id}") { yield } + end + end + + def init_tutorial_search + topic = @post.topic + post = topic.first_post + + MessageBus.publish('/new_user_narrative/tutorial_search', {}, user_ids: [@user.id]) + + raw = <<~RAW + #{post.raw} + + #{I18n.t("#{I18N_KEY}.search.hidden_message")} + RAW + + PostRevisor.new(post, topic).revise!( + self.class.discobot_user, + { raw: raw }, + { skip_validations: true, force_new_version: true } + ) + + set_state_data(:post_version, post.reload.version || 0) + end + + def clean_up_tutorial_search + first_post = @post.topic.first_post + first_post.revert_to(get_state_data(:post_version) - 1) + first_post.save! + first_post.publish_change_to_clients!(:revised) + end + + def say_hello + raw = I18n.t( + "#{I18N_KEY}.hello.message", + username: @user.username, + title: SiteSetting.title + ) + + raw = <<~RAW + #{raw} + + #{instance_eval(&@next_instructions)} + RAW + + opts = { + title: I18n.t("#{I18N_KEY}.hello.title", title: SiteSetting.title), + target_usernames: @user.username, + archetype: Archetype.private_message + } + + if @post && + @post.archetype == Archetype.private_message && + @post.topic.topic_allowed_users.pluck(:user_id).include?(@user.id) + + opts = opts.merge(topic_id: @post.topic_id) + end + + if @data[:topic_id] + opts = opts.merge(topic_id: @data[:topic_id]) + end + + post = reply_to(@post, raw, opts) + @data[:topic_id] = post.topic.id + @data[:track] = self.class.to_s + post + end + + def missing_bookmark + return unless valid_topic?(@post.topic_id) + return if @post.user_id == self.class.discobot_user.id + + fake_delay + enqueue_timeout_job(@user) + reply_to(@post, I18n.t("#{I18N_KEY}.bookmark.not_found")) unless @data[:attempted] + false + end + + def reply_to_bookmark + return unless valid_topic?(@post.topic_id) + return unless @post.user_id == self.class.discobot_user.id + + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.bookmark.reply", profile_page_url: url_helpers(:user_url, username: @user.username))} + + #{instance_eval(&@next_instructions)} + RAW + + fake_delay + + reply = reply_to(@post, raw) + enqueue_timeout_job(@user) + reply + end + + def reply_to_onebox + post_topic_id = @post.topic_id + return unless valid_topic?(post_topic_id) + + @post.post_analyzer.cook(@post.raw, {}) + + if @post.post_analyzer.found_oneboxes? + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.onebox.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + fake_delay + + reply = reply_to(@post, raw) + enqueue_timeout_job(@user) + reply + else + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.onebox.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + end + + def track_like + post_topic_id = @post.topic_id + return unless valid_topic?(post_topic_id) + + post_liked = PostAction.find_by( + post_action_type_id: PostActionType.types[:like], + post_id: @data[:last_post_id], + user_id: @user.id + ) + + if post_liked + set_state_data(:liked, true) + + if (post_id = get_state_data(:post_id)) && (post = Post.find_by(id: post_id)) + fake_delay + like_post(post) + + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.images.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + reply = reply_to(@post, raw) + enqueue_timeout_job(@user) + return reply + end + end + + false + end + + def reply_to_image + post_topic_id = @post.topic_id + return unless valid_topic?(post_topic_id) + + @post.post_analyzer.cook(@post.raw, {}) + transition = true + attempted_count = get_state_data(:attempted) || 0 + + if attempted_count < 2 + @data[:skip_attempted] = true + @data[:attempted] = false + else + @data[:skip_attempted] = false + end + + if @post.post_analyzer.image_count > 0 + set_state_data(:post_id, @post.id) + + if get_state_data(:liked) + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.images.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + like_post(@post) + else + raw = I18n.t( + "#{I18N_KEY}.images.like_not_found", + url: Post.find_by(id: @data[:last_post_id]).url + ) + + transition = false + end + else + raw = I18n.t( + "#{I18N_KEY}.images.not_found", + image_url: "#{Discourse.base_url}/images/dog-walk.gif" + ) + + transition = false + end + + fake_delay + + set_state_data(:attempted, attempted_count + 1) if !transition + reply = reply_to(@post, raw) unless @data[:attempted] && !transition + enqueue_timeout_job(@user) + transition ? reply : false + end + + def reply_to_formatting + post_topic_id = @post.topic_id + return unless valid_topic?(post_topic_id) + + if Nokogiri::HTML.fragment(@post.cooked).css("b", "strong", "em", "i", ".bbcode-i", ".bbcode-b").size > 0 + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.formatting.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + fake_delay + + reply = reply_to(@post, raw) + enqueue_timeout_job(@user) + reply + else + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.formatting.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + end + + def reply_to_quote + post_topic_id = @post.topic_id + return unless valid_topic?(post_topic_id) + + doc = Nokogiri::HTML.fragment(@post.cooked) + + if doc.css(".quote").size > 0 + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.quoting.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + fake_delay + + reply = reply_to(@post, raw) + enqueue_timeout_job(@user) + reply + else + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.quoting.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + end + + def reply_to_emoji + post_topic_id = @post.topic_id + return unless valid_topic?(post_topic_id) + + doc = Nokogiri::HTML.fragment(@post.cooked) + + if doc.css(".emoji").size > 0 + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.emoji.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + fake_delay + + reply = reply_to(@post, raw) + enqueue_timeout_job(@user) + reply + else + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.emoji.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + end + + def reply_to_mention + post_topic_id = @post.topic_id + return unless valid_topic?(post_topic_id) + + if bot_mentioned?(@post) + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.mention.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + fake_delay + + reply = reply_to(@post, raw) + enqueue_timeout_job(@user) + reply + else + fake_delay + + unless @data[:attempted] + reply_to(@post, I18n.t( + "#{I18N_KEY}.mention.not_found", + username: @user.username, + discobot_username: self.class.discobot_user.username + )) + end + + enqueue_timeout_job(@user) + false + end + end + + def missing_flag + return unless valid_topic?(@post.topic_id) + return if @post.user_id == -2 + + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.flag.not_found")) unless @data[:attempted] + false + end + + def reply_to_flag + post_topic_id = @post.topic_id + return unless valid_topic?(post_topic_id) + return unless @post.user.id == -2 + + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.flag.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + fake_delay + + reply = reply_to(@post, raw) + @post.post_actions.where(user_id: @user.id).destroy_all + + enqueue_timeout_job(@user) + reply + end + + def reply_to_search + post_topic_id = @post.topic_id + return unless valid_topic?(post_topic_id) + + if @post.raw.match(/#{SEARCH_ANSWER}/) + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.search.reply", search_url: url_helpers(:search_url))) + else + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.search.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + end + + def end_reply + fake_delay + + reply_to( + @post, + I18n.t("#{I18N_KEY}.end.message", + username: @user.username, + base_url: Discourse.base_url, + certificate: certificate, + discobot_username: self.class.discobot_user.username, + advanced_trigger: AdvancedUserNarrative.reset_trigger + ), + topic_id: @data[:topic_id] + ) + end + + def like_post(post) + PostAction.act(self.class.discobot_user, post, PostActionType.types[:like]) + end + + def welcome_topic + Topic.find_by(slug: 'welcome-to-discourse', archetype: Archetype.default) || + Topic.recent(1).first + end + + def url_helpers(url, opts = {}) + Rails.application.routes.url_helpers.send(url, opts.merge(host: Discourse.base_url)) + end + end +end diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/quote_generator.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/quote_generator.rb new file mode 100644 index 00000000000..729a3a62561 --- /dev/null +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/quote_generator.rb @@ -0,0 +1,27 @@ +require 'excon' + +module DiscourseNarrativeBot + class QuoteGenerator + API_ENDPOINT = 'http://api.forismatic.com/api/1.0/'.freeze + + def self.generate(user) + quote, author = + if user.effective_locale != 'en' + translation_key = "discourse_narrative_bot.quote.#{rand(1..10)}" + + [ + I18n.t("#{translation_key}.quote"), + I18n.t("#{translation_key}.author") + ] + else + connection = Excon.new("#{API_ENDPOINT}?lang=en&format=json&method=getQuote") + response = connection.request(expects: [200, 201], method: :Get) + + response_body = JSON.parse(response.body) + [response_body["quoteText"].strip, response_body["quoteAuthor"].strip] + end + + I18n.t('discourse_narrative_bot.quote.results', quote: quote, author: author) + end + end +end diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/track_selector.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/track_selector.rb new file mode 100644 index 00000000000..24a3df5788a --- /dev/null +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/track_selector.rb @@ -0,0 +1,266 @@ +module DiscourseNarrativeBot + class TrackSelector + include Actions + + GENERIC_REPLIES_COUNT_PREFIX = 'discourse-narrative-bot:track-selector-count:'.freeze + PUBLIC_DISPLAY_BOT_HELP_KEY = 'discourse-narrative-bot:track-selector:display-bot-help'.freeze + + TRACKS = [ + NewUserNarrative, + AdvancedUserNarrative + ] + + TOPIC_ACTIONS = [ + :delete, + :topic_notification_level_changed + ].each(&:freeze) + + RESET_TRIGGER_EXACT_MATCH_LENGTH = 200 + + def initialize(input, user, post_id:, topic_id: nil) + @input = input + @user = user + @post_id = post_id + @topic_id = topic_id + @post = Post.find_by(id: post_id) + end + + def select + data = Store.get(@user.id) + + if @post && !is_topic_action? + is_reply = @input == :reply + return if is_reply && reset_track + + topic_id = @post.topic_id + + if (data && data[:topic_id] == topic_id) + state = data[:state] + klass = (data[:track] || NewUserNarrative.to_s).constantize + + if is_reply && like_user_post + Store.set(@user.id, data.merge!(state: nil, topic_id: nil)) + elsif state&.to_sym == :end && is_reply + bot_commands(bot_mentioned?) || generic_replies(klass.reset_trigger) + elsif is_reply + previous_status = data[:attempted] + current_status = klass.new.input(@input, @user, post: @post, skip: skip_track?) + data = Store.get(@user.id) + data[:attempted] = !current_status + + if previous_status && data[:attempted] == previous_status && !data[:skip_attempted] + generic_replies(klass.reset_trigger, state) + else + $redis.del(generic_replies_key(@user)) + end + + Store.set(@user.id, data) + else + klass.new.input(@input, @user, post: @post, skip: skip_track?) + end + elsif is_reply && (pm_to_bot?(@post) || public_reply?) + like_user_post + bot_commands + end + elsif data && data.dig(:state)&.to_sym != :end && is_topic_action? + klass = (data[:track] || NewUserNarrative.to_s).constantize + klass.new.input(@input, @user, post: @post, topic_id: @topic_id) + end + end + + def self.reset_trigger + I18n.t(i18n_key("reset_trigger")) + end + + def self.skip_trigger + I18n.t(i18n_key("skip_trigger")) + end + + def self.help_trigger + I18n.t(i18n_key("help_trigger")) + end + + def self.quote_trigger + I18n.t("discourse_narrative_bot.quote.trigger") + end + + def self.dice_trigger + I18n.t("discourse_narrative_bot.dice.trigger") + end + + def self.magic_8_ball_trigger + I18n.t("discourse_narrative_bot.magic_8_ball.trigger") + end + + private + + def is_topic_action? + @is_topic_action ||= TOPIC_ACTIONS.include?(@input) + end + + def reset_track + reset = false + + TRACKS.each do |klass| + if selected_track(klass) + klass.new.reset_bot(@user, @post) + reset = true + break + end + end + + reset + end + + def selected_track(klass) + return if klass.respond_to?(:can_start?) && !klass.can_start?(@user) + post_raw = @post.raw + trigger = "#{self.class.reset_trigger} #{klass.reset_trigger}" + + if post_raw.length < RESET_TRIGGER_EXACT_MATCH_LENGTH && pm_to_bot?(@post) + post_raw.match(Regexp.new("\\b\\W\?#{trigger}\\W\?\\b", 'i')) + else + match_trigger?(trigger) + end + end + + def bot_commands(hint = true) + raw = + if match_data = match_trigger?("#{self.class.dice_trigger} (\\d+)d(\\d+)") + DiscourseNarrativeBot::Dice.roll(match_data[1].to_i, match_data[2].to_i) + elsif match_trigger?(self.class.quote_trigger) + DiscourseNarrativeBot::QuoteGenerator.generate(@user) + elsif match_trigger?(self.class.magic_8_ball_trigger) + DiscourseNarrativeBot::Magic8Ball.generate_answer + elsif match_trigger?(self.class.help_trigger) + help_message + elsif hint + message = I18n.t(self.class.i18n_key('random_mention.reply'), + discobot_username: self.class.discobot_user.username, + help_trigger: self.class.help_trigger + ) + + if public_reply? + key = "#{PUBLIC_DISPLAY_BOT_HELP_KEY}:#{@post.topic_id}" + last_bot_help_post_number = $redis.get(key) + + if !last_bot_help_post_number || + (last_bot_help_post_number && + @post.post_number - 10 > last_bot_help_post_number.to_i && + (1.day.to_i - $redis.ttl(key)) > 6.hours.to_i) + + $redis.setex(key, 1.day.to_i, @post.post_number) + message + end + else + message + end + end + + if raw + fake_delay + reply_to(@post, raw, skip_validations: true) + end + end + + def help_message + tracks = [NewUserNarrative.reset_trigger] + + if @user.staff? || + @user.badges.where(name: DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME).exists? + + tracks << AdvancedUserNarrative.reset_trigger + end + + discobot_username = self.class.discobot_user.username + + message = I18n.t( + self.class.i18n_key('random_mention.tracks'), + discobot_username: discobot_username, + reset_trigger: self.class.reset_trigger, + default_track: NewUserNarrative.reset_trigger, + tracks: tracks.join(', ') + ) + + message << "\n\n#{I18n.t(self.class.i18n_key('random_mention.bot_actions'), + discobot_username: discobot_username, + dice_trigger: self.class.dice_trigger, + quote_trigger: self.class.quote_trigger, + magic_8_ball_trigger: self.class.magic_8_ball_trigger + )}" + end + + def generic_replies_key(user) + "#{GENERIC_REPLIES_COUNT_PREFIX}#{user.id}" + end + + def generic_replies(track_reset_trigger, state = nil) + reset_trigger = "#{self.class.reset_trigger} #{track_reset_trigger}" + key = generic_replies_key(@user) + count = ($redis.get(key) || $redis.setex(key, 900, 0)).to_i + + case count + when 0 + raw = I18n.t(self.class.i18n_key('do_not_understand.first_response')) + + if state && state.to_sym != :end + raw = "#{raw}\n\n#{I18n.t(self.class.i18n_key('do_not_understand.track_response'), reset_trigger: reset_trigger, skip_trigger: self.class.skip_trigger)}" + end + + reply_to(@post, raw) + when 1 + reply_to(@post, I18n.t(self.class.i18n_key('do_not_understand.second_response'), + reset_trigger: self.class.reset_trigger + )) + else + # Stay out of the user's way + end + + $redis.incr(key) + end + + def self.i18n_key(key) + "discourse_narrative_bot.track_selector.#{key}" + end + + def skip_track? + if pm_to_bot?(@post) + post_raw = @post.raw + + post_raw.match(/^@#{self.class.discobot_user.username} #{self.class.skip_trigger}/i) || + post_raw.strip == self.class.skip_trigger + else + false + end + end + + def match_trigger?(trigger) + discobot_username = self.class.discobot_user.username + regexp = Regexp.new("@#{discobot_username} #{trigger}", 'i') + match = @post.cooked.match(regexp) + + if pm_to_bot?(@post) + match || @post.raw.strip.match(Regexp.new("^#{trigger}$", 'i')) + else + match + end + end + + def like_user_post + if @post.raw.match(/thank/i) + PostAction.act(self.class.discobot_user, @post, PostActionType.types[:like]) + end + end + + def bot_mentioned? + @bot_mentioned ||= PostAnalyzer.new(@post.raw, @post.topic_id).raw_mentions.include?( + self.class.discobot_user.username + ) + end + + def public_reply? + !SiteSetting.discourse_narrative_bot_disable_public_replies && + (bot_mentioned? || reply_to_bot_post?(@post)) + end + end +end diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/welcome_post_type_site_setting.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/welcome_post_type_site_setting.rb new file mode 100644 index 00000000000..7ab39db3168 --- /dev/null +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/welcome_post_type_site_setting.rb @@ -0,0 +1,18 @@ +module DiscourseNarrativeBot + class WelcomePostTypeSiteSetting + def self.valid_value?(val) + values.any? { |v| v[:value] == val.to_s } + end + + def self.values + @values ||= [ + { name: 'discourse_narrative_bot.welcome_post_type.new_user_track', value: 'new_user_track' }, + { name: 'discourse_narrative_bot.welcome_post_type.welcome_message', value: 'welcome_message' } + ] + end + + def self.translate_names? + true + end + end +end diff --git a/plugins/discourse-narrative-bot/plugin.rb b/plugins/discourse-narrative-bot/plugin.rb new file mode 100644 index 00000000000..fa652aecd59 --- /dev/null +++ b/plugins/discourse-narrative-bot/plugin.rb @@ -0,0 +1,223 @@ +# name: discourse-narrative-bot +# about: Introduces staff to Discourse +# version: 0.0.1 +# authors: Nick Sahler (@nicksahler) + +enabled_site_setting :discourse_narrative_bot_enabled + +if Rails.env.development? + Rails.application.config.before_initialize do |app| + app.middleware.insert_before( + ::ActionDispatch::Static, + ::ActionDispatch::Static, + Rails.root.join("plugins/discourse-narrative-bot/public").to_s + ) + end +end + +require_relative 'lib/discourse_narrative_bot/welcome_post_type_site_setting.rb' + +after_initialize do + SeedFu.fixture_paths << Rails.root.join("plugins", "discourse-narrative-bot", "db", "fixtures").to_s + + Mime::Type.register "image/svg+xml", :svg + + [ + '../jobs/bot_input.rb', + '../jobs/narrative_timeout.rb', + '../jobs/narrative_init.rb', + '../jobs/send_default_welcome_message.rb', + '../jobs/onceoff/grant_badges.rb', + '../lib/discourse_narrative_bot/actions.rb', + '../lib/discourse_narrative_bot/base.rb', + '../lib/discourse_narrative_bot/new_user_narrative.rb', + '../lib/discourse_narrative_bot/advanced_user_narrative.rb', + '../lib/discourse_narrative_bot/track_selector.rb', + '../lib/discourse_narrative_bot/certificate_generator.rb', + '../lib/discourse_narrative_bot/dice.rb', + '../lib/discourse_narrative_bot/quote_generator.rb', + '../lib/discourse_narrative_bot/magic_8_ball.rb', + '../lib/discourse_narrative_bot/welcome_post_type_site_setting.rb' + ].each { |path| load File.expand_path(path, __FILE__) } + + # Disable welcome message because that is what the bot is supposed to replace. + SiteSetting.send_welcome_message = false + + module ::DiscourseNarrativeBot + PLUGIN_NAME = "discourse-narrative-bot".freeze + + class Engine < ::Rails::Engine + engine_name PLUGIN_NAME + isolate_namespace DiscourseNarrativeBot + + if Rails.env.production? + Dir[Rails.root.join("plugins/discourse-narrative-bot/public/images/*")].each do |src| + dest = Rails.root.join("public/images/#{File.basename(src)}") + File.symlink(src, dest) if !File.exists?(dest) + end + end + end + + class Store + def self.set(key, value) + ::PluginStore.set(PLUGIN_NAME, key, value) + end + + def self.get(key) + ::PluginStore.get(PLUGIN_NAME, key) + end + + def self.remove(key) + ::PluginStore.remove(PLUGIN_NAME, key) + end + end + + class CertificatesController < ::ApplicationController + layout :false + skip_before_filter :check_xhr + + def generate + raise Discourse::InvalidParameters.new('user_id must be present') unless params[:user_id]&.present? + + user = User.find_by(id: params[:user_id]) + raise Discourse::NotFound if user.blank? + + raise Discourse::InvalidParameters.new('date must be present') unless params[:date]&.present? + + generator = CertificateGenerator.new(user, params[:date]) + + svg = + case params[:type] + when 'advanced' + generator.advanced_user_track + else + generator.new_user_track + end + + respond_to do |format| + format.svg { render inline: svg} + end + end + end + end + + DiscourseNarrativeBot::Engine.routes.draw do + get "/certificate" => "certificates#generate", format: :svg + end + + Discourse::Application.routes.append do + mount ::DiscourseNarrativeBot::Engine, at: "/discobot" + end + + self.add_model_callback(User, :after_destroy) do + DiscourseNarrativeBot::Store.remove(self.id) + end + + self.add_model_callback(User, :after_commit, on: :create) do + return if SiteSetting.disable_discourse_narrative_bot_welcome_post + + delay = SiteSetting.discourse_narrative_bot_welcome_post_delay + + case SiteSetting.discourse_narrative_bot_welcome_post_type + when 'new_user_track' + if enqueue_narrative_bot_job? + Jobs.enqueue_in(delay, :narrative_init, + user_id: self.id, + klass: DiscourseNarrativeBot::NewUserNarrative.to_s + ) + end + when 'welcome_message' + Jobs.enqueue_in(delay, :send_default_welcome_message, user_id: self.id) + end + end + + require_dependency "user" + + User.class_eval do + def enqueue_narrative_bot_job? + SiteSetting.discourse_narrative_bot_enabled && + self.id > 0 && + !self.anonymous? && + !self.user_option.mailing_list_mode && + !self.staged && + !SiteSetting.discourse_narrative_bot_ignored_usernames.split('|'.freeze).include?(self.username) + end + end + + self.on(:post_created) do |post, options| + user = post.user + + if user.enqueue_narrative_bot_job? && !options[:skip_bot] + Jobs.enqueue(:bot_input, + user_id: user.id, + post_id: post.id, + input: :reply + ) + end + end + + self.on(:post_edited) do |post| + if post.user.enqueue_narrative_bot_job? + Jobs.enqueue(:bot_input, + user_id: post.user.id, + post_id: post.id, + input: :edit + ) + end + end + + self.on(:post_destroyed) do |post, options, user| + if user.enqueue_narrative_bot_job? && !options[:skip_bot] + Jobs.enqueue(:bot_input, + user_id: user.id, + post_id: post.id, + topic_id: post.topic_id, + input: :delete + ) + end + end + + self.on(:post_recovered) do |post, _, user| + if user.enqueue_narrative_bot_job? + Jobs.enqueue(:bot_input, + user_id: user.id, + post_id: post.id, + input: :recover + ) + end + end + + self.add_model_callback(PostAction, :after_commit, on: :create) do + if self.user.enqueue_narrative_bot_job? + input = + case self.post_action_type_id + when *PostActionType.flag_types.values + :flag + when PostActionType.types[:like] + :like + when PostActionType.types[:bookmark] + :bookmark + end + + if input + Jobs.enqueue(:bot_input, + user_id: self.user.id, + post_id: self.post.id, + input: input + ) + end + end + end + + self.on(:topic_notification_level_changed) do |_, user_id, topic_id| + user = User.find_by(id: user_id) + + if user && user.enqueue_narrative_bot_job? + Jobs.enqueue(:bot_input, + user_id: user_id, + topic_id: topic_id, + input: :topic_notification_level_changed + ) + end + end +end diff --git a/plugins/discourse-narrative-bot/public/images/capybara-eating.gif b/plugins/discourse-narrative-bot/public/images/capybara-eating.gif new file mode 100644 index 00000000000..ef32566b6fb Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/capybara-eating.gif differ diff --git a/plugins/discourse-narrative-bot/public/images/dog-walk.gif b/plugins/discourse-narrative-bot/public/images/dog-walk.gif new file mode 100644 index 00000000000..cf575ac0567 Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/dog-walk.gif differ diff --git a/plugins/discourse-narrative-bot/public/images/font-awesome-bookmark.png b/plugins/discourse-narrative-bot/public/images/font-awesome-bookmark.png new file mode 100644 index 00000000000..7e274ce6f27 Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/font-awesome-bookmark.png differ diff --git a/plugins/discourse-narrative-bot/public/images/font-awesome-ellipsis.png b/plugins/discourse-narrative-bot/public/images/font-awesome-ellipsis.png new file mode 100644 index 00000000000..15890f217b6 Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/font-awesome-ellipsis.png differ diff --git a/plugins/discourse-narrative-bot/public/images/font-awesome-flag.png b/plugins/discourse-narrative-bot/public/images/font-awesome-flag.png new file mode 100644 index 00000000000..84dea7c3461 Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/font-awesome-flag.png differ diff --git a/plugins/discourse-narrative-bot/public/images/font-awesome-gear.png b/plugins/discourse-narrative-bot/public/images/font-awesome-gear.png new file mode 100644 index 00000000000..73eec571f8d Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/font-awesome-gear.png differ diff --git a/plugins/discourse-narrative-bot/public/images/font-awesome-link.png b/plugins/discourse-narrative-bot/public/images/font-awesome-link.png new file mode 100644 index 00000000000..daa81336e31 Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/font-awesome-link.png differ diff --git a/plugins/discourse-narrative-bot/public/images/font-awesome-pencil.png b/plugins/discourse-narrative-bot/public/images/font-awesome-pencil.png new file mode 100644 index 00000000000..444c7620d92 Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/font-awesome-pencil.png differ diff --git a/plugins/discourse-narrative-bot/public/images/font-awesome-rotate-left.png b/plugins/discourse-narrative-bot/public/images/font-awesome-rotate-left.png new file mode 100644 index 00000000000..d2046c653c6 Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/font-awesome-rotate-left.png differ diff --git a/plugins/discourse-narrative-bot/public/images/font-awesome-search.png b/plugins/discourse-narrative-bot/public/images/font-awesome-search.png new file mode 100644 index 00000000000..87568be2f53 Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/font-awesome-search.png differ diff --git a/plugins/discourse-narrative-bot/public/images/font-awesome-smile.png b/plugins/discourse-narrative-bot/public/images/font-awesome-smile.png new file mode 100644 index 00000000000..579525498cc Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/font-awesome-smile.png differ diff --git a/plugins/discourse-narrative-bot/public/images/font-awesome-trash.png b/plugins/discourse-narrative-bot/public/images/font-awesome-trash.png new file mode 100644 index 00000000000..ab108048454 Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/font-awesome-trash.png differ diff --git a/plugins/discourse-narrative-bot/public/images/sad-cat.gif b/plugins/discourse-narrative-bot/public/images/sad-cat.gif new file mode 100644 index 00000000000..37941c8456e Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/sad-cat.gif differ diff --git a/plugins/discourse-narrative-bot/public/images/unicorn.jpg b/plugins/discourse-narrative-bot/public/images/unicorn.jpg new file mode 100644 index 00000000000..8f2dd260e1c Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/unicorn.jpg differ diff --git a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/advanced_user_narrative_spec.rb b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/advanced_user_narrative_spec.rb new file mode 100644 index 00000000000..2afb149bb00 --- /dev/null +++ b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/advanced_user_narrative_spec.rb @@ -0,0 +1,658 @@ +require 'rails_helper' + +RSpec.describe DiscourseNarrativeBot::AdvancedUserNarrative do + let(:discobot_user) { User.find(-2) } + let(:first_post) { Fabricate(:post, user: discobot_user) } + let(:user) { Fabricate(:user) } + + let(:topic) do + Fabricate(:private_message_topic, first_post: first_post, + topic_allowed_users: [ + Fabricate.build(:topic_allowed_user, user: discobot_user), + Fabricate.build(:topic_allowed_user, user: user), + ] + ) + end + + let(:post) { Fabricate(:post, topic: topic, user: user) } + let(:narrative) { described_class.new } + let(:other_topic) { Fabricate(:topic) } + let(:other_post) { Fabricate(:post, topic: other_topic) } + let(:skip_trigger) { DiscourseNarrativeBot::TrackSelector.skip_trigger } + let(:reset_trigger) { DiscourseNarrativeBot::TrackSelector.reset_trigger } + + before do + SiteSetting.discourse_narrative_bot_enabled = true + end + + describe '.can_start?' do + describe 'when user is a moderator' do + it 'should return true' do + user.update!(moderator: true) + + expect(described_class.can_start?(user)).to eq(true) + end + end + end + + describe '#notify_timeout' do + before do + narrative.set_data(user, + state: :tutorial_poll, + topic_id: topic.id, + last_post_id: post.id + ) + end + + it 'should create the right message' do + expect { narrative.notify_timeout(user) }.to change { Post.count }.by(1) + + expect(Post.last.raw).to eq(I18n.t( + 'discourse_narrative_bot.timeout.message', + username: user.username, + skip_trigger: skip_trigger, + reset_trigger: "#{reset_trigger} #{described_class.reset_trigger}", + )) + end + end + + describe '#reset_bot' do + before do + narrative.set_data(user, state: :tutorial_images, topic_id: topic.id) + end + + context 'when trigger is initiated in a PM' do + let(:user) { Fabricate(:user) } + + let(:topic) do + topic_allowed_user = Fabricate.build(:topic_allowed_user, user: user) + bot = Fabricate.build(:topic_allowed_user, user: discobot_user) + Fabricate(:private_message_topic, topic_allowed_users: [topic_allowed_user, bot]) + end + + let(:post) { Fabricate(:post, topic: topic) } + + it 'should reset the bot' do + narrative.reset_bot(user, post) + + expected_raw = I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.start_message', + username: user.username + ) + + expected_raw = <<~RAW + #{expected_raw} + + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.edit.instructions')} + RAW + + new_post = Post.offset(1).last + + expect(narrative.get_data(user)).to eq({ + "topic_id" => topic.id, + "state" => "tutorial_edit", + "last_post_id" => new_post.id, + "track" => described_class.to_s, + "tutorial_edit" => { + "post_id" => Post.last.id + } + }) + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(new_post.topic.id).to eq(topic.id) + end + end + + context 'when trigger is not initiated in a PM' do + it 'should start the new track in a PM' do + narrative.reset_bot(user, other_post) + + expected_raw = I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.start_message', + username: user.username + ) + + expected_raw = <<~RAW + #{expected_raw} + + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.edit.instructions')} + RAW + + new_post = Post.offset(1).last + + expect(narrative.get_data(user)).to eq({ + "topic_id" => new_post.topic.id, + "state" => "tutorial_edit", + "last_post_id" => new_post.id, + "track" => described_class.to_s, + "tutorial_edit" => { + "post_id" => Post.last.id + } + }) + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(new_post.topic.id).to_not eq(topic.id) + end + end + end + + describe "#input" do + context 'edit tutorial' do + before do + narrative.set_data(user, + state: :tutorial_edit, + topic_id: topic.id, + track: described_class.to_s, + tutorial_edit: { + post_id: first_post.id + } + ) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_edit) + end + end + + describe 'when user replies to the post' do + it 'should create the right reply' do + post + narrative.expects(:enqueue_timeout_job).with(user).once + + expect { narrative.input(:reply, user, post: post) } + .to change { Post.count }.by(1) + + expect(Post.last.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.edit.not_found', + url: first_post.url + )) + end + + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: "@#{discobot_user.username} #{skip_trigger}") + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.delete.instructions') + ) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_delete) + end + end + end + + describe 'when user edits the right post' do + let(:post_2) { Fabricate(:post, user: post.user, topic: post.topic) } + + it 'should create the right reply' do + post_2 + + expect do + PostRevisor.new(post_2).revise!(post_2.user, raw: 'something new') + end.to change { Post.count }.by(1) + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.edit.reply')} + + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.delete.instructions')} + RAW + + expect(Post.last.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_delete) + end + end + end + + context 'delete tutorial' do + before do + narrative.set_data(user, + state: :tutorial_delete, + topic_id: topic.id, + track: described_class.to_s + ) + end + + describe 'when user replies to the topic' do + it 'should create the right reply' do + narrative.expects(:enqueue_timeout_job).with(user).once + + narrative.input(:reply, user, post: post) + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.delete.not_found' + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_delete) + end + + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.offset(1).last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.recover.instructions') + ) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_recover) + end + end + end + + describe 'when user destroys a post in a different topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + PostDestroyer.new(user, other_post).destroy + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_delete) + end + end + + describe 'when user deletes a post in the right topic' do + it 'should create the right reply' do + post + + expect { PostDestroyer.new(user, post).destroy } + .to change { Post.count }.by(2) + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.delete.reply')} + + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.recover.instructions')} + RAW + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_recover) + expect(Post.offset(1).last.raw).to eq(expected_raw.chomp) + end + + context 'when user is an admin' do + it 'should create the right reply' do + post + user.update!(admin: true) + + expect { PostDestroyer.new(user, post).destroy } + .to_not change { Post.count } + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.delete.reply')} + + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.recover.instructions')} + RAW + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_recover) + expect(Post.last.raw).to eq(expected_raw.chomp) + end + end + end + end + + context 'undelete post tutorial' do + before do + narrative.set_data(user, + state: :tutorial_recover, + topic_id: topic.id, + track: described_class.to_s + ) + end + + describe 'when user replies to the topic' do + it 'should create the right reply' do + narrative.set_data(user, narrative.get_data(user).merge( + tutorial_recover: { post_id: '1' } + )) + + narrative.expects(:enqueue_timeout_job).with(user).once + + narrative.input(:reply, user, post: post) + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.recover.not_found' + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_recover) + end + + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + parent_category = Fabricate(:category, name: 'a') + category = Fabricate(:category, parent_category: parent_category, name: 'b') + + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.category_hashtag.instructions', + category: "#a:b" + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_category_hashtag) + end + end + end + + describe 'when user recovers a post in a different topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + PostDestroyer.new(user, other_post).destroy + PostDestroyer.new(user, other_post).recover + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_recover) + end + end + + describe 'when user recovers a post in the right topic' do + it 'should create the right reply' do + parent_category = Fabricate(:category, name: 'a') + category = Fabricate(:category, parent_category: parent_category, name: 'b') + post + + PostDestroyer.new(user, post).destroy + + expect { PostDestroyer.new(user, post).recover } + .to change { Post.count }.by(1) + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.recover.reply')} + + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.category_hashtag.instructions', category: "#a:b")} + RAW + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_category_hashtag) + expect(Post.last.raw).to eq(expected_raw.chomp) + end + end + end + + context 'category hashtag tutorial' do + before do + narrative.set_data(user, + state: :tutorial_category_hashtag, + topic_id: topic.id, + track: described_class.to_s + ) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) } + .to_not change { Post.count } + + expect(narrative.get_data(user)[:state].to_sym) + .to eq(:tutorial_category_hashtag) + end + end + + describe 'when user replies to the topic' do + it 'should create the right reply' do + narrative.input(:reply, user, post: post) + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.category_hashtag.not_found' + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_category_hashtag) + end + + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.change_topic_notification_level.instructions' + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_change_topic_notification_level) + end + end + end + + it 'should create the right reply' do + category = Fabricate(:category) + + post.update!(raw: "Check out this ##{category.slug}") + narrative.input(:reply, user, post: post) + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.category_hashtag.reply')} + + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.change_topic_notification_level.instructions')} + RAW + + expect(Post.last.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_change_topic_notification_level) + end + end + + context 'topic notification level tutorial' do + before do + narrative.set_data(user, + state: :tutorial_change_topic_notification_level, + topic_id: topic.id, + track: described_class.to_s + ) + end + + describe 'when notification level is changed for another topic' do + it 'should not do anything' do + other_topic + user + narrative.expects(:enqueue_timeout_job).with(user).never + + expect do + TopicUser.change( + user.id, + other_topic.id, + notification_level: TopicUser.notification_levels[:tracking] + ) + end.to_not change { Post.count } + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_change_topic_notification_level) + end + end + + describe 'when user replies to the topic' do + it 'should create the right reply' do + narrative.input(:reply, user, post: post) + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.change_topic_notification_level.not_found' + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_change_topic_notification_level) + end + + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.poll.instructions') + ) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_poll) + end + end + end + + describe 'when user changed the topic notification level' do + it 'should create the right reply' do + TopicUser.change( + user.id, + topic.id, + notification_level: TopicUser.notification_levels[:tracking] + ) + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.change_topic_notification_level.reply')} + + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.poll.instructions')} + RAW + + expect(Post.last.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_poll) + end + end + end + + context 'poll tutorial' do + before do + narrative.set_data(user, + state: :tutorial_poll, + topic_id: topic.id, + track: described_class.to_s + ) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_poll) + end + end + + describe 'when user replies to the topic' do + it 'should create the right reply' do + narrative.input(:reply, user, post: post) + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t('discourse_narrative_bot.advanced_user_narrative.poll.not_found')) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_poll) + end + + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.details.instructions') + ) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_details) + end + end + end + + it 'should create the right reply' do + post.update!(raw: "[poll]\n* 1\n* 2\n[/poll]\n") + narrative.input(:reply, user, post: post) + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.poll.reply')} + + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.details.instructions')} + RAW + + expect(Post.last.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_details) + end + end + + context "details tutorial" do + before do + narrative.set_data(user, + state: :tutorial_details, + topic_id: topic.id, + track: described_class.to_s + ) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_details) + end + end + + describe 'when user replies to the topic' do + it 'should create the right reply' do + narrative.input(:reply, user, post: post) + + expect(Post.last.raw).to eq(I18n.t('discourse_narrative_bot.advanced_user_narrative.details.not_found')) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_details) + end + + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + + expect do + DiscourseNarrativeBot::TrackSelector.new( + :reply, user, post_id: post.id + ).select + end.to change { Post.count }.by(1) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:end) + end + end + end + + it 'should create the right reply' do + post.update!(raw: "[details=\"This is a test\"]wooohoo[/details]") + narrative.input(:reply, user, post: post) + + expect(Post.offset(1).last.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.details.reply' + )) + + expect(narrative.get_data(user)).to eq({ + "state" => "end", + "topic_id" => topic.id, + "track" => described_class.to_s + }) + + expect(user.badges.where(name: DiscourseNarrativeBot::AdvancedUserNarrative::BADGE_NAME).exists?) + .to eq(true) + end + end + end +end diff --git a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/new_user_narrative_spec.rb b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/new_user_narrative_spec.rb new file mode 100644 index 00000000000..6e0a59aea00 --- /dev/null +++ b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/new_user_narrative_spec.rb @@ -0,0 +1,895 @@ +require 'rails_helper' + +describe DiscourseNarrativeBot::NewUserNarrative do + let!(:welcome_topic) { Fabricate(:topic, title: 'Welcome to Discourse') } + let(:discobot_user) { User.find(-2) } + let(:first_post) { Fabricate(:post, user: discobot_user) } + let(:user) { Fabricate(:user) } + + let(:topic) do + Fabricate(:private_message_topic, first_post: first_post, + topic_allowed_users: [ + Fabricate.build(:topic_allowed_user, user: discobot_user), + Fabricate.build(:topic_allowed_user, user: user), + ] + ) + end + + let(:post) { Fabricate(:post, topic: topic, user: user) } + let(:narrative) { described_class.new } + let(:other_topic) { Fabricate(:topic) } + let(:other_post) { Fabricate(:post, topic: other_topic) } + let(:profile_page_url) { "#{Discourse.base_url}/users/#{user.username}" } + let(:skip_trigger) { DiscourseNarrativeBot::TrackSelector.skip_trigger } + let(:reset_trigger) { DiscourseNarrativeBot::TrackSelector.reset_trigger } + + before do + SiteSetting.discourse_narrative_bot_enabled = true + end + + describe '#notify_timeout' do + before do + narrative.set_data(user, + state: :tutorial_images, + topic_id: topic.id, + last_post_id: post.id + ) + end + + it 'should create the right message' do + expect { narrative.notify_timeout(user) }.to change { Post.count }.by(1) + + expect(Post.last.raw).to eq(I18n.t( + 'discourse_narrative_bot.timeout.message', + username: user.username, + skip_trigger: skip_trigger, + reset_trigger: "#{reset_trigger} #{described_class.reset_trigger}", + )) + end + end + + describe '#reset_bot' do + before do + narrative.set_data(user, state: :tutorial_images, topic_id: topic.id) + end + + context 'when trigger is initiated in a PM' do + let(:user) { Fabricate(:user) } + + let(:topic) do + topic_allowed_user = Fabricate.build(:topic_allowed_user, user: user) + bot = Fabricate.build(:topic_allowed_user, user: discobot_user) + Fabricate(:private_message_topic, topic_allowed_users: [topic_allowed_user, bot]) + end + + let(:post) { Fabricate(:post, topic: topic) } + + it 'should reset the bot' do + narrative.reset_bot(user, post) + + expected_raw = I18n.t('discourse_narrative_bot.new_user_narrative.hello.message', + username: user.username, title: SiteSetting.title + ) + + expected_raw = <<~RAW + #{expected_raw} + + #{I18n.t('discourse_narrative_bot.new_user_narrative.bookmark.instructions', profile_page_url: profile_page_url)} + RAW + + new_post = Post.last + + expect(narrative.get_data(user)).to eq({ + "topic_id" => topic.id, + "state" => "tutorial_bookmark", + "last_post_id" => new_post.id, + "track" => described_class.to_s + }) + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(new_post.topic.id).to eq(topic.id) + end + end + + context 'when trigger is not initiated in a PM' do + it 'should start the new track in a PM' do + narrative.reset_bot(user, other_post) + + expected_raw = I18n.t('discourse_narrative_bot.new_user_narrative.hello.message', + username: user.username, title: SiteSetting.title + ) + + expected_raw = <<~RAW + #{expected_raw} + + #{I18n.t('discourse_narrative_bot.new_user_narrative.bookmark.instructions', profile_page_url: profile_page_url)} + RAW + + new_post = Post.last + + expect(narrative.get_data(user)).to eq({ + "topic_id" => new_post.topic.id, + "state" => "tutorial_bookmark", + "last_post_id" => new_post.id, + "track" => described_class.to_s + }) + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(new_post.topic.id).to_not eq(topic.id) + end + end + end + + describe '#input' do + before do + SiteSetting.title = "This is an awesome site!" + narrative.set_data(user, state: :begin) + end + + describe 'when an error occurs' do + before do + narrative.set_data(user, state: :tutorial_flag, topic_id: topic.id) + end + + it 'should revert to the previous state' do + narrative.expects(:send).with('init_tutorial_search').raises(StandardError.new('some error')) + narrative.expects(:send).with(:reply_to_flag).returns(post) + + expect { narrative.input(:flag, user, post: post) }.to raise_error(StandardError, 'some error') + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_flag) + end + end + + describe 'when input does not have a valid transition from current state' do + before do + narrative.set_data(user, state: :begin) + end + + it 'should raise the right error' do + expect(narrative.input(:something, user, post: post)).to eq(nil) + expect(narrative.get_data(user)[:state].to_sym).to eq(:begin) + end + end + + describe 'when [:begin, :init]' do + it 'should create the right post' do + narrative.expects(:enqueue_timeout_job).never + + narrative.input(:init, user, post: nil) + new_post = Post.last + + expected_raw = I18n.t('discourse_narrative_bot.new_user_narrative.hello.message', + username: user.username, title: SiteSetting.title + ) + + expected_raw = <<~RAW + #{expected_raw} + + #{I18n.t('discourse_narrative_bot.new_user_narrative.bookmark.instructions', profile_page_url: profile_page_url)} + RAW + + expect(new_post.raw).to eq(expected_raw.chomp) + + expect(narrative.get_data(user)[:state].to_sym) + .to eq(:tutorial_bookmark) + end + end + + describe "bookmark tutorial" do + before do + narrative.set_data(user, state: :tutorial_bookmark, topic_id: topic.id) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post.update!(user_id: -2) + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:bookmark, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_bookmark) + end + end + + describe "when bookmark is not on bot's post" do + it 'should not do anything' do + narrative.expects(:enqueue_timeout_job).with(user).never + post + + expect { narrative.input(:bookmark, user, post: post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_bookmark) + end + end + + describe 'when user replies to the topic' do + it 'should create the right reply' do + narrative.expects(:enqueue_timeout_job).with(user).once + + narrative.input(:reply, user, post: post) + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t('discourse_narrative_bot.new_user_narrative.bookmark.not_found')) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_bookmark) + end + + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: "@#{discobot_user.username} #{skip_trigger}") + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.onebox.instructions') + ) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_onebox) + end + end + end + + it 'should create the right reply' do + post.update!(user: described_class.discobot_user) + narrative.expects(:enqueue_timeout_job).with(user) + + narrative.input(:bookmark, user, post: post) + new_post = Post.last + profile_page_url = "#{Discourse.base_url}/u/#{user.username}" + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.new_user_narrative.bookmark.reply', profile_page_url: profile_page_url)} + + #{I18n.t('discourse_narrative_bot.new_user_narrative.onebox.instructions')} + RAW + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_onebox) + end + end + + describe 'onebox tutorial' do + before do + narrative.set_data(user, state: :tutorial_onebox, topic_id: topic.id) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_onebox) + end + end + + describe 'when post does not contain onebox' do + it 'should create the right reply' do + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t('discourse_narrative_bot.new_user_narrative.onebox.not_found')) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_onebox) + end + end + + describe "when user has not liked bot's post" do + it 'should create the right reply' do + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t('discourse_narrative_bot.new_user_narrative.onebox.not_found')) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_onebox) + end + end + + describe 'when user replies to the topic' do + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.emoji.instructions') + ) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_emoji) + end + end + end + + it 'should create the right reply' do + post.update!(raw: 'https://en.wikipedia.org/wiki/ROT13') + + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + new_post = Post.last + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.new_user_narrative.onebox.reply')} + + #{I18n.t('discourse_narrative_bot.new_user_narrative.emoji.instructions')} + RAW + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_emoji) + end + end + + describe 'images tutorial' do + let(:post_2) { Fabricate(:post, topic: topic) } + + before do + narrative.set_data(user, + state: :tutorial_images, + topic_id: topic.id, + last_post_id: post_2.id, + track: described_class.to_s + ) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_images) + end + end + + describe 'when user replies to the topic' do + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.flag.instructions', + guidelines_url: Discourse.base_url + '/guidelines', + about_url: Discourse.base_url + '/about' + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_flag) + end + end + end + + context 'when image is not found' do + it 'should create the right replies' do + PostAction.act(user, post_2, PostActionType.types[:like]) + + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + expect(Post.last.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.images.not_found', + image_url: "#{Discourse.base_url}/images/dog-walk.gif" + )) + + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + new_post = Fabricate(:post, + user: user, + topic: topic, + raw: "" + ) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: new_post.id).select + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.new_user_narrative.images.reply')} + + #{I18n.t( + 'discourse_narrative_bot.new_user_narrative.flag.instructions', + guidelines_url: "#{Discourse.base_url}/guidelines", + about_url: "#{Discourse.base_url}/about" + )} + RAW + + expect(Post.last.raw).to eq(expected_raw.chomp) + + post_action = PostAction.last + + expect(post_action.post_action_type_id).to eq(PostActionType.types[:like]) + expect(post_action.user).to eq(described_class.discobot_user) + expect(post_action.post).to eq(new_post) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_flag) + end + end + + it 'should create the right replies' do + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + expect(Post.last.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.images.not_found', + image_url: "#{Discourse.base_url}/images/dog-walk.gif" + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_images) + + new_post = Fabricate(:post, + user: user, + topic: topic, + raw: "" + ) + + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: new_post.id).select + + expect(Post.last.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.images.like_not_found', + url: post_2.url + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_images) + + expect(narrative.get_data(user)[:tutorial_images][:post_id]) + .to eq(new_post.id) + + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + PostAction.act(user, post_2, PostActionType.types[:like]) + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.new_user_narrative.images.reply')} + + #{I18n.t( + 'discourse_narrative_bot.new_user_narrative.flag.instructions', + guidelines_url: "#{Discourse.base_url}/guidelines", + about_url: "#{Discourse.base_url}/about" + )} + RAW + + expect(Post.last.raw).to eq(expected_raw.chomp) + + post_action = PostAction.last + + expect(post_action.post_action_type_id).to eq(PostActionType.types[:like]) + expect(post_action.user).to eq(described_class.discobot_user) + expect(post_action.post).to eq(new_post) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_flag) + end + end + + describe 'fomatting tutorial' do + before do + narrative.set_data(user, state: :tutorial_formatting, topic_id: topic.id) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_formatting) + end + end + + describe 'when post does not contain any formatting' do + it 'should create the right reply' do + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + + expect(Post.last.raw).to eq(I18n.t('discourse_narrative_bot.new_user_narrative.formatting.not_found')) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_formatting) + end + end + + describe 'when user replies to the topic' do + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.quoting.instructions', + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_quote) + end + end + end + + ["**bold**", "__italic__", "[b]bold[/b]", "[i]italic[/i]"].each do |raw| + it 'should create the right reply' do + post.update!(raw: raw) + + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + new_post = Post.last + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.new_user_narrative.formatting.reply')} + + #{I18n.t('discourse_narrative_bot.new_user_narrative.quoting.instructions')} + RAW + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_quote) + end + end + end + + describe 'quote tutorial' do + before do + narrative.set_data(user, state: :tutorial_quote, topic_id: topic.id) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_quote) + end + end + + describe 'when post does not contain any quotes' do + it 'should create the right reply' do + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + + expect(Post.last.raw).to eq(I18n.t('discourse_narrative_bot.new_user_narrative.quoting.not_found')) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_quote) + end + end + + describe 'when user replies to the topic' do + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.images.instructions', + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_images) + end + end + end + + it 'should create the right reply' do + post.update!( + raw: '[quote="#{post.user}, post:#{post.post_number}, topic:#{topic.id}"]\n:monkey: :fries:\n[/quote]' + ) + + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + new_post = Post.last + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.new_user_narrative.quoting.reply')} + + #{I18n.t('discourse_narrative_bot.new_user_narrative.images.instructions')} + RAW + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_images) + end + end + + describe 'emoji tutorial' do + before do + narrative.set_data(user, state: :tutorial_emoji, topic_id: topic.id) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_emoji) + end + end + + describe 'when post does not contain any emoji' do + it 'should create the right reply' do + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + + expect(Post.last.raw).to eq(I18n.t('discourse_narrative_bot.new_user_narrative.emoji.not_found')) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_emoji) + end + end + + describe 'when user replies to the topic' do + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.mention.instructions', + discobot_username: discobot_user.username + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_mention) + end + end + end + + it 'should create the right reply' do + post.update!( + raw: ':monkey: :fries:' + ) + + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + new_post = Post.last + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.new_user_narrative.emoji.reply')} + + #{I18n.t('discourse_narrative_bot.new_user_narrative.mention.instructions', + discobot_username: discobot_user.username + )} + RAW + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_mention) + end + end + + describe 'mention tutorial' do + before do + narrative.set_data(user, state: :tutorial_mention, topic_id: topic.id) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_mention) + end + end + + describe 'when post does not contain any mentions' do + it 'should create the right reply' do + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + + expect(Post.last.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.mention.not_found', + username: user.username, + discobot_username: discobot_user.username + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_mention) + end + end + + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.formatting.instructions', + discobot_username: discobot_user.username + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_formatting) + end + end + + it 'should create the right reply' do + post.update!( + raw: '@discobot hello how are you doing today?' + ) + + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + new_post = Post.last + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.new_user_narrative.mention.reply')} + + #{I18n.t( + 'discourse_narrative_bot.new_user_narrative.formatting.instructions' + )} + RAW + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_formatting) + end + end + + describe 'flag tutorial' do + let(:post) { Fabricate(:post, user: described_class.discobot_user, topic: topic) } + let(:flag) { Fabricate(:flag, post: post, user: user) } + let(:other_post) { Fabricate(:post, user: user, topic: topic) } + + before do + flag + narrative.set_data(user, state: :tutorial_flag, topic_id: topic.id) + end + + describe 'when post flagged is not for the right topic' do + it 'should not do anything' do + narrative.expects(:enqueue_timeout_job).with(user).never + flag.update!(post: other_post) + + expect { narrative.input(:flag, user, post: flag.post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_flag) + end + end + + describe 'when post being flagged does not belong to discobot ' do + it 'should not do anything' do + narrative.expects(:enqueue_timeout_job).with(user).never + flag.update!(post: other_post) + + expect { narrative.input(:flag, user, post: flag.post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_flag) + end + end + + describe 'when user replies to the topic' do + it 'should create the right reply' do + narrative.input(:reply, user, post: other_post) + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t('discourse_narrative_bot.new_user_narrative.flag.not_found')) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_flag) + end + + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + other_post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: other_post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.search.instructions' + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_search) + end + end + end + + it 'should create the right reply' do + narrative.expects(:enqueue_timeout_job).with(user) + + expect { narrative.input(:flag, user, post: flag.post) }.to change { PostAction.count }.by(-1) + + new_post = Post.last + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.new_user_narrative.flag.reply')} + + #{I18n.t( + 'discourse_narrative_bot.new_user_narrative.search.instructions' + )} + RAW + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_search) + end + end + + describe 'search tutorial' do + before do + narrative.set_data(user, state: :tutorial_search, topic_id: topic.id) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_search) + end + end + + describe 'when post does not contain the right answer' do + it 'should create the right reply' do + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + + expect(Post.last.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.search.not_found' + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_search) + end + end + + describe 'when post contain the right answer' do + let(:post) { Fabricate(:post, user: described_class.discobot_user, topic: topic) } + let(:flag) { Fabricate(:flag, post: post, user: user) } + + before do + narrative.set_data(user, + state: :tutorial_flag, + topic_id: topic.id + ) + + DiscourseNarrativeBot::TrackSelector.new(:flag, user, post_id: flag.post_id).select + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_search) + + expect(post.reload.topic.first_post.raw).to include(I18n.t( + "discourse_narrative_bot.new_user_narrative.search.hidden_message" + )) + end + + it 'should clean up if the tutorial is skipped' do + post.update!(raw: skip_trigger) + + expect do + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + end.to change { Post.count }.by(1) + + expect(first_post.reload.raw).to eq('Hello world') + expect(narrative.get_data(user)[:state].to_sym).to eq(:end) + end + + it 'should create the right reply' do + post.update!( + raw: "#{described_class::SEARCH_ANSWER} this is a capybara" + ) + + expect do + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + end.to change { Post.count }.by(2) + + new_post = Post.offset(1).last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.search.reply', + search_url: "#{Discourse.base_url}/search" + ).chomp) + + expect(first_post.reload.raw).to eq('Hello world') + + expect(narrative.get_data(user)).to include({ + "state" => "end", + "topic_id" => new_post.topic_id, + "track" => described_class.to_s, + }) + + expect(user.badges.where(name: DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME).exists?) + .to eq(true) + end + end + end + end +end diff --git a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/store_spec.rb b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/store_spec.rb new file mode 100644 index 00000000000..7d435279a60 --- /dev/null +++ b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/store_spec.rb @@ -0,0 +1,28 @@ +require 'rails_helper' + +describe DiscourseNarrativeBot::Store do + describe '.set' do + it 'should set the right value in the plugin store' do + key = 'somekey' + described_class.set(key, 'yay') + plugin_store_row = PluginStoreRow.last + + expect(plugin_store_row.value).to eq('yay') + expect(plugin_store_row.plugin_name).to eq(DiscourseNarrativeBot::PLUGIN_NAME) + expect(plugin_store_row.key).to eq(key) + end + end + + describe '.get' do + it 'should get the right value from the plugin store' do + PluginStoreRow.create!( + plugin_name: DiscourseNarrativeBot::PLUGIN_NAME, + key: 'somekey', + value: 'yay', + type_name: 'string' + ) + + expect(described_class.get('somekey')).to eq('yay') + end + end +end diff --git a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/track_selector_spec.rb b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/track_selector_spec.rb new file mode 100644 index 00000000000..d0699754430 --- /dev/null +++ b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/track_selector_spec.rb @@ -0,0 +1,704 @@ +require 'rails_helper' + +describe DiscourseNarrativeBot::TrackSelector do + let(:user) { Fabricate(:user) } + let(:discobot_user) { described_class.discobot_user } + let(:narrative) { DiscourseNarrativeBot::NewUserNarrative.new } + + let(:random_mention_reply) do + I18n.t('discourse_narrative_bot.track_selector.random_mention.reply', + discobot_username: discobot_user.username, + help_trigger: described_class.help_trigger + ) + end + + let(:help_message) do + discobot_username = discobot_user.username + + end_message = <<~RAW + #{I18n.t( + 'discourse_narrative_bot.track_selector.random_mention.tracks', + discobot_username: discobot_username, + default_track: DiscourseNarrativeBot::NewUserNarrative.reset_trigger, + reset_trigger: described_class.reset_trigger, + tracks: DiscourseNarrativeBot::NewUserNarrative.reset_trigger + )} + + #{I18n.t( + 'discourse_narrative_bot.track_selector.random_mention.bot_actions', + discobot_username: discobot_username, + dice_trigger: described_class.dice_trigger, + quote_trigger: described_class.quote_trigger, + magic_8_ball_trigger: described_class.magic_8_ball_trigger + )} + RAW + + end_message.chomp + end + + describe '#select' do + context 'in a PM with discobot' do + let(:first_post) { Fabricate(:post, user: discobot_user) } + + let(:topic) do + Fabricate(:private_message_topic, first_post: first_post, + topic_allowed_users: [ + Fabricate.build(:topic_allowed_user, user: discobot_user), + Fabricate.build(:topic_allowed_user, user: user), + ] + ) + end + + let(:post) { Fabricate(:post, topic: topic, user: user) } + + context 'during a tutorial track' do + before do + narrative.set_data(user, + state: :tutorial_formatting, + topic_id: topic.id, + track: "DiscourseNarrativeBot::NewUserNarrative" + ) + end + + context 'when bot is mentioned' do + it 'should select the right track' do + post.update!(raw: '@discobot show me what you can do') + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + "discourse_narrative_bot.new_user_narrative.formatting.not_found" + )) + end + end + + context 'when bot is replied to' do + it 'should select the right track' do + post.update!( + raw: 'show me what you can do', + reply_to_post_number: first_post.post_number + ) + + described_class.new(:reply, user, post_id: post.id).select + + expect(Post.last.raw).to eq(I18n.t( + "discourse_narrative_bot.new_user_narrative.formatting.not_found" + )) + + described_class.new(:reply, user, post_id: post.id).select + + expected_raw = <<~RAW + #{I18n.t( + 'discourse_narrative_bot.track_selector.do_not_understand.first_response', + reset_trigger: "#{described_class.reset_trigger} #{DiscourseNarrativeBot::NewUserNarrative.reset_trigger}", + )} + + #{I18n.t( + 'discourse_narrative_bot.track_selector.do_not_understand.track_response', + reset_trigger: "#{described_class.reset_trigger} #{DiscourseNarrativeBot::NewUserNarrative.reset_trigger}", + skip_trigger: described_class.skip_trigger + )} + RAW + + expect(Post.last.raw).to eq(expected_raw.chomp) + end + end + + describe 'when user thank the bot' do + it 'should like the post' do + post.update!(raw: 'thanks!') + + expect { described_class.new(:reply, user, post_id: post.id).select } + .to change { PostAction.count }.by(1) + + post_action = PostAction.last + + expect(post_action.post).to eq(post) + expect(post_action.post_action_type_id).to eq(PostActionType.types[:like]) + + post = Post.last + + expect(Post.last).to eq(post) + + expect(DiscourseNarrativeBot::NewUserNarrative.new.get_data(user)['state']) + .to eq(nil) + end + end + + context 'when reply contains a reset trigger' do + it 'should reset the track' do + post.update!( + raw: "#{described_class.reset_trigger} #{DiscourseNarrativeBot::NewUserNarrative.reset_trigger}" + ) + + described_class.new(:reply, user, post_id: post.id).select + + expect(DiscourseNarrativeBot::NewUserNarrative.new.get_data(user)['state']) + .to eq("tutorial_bookmark") + end + + describe 'reset trigger in surrounded by quotes' do + it 'should reset the track' do + post.update!( + raw: "'#{described_class.reset_trigger} #{DiscourseNarrativeBot::NewUserNarrative.reset_trigger}'" + ) + + described_class.new(:reply, user, post_id: post.id).select + + expect(DiscourseNarrativeBot::NewUserNarrative.new.get_data(user)['state']) + .to eq("tutorial_bookmark") + end + end + + describe 'reset trigger in a middle of a sentence' do + describe 'when post is less than reset trigger exact match limit' do + it 'should reset the track' do + post.update!( + raw: "I would like to #{described_class.reset_trigger} #{DiscourseNarrativeBot::NewUserNarrative.reset_trigger} now" + ) + + described_class.new(:reply, user, post_id: post.id).select + + expect(DiscourseNarrativeBot::NewUserNarrative.new.get_data(user)['state']) + .to eq("tutorial_bookmark") + end + end + + describe 'when post exceeds reset trigger exact match limit' do + it 'should not reset the track' do + post.update!( + raw: "I would like to #{described_class.reset_trigger} #{DiscourseNarrativeBot::NewUserNarrative.reset_trigger} now #{'a' * described_class::RESET_TRIGGER_EXACT_MATCH_LENGTH}" + ) + + expect { described_class.new(:reply, user, post_id: post.id).select } + .to change { Post.count }.by(1) + + expect(DiscourseNarrativeBot::NewUserNarrative.new.get_data(user)['state']) + .to eq("tutorial_formatting") + end + end + end + + context 'start/reset advanced track' do + before do + post.update!( + raw: "@#{discobot_user.username} #{described_class.reset_trigger} #{DiscourseNarrativeBot::AdvancedUserNarrative.reset_trigger}" + ) + end + + context 'when new user track has not been completed' do + it 'should not start the track' do + described_class.new(:reply, user, post_id: post.id).select + + expect(DiscourseNarrativeBot::Store.get(user.id)['track']) + .to eq(DiscourseNarrativeBot::NewUserNarrative.to_s) + end + end + + context 'when new user track has been completed' do + it 'should start the track' do + BadgeGranter.grant( + Badge.find_by(name: DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME), + user + ) + + described_class.new(:reply, user, post_id: post.id).select + + expect(DiscourseNarrativeBot::Store.get(user.id)['track']) + .to eq(DiscourseNarrativeBot::AdvancedUserNarrative.to_s) + end + end + end + end + end + + context 'at the end of a tutorial track' do + before do + narrative.set_data(user, + state: :end, + topic_id: topic.id, + track: "DiscourseNarrativeBot::NewUserNarrative" + ) + end + + context 'generic replies' do + after do + $redis.del("#{described_class::GENERIC_REPLIES_COUNT_PREFIX}#{user.id}") + end + + it 'should create the right generic do not understand responses' do + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.track_selector.do_not_understand.first_response', + reset_trigger: "#{described_class.reset_trigger} #{DiscourseNarrativeBot::NewUserNarrative.reset_trigger}", + )) + + described_class.new(:reply, user, post_id: Fabricate(:post, + topic: new_post.topic, + user: user, + reply_to_post_number: new_post.post_number + ).id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.track_selector.do_not_understand.second_response', + reset_trigger: "#{described_class.reset_trigger} #{DiscourseNarrativeBot::NewUserNarrative.reset_trigger}", + )) + + new_post = Fabricate(:post, + topic: new_post.topic, + user: user, + reply_to_post_number: new_post.post_number + ) + + expect { described_class.new(:reply, user, post_id: new_post.id).select } + .to_not change { Post.count } + end + end + + context 'when discobot is mentioned at the end of a track' do + it 'should create the right reply' do + post.update!(raw: 'Show me what you can do @discobot') + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expect(new_post.raw).to eq(random_mention_reply) + end + + describe 'when asking discobot for help' do + it 'should create the right reply' do + post.update!(raw: 'show me what you can do @discobot display help') + described_class.new(:reply, user, post_id: post.id).select + + expect(Post.last.raw).to include(help_message) + end + + describe 'as an admin or moderator' do + it 'should include the commands to start the advanced user track' do + user.update!(moderator: true) + post.update!(raw: 'Show me what you can do @discobot display help') + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expect(new_post.raw).to include( + DiscourseNarrativeBot::AdvancedUserNarrative.reset_trigger + ) + end + end + + describe 'as a user that has completed the new user track' do + it 'should include the commands to start the advanced user track' do + narrative.set_data(user, + state: :end, + topic_id: post.topic.id, + track: "DiscourseNarrativeBot::NewUserNarrative", + ) + + BadgeGranter.grant( + Badge.find_by(name: DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME), + user + ) + + post.update!(raw: 'Show me what you can do @discobot display help') + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expect(new_post.raw).to include( + DiscourseNarrativeBot::AdvancedUserNarrative.reset_trigger + ) + end + end + end + + describe 'when discobot is asked to roll dice' do + before do + narrative.set_data(user, + state: :end, + topic_id: topic.id + ) + end + + it 'should create the right reply' do + post.update!(raw: 'roll 2d1') + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + "discourse_narrative_bot.dice.results", results: '1, 1' + )) + end + + describe 'when range of dice request is too high' do + before do + srand(1) + end + + it 'should create the right reply' do + stub_request(:get, "https://www.wired.com/2016/05/mathematical-challenge-of-designing-the-worlds-most-complex-120-sided-dice") + .to_return(status: 200, body: "", headers: {}) + + post.update!(raw: "roll 1d#{DiscourseNarrativeBot::Dice::MAXIMUM_RANGE_OF_DICE + 1}") + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.dice.out_of_range')} + + #{I18n.t('discourse_narrative_bot.dice.results', results: '38')} + RAW + + expect(new_post.raw).to eq(expected_raw.chomp) + end + end + + describe 'when number of dice to roll is too high' do + it 'should create the right reply' do + post.update!(raw: "roll #{DiscourseNarrativeBot::Dice::MAXIMUM_NUM_OF_DICE + 1}d1") + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.dice.not_enough_dice', num_of_dice: DiscourseNarrativeBot::Dice::MAXIMUM_NUM_OF_DICE)} + + #{I18n.t('discourse_narrative_bot.dice.results', results: '1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1')} + RAW + + expect(new_post.raw).to eq(expected_raw.chomp) + end + end + + describe 'when dice combination is invalid' do + it 'should create the right reply' do + post.update!(raw: "roll 0d1") + described_class.new(:reply, user, post_id: post.id).select + + expect(Post.last.raw).to eq(I18n.t( + 'discourse_narrative_bot.dice.invalid' + )) + end + end + end + end + end + + context 'when in a normal PM with discobot' do + describe 'when discobot is replied to' do + it 'should create the right reply' do + SiteSetting.discourse_narrative_bot_disable_public_replies = true + post.update!(raw: 'Show me what you can do @discobot') + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expect(new_post.raw).to eq(random_mention_reply) + end + + it 'should not rate limit help message' do + post.update!(raw: '@discobot') + other_post = Fabricate(:post, raw: 'discobot', topic: post.topic) + + [post, other_post].each do |reply| + described_class.new(:reply, user, post_id: reply.id).select + expect(Post.last.raw).to eq(random_mention_reply) + end + end + end + end + end + + context 'random discobot mentions' do + let(:topic) { Fabricate(:topic) } + let(:post) { Fabricate(:post, topic: topic, user: user) } + + describe 'when discobot public replies are disabled' do + before do + SiteSetting.discourse_narrative_bot_disable_public_replies = true + end + + describe 'when discobot is mentioned' do + it 'should not reply' do + post.update!(raw: 'Show me what you can do @discobot') + + expect do + described_class.new(:reply, user, post_id: post.id).select + end.to_not change { Post.count } + end + end + end + + describe 'when discobot is mentioned' do + it 'should create the right reply' do + post.update!(raw: 'Show me what you can do @discobot') + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + expect(new_post.raw).to eq(random_mention_reply) + end + + describe 'rate limiting random reply message in public topic' do + let(:topic) { Fabricate(:topic) } + let(:other_post) { Fabricate(:post, raw: '@discobot show me something', topic: topic) } + let(:post) { Fabricate(:post, topic: topic) } + + after do + $redis.flushall + end + + describe 'when random reply massage has been displayed in the last 6 hours' do + it 'should not do anything' do + $redis.set( + "#{described_class::PUBLIC_DISPLAY_BOT_HELP_KEY}:#{other_post.topic_id}", + post.post_number - 11 + ) + + $redis.class.any_instance.expects(:ttl).returns(19.hours.to_i) + + user + post.update!(raw: "Show me what you can do @discobot") + + expect { described_class.new(:reply, user, post_id: post.id).select } + .to_not change { Post.count } + end + end + + describe 'when random reply message has not been displayed in the last 6 hours' do + it 'should create the right reply' do + $redis.set( + "#{described_class::PUBLIC_DISPLAY_BOT_HELP_KEY}:#{other_post.topic_id}", + post.post_number - 11 + ) + + $redis.class.any_instance.expects(:ttl).returns(7.hours.to_i) + + user + post.update!(raw: "Show me what you can do @discobot") + + described_class.new(:reply, user, post_id: post.id).select + + expect(Post.last.raw).to eq(random_mention_reply) + end + end + + describe 'when random reply message has been displayed in the last 10 replies' do + it 'should not do anything' do + described_class.new(:reply, user, post_id: other_post.id).select + expect(Post.last.raw).to eq(random_mention_reply) + + expect($redis.get( + "#{described_class::PUBLIC_DISPLAY_BOT_HELP_KEY}:#{other_post.topic_id}" + ).to_i).to eq(other_post.post_number.to_i) + + user + post.update!(raw: "Show me what you can do @discobot") + + expect do + described_class.new(:reply, user, post_id: post.id).select + end.to_not change { Post.count } + end + end + end + + describe 'when asking discobot for help' do + it 'should create the right reply' do + post.update!(raw: '@discobot display help') + described_class.new(:reply, user, post_id: post.id).select + + expect(Post.last.raw).to eq(help_message) + end + end + + describe 'when asking discobot to start new user track' do + describe 'invalid text' do + it 'should not trigger the bot' do + post.update!(raw: '`@discobot start new user track`') + + expect { described_class.new(:reply, user, post_id: post.id).select } + .to_not change { Post.count } + end + end + end + + describe 'when discobot is asked to roll dice' do + it 'should create the right reply' do + post.update!(raw: '@discobot roll 2d1') + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expect(new_post.raw).to eq( + I18n.t("discourse_narrative_bot.dice.results", + results: '1, 1' + )) + end + + describe 'when dice roll is requested incorrectly' do + it 'should create the right reply' do + post.update!(raw: 'roll 2d1 @discobot') + described_class.new(:reply, user, post_id: post.id).select + + expect(Post.last.raw).to eq(random_mention_reply) + end + end + + describe 'when roll dice command is present inside a quote' do + it 'should ignore the command' do + user + post.update!(raw: '[quote="Donkey, post:6, topic:1"]@discobot roll 2d1[/quote]') + + expect { described_class.new(:reply, user, post_id: post.id).select } + .to_not change { Post.count } + end + end + end + + describe 'when a quote is requested' do + it 'should create the right reply' do + Excon.stub({ method: :get, hostname: 'api.forismatic.com' }, + status: 200, + body: "{\"quoteText\":\"Be Like Water\",\"quoteAuthor\":\"Bruce Lee\"}" + ) + + ['@discobot quote', 'hello @discobot quote there'].each do |raw| + post.update!(raw: raw) + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expect(new_post.raw).to eq( + I18n.t("discourse_narrative_bot.quote.results", + quote: "Be Like Water", author: "Bruce Lee" + )) + end + end + + describe 'when quote is requested incorrectly' do + it 'should create the right reply' do + post.update!(raw: 'quote @discobot') + described_class.new(:reply, user, post_id: post.id).select + + expect(Post.last.raw).to eq(random_mention_reply) + end + end + + describe 'when quote command is present inside a onebox or quote' do + it 'should ignore the command' do + user + post.update!(raw: '[quote="Donkey, post:6, topic:1"]@discobot quote[/quote]') + + expect { described_class.new(:reply, user, post_id: post.id).select } + .to_not change { Post.count } + end + end + + describe 'when user requesting quote has a preferred locale' do + before do + SiteSetting.allow_user_locale = true + user.update!(locale: 'it') + srand(1) + end + + it 'should create the right reply' do + post.update!(raw: '@discobot quote') + described_class.new(:reply, user, post_id: post.id).select + key = "discourse_narrative_bot.quote.6" + + expect(Post.last.raw).to eq(I18n.t('discourse_narrative_bot.quote.results', + quote: I18n.t("#{key}.quote"), author: I18n.t("#{key}.author") + )) + end + end + end + + describe 'when magic 8 ball is requested' do + before do + srand(1) + end + + it 'should create the right reply' do + post.update!(raw: '@discobot fortune') + described_class.new(:reply, user, post_id: post.id).select + + expect(Post.last.raw).to eq(I18n.t('discourse_narrative_bot.magic_8_ball.result', + result: I18n.t("discourse_narrative_bot.magic_8_ball.answers.6") + )) + end + end + + describe 'when bot is asked to reset/start a track' do + describe 'when user likes a post containing a reset trigger' do + it 'should not start the track' do + another_post = Fabricate(:post, + user: Fabricate(:user), + topic: topic, + raw: "@discobot start new user" + ) + + user + + expect do + PostAction.act(user, another_post, PostActionType.types[:like]) + end.to_not change { Post.count } + end + end + end + end + end + + context 'pm to self' do + let(:other_topic) do + topic_allowed_user = Fabricate.build(:topic_allowed_user, user: user) + Fabricate(:private_message_topic, topic_allowed_users: [topic_allowed_user]) + end + + let(:other_post) { Fabricate(:post, topic: other_topic) } + + describe 'when a new message is made' do + it 'should not do anything' do + other_post + + expect { described_class.new(:reply, user, post_id: other_post.id).select } + .to_not change { Post.count } + end + end + end + + context 'pms to bot' do + let(:other_topic) do + topic_allowed_user = Fabricate.build(:topic_allowed_user, user: user) + bot = Fabricate.build(:topic_allowed_user, user: discobot_user) + Fabricate(:private_message_topic, topic_allowed_users: [topic_allowed_user, bot]) + end + + let(:other_post) { Fabricate(:post, topic: other_topic) } + + describe 'when a new like is made' do + it 'should not do anything' do + other_post + expect { described_class.new(:like, user, post_id: other_post.id).select } + .to_not change { Post.count } + end + end + + describe 'when a new message is made' do + it 'should create the right reply' do + described_class.new(:reply, user, post_id: other_post.id).select + + expect(Post.last.raw).to eq(random_mention_reply) + end + end + + describe 'when user thanks the bot' do + it 'should like the post' do + other_post.update!(raw: 'thanks!') + + expect { described_class.new(:reply, user, post_id: other_post.id).select } + .to change { PostAction.count }.by(1) + + post_action = PostAction.last + + expect(post_action.post).to eq(other_post) + expect(post_action.post_action_type_id).to eq(PostActionType.types[:like]) + end + end + end + end +end diff --git a/plugins/discourse-narrative-bot/spec/integration/discobot_certificate_spec.rb b/plugins/discourse-narrative-bot/spec/integration/discobot_certificate_spec.rb new file mode 100644 index 00000000000..2cb9fec868b --- /dev/null +++ b/plugins/discourse-narrative-bot/spec/integration/discobot_certificate_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + +describe "Discobot Certificate" do + let(:user) { Fabricate(:user, name: 'Jeff Atwood') } + + describe 'when viewing the certificate' do + describe 'when params are missing' do + it "should raise the right errors" do + params = { + date: Time.zone.now.strftime("%b %d %Y"), + user_id: user.id + } + + params.each do |key, _| + expect { xhr :get, '/discobot/certificate.svg', params.except(key) } + .to raise_error(Discourse::InvalidParameters) + end + end + end + + describe 'when date is invalid' do + it 'should raise the right error' do + expect do + xhr :get, '/discobot/certificate.svg', + name: user.name, + date: "", + avatar_url: 'https://somesite.com/someavatar', + user_id: user.id + end.to raise_error(ArgumentError, 'invalid date') + end + end + end +end diff --git a/plugins/discourse-narrative-bot/spec/jobs/onceoff/grant_badges.rb b/plugins/discourse-narrative-bot/spec/jobs/onceoff/grant_badges.rb new file mode 100644 index 00000000000..355a51e6d83 --- /dev/null +++ b/plugins/discourse-narrative-bot/spec/jobs/onceoff/grant_badges.rb @@ -0,0 +1,26 @@ +require 'rails_helper' + +RSpec.describe Jobs::DiscourseNarrativeBot::GrantBadges do + let(:user) { Fabricate(:user) } + let(:other_user) { Fabricate(:user) } + + before do + DiscourseNarrativeBot::Store.set(user.id, completed: [ + DiscourseNarrativeBot::NewUserNarrative.to_s, + DiscourseNarrativeBot::AdvancedUserNarrative.to_s + ]) + end + + it 'should grant the right badges' do + described_class.new.execute_onceoff({}) + + expect(user.badges.count).to eq(2) + + expect(user.badges.map(&:name)).to eq([ + DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME, + DiscourseNarrativeBot::AdvancedUserNarrative::BADGE_NAME, + ]) + + expect(other_user.badges.count).to eq(0) + end +end diff --git a/plugins/discourse-narrative-bot/spec/jobs/send_default_welcome_message_spec.rb b/plugins/discourse-narrative-bot/spec/jobs/send_default_welcome_message_spec.rb new file mode 100644 index 00000000000..7f8a1c5c12b --- /dev/null +++ b/plugins/discourse-narrative-bot/spec/jobs/send_default_welcome_message_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' + +RSpec.describe Jobs::SendDefaultWelcomeMessage do + let(:user) { Fabricate(:user) } + + it 'should send the right welcome message' do + described_class.new.execute(user_id: user.id) + + topic = Topic.last + + expect(topic.title).to eq(I18n.t( + "system_messages.welcome_user.subject_template", + site_name: SiteSetting.title + )) + + expect(topic.first_post.raw).to eq(I18n.t( + "system_messages.welcome_user.text_body_template", + SystemMessage.new(user).defaults + ).chomp) + + expect(topic.closed).to eq(true) + end + + describe 'for an invited user' do + let(:invite) { Fabricate(:invite, user: user, redeemed_at: Time.zone.now) } + + it 'should send the right welcome message' do + described_class.new.execute(user_id: invite.user_id) + + topic = Topic.last + + expect(topic.title).to eq(I18n.t( + "system_messages.welcome_invite.subject_template", + site_name: SiteSetting.title + )) + + expect(topic.first_post.raw).to eq(I18n.t( + "system_messages.welcome_invite.text_body_template", + SystemMessage.new(user).defaults + ).chomp) + + expect(topic.closed).to eq(true) + end + end +end diff --git a/plugins/discourse-narrative-bot/spec/user_spec.rb b/plugins/discourse-narrative-bot/spec/user_spec.rb new file mode 100644 index 00000000000..a18d48837ce --- /dev/null +++ b/plugins/discourse-narrative-bot/spec/user_spec.rb @@ -0,0 +1,132 @@ +require 'rails_helper' + +describe User do + let(:user) { Fabricate(:user) } + let(:profile_page_url) { "#{Discourse.base_url}/users/#{user.username}" } + + before do + SiteSetting.discourse_narrative_bot_enabled = true + end + + describe 'when a user is created' do + it 'should initiate the bot' do + user + + expected_raw = I18n.t('discourse_narrative_bot.new_user_narrative.hello.message', + username: user.username, title: SiteSetting.title + ) + + expect(Post.last.raw).to include(expected_raw.chomp) + end + + describe 'welcome post' do + context 'disabled' do + before do + SiteSetting.disable_discourse_narrative_bot_welcome_post = true + end + + it 'should not initiate the bot' do + expect { user }.to_not change { Post.count } + end + end + + describe 'enabled' do + before do + SiteSetting.disable_discourse_narrative_bot_welcome_post = false + end + + it 'initiate the bot' do + expect { user }.to change { Topic.count }.by(1) + + expect(Topic.last.title).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.hello.title' + )) + end + + describe "when send welcome message is selected" do + before do + SiteSetting.discourse_narrative_bot_welcome_post_type = 'welcome_message' + end + + it 'should send the right welcome message' do + expect { user }.to change { Topic.count }.by(1) + + expect(Topic.last.title).to eq(I18n.t( + "system_messages.welcome_user.subject_template", + site_name: SiteSetting.title + )) + end + end + + describe 'when welcome message is delayed' do + before do + SiteSetting.discourse_narrative_bot_welcome_post_delay = 100 + SiteSetting.queue_jobs = true + end + + it 'should delay the initialization of the new user track' do + Timecop.freeze do + user + + expect(Jobs::NarrativeInit.jobs.first['at']) + .to be_within(1.second).of(Time.zone.now.to_f + 100) + end + end + + it 'should delay sending the welcome message' do + SiteSetting.discourse_narrative_bot_welcome_post_type = 'welcome_message' + + Timecop.freeze do + user + + expect(Jobs::SendDefaultWelcomeMessage.jobs.first['at']) + .to be_within(1.second).of(Time.zone.now.to_f + 100) + end + end + end + end + end + + context 'when user is staged' do + let(:user) { Fabricate(:user, staged: true) } + + it 'should not initiate the bot' do + expect { user }.to_not change { Post.count } + end + end + + context 'when user is anonymous?' do + let(:anonymous_user) { Fabricate(:anonymous) } + + it 'should not initiate the bot' do + SiteSetting.allow_anonymous_posting = true + + expect { anonymous_user }.to_not change { Post.count } + end + end + + context "when user's username should be ignored" do + let(:user) { Fabricate.build(:user) } + + before do + SiteSetting.discourse_narrative_bot_ignored_usernames = 'discourse|test' + end + + ['discourse', 'test'].each do |username| + it 'should not initiate the bot' do + expect { user.update!(username: username) }.to_not change { Post.count } + end + end + end + end + + describe 'when a user has been destroyed' do + it "should clean up plugin's store" do + DiscourseNarrativeBot::Store.set(user.id, 'test') + + user.destroy! + + expect(DiscourseNarrativeBot::Store.get(user.id)).to eq(nil) + end + end +end