├── .gitignore ├── Cargo.toml ├── README.md ├── embed.json ├── js.embed.json ├── js.message.json ├── src └── lib.rs ├── test.html └── webhook.json /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | 4 | .cargo 5 | .env* 6 | .direnv/ 7 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kw-hn-discord" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | path = "src/lib.rs" 8 | crate-type = ["cdylib"] 9 | 10 | [dependencies] 11 | http_req_wasi = { version = "0.11.1", features = ["wasmedge_rustls_api","wasmedge_rustls"] } 12 | dotenv = "0.15.0" 13 | openai-flows = "0.8.0" 14 | schedule-flows = "0.2" 15 | serde = "1.0.156" 16 | serde_derive = "1.0.156" 17 | serde_json = "1.0.94" 18 | tokio_wasi = { version = "1.25.1", features = ["macros", "rt", "sync"] } 19 | web-scraper-flows = "0.1.0" 20 | anyhow = "1.0.71" 21 | discord-flows = "0.4.0" 22 | flowsnet-platform-sdk = "0.1.5" 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #

Summarize Hacker News Posts Using ChatGPT -- Discord version

2 |

3 | 4 | flows.network Discord 5 | 6 | 7 | flows.network Twitter 8 | 9 | 10 | Create a flow 11 | 12 |

