├── .gitignore ├── LICENSE.txt ├── README.md ├── app ├── controllers │ └── campaigns.rb └── jobs │ ├── base.rb │ ├── regular │ ├── poll_twitter_user.rb │ ├── poll_website_feed.rb │ └── poll_youtube_channel.rb │ └── scheduled │ └── campaigns_handler.rb ├── assets ├── javascripts │ └── discourse │ │ ├── autobot-route-map.js.es6 │ │ ├── controllers │ │ └── admin-plugins-autobot-campaigns.js.es6 │ │ ├── models │ │ ├── campaign.js.es6 │ │ ├── campaign_provider.js.es6 │ │ └── campaign_source.js.es6 │ │ ├── routes │ │ ├── admin-plugins-autobot-campaigns.js.es6 │ │ └── admin-plugins-autobot-index.js.es6 │ │ └── templates │ │ └── admin │ │ ├── plugins-autobot-campaigns.hbs │ │ └── plugins-autobot.hbs └── stylesheets │ └── admin │ └── autobot.scss ├── config ├── locales │ ├── client.en.yml │ └── server.en.yml └── settings.yml ├── db └── fixtures │ └── 001_autobot.rb ├── lib ├── autobot │ ├── campaign.rb │ ├── post_creator.rb │ ├── provider.rb │ ├── store.rb │ ├── twitter │ │ ├── post_creator.rb │ │ └── provider.rb │ ├── website │ │ ├── post_creator.rb │ │ └── provider.rb │ └── youtube │ │ ├── post_creator.rb │ │ └── provider.rb └── twitter_api.rb └── plugin.rb /.gitignore: -------------------------------------------------------------------------------- 1 | gems/ 2 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Vinkas (vinkas.com), Vinoth Kannan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Autobot plugin for Discourse 2 | 3 | ### Features: 4 | 5 | Currently it supports importing from below 6 | 7 | - YouTube Channel 8 | - Website RSS Feed 9 | - Twitter User Timeline 10 | 11 | ### Options: 12 | 13 | - Can configure how frequent Discourse should poll from source for content. 14 | - Import content as either new topic or reply post for exsiting topic. 15 | - Setting custom owner username for improted posts. 16 | 17 | ### Settings: 18 | 19 | - To import from any YouTube channels you have to configure Google API key in Site Settings. 20 | - To import tweets configure twitter consumer key and secret. 21 | 22 | ### Further Development: 23 | 24 | - Ability to hide source URL for RSS feed imports. (via Campaign setting) 25 | 26 | # Installation: 27 | 28 | #### Register a YouTube API key. 29 | (Taken from Google deveeloper docs: https://developers.google.com/youtube/v3/getting-started) 30 | - You need a Google Account to access the Google Developers Console, request an API key, and register your application. 31 | - Create a project in the Google Developers Console and obtain authorization credentials so your application can submit API requests. 32 | - After creating your project, make sure the YouTube Data API is one of the services that your application is registered to use: 33 | - Go to the Developers Console and select the project that you just registered. 34 | - Open the API Library in the Google Developers Console. If prompted, select a project or create a new one. In the list of APIs, make sure the status is ON for the YouTube Data API v3. 35 | - If your application will use any API methods that require user authorization, read the authentication guide to learn how to implement OAuth 2.0 authorization. 36 | - Select a client library to simplify your API implementation. 37 | - Familiarize yourself with the core concepts of the JSON (JavaScript Object Notation) data format. JSON is a common, language-independent data format that provides a simpleext representation of arbitrary data structures. For more information, see json.org. 38 | 39 | #### Add the plugin repo. 40 | - cd /var/discourse/containers and edit app.yml . 41 | - add repo to the plugin section: 42 | ``- git clone https://github.com/vinkashq/discourse-autobot.git`` . 43 | - cd /var/discourse & ``./launcer rebuild app`` . 44 | 45 | #### Setup plugin for YouTube. 46 | - Once your app has rebuilt, navigate to ``/admin/plugins/autobot/campaigns`` in the browser 47 | - create "New Campaign" filling in the required details. 48 | - __Provider__: select YouTube. 49 | - __Source__: choose Channel 50 | - __Channel Id__: to obtain a YouTube channel's ID, go to channel URL, click on "Videos", the ID will be the value after ``/channel/`` and before ``/videos`` - copy this value into the "Channel Id" field. 51 | - __Category__: selecting a category will import new videos to said cateory _or_ .. 52 | - __Topic Id__: alternatively, selecting "Topic Id" will add each video as a reply to that topic. Topic Id can be obtained from going to a topic on discourse and noting the number at the end of the URL. 53 | - __Polling Interval(in minutes)__: the polling interval is how long the plugin should reach out to the API. 54 | - __Username for post ownership__: choose a username to be attributed to the posts. -------------------------------------------------------------------------------- /app/controllers/campaigns.rb: -------------------------------------------------------------------------------- 1 | require_dependency 'application_controller' 2 | 3 | module Autobot 4 | class CampaignsController < ::ApplicationController 5 | 6 | def list 7 | render json: Autobot::Campaign.list 8 | end 9 | 10 | def create 11 | Autobot::Campaign.create(campaign_params.except(:id)) 12 | render json: success_json 13 | end 14 | 15 | def update 16 | Autobot::Campaign.update(campaign_params) 17 | render json: success_json 18 | end 19 | 20 | def delete 21 | params.permit(:id) 22 | 23 | Autobot::Campaign.delete(params[:id]) 24 | render json: success_json 25 | end 26 | 27 | private 28 | 29 | def campaign_params 30 | params.permit(:id, :provider_id, :source_id, :topic_id, :category_id, :key, :polling_interval, :owner_username) 31 | end 32 | 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/jobs/base.rb: -------------------------------------------------------------------------------- 1 | module Autobot 2 | module Jobs 3 | class Base < ::Jobs::Base 4 | 5 | def execute(args) 6 | @campaign = Autobot::Campaign.find(args[:campaign_id]) 7 | 8 | poll(@campaign) 9 | 10 | @campaign["last_polled_at"] = Time.now 11 | Autobot::Campaign.update(@campaign) 12 | end 13 | 14 | def poll(campaign) 15 | raise "Overwrite me!" 16 | end 17 | 18 | end 19 | end 20 | end 21 | 22 | 23 | require_relative "scheduled/campaigns_handler.rb" 24 | 25 | require_relative "regular/poll_twitter_user.rb" 26 | require_relative "regular/poll_website_feed.rb" 27 | require_relative "regular/poll_youtube_channel.rb" 28 | -------------------------------------------------------------------------------- /app/jobs/regular/poll_twitter_user.rb: -------------------------------------------------------------------------------- 1 | module Jobs 2 | class PollTwitterUser < Autobot::Jobs::Base 3 | 4 | def poll(campaign) 5 | @username = campaign[:key] 6 | since_id = campaign[:since_id].presence 7 | 8 | tweets = TwitterApi.user_timeline({screen_name: @username, since_id: since_id}) 9 | tweets.reverse_each do |tweet| 10 | creator = Autobot::Twitter::PostCreator.new(campaign, tweet) 11 | creator.create! 12 | end 13 | end 14 | 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/jobs/regular/poll_website_feed.rb: -------------------------------------------------------------------------------- 1 | require 'open-uri' 2 | 3 | module Jobs 4 | class PollWebsiteFeed < Autobot::Jobs::Base 5 | require 'simple-rss' 6 | 7 | def poll(campaign) 8 | @feed_url = campaign[:key] 9 | last_polled_at = campaign[:last_polled_at] 10 | 11 | rss = fetch_rss 12 | 13 | if rss.present? 14 | if last_polled_at.present? 15 | build_date = rss.channel.lastBuildDate || rss.channel.pubDate 16 | return if build_date.present? && build_date < last_polled_at 17 | end 18 | 19 | rss.items.reverse_each do |i| 20 | creator = Autobot::Website::PostCreator.new(campaign, i) 21 | creator.create! 22 | end 23 | end 24 | end 25 | 26 | private 27 | 28 | def fetch_rss 29 | SimpleRSS.parse open(@feed_url, allow_redirections: :all) 30 | rescue OpenURI::HTTPError, SimpleRSSError 31 | nil 32 | end 33 | 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /app/jobs/regular/poll_youtube_channel.rb: -------------------------------------------------------------------------------- 1 | require_dependency 'yt' 2 | 3 | module Jobs 4 | class PollYoutubeChannel < Autobot::Jobs::Base 5 | 6 | def poll(campaign) 7 | Autobot::Youtube::Provider.configure 8 | last_polled_at = campaign[:last_polled_at] 9 | 10 | channel = ::Yt::Channel.new id: campaign[:key] 11 | videos = channel.videos 12 | videos = videos.where(publishedAfter: last_polled_at) if last_polled_at.present? 13 | 14 | videos.reverse_each do |video| 15 | creator = Autobot::Youtube::PostCreator.new(campaign, video) 16 | creator.create! 17 | end 18 | end 19 | 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/jobs/scheduled/campaigns_handler.rb: -------------------------------------------------------------------------------- 1 | 2 | module Jobs 3 | class CampaignsHandler < Jobs::Scheduled 4 | every 5.minutes 5 | 6 | sidekiq_options retry: false 7 | 8 | def execute(args) 9 | campaigns = ::Autobot::Campaign.list 10 | 11 | campaigns.each do |c| 12 | if c["last_polled_at"].present? 13 | polling_interval = Integer(c["polling_interval"].presence || "60").minutes 14 | last_polled_at = Time.parse(c["last_polled_at"]) 15 | 16 | next if last_polled_at + polling_interval > Time.now 17 | end 18 | 19 | case c["source_id"] 20 | when "1" # YouTube Channel 21 | Jobs.enqueue(:poll_youtube_channel, campaign_id: c["id"]) 22 | when "2" # Website Feed 23 | Jobs.enqueue(:poll_website_feed, campaign_id: c["id"]) 24 | when "3" # Twitter User Timeline 25 | Jobs.enqueue(:poll_twitter_user, campaign_id: c["id"]) 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/autobot-route-map.js.es6: -------------------------------------------------------------------------------- 1 | export default { 2 | resource: 'admin.adminPlugins', 3 | path: '/plugins', 4 | map() { 5 | this.route('autobot', function () { 6 | this.route('campaigns'); 7 | }); 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/controllers/admin-plugins-autobot-campaigns.js.es6: -------------------------------------------------------------------------------- 1 | import Campaign from 'discourse/plugins/autobot/discourse/models/campaign'; 2 | import CampaignProvider from 'discourse/plugins/autobot/discourse/models/campaign_provider'; 3 | import CampaignSource from 'discourse/plugins/autobot/discourse/models/campaign_source'; 4 | import { ajax } from 'discourse/lib/ajax'; 5 | import { popupAjaxError } from 'discourse/lib/ajax-error'; 6 | import computed from "ember-addons/ember-computed-decorators"; 7 | 8 | export default Ember.Controller.extend({ 9 | editing: false, 10 | 11 | @computed 12 | providers() { 13 | return CampaignProvider.list(); 14 | }, 15 | 16 | @computed('editing.provider_id') 17 | sources(provider_id) { 18 | if (Ember.isEmpty(provider_id)) 19 | return []; 20 | return CampaignSource.filterByProvider(parseInt(provider_id)); 21 | }, 22 | 23 | @computed('editing.provider_id', 'editing.source_id') 24 | keyLabel(provider_id, source_id) { 25 | var provider = CampaignProvider.findById(parseInt(provider_id)); 26 | var source = CampaignSource.findById(parseInt(source_id)); 27 | if (provider && source) 28 | return 'autobot.campaign.key.' + provider.key + '.' + source.key; 29 | return null; 30 | }, 31 | 32 | @computed('editing.id') 33 | commandAction(id) { 34 | if (id) return 'update'; 35 | return 'create'; 36 | }, 37 | 38 | @computed('commandAction') 39 | commandLabel(action) { 40 | return 'autobot.campaign.button.' + action; 41 | }, 42 | 43 | @computed('editing.key', 'editing.category_id', 'editing.topic_id') 44 | saveDisabled(key, category_id, topic_id) { 45 | return Ember.isEmpty(key) || (Ember.isEmpty(category_id) == Ember.isEmpty(topic_id)); 46 | }, 47 | 48 | actions: { 49 | new() { 50 | this.set('editing', Campaign.create({})); 51 | }, 52 | 53 | cancel() { 54 | this.set('editing', false); 55 | }, 56 | 57 | create() { 58 | const campaign = this.get('editing'); 59 | 60 | ajax("/autobot/campaigns.json", { 61 | method: 'POST', 62 | data: campaign.getProperties('provider_id', 'source_id', 'key', 'category_id', 'topic_id', 'polling_interval', 'owner_username') 63 | }).then((result) => { 64 | const model = this.get('model'); 65 | const obj = model.find(x => (x.get('id') === campaign.get('id'))); 66 | model.pushObject(Campaign.create(campaign.getProperties('provider_id', 'source_id', 'key', 'category_id', 'topic_id', 'polling_interval', 'owner_username'))); 67 | this.set('editing', false); 68 | }).catch(popupAjaxError); 69 | }, 70 | 71 | edit(campaign) { 72 | this.set('editing', campaign); 73 | }, 74 | 75 | delete(campaign) { 76 | const model = this.get('model'); 77 | 78 | ajax("/autobot/campaigns.json", { 79 | method: 'DELETE', 80 | data: campaign.getProperties('id') 81 | }).then(() => { 82 | const obj = model.find((x) => (x.get('id') === campaign.get('id'))); 83 | model.removeObject(obj); 84 | }).catch(popupAjaxError); 85 | }, 86 | 87 | update() { 88 | const campaign = this.get('editing'); 89 | 90 | ajax("/autobot/campaigns.json", { 91 | method: 'PUT', 92 | data: campaign.getProperties('id', 'provider_id', 'source_id', 'key', 'category_id', 'topic_id', 'polling_interval', 'owner_username') 93 | }).then((result) => { 94 | const model = this.get('model'); 95 | const obj = model.find(x => (x.get('id') === campaign.get('id'))); 96 | if (obj) { 97 | obj.setProperties({ 98 | 99 | }); 100 | } 101 | this.set('editing', false); 102 | }).catch(popupAjaxError); 103 | } 104 | } 105 | }); 106 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/models/campaign.js.es6: -------------------------------------------------------------------------------- 1 | import RestModel from 'discourse/models/rest'; 2 | import Category from 'discourse/models/category'; 3 | import CampaignProvider from 'discourse/plugins/autobot/discourse/models/campaign_provider'; 4 | import CampaignSource from 'discourse/plugins/autobot/discourse/models/campaign_source'; 5 | import computed from "ember-addons/ember-computed-decorators"; 6 | 7 | export default RestModel.extend({ 8 | provider_id: null, 9 | source_id: null, 10 | key: null, 11 | category_id: null, 12 | topic_id: null, 13 | polling_interval: 30, 14 | owner_username: null, 15 | 16 | @computed('category_id') 17 | categoryName(categoryId) { 18 | if (!categoryId) { 19 | return; 20 | } 21 | 22 | const category = Category.findById(categoryId); 23 | if (!category) { 24 | return I18n.t('autobot.choose.deleted_category'); 25 | } 26 | 27 | return category.get('name'); 28 | }, 29 | 30 | @computed('provider_id') 31 | providerName(providerId) { 32 | if (!providerId) 33 | return; 34 | return CampaignProvider.findById(providerId).name; 35 | }, 36 | 37 | @computed('source_id') 38 | sourceName(sourceId) { 39 | if (!sourceId) 40 | return; 41 | return CampaignSource.findById(sourceId).name; 42 | } 43 | }); 44 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/models/campaign_provider.js.es6: -------------------------------------------------------------------------------- 1 | import computed from "ember-addons/ember-computed-decorators"; 2 | import { on } from 'ember-addons/ember-computed-decorators'; 3 | 4 | const values = [ 5 | { id: 1, name: 'YouTube', key: 'youtube' }, 6 | { id: 2, name: 'Website', key: 'website' }, 7 | { id: 3, name: 'Twitter', key: 'twitter' } 8 | ] 9 | 10 | const CampaignProvider = Discourse.Model.extend({ 11 | name: '', 12 | key: '', 13 | 14 | @on("init") 15 | _setup() { 16 | var data = CampaignProvider.findById(this.get('id')); 17 | this.name = data.name; 18 | this.key = data.key; 19 | } 20 | }); 21 | 22 | CampaignProvider.reopenClass({ 23 | 24 | findById(id) { 25 | if (!id) { return; } 26 | 27 | return values.findBy('id', parseInt(id)); 28 | }, 29 | 30 | list() { 31 | return values; 32 | } 33 | 34 | }); 35 | 36 | export default CampaignProvider; 37 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/models/campaign_source.js.es6: -------------------------------------------------------------------------------- 1 | import computed from "ember-addons/ember-computed-decorators"; 2 | import { on } from 'ember-addons/ember-computed-decorators'; 3 | 4 | const values = [ 5 | { provider_id: 1, id: 1, name: 'Channel', key: 'channel' }, 6 | { provider_id: 2, id: 2, name: 'Feed', key: 'feed' }, 7 | { provider_id: 3, id: 3, name: 'User', key: 'user' } 8 | ] 9 | 10 | const CampaignSource = Discourse.Model.extend({ 11 | provider_id: null, 12 | name: '', 13 | key: '', 14 | 15 | @on("init") 16 | _setup() { 17 | var data = CampaignProvider.findById(this.get('id')); 18 | this.provider_id = data.provider_id; 19 | this.name = data.name; 20 | this.key = data.key; 21 | } 22 | }); 23 | 24 | CampaignSource.reopenClass({ 25 | 26 | findById(id) { 27 | if (!id) { return; } 28 | 29 | return values.findBy('id', parseInt(id)); 30 | }, 31 | 32 | filterByProvider(id) { 33 | return values.filter(function (el) { 34 | return el.provider_id == id; 35 | }); 36 | }, 37 | 38 | list() { 39 | return values; 40 | } 41 | 42 | }); 43 | 44 | export default CampaignSource; 45 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/routes/admin-plugins-autobot-campaigns.js.es6: -------------------------------------------------------------------------------- 1 | import Campaign from 'discourse/plugins/autobot/discourse/models/campaign'; 2 | import { ajax } from 'discourse/lib/ajax'; 3 | 4 | export default Discourse.Route.extend({ 5 | model() { 6 | return ajax("/autobot/campaigns.json").then(result => { 7 | return result.campaigns.map(v => Campaign.create(v)); 8 | }); 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/routes/admin-plugins-autobot-index.js.es6: -------------------------------------------------------------------------------- 1 | export default Discourse.Route.extend({ 2 | beforeModel: function() { 3 | this.transitionTo("adminPlugins.autobot.campaigns"); 4 | } 5 | }); 6 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/templates/admin/plugins-autobot-campaigns.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{#if editing}} 3 |
4 |
5 | 6 | {{combo-box content=providers value=editing.provider_id}} 7 |
8 |
9 | 10 | {{combo-box content=sources value=editing.source_id}} 11 |
12 |
13 | 14 | {{text-field value=editing.key}} 15 |
16 |
17 | 18 | {{category-chooser 19 | allowUncategorized="true" 20 | value=editing.category_id 21 | rootNone=true}} 22 |
23 |
24 | 25 | {{text-field value=editing.topic_id}} 26 |
27 |
28 | 29 | {{text-field value=editing.polling_interval}} 30 |
31 |
32 | 33 | {{text-field value=editing.owner_username}} 34 |
35 |
36 | {{d-button 37 | action=commandAction 38 | class="save btn btn-primary" 39 | title=commandLabel 40 | label=commandLabel 41 | disabled=saveDisabled}} 42 | {{d-button 43 | action="cancel" 44 | class="btn btn-default" 45 | title="autobot.campaign.button.cancel" 46 | label="autobot.campaign.button.cancel"}} 47 |
48 |
49 | {{else}} 50 |
51 | {{d-button action="new" 52 | icon="plus" 53 | title="autobot.campaign.button.new" 54 | label="autobot.campaign.button.new"}} 55 |
56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | {{#each model as |c|}} 66 | 67 | 68 | 69 | 70 | 71 | 72 | 76 | 77 | {{/each}} 78 |
{{i18n "autobot.campaign.field.provider"}}{{i18n "autobot.campaign.field.source"}}{{i18n "autobot.campaign.field.key"}}{{i18n "autobot.campaign.field.category"}}{{i18n "autobot.campaign.field.topic_id"}}
{{c.providerName}}{{c.sourceName}}{{c.key}}{{c.categoryName}}{{c.topic_id}} 73 | {{d-button action="edit" actionParam=c icon="pencil" class="edit" title="autobot.campaign.button.edit"}} 74 | {{d-button action="delete" actionParam=c icon="trash-o" class="delete btn-danger" title="autobot.campaign.button.delete"}} 75 |
79 | {{/if}} 80 |
81 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/templates/admin/plugins-autobot.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 7 |
8 |
9 |
10 | {{outlet}} 11 |
12 |
13 | -------------------------------------------------------------------------------- /assets/stylesheets/admin/autobot.scss: -------------------------------------------------------------------------------- 1 | .admin-plugin-autobot { 2 | margin-top: -30px; 3 | 4 | .form-horizontal > div { 5 | margin-top: 10px; 6 | 7 | label { 8 | font-weight: bold; 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /config/locales/client.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | js: 3 | autobot: 4 | title: 'Autobot' 5 | campaigns: 6 | title: 'Campaigns' 7 | campaign: 8 | field: 9 | provider: 'Provider' 10 | source: 'Source' 11 | key: 'Key' 12 | category: 'Category' 13 | topic_id: 'Topic Id' 14 | polling_interval: 'Polling Interval (in minutes)' 15 | owner_username: 'Username for post ownership' 16 | button: 17 | new: 'New Campaign' 18 | create: 'Create' 19 | update: 'Update' 20 | cancel: 'Cancel' 21 | edit: 'Edit' 22 | delete: 'Delete' 23 | key: 24 | youtube: 25 | channel: 'Channel Id' 26 | website: 27 | feed: 'RSS Feed URL' 28 | twitter: 29 | user: 'Twitter Username' 30 | -------------------------------------------------------------------------------- /config/locales/server.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | site_settings: 3 | autobot_enabled: 'Enable Autobot' 4 | google_api_server_key: 'Server Key of your Google application' 5 | 6 | autobot: 7 | bio: "Hi, I’m not a real person. I’m a bot that can create automatic content for this site." 8 | -------------------------------------------------------------------------------- /config/settings.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | autobot_enabled: 3 | default: true 4 | client: true 5 | google_api_server_key: 6 | default: '' 7 | client: false 8 | -------------------------------------------------------------------------------- /db/fixtures/001_autobot.rb: -------------------------------------------------------------------------------- 1 | autobot_username = 'autobot' 2 | user = User.find_by(id: -3) 3 | 4 | if !user 5 | suggested_username = UserNameSuggester.suggest(autobot_username) 6 | 7 | UserEmail.seed do |ue| 8 | ue.id = -3 9 | ue.email = "autobot_email" 10 | ue.primary = true 11 | ue.user_id = -3 12 | end 13 | 14 | User.seed do |u| 15 | u.id = -3 16 | u.name = autobot_username 17 | u.username = suggested_username 18 | u.username_lower = suggested_username.downcase 19 | u.password = SecureRandom.hex 20 | u.active = true 21 | u.approved = true 22 | u.trust_level = TrustLevel[4] 23 | end 24 | 25 | # # TODO Design a unique bot icon 26 | # if !Rails.env.test? 27 | # begin 28 | # UserAvatar.import_url_for_user( 29 | # "", 30 | # User.find(-3), 31 | # override_gravatar: true 32 | # ) 33 | # rescue 34 | # # In case the avatar can't be downloaded, don't fail seed 35 | # end 36 | # end 37 | end 38 | 39 | bot = User.find(-3) 40 | 41 | bot.user_option.update!( 42 | email_private_messages: false, 43 | email_direct: false 44 | ) 45 | 46 | if !bot.user_profile.bio_raw 47 | bot.user_profile.update!( 48 | bio_raw: I18n.t('autobot.bio', site_title: SiteSetting.title, autobot_username: bot.username) 49 | ) 50 | end 51 | 52 | Group.user_trust_level_change!(-3, TrustLevel[4]) 53 | -------------------------------------------------------------------------------- /lib/autobot/campaign.rb: -------------------------------------------------------------------------------- 1 | module Autobot 2 | class Campaign 3 | KEY = 'campaign'.freeze 4 | 5 | def self.list 6 | Autobot::Store.get(KEY) || [] 7 | end 8 | 9 | def self.set(value) 10 | Autobot::Store.set(KEY, value) 11 | end 12 | 13 | def self.create(value) 14 | data = list 15 | value["id"] = SecureRandom.uuid 16 | value["last_polled_at"] = nil 17 | 18 | data.push(value) 19 | set(data) 20 | 21 | value["id"] 22 | end 23 | 24 | def self.update(value) 25 | data = list 26 | 27 | index = data.index do |i| 28 | i["id"] == value["id"] 29 | end 30 | 31 | return unless index 32 | 33 | data[index].merge!(value.except(:id)) 34 | set(data) 35 | 36 | value["id"] 37 | end 38 | 39 | def self.delete(id) 40 | data = list 41 | 42 | data.delete_if do |i| 43 | i['id'] == id 44 | end 45 | 46 | set(data) 47 | end 48 | 49 | def self.find(id) 50 | data = list 51 | 52 | index = data.index do |i| 53 | i["id"] == id 54 | end 55 | 56 | return unless index 57 | 58 | data[index] 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/autobot/post_creator.rb: -------------------------------------------------------------------------------- 1 | module Autobot 2 | class PostCreator 3 | 4 | attr_reader :campaign 5 | 6 | def initialize(campaign) 7 | @campaign = campaign 8 | end 9 | 10 | def id 11 | 0 12 | end 13 | 14 | def owner 15 | username = campaign[:owner_username] 16 | return unless username.present? 17 | 18 | User.find_by(username: username) 19 | end 20 | 21 | def default_user 22 | Autobot.user || Discourse.system_user 23 | end 24 | 25 | def title 26 | raise "Overwrite me!" 27 | end 28 | 29 | def content 30 | raise "Overwrite me!" 31 | end 32 | 33 | def source_url 34 | raise "Overwrite me!" 35 | end 36 | 37 | def raw 38 | raw = "" 39 | raw << "## #{title}\n\n" unless new_topic? 40 | raw << "#{image_url}\n\n" if image_url.present? 41 | raw << content 42 | 43 | raw 44 | end 45 | 46 | def cook_method 47 | Post.cook_methods[:regular] 48 | end 49 | 50 | def skip_validations 51 | true 52 | end 53 | 54 | def category 55 | campaign["category_id"] 56 | end 57 | 58 | def topic_id 59 | campaign["topic_id"] 60 | end 61 | 62 | def new_topic? 63 | topic_id.blank? 64 | end 65 | 66 | def display_featured_link? 67 | false 68 | end 69 | 70 | def image_url 71 | nil 72 | end 73 | 74 | def custom_fields 75 | { 76 | autobot_campaign_id: campaign["id"], 77 | autobot_source_url: source_url 78 | } 79 | end 80 | 81 | def params 82 | {}.tap do |h| 83 | h[:title] = title if new_topic? 84 | h[:raw] = raw 85 | h[:skip_validations] = skip_validations 86 | h[:cook_method] = cook_method 87 | h[:category] = category if new_topic? 88 | h[:topic_id] = topic_id unless new_topic? 89 | h[:featured_link] = source_url if new_topic? && display_featured_link? 90 | h[:custom_fields] = custom_fields 91 | end 92 | end 93 | 94 | def update_since_id 95 | return if id == 0 96 | 97 | existing = campaign["since_id"].presence.try(:to_i) || 0 98 | if id > existing 99 | campaign["since_id"] = id 100 | Autobot::Campaign.update(campaign) 101 | end 102 | end 103 | 104 | def create! 105 | post = get_existing_post || post_creator.create! 106 | update_since_id 107 | post 108 | end 109 | 110 | def get_existing_post 111 | existing_post_ids = PostCustomField 112 | .where(name: "autobot_source_url", value: source_url) 113 | .where(name: "autobot_campaign_id", value: campaign["id"]) 114 | .pluck(:post_id) 115 | 116 | return if existing_post_ids.blank? 117 | 118 | Post.unscoped.find(existing_post_ids).last 119 | end 120 | 121 | private 122 | 123 | def post_creator 124 | user = owner || default_user 125 | ::PostCreator.new(user, params) 126 | end 127 | 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/autobot/provider.rb: -------------------------------------------------------------------------------- 1 | module Autobot 2 | class Provider 3 | end 4 | end 5 | 6 | require_relative "youtube/provider.rb" 7 | require_relative "website/provider.rb" 8 | require_relative "twitter/provider.rb" 9 | -------------------------------------------------------------------------------- /lib/autobot/store.rb: -------------------------------------------------------------------------------- 1 | module Autobot 2 | class Store 3 | def self.set(key, value) 4 | ::PluginStore.set(Autobot::PLUGIN_NAME, key, value) 5 | end 6 | 7 | def self.get(key) 8 | ::PluginStore.get(Autobot::PLUGIN_NAME, key) 9 | end 10 | 11 | def self.remove(key) 12 | ::PluginStore.remove(Autobot::PLUGIN_NAME, key) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/autobot/twitter/post_creator.rb: -------------------------------------------------------------------------------- 1 | module Autobot 2 | module Twitter 3 | class PostCreator < Autobot::PostCreator 4 | 5 | attr_reader :tweet 6 | 7 | def initialize(campaign, tweet) 8 | super(campaign) 9 | @tweet = tweet 10 | end 11 | 12 | def id 13 | tweet["id"].to_i 14 | end 15 | 16 | def title 17 | return "[#{full_name}](#{source_url})" unless new_topic? 18 | 19 | "#{full_name} on Twitter - #{content}"[0..SiteSetting.max_topic_title_length] 20 | end 21 | 22 | def content 23 | tweet["text"] 24 | end 25 | 26 | def source_url 27 | "https://twitter.com/#{user["screen_name"]}/status/#{id}" 28 | end 29 | 30 | def image_url 31 | media["media_url_https"] if media.present? 32 | end 33 | 34 | private 35 | 36 | def full_name 37 | "#{user["name"]} (#{user["screen_name"]})" 38 | end 39 | 40 | def user 41 | tweet["user"] 42 | end 43 | 44 | def media 45 | tweet["entities"]["media"] 46 | end 47 | 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/autobot/twitter/provider.rb: -------------------------------------------------------------------------------- 1 | require_relative "post_creator.rb" 2 | -------------------------------------------------------------------------------- /lib/autobot/website/post_creator.rb: -------------------------------------------------------------------------------- 1 | module Autobot 2 | module Website 3 | class PostCreator < Autobot::PostCreator 4 | 5 | def initialize(campaign, article_rss_item) 6 | super(campaign) 7 | @article_rss_item = article_rss_item 8 | end 9 | 10 | def title 11 | @article_rss_item.title.force_encoding("UTF-8").scrub 12 | end 13 | 14 | def content 15 | content = @article_rss_item.content_encoded&.force_encoding("UTF-8")&.scrub || 16 | @article_rss_item.content&.force_encoding("UTF-8")&.scrub || 17 | @article_rss_item.description&.force_encoding("UTF-8")&.scrub 18 | # content += "\n\n #{source_url}" 19 | 20 | content 21 | end 22 | 23 | def display_featured_link? 24 | false 25 | end 26 | 27 | def source_url 28 | link = @article_rss_item.link 29 | if url?(link) 30 | return link 31 | else 32 | return @article_rss_item.id 33 | end 34 | end 35 | 36 | def image_url 37 | @article_rss_item.enclosure_url 38 | end 39 | 40 | private 41 | 42 | def url?(link) 43 | if link.blank? || link !~ /^https?\:\/\// 44 | return false 45 | else 46 | return true 47 | end 48 | end 49 | 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/autobot/website/provider.rb: -------------------------------------------------------------------------------- 1 | require_relative "post_creator.rb" 2 | -------------------------------------------------------------------------------- /lib/autobot/youtube/post_creator.rb: -------------------------------------------------------------------------------- 1 | module Autobot 2 | module Youtube 3 | class PostCreator < Autobot::PostCreator 4 | 5 | def initialize(campaign, yt_video) 6 | super(campaign) 7 | @video = yt_video 8 | end 9 | 10 | def title 11 | @video.snippet.title 12 | end 13 | 14 | def content 15 | %{https://www.youtube.com/watch?v=#{@video.id} 16 | 17 | #{@video.snippet.description}} 18 | end 19 | 20 | def source_url 21 | "https://www.youtube.com/watch?v=#{@video.id}" 22 | end 23 | 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/autobot/youtube/provider.rb: -------------------------------------------------------------------------------- 1 | require_dependency 'yt' 2 | 3 | module Autobot 4 | module Youtube 5 | module Provider 6 | 7 | def self.configure 8 | Yt.configure do |config| 9 | config.api_key = SiteSetting.google_api_server_key 10 | end 11 | end 12 | 13 | end 14 | end 15 | end 16 | 17 | require_relative "post_creator.rb" 18 | -------------------------------------------------------------------------------- /lib/twitter_api.rb: -------------------------------------------------------------------------------- 1 | TwitterApi.instance_eval do 2 | 3 | protected 4 | 5 | def user_timeline_uri_for(params) 6 | url = "#{BASE_URL}/1.1/statuses/user_timeline.json?screen_name=#{params[:screen_name]}&count=50&include_rts=false&exclude_replies=true" 7 | url = "#{url}&since_id=#{params[:since_id]}" if params[:since_id].present? 8 | 9 | URI.parse url 10 | end 11 | 12 | unless defined? BASE_URL 13 | BASE_URL = 'https://api.twitter.com'.freeze 14 | end 15 | 16 | end 17 | -------------------------------------------------------------------------------- /plugin.rb: -------------------------------------------------------------------------------- 1 | # name: autobot 2 | # about: Automatic content creator bot for Discourse 3 | # version: 0.0.1 4 | # authors: Vinoth Kannan (@vinothkannans) 5 | # url: https://github.com/vinkashq/discourse-autobot 6 | 7 | gem 'yt-support', '0.1.3', { require: false } 8 | gem 'yt', '0.32.1', { require: false } 9 | gem 'simple-rss', '1.3.1', { require: false } 10 | 11 | enabled_site_setting :autobot_enabled 12 | 13 | register_asset "stylesheets/admin/autobot.scss", :admin 14 | 15 | after_initialize do 16 | register_seedfu_fixtures(Rails.root.join("plugins", "discourse-autobot", "db", "fixtures").to_s) 17 | 18 | [ 19 | '../lib/twitter_api.rb', 20 | '../lib/autobot/store.rb', 21 | '../lib/autobot/campaign.rb', 22 | '../lib/autobot/post_creator.rb', 23 | '../lib/autobot/provider.rb', 24 | '../app/controllers/campaigns.rb', 25 | '../app/jobs/base.rb' 26 | ].each { |path| load File.expand_path(path, __FILE__) } 27 | 28 | module ::Autobot 29 | PLUGIN_NAME = "autobot".freeze 30 | 31 | class Engine < ::Rails::Engine 32 | engine_name PLUGIN_NAME 33 | isolate_namespace Autobot 34 | end 35 | 36 | USER_ID ||= -3 37 | 38 | def self.user 39 | @user ||= User.find_by(id: USER_ID) 40 | end 41 | end 42 | 43 | require_dependency 'staff_constraint' 44 | 45 | Autobot::Engine.routes.draw do 46 | get "/campaigns" => "campaigns#list", constraints: StaffConstraint.new 47 | post "/campaigns" => "campaigns#create", constraints: StaffConstraint.new 48 | put "/campaigns" => "campaigns#update", constraints: StaffConstraint.new 49 | delete "/campaigns" => "campaigns#delete", constraints: StaffConstraint.new 50 | end 51 | 52 | Discourse::Application.routes.prepend do 53 | mount Autobot::Engine, at: "/autobot" 54 | end 55 | 56 | add_admin_route "autobot.title", "autobot" 57 | 58 | Discourse::Application.routes.append do 59 | get "/admin/plugins/autobot" => "admin/plugins#index", constraints: StaffConstraint.new 60 | get "/admin/plugins/autobot/:page" => "admin/plugins#index", constraints: StaffConstraint.new 61 | end 62 | end 63 | --------------------------------------------------------------------------------