13 | 14 | [Deploy this function on flows.network](#deploy-your-own-hacker-news-summary-bot-in-3-steps) and receive hourly alerts with summarized Hacker News posts tailored to your interests. 15 | 16 | ![image](https://github.com/flows-network/hacker-news-alert-chatgpt-discord/assets/45785633/77463fb6-ffa5-4d15-b032-0549b9146786) 17 | 18 | > You can also [send the ChatGPT summary as a Slack Message](https://github.com/flows-network/hacker-news-alert-chatgpt-slack). 19 | ## How it works 20 | 21 | This scheduled bot uses ChatGPT to summarize Hacker News posts. At the specified time, the bot searches for posts from the past hour, filters them based on your chosen keyword, and sends you a Discord message with a summary. 22 | 23 | ## Deploy your own Hacker News summary bot in 3 steps 24 | 25 | 1. Create a bot from a template 26 | 2. Add your OpenAI API key 27 | 3. Configure the bot on a specified Discord channel 28 | 29 | ### 0 Prerequisites 30 | 31 | * You will need to bring your own [OpenAI API key](https://openai.com/blog/openai-api). If you do not already have one, [sign up here](https://platform.openai.com/signup). 32 | 33 | * Sign up on [flows.network](https://flows.network/) using your GitHub account. It is free. 34 | 35 | ### 1 Create a bot from a template 36 | 37 | 38 | Go to [the Hacker News Alert ChatGPT Discord template](https://flows.network/flow/createByTemplate/hacker-news-alert-chatgpt-discord). 39 | 40 | Review the `KEYWORD` variable to specify your keyword of interest (supporting only one keyword for each bot). 41 | 42 | Click on the **Create and Build** button. 43 | 44 | ### 2 Configure the bot to access Discord 45 | 46 | Set up the Discord integration. Enter the `discord_channel_id` and `discord_token` to configure the bot. [Click here to learn how to get a Discord channel id and Discord bot token](https://flows.network/blog/discord-bot-guide). 47 | 48 | * `discord_channel_id`: Specify the channel where you want to deploy the bot. Copy and paste the final set of serial numbers from the discord channel's webpage URL. 49 | * `discord_token`: Get the Discord token from the Discord Developer Portal. 50 | 51 | image 52 | 53 | Click **Continue**. 54 | 55 | ### 2 Add your OpenAI API key 56 | 57 | Set up the OpenAI integration. Click on **Connect**, and enter your key. The default key name is `Default`. 58 | 59 | [image](https://user-images.githubusercontent.com/45785633/222973214-ecd052dc-72c2-4711-90ec-db1ec9d5f24e.png) 60 | 61 | Close the tab and go back to the flow.network page once you are done. Finally, click **Deploy**. 62 | 63 | ## Wait for the magic! 64 | 65 | You are now on the flow details page and the flow function takes a few seconds to build. Once the flow's status changes to `running`, your bot is ready to summarize Hacker News posts. 66 | 67 | 68 | ## FAQ 69 | 70 | ### How to customize the bot's scheduled messaging time? 71 | 72 | To customize the time when the bot sends Discord messages, you can modify the value in the cron expression ("37 * * * *"). This expression means the bot sends messages at the 37th minute of every hour. 73 | 74 | ``` 75 | schedule_cron_job(String::from("37 * * * *"), keyword, callback).await; 76 | ``` 77 | 78 | To adjust the timing, you can change the number 37 to your desired minute. For example, if you want the messages to be sent at the 15th minute of every hour, you can modify the expression to be ("15 * * * *"). 79 | 80 | By customizing the cron expression, you can set the desired timing for the bot to send Discord messages. 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /embed.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "[hello](https://example.com/)", 3 | "color": 2105893 4 | } -------------------------------------------------------------------------------- /js.embed.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Example Embed", 3 | "description": "This is an example description for the embed.", 4 | "color": 16711680, 5 | "author": { 6 | "name": "Author Name", 7 | "url": "https://example.com", 8 | "icon_url": "https://example.com/icon.png" 9 | }, 10 | "fields": [ 11 | { 12 | "name": "Field 1", 13 | "value": "Value 1", 14 | "inline": true 15 | }, 16 | { 17 | "name": "Field 2", 18 | "value": "Value 2", 19 | "inline": true 20 | } 21 | ], 22 | "footer": { 23 | "text": "Footer text", 24 | "icon_url": "https://example.com/icon.png" 25 | }, 26 | "image": { 27 | "url": "https://example.com/image.png" 28 | }, 29 | "thumbnail": { 30 | "url": "https://example.com/thumbnail.png" 31 | }, 32 | "timestamp": "2023-07-01T00:00:00Z", 33 | "url": "https://example.com" 34 | } -------------------------------------------------------------------------------- /js.message.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "859303273597747281", 3 | "channelId": "858828282926710785", 4 | "guildId": "859079709056262218", 5 | "author": { 6 | "id": "213567894687727617", 7 | "username": "User", 8 | "avatar": "a_0389c1a14e4a3109e7a332f8489888c7", 9 | "discriminator": "1234", 10 | "public_flags": 0 11 | }, 12 | "member": { 13 | "nick": "Nickname", 14 | "roles": [ 15 | "859090974109270107" 16 | ], 17 | "joined_at": "2021-01-13T19:55:29.564Z", 18 | "premium_since": null, 19 | "deaf": false, 20 | "mute": false 21 | }, 22 | "content": "Hello, World!", 23 | "timestamp": "2023-06-28T12:01:22.698Z", 24 | "edited_timestamp": null, 25 | "tts": false, 26 | "mention_everyone": false, 27 | "mentions": [], 28 | "mention_roles": [], 29 | "attachments": [], 30 | "embeds": [], 31 | "reactions": [ 32 | { 33 | "count": 1, 34 | "me": false, 35 | "emoji": { 36 | "id": null, 37 | "name": "👍" 38 | } 39 | } 40 | ], 41 | "pinned": false, 42 | "type": 0 43 | } -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use anyhow; 2 | use discord_flows::http::HttpBuilder; 3 | use dotenv::dotenv; 4 | use flowsnet_platform_sdk::{logger, write_error_log}; 5 | use http_req::request; 6 | use openai_flows::{ 7 | chat::{ChatModel, ChatOptions}, 8 | OpenAIFlows, 9 | }; 10 | use schedule_flows::schedule_cron_job; 11 | use serde::Deserialize; 12 | use serde_json; 13 | use std::env; 14 | use std::time::{SystemTime, UNIX_EPOCH}; 15 | use web_scraper_flows::get_page_text; 16 | 17 | #[no_mangle] 18 | #[tokio::main(flavor = "current_thread")] 19 | pub async fn run() { 20 | logger::init(); 21 | dotenv().ok(); 22 | let keyword = std::env::var("KEYWORD").unwrap_or("ChatGPT".to_string()); 23 | 24 | schedule_cron_job(String::from("37 * * * *"), keyword, callback).await; 25 | } 26 | 27 | async fn callback(keyword: Vec) { 28 | let query = String::from_utf8_lossy(&keyword); 29 | let now = SystemTime::now(); 30 | let dura = now.duration_since(UNIX_EPOCH).unwrap().as_secs() - 3600; 31 | let url = format!("https://hn.algolia.com/api/v1/search_by_date?tags=story&query={query}&numericFilters=created_at_i>{dura}"); 32 | 33 | let mut writer = Vec::new(); 34 | if let Ok(_) = request::get(url, &mut writer) { 35 | if let Ok(search) = serde_json::from_slice::(&writer) { 36 | for hit in search.hits { 37 | let _ = send_message_wrapper(hit).await; 38 | } 39 | } 40 | } 41 | } 42 | 43 | #[derive(Deserialize)] 44 | pub struct Search { 45 | pub hits: Vec, 46 | } 47 | 48 | #[derive(Deserialize)] 49 | #[serde(rename_all = "snake_case")] 50 | pub struct Hit { 51 | pub title: String, 52 | pub url: Option, 53 | #[serde(rename = "objectID")] 54 | pub object_id: String, 55 | pub author: String, 56 | pub created_at_i: i64, 57 | } 58 | 59 | async fn get_summary_truncated(inp: &str) -> anyhow::Result { 60 | let mut openai = OpenAIFlows::new(); 61 | openai.set_retry_times(3); 62 | 63 | let news_body = inp 64 | .split_whitespace() 65 | .take(10000) 66 | .collect::>() 67 | .join(" "); 68 | 69 | let chat_id = format!("summary#99"); 70 | let system = &format!("You're an AI assistant."); 71 | 72 | let co = ChatOptions { 73 | model: ChatModel::GPT35Turbo16K, 74 | restart: true, 75 | system_prompt: Some(system), 76 | max_tokens: Some(128), 77 | temperature: Some(0.8), 78 | ..Default::default() 79 | }; 80 | 81 | let question = format!("summarize this within 100 words: {news_body}"); 82 | 83 | match openai.chat_completion(&chat_id, &question, &co).await { 84 | Ok(r) => Ok(r.choice), 85 | Err(_e) => Err(anyhow::Error::msg(_e.to_string())), 86 | } 87 | } 88 | 89 | pub async fn send_message_wrapper(hit: Hit) -> anyhow::Result<()> { 90 | let token = env::var("discord_token").expect("failed to get discord token"); 91 | let channel_id = env::var("discord_channel_id").unwrap_or("1112553551789572167".to_string()); 92 | let discord = HttpBuilder::new(token).build(); 93 | 94 | let title = &hit.title; 95 | let author = &hit.author; 96 | let post = format!("https://news.ycombinator.com/item?id={}", &hit.object_id); 97 | let mut source = "".to_string(); 98 | let mut inner_url = "".to_string(); 99 | 100 | let _text = match &hit.url { 101 | Some(u) => { 102 | source = format!("(<{u}|source>)"); 103 | inner_url = u.clone(); 104 | get_page_text(u) 105 | .await 106 | .unwrap_or("failed to scrape text with hit url".to_string()) 107 | } 108 | None => get_page_text(&post) 109 | .await 110 | .unwrap_or("failed to scrape text with post url".to_string()), 111 | }; 112 | 113 | let summary = if _text.split_whitespace().count() > 100 { 114 | get_summary_truncated(&_text).await? 115 | } else { 116 | format!("Bot found minimal info on webpage to warrant a summary, please see the text on the page the Bot grabbed below if there are any, or use the link above to see the news at its source:\n{_text}") 117 | }; 118 | 119 | let content_str = format!( 120 | "[**{title}**]({post}) [*click link for the original URL*]({inner_url}) by {author}\n{summary}" 121 | ); 122 | let content_value = serde_json::json!( 123 | { 124 | "embeds": [{ 125 | "description": content_str, 126 | }] 127 | }); 128 | 129 | match channel_id.parse::() { 130 | Ok(channel_id) => match discord.send_message(channel_id, &content_value).await { 131 | Ok(_) => (), 132 | Err(_e) => { 133 | write_error_log!("error sending message"); 134 | } 135 | }, 136 | Err(_e) => { 137 | write_error_log!("error parsing channel_id"); 138 | } 139 | } 140 | 141 | Ok(()) 142 | } 143 | -------------------------------------------------------------------------------- /test.html: -------------------------------------------------------------------------------- 1 | ``` 2 | 3 |
4 |

Example Domain

5 |

This domain is for use in illustrative examples in documents. You may use this 6 | domain in literature without prior coordination or asking for permission.

7 |

More information...

8 |
9 | 10 | ``` -------------------------------------------------------------------------------- /webhook.json: -------------------------------------------------------------------------------- 1 | { 2 | "application_id": null, 3 | "avatar": null, 4 | "channel_id": "1112553551789572167", 5 | "guild_id": "1091003237827608647", 6 | "id": "1123731541084872855", 7 | "name": "Spidey Bot", 8 | "type": 1, 9 | "user": { 10 | "id": "636636645084495872", 11 | "username": "jaykchen", 12 | "avatar": null, 13 | "discriminator": "0", 14 | "public_flags": 0, 15 | "flags": 0, 16 | "banner": null, 17 | "accent_color": null, 18 | "global_name": "jaykchen@gmail.com", 19 | "avatar_decoration": null, 20 | "display_name": "jaykchen@gmail.com", 21 | "banner_color": null 22 | }, 23 | "token": "LeZ9UKQslNJIOaSOxCuRSyDeerucEkv6_46mPbMwhAHdpIYt3ARud5POnLBdtXoUoLef", 24 | "url": "https://discord.com/api/webhooks/1123731541084872855/LeZ9UKQslNJIOaSOxCuRSyDeerucEkv6_46mPbMwhAHdpIYt3ARud5POnLBdtXoUoLef" 25 | } --------------------------------------------------------------------------------