Merge remote-tracking branch 'old_h/master' into merge-more-old-code

This commit is contained in:
Brandon Thomas committed 2022-04-03 21:21:54 -04:00
commit 5040ec8524
19 files changed
+3314

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
# Redis connection
REDIS_USERNAME=
REDIS_PASSWORD=
REDIS_HOST=
REDIS_PORT=
REDIS_USES_TLS=
# Redis publishing
#REDIS_TOPIC=firehose
#REDIS_MAX_RETRY_COUNT=3
# Twitter secrets
TWITTER_API_KEY=
TWITTER_API_SECRET=
TWITTER_ACCESS_KEY=
TWITTER_ACCESS_SECRET=
+65
View File
@@ -0,0 +1,65 @@
# Adapted from
# https://github.com/actions/starter-workflows/ci/docker-publish.yml
# If using base images, don't forget to add READ access, eg. from a package settings page like:
# https://github.com/orgs/storytold/packages/container/docker-base-images-rust-ssl/settings/actions_access
name: Publish Docker Container to GHCR
on:
push:
branches:
# NB: Default-branch doesn't work, despite Github's documentation.
#- $default-branch
- master
env:
IMAGE_NAME: twitter-ingestion-client
jobs:
# Push image to GitHub Packages.
# See also https://docs.docker.com/docker-hub/builds/
push:
runs-on: ubuntu-latest
if: github.event_name == 'push'
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
#username: ${{ github.repository_owner }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build image
run: docker build . --file Dockerfile --tag $IMAGE_NAME --label "runnumber=${GITHUB_RUN_ID}"
- name: Push image to GitHub Container Registry
run: |
IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME
# Change all uppercase to lowercase
IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
# Strip git ref prefix from version
VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
# Strip "v" prefix from tag name
[[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')
# Use Docker `latest` tag convention
[ "$VERSION" == "$default-branch" ] && VERSION=latest
echo IMAGE_ID=$IMAGE_ID
echo VERSION=$VERSION
docker tag $IMAGE_NAME $IMAGE_ID:$VERSION
docker push $IMAGE_ID:$VERSION
SHORT_SHA=$(echo ${GITHUB_SHA} | cut -c1-12)
docker tag $IMAGE_NAME $IMAGE_ID:$SHORT_SHA
docker push $IMAGE_ID:$SHORT_SHA
+6
View File
@@ -0,0 +1,6 @@
*~
.env
.idea/
secrets.toml
secrets.yaml
target/
+3
View File
@@ -0,0 +1,3 @@
[submodule "protos/storyteller-protos"]
path = protos/storyteller-protos
url = git@github.com:storytold/storyteller-protos.git
Generated
+2054
View File
File diff suppressed because it is too large. Load diff
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "twitter-ingestion-client"
version = "0.0.0"
edition = "2018"
authors = [
"Brandon Thomas <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>"
]
[[bin]]
name = "twitter-ingestion-client"
path = "src/main.rs"
[[bin]]
name = "twitter-get-acccess-token"
path = "src/main_get_access_token.rs"
[dependencies]
anyhow = "1.0.38"
easyenv = "0.0.5"
futures = "0.3.14"
log = "0.4.14"
prost = "0.7.0"
rustls = "0.18"
serde = "1.0.123"
serde_derive = "1.0.123"
tokio = { version = "0.2", features = ["full"] }
toml = "0.5.8"
redis = { version = "0.17.0", features = ["tls", "async-std-tls-comp", "tokio-tls-comp", "connection-manager"] }
# Twitter
egg-mode = "0.15.0"
[build-dependencies]
anyhow = "1.0.38"
prost-build = "0.7.0"
+49
View File
@@ -0,0 +1,49 @@
# Custom base image
# Make sure to add this repository so it has read acces to the base image:
# https://github.com/orgs/storytold/packages/container/docker-base-images-rust-ssl/settings/actions_access
FROM ghcr.io/storytold/docker-base-images-rust-ssl:latest as build
WORKDIR /tmp
COPY Cargo.lock .
COPY Cargo.toml .
COPY src/ ./src
COPY protos/ ./protos
RUN $HOME/.cargo/bin/cargo fetch
#RUN LD_LIBRARY_PATH=/usr/lib:${LD_LIBRARY_PATH} $HOME/.cargo/bin/cargo build
RUN LD_LIBRARY_PATH=/usr/lib:${LD_LIBRARY_PATH} $HOME/.cargo/bin/cargo build --release --bin twitter-ingestion-client
# Final image
FROM ubuntu:xenial
WORKDIR /
# See: https://github.com/opencontainers/image-spec/blob/master/annotations.md
LABEL org.opencontainers.image.authors='bt@brand.io, echelon@gmail.com'
LABEL org.opencontainers.image.url='https://github.com/storytold/twitter-ingestion-client'
LABEL org.opencontainers.image.documentation='https://github.com/storytold/twitter-ingestion-client'
LABEL org.opencontainers.image.source='https://github.com/storytold/twitter-ingestion-client'
# NB: Comment this out for non-debug images
# TODO: Figure out how this is done elsewhere with just the static binaries
RUN apt-get update \
&& apt-get install -y \
curl \
wget
#COPY --from=build /tmp/target/debug/twitter-ingestion-client /
COPY --from=build /tmp/target/release/twitter-ingestion-client /
# SSL certs are required for crypto
COPY --from=build /etc/ssl /etc/ssl
# Required dynamically linked libraries
COPY --from=build /lib/x86_64-linux-gnu/libssl.* /lib/x86_64-linux-gnu/
COPY --from=build /lib/x86_64-linux-gnu/libcrypto.* /lib/x86_64-linux-gnu/
# Make sure all the links resolve
RUN ldd twitter-ingestion-client
EXPOSE 8080
CMD LD_LIBRARY_PATH=/usr/lib /twitter-ingestion-client
+12
View File
@@ -0,0 +1,12 @@
twitter-ingestion-client
========================
Ingest events from Twitter and send them to Redis PubSub
Protos
------
Protos are defined in a git submodule:
```
git submodule add git@github.com:storytold/storyteller-protos.git
```
+17
View File
@@ -0,0 +1,17 @@
extern crate anyhow; // TODO: No!
extern crate prost_build;
use std::env;
fn main() -> anyhow::Result<()> {
// NB: Control prost output dir with env var
// Typically these land in `target/`, but I want to source control these.
env::set_var("OUT_DIR", "src/proto_codegen");
prost_build::compile_protos(
&[
"protos/storyteller-protos/protos/pubsub-events/pubsub-events.proto"
],
&["protos/"])?;
Ok(())
}
+2
View File
@@ -0,0 +1,2 @@
pub mod redis_client;
pub mod twitter_client;
+65
View File
@@ -0,0 +1,65 @@
use anyhow::anyhow;
use crate::AnyhowResult;
use crate::secrets::RedisSecrets;
use log::{debug, info};
use redis::AsyncCommands;
use redis::aio::{Connection, ConnectionManager};
use redis;
use std::thread;
use std::time::Duration;
pub struct RedisClient {
secrets: RedisSecrets,
/// This manages our connection.
/// It's meant to be absent when not connected.
connection_manager: Option<ConnectionManager>,
/// Attempts to retry a Redis command.
max_retry_count: u32,
}
impl RedisClient {
pub fn new(secrets: &RedisSecrets, retry_count: u32) -> Self {
Self {
secrets: secrets.clone(),
connection_manager: None,
max_retry_count: retry_count,
}
}
pub async fn connect(&mut self) -> AnyhowResult<()> {
let client = redis::Client::open(self.secrets.connection_url())?;
let connection_manager = client.get_tokio_connection_manager().await?;
self.connection_manager = Some(connection_manager);
Ok(())
}
pub async fn publish_bytes(&mut self, channel: &str, message: &[u8]) -> AnyhowResult<u32> {
for i in 0 .. self.max_retry_count {
match self.try_publish_bytes(channel, message).await {
Ok(r) => return Ok(r),
Err(_) => {
debug!("Redis publish_bytes failed; retrying (attempt {})", i);
continue;
},
}
}
Err(anyhow!("Retry count elapsed."))
}
async fn try_publish_bytes(&mut self, channel: &str, message: &[u8]) -> AnyhowResult<u32> {
let connection_manager = match &mut self.connection_manager {
None => {
self.connect().await?;
return Err(anyhow!("Not connected"));
},
Some(cm) => cm,
};
let result = connection_manager.publish(channel, message).await?;
Ok(result)
}
}
+210
View File
@@ -0,0 +1,210 @@
use crate::AnyhowResult;
use anyhow::anyhow;
use egg_mode::stream::StreamMessage;
use egg_mode::tweet::Tweet;
use futures::TryStreamExt;
use log::{info, debug, warn};
use std::thread;
use std::time::Duration;
use crate::clients::redis_client::RedisClient;
use futures::executor::block_on;
use crate::protos::{mention_to_proto, binary_encode_proto};
use std::sync::{RwLock, Arc};
const VOCODES_USER_ID : u64 = 1297106371238932481;
/// The client logic
pub struct TwitterClient {
access_token: egg_mode::Token,
redis_client: RedisClientWrapper,
redis_publish_topic: String,
}
/// The parts of tweets we care about
#[derive(Clone, Debug)]
pub struct TweetDetails {
pub tweet_text: String,
pub has_mention: bool,
pub is_retweet: bool,
pub retweeted_text: Option<String>,
pub user_id : Option<u64>,
pub username : Option<String>,
pub display_name : Option<String>,
pub profile_image_url : Option<String>,
}
// NB: We're using interior mutability.
struct RedisClientWrapper {
redis_client: Arc::<RwLock<RedisClient>>,
}
impl RedisClientWrapper {
pub fn new(redis_client: RedisClient) -> Self {
Self {
redis_client: Arc::new(RwLock::new(redis_client))
}
}
pub async fn publish_bytes(&self, channel: &str, message: &[u8]) -> AnyhowResult<u32> {
let mut redis_client = match self.redis_client.write() {
Ok(rc) => rc,
Err(e) => return Err(anyhow!("Error: {:?}", )),
};
redis_client.publish_bytes(channel, message).await
}
}
impl TwitterClient {
pub fn new(
access_token: egg_mode::Token,
redis_client: RedisClient,
redis_publish_topic: String) -> Self
{
Self {
access_token,
redis_client: RedisClientWrapper::new(redis_client),
redis_publish_topic,
}
}
pub async fn main_loop(&mut self) {
loop {
info!("Connecting to twitter stream...");
if let Err(e) = self.run_stream().await {
warn!("Disconnected from stream; error = {:?}", e);
}
info!("Disconnected; reconnecting after timeout...");
thread::sleep(Duration::from_secs(30));
}
}
pub async fn run_stream(&mut self) -> AnyhowResult<()> {
let stream = egg_mode::stream::filter()
.follow(&[VOCODES_USER_ID]) // vocodes
.track(&["vocodes"])
.start(&self.access_token)
//.fold(&self)
.try_for_each(|m: StreamMessage| {
// NB: Odd implementation is due to future type juggling.
// I should fix this, but I'm too lazy and have more work to do.
let future = if let StreamMessage::Tweet(tweet) = m {
self.handle_tweet_mapping_errors(Some(tweet))
} else {
info!("Other (non-Tweet) message: {:?}", &m);
self.handle_tweet_mapping_errors(None)
};
match block_on(future) {
Ok(x) => futures::future::ok(x),
Err(e) => futures::future::err(e),
}
});
match stream.await {
Ok(_) => Ok(()),
Err(_) => Err(anyhow!("Stream error")), // NB: loss of error due to shenanigans
}
}
// NB: This is some nonsense to satisfy type requirements.
// I'm too lazy to figure out why try_for_each demands egg_mode's Error.
async fn handle_tweet_mapping_errors(&mut self, tweet: Option<Tweet>)
-> Result<(), egg_mode::error::Error>
{
// NB: This is horrible.
self.handle_tweet(tweet)
.await
.map_err(|e| {
warn!("Error during stream: {:?}", e);
egg_mode::error::Error::BadUrl
})
}
async fn handle_tweet(&mut self, tweet: Option<Tweet>) -> AnyhowResult<()> {
let tweet = match tweet {
None => return Ok(()),
Some(tweet) => tweet,
};
debug!("Tweet: {:?}", &tweet);
let mut user_id : Option<u64> = None;
let mut username : Option<String> = None;
let mut display_name : Option<String> = None;
let mut profile_image_url : Option<String> = None;
if let Some(user) = tweet.user {
if user.id == VOCODES_USER_ID {
warn!("Tweet is by @vocodes account; skipping");
return Ok(());
}
user_id = Some(user.id);
username = Some(user.screen_name.clone());
display_name = Some(user.name.clone());
profile_image_url = Some(user.profile_image_url.clone());
}
let mut has_mention = false;
for mention in tweet.entities.user_mentions.iter() {
if mention.id == VOCODES_USER_ID {
has_mention = true;
}
}
let mut is_retweet = false;
let mut retweeted_text : Option<String> = None;
if let Some(retweet) = tweet.retweeted_status {
if let Some(original_author) = retweet.user {
if original_author.id == VOCODES_USER_ID {
is_retweet = true;
retweeted_text = Some(retweet.text.clone());
}
}
}
let tweet_text = tweet.text.clone();
let tweet_details = TweetDetails {
has_mention,
is_retweet,
retweeted_text,
user_id,
username,
display_name,
profile_image_url,
tweet_text,
};
info!("Tweet Details: {:?}", tweet_details);
self.publish_tweet(tweet_details).await?;
Ok(())
}
async fn publish_tweet(&mut self, tweet_details: TweetDetails) -> AnyhowResult<()> {
let binary_proto = if !tweet_details.is_retweet && tweet_details.has_mention {
// NB: Retweets can have mentions of the target user.
let proto = mention_to_proto(tweet_details)?;
binary_encode_proto(proto)?
} else if tweet_details.is_retweet {
let proto = mention_to_proto(tweet_details)?;
binary_encode_proto(proto)?
} else {
return Ok(());
};
self.redis_client.publish_bytes(
&self.redis_publish_topic,
&binary_proto
).await?;
Ok(())
}
}
+99
View File
@@ -0,0 +1,99 @@
#![allow(dead_code)]
#![allow(unused_mut)]
#![allow(unused_imports)]
#![warn(unused_must_use)]
//#![allow(warnings)]
#[macro_use]
extern crate serde_derive;
mod clients;
mod protos;
mod secrets;
use crate::clients::redis_client::RedisClient;
use crate::clients::twitter_client::TwitterClient;
use crate::secrets::RedisSecrets;
use crate::secrets::Secrets;
use crate::secrets::TwitterSecrets;
use egg_mode::stream::StreamMessage;
use futures::TryStreamExt;
use log::{info, warn, debug};
pub type AnyhowResult<T> = anyhow::Result<T>;
const ENV_PUBLISH_TOPIC : &'static str = "PUBLISH_TOPIC";
const ENV_PUBLISH_TOPIC_DEFAULT : &'static str = "firehose";
const ENV_REDIS_MAX_RETRY_COUNT : &'static str = "REDIS_MAX_RETRY_COUNT";
const ENV_REDIS_MAX_RETRY_COUNT_DEFAULT : u32 = 3;
/// Monitor Twitter for interesting events against our account.
/// With this implementation, we can capture: Mentions, Retweets, Replies.
/// We cannot capture: Follows.
#[tokio::main]
async fn main() -> anyhow::Result<()>
{
easyenv::init_all_with_default_logging(None);
info!("Reading env config...");
let redis_publish_topic = easyenv::get_env_string_or_default(
ENV_PUBLISH_TOPIC, ENV_PUBLISH_TOPIC_DEFAULT);
let redis_max_retry_count = easyenv::get_env_num(
ENV_REDIS_MAX_RETRY_COUNT,
ENV_REDIS_MAX_RETRY_COUNT_DEFAULT)?;
//let secrets = Secrets::from_file("secrets.toml")?;
let redis_secrets = get_redis_secrets()?;
let twitter_secrets = get_twitter_secrets()?;
let mut redis_client = RedisClient::new(
&redis_secrets,
redis_max_retry_count,
);
info!("Connecting to Redis...");
redis_client.connect().await?;
info!("Verifying Twitter access token...");
let twitter_access_token = twitter_secrets.verify_access_token().await?;
info!("Streaming from Twitter...");
let mut twitter_client = TwitterClient::new(
twitter_access_token,
redis_client,
redis_publish_topic,
);
twitter_client.main_loop().await;
Ok(())
}
fn get_redis_secrets() -> AnyhowResult<RedisSecrets> {
Ok(RedisSecrets::new(
&easyenv::get_env_string_or_default(
"REDIS_USERNAME", ""),
&easyenv::get_env_string_or_default(
"REDIS_PASSWORD", ""),
&easyenv::get_env_string_or_default(
"REDIS_HOST", ""),
easyenv::get_env_num::<u32>(
"REDIS_PORT", 6379)?,
easyenv::get_env_bool_or_default(
"REDIS_USES_TLS", false),
))
}
fn get_twitter_secrets() -> AnyhowResult<TwitterSecrets> {
Ok(TwitterSecrets::new(
&easyenv::get_env_string_required("TWITTER_API_KEY")?,
&easyenv::get_env_string_required("TWITTER_API_SECRET")?,
&easyenv::get_env_string_optional("TWITTER_ACCESS_KEY"),
&easyenv::get_env_string_optional("TWITTER_ACCESS_SECRET"),
))
}
+44
View File
@@ -0,0 +1,44 @@
#![allow(dead_code)]
#![allow(unused_mut)]
#![allow(unused_imports)]
#![warn(unused_must_use)]
//#![allow(warnings)]
#[macro_use]
extern crate serde_derive;
mod secrets;
//use futures::TryStreamExt;
use egg_mode::stream::StreamMessage;
use crate::secrets::Secrets;
pub type AnyhowResult<T> = anyhow::Result<T>;
const ENV_PUBLISH_TOPIC : &'static str = "PUBLISH_TOPIC";
const ENV_PUBLISH_TOPIC_DEFAULT : &'static str = "firehose";
const ENV_REDIS_MAX_RETRY_COUNT : &'static str = "REDIS_MAX_RETRY_COUNT";
const ENV_REDIS_MAX_RETRY_COUNT_DEFAULT : u32 = 3;
/// Monitor Twitter for interesting events against our account.
/// With this implementation, we can capture: Mentions, Retweets, Replies.
/// We cannot capture: Follows.
#[tokio::main]
async fn main() -> anyhow::Result<()>
{
easyenv::init_env_logger(None);
let redis_publish_topic = easyenv::get_env_string_or_default(
ENV_PUBLISH_TOPIC, ENV_PUBLISH_TOPIC_DEFAULT);
let redis_max_retry_count = easyenv::get_env_num(
ENV_REDIS_MAX_RETRY_COUNT,
ENV_REDIS_MAX_RETRY_COUNT_DEFAULT)?;
let secrets = Secrets::from_file("secrets.toml")?;
secrets.twitter.request_access_token().await?;
Ok(())
}
+192
View File
@@ -0,0 +1,192 @@
/// Ingestion platforms are "dumb" and shouldn't have much logic in them.
/// (Granted, we might eventually grow to send them back messages.)
///
/// The "enricher" is the smarts. It does denylisting, rate limiting,
/// service calls, transforms, etc.
///
/// What if we need to scale? Several ideas: Sharding (redis db indices),
/// brokers with read-once semantics (our own intelligence - UUIDs with
/// transactional semantics in MySQL).
///
/// Events coming from multiple sources
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct PubsubEventPayloadV1 {
/// Source of the event.
#[prost(enumeration="pubsub_event_payload_v1::IngestionSourceType", optional, tag="1")]
pub ingestion_source_type: ::core::option::Option<i32>,
#[prost(enumeration="pubsub_event_payload_v1::IngestionPayloadType", optional, tag="2")]
pub ingestion_payload_type: ::core::option::Option<i32>,
/// A binary-encoded proto whose type is based on IngestionSourceType
#[prost(bytes="vec", optional, tag="3")]
pub ingestion_source_data: ::core::option::Option<::prost::alloc::vec::Vec<u8>>,
/// A binary-encoded proto whose type is based on IngestionPayloadType
#[prost(bytes="vec", optional, tag="4")]
pub ingestion_payload_data: ::core::option::Option<::prost::alloc::vec::Vec<u8>>,
/// An optional debug message.
#[prost(string, optional, tag="5")]
pub debug_message: ::core::option::Option<::prost::alloc::string::String>,
}
/// Nested message and enum types in `PubsubEventPayloadV1`.
pub mod pubsub_event_payload_v1 {
/// TODO: This should all be merged in the future, but I need to settle on proto2 vs proto3
/// And I also need to figure out package names, etc.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
#[repr(i32)]
pub enum IngestionSourceType {
IstDoNotUse = 0,
IstAdminPanel = 1,
IstVocodesWebsite = 2,
IstTwitch = 3,
IstTwitter = 4,
IstDiscord = 5,
IstYoutube = 6,
IstReddit = 7,
IstTiktok = 8,
}
/// These will differ from the "enriched" types.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
#[repr(i32)]
pub enum IngestionPayloadType {
IptDoNotUse = 0,
/// Twitch-specific events.
///
/// This can encode lots of things.
TwitchMessage = 100,
TwitchSubscribed = 101,
/// Twitter-specific events.
TwitterFollow = 200,
TwitterRetweet = 201,
TwitterMention = 202,
/// Discord-specific events.
DiscordMessage = 300,
DiscordJoined = 301,
/// YouTube-specific events.
YoutubeSubscribed = 400,
/// Reddit-specific events.
RedditMessage = 500,
}
}
// ============================== SOURCE PROTOS ============================== //
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IngestionTwitchMetadata {
/// Optional. Fields describing the user that sent the message.
#[prost(int64, optional, tag="1")]
pub user_id: ::core::option::Option<i64>,
#[prost(string, optional, tag="2")]
pub username: ::core::option::Option<::prost::alloc::string::String>,
#[prost(bool, optional, tag="3")]
pub user_is_mod: ::core::option::Option<bool>,
#[prost(bool, optional, tag="4")]
pub user_is_subscribed: ::core::option::Option<bool>,
/// Optional. Name of the channel, prefixed with hash. eg. `#vocodes`.
#[prost(string, optional, tag="5")]
pub channel: ::core::option::Option<::prost::alloc::string::String>,
}
/// Optional. Fields describing the user that sent the message.
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IngestionTwitterMetadata {
// TODO: This is a u64. We need to be careful with math to assure non-overflow.
//optional int64 user_id = 1;
#[prost(string, optional, tag="2")]
pub username: ::core::option::Option<::prost::alloc::string::String>,
#[prost(string, optional, tag="3")]
pub display_name: ::core::option::Option<::prost::alloc::string::String>,
#[prost(string, optional, tag="4")]
pub avatar_url: ::core::option::Option<::prost::alloc::string::String>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IngestionDiscordMetadata {
/// Optional. Fields describing the user that sent the message.
#[prost(string, optional, tag="1")]
pub username: ::core::option::Option<::prost::alloc::string::String>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IngestionYoutubeMetadata {
/// Optional. Fields describing the user that sent the message.
#[prost(string, optional, tag="1")]
pub username: ::core::option::Option<::prost::alloc::string::String>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IngestionRedditMetadata {
/// Optional. Fields describing the user that sent the message.
#[prost(string, optional, tag="1")]
pub username: ::core::option::Option<::prost::alloc::string::String>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IngestionTikTokMetadata {
/// Optional. Fields describing the user that sent the message.
#[prost(string, optional, tag="1")]
pub username: ::core::option::Option<::prost::alloc::string::String>,
}
// ============================== TWITCH PAYLOAD PROTOS ============================== //
/// These are raw messages from Twitch that we send to the PubSub enricher.
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IngestionTwitchMessage {
/// TODO: DEPRECATE THESE
#[prost(int64, optional, tag="1")]
pub user_id: ::core::option::Option<i64>,
#[prost(string, optional, tag="2")]
pub username: ::core::option::Option<::prost::alloc::string::String>,
#[prost(bool, optional, tag="3")]
pub is_mod: ::core::option::Option<bool>,
#[prost(bool, optional, tag="4")]
pub is_subscribed: ::core::option::Option<bool>,
/// TODO: DEPRECATE THESE
/// Name of the channel, prefixed with hash. eg. `#vocodes`.
#[prost(string, optional, tag="5")]
pub channel: ::core::option::Option<::prost::alloc::string::String>,
/// Actual message. Doesn't need to be trimmed.
#[prost(string, optional, tag="6")]
pub message_contents: ::core::option::Option<::prost::alloc::string::String>,
}
/// Intentionally blank for now
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IngestionTwitchSubscribed {
}
// ============================== TWITTER PAYLOAD PROTOS ============================== //
/// Intentionally blank for now
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IngestionTwitterFollow {
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IngestionTwitterRetweet {
/// Intentionally blank for now
#[prost(string, optional, tag="1")]
pub original_text: ::core::option::Option<::prost::alloc::string::String>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IngestionTwitterMention {
/// Intentionally blank for now
#[prost(string, optional, tag="1")]
pub text: ::core::option::Option<::prost::alloc::string::String>,
}
// ============================== DISCORD PAYLOAD PROTOS ============================== //
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IngestionDiscordMessage {
/// Raw message. Needs to be trimmed
#[prost(string, optional, tag="6")]
pub message_contents: ::core::option::Option<::prost::alloc::string::String>,
}
/// Intentionally blank for now
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IngestionDiscordJoined {
}
// ============================== YOUTUBE PAYLOAD PROTOS ============================== //
/// Intentionally blank for now
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IngestionYoutubeSubscribed {
}
// ============================== REDDIT PAYLOAD PROTOS ============================== //
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IngestionRedditMessage {
/// Raw message. Needs to be trimmed
#[prost(string, optional, tag="6")]
pub message_contents: ::core::option::Option<::prost::alloc::string::String>,
}
+102
View File
@@ -0,0 +1,102 @@
use anyhow::anyhow;
use crate::AnyhowResult;
use log::{info, warn, debug};
use crate::clients::twitter_client::TweetDetails;
// Include the protos
pub mod protos {
include!("proto_codegen/storyteller.pubsub.rs");
}
// Binary encode a proto.
pub fn binary_encode_proto(proto: impl prost::Message) -> AnyhowResult<Vec<u8>> {
let mut buffer : Vec<u8> = Vec::with_capacity(proto.encoded_len());
let encode_result = proto.encode(&mut buffer);
match encode_result {
Err(e) => {
Err(anyhow!("Inner proto encode result: {:?}", e))
}
Ok(_) => {
Ok(buffer)
}
}
}
pub fn mention_to_proto<'a>(tweet_details: TweetDetails) -> AnyhowResult<protos::PubsubEventPayloadV1>
{
let mut payload_proto = protos::PubsubEventPayloadV1::default();
payload_proto.ingestion_source_type =
Some(protos::pubsub_event_payload_v1::IngestionSourceType::IstTwitter as i32);
let binary_twitter_metadata = {
let mut twitter_metadata = protos::IngestionTwitterMetadata::default();
// TODO: Don't want to overflow. Represent as bytes in the meantime
// twitter_metadata.user_id = Some(tweet_details.user_id as i64);
twitter_metadata.username = tweet_details.username.clone();
twitter_metadata.display_name = tweet_details.display_name.clone();
twitter_metadata.avatar_url = tweet_details.profile_image_url.clone();
debug!("Twitter Metadata Proto: {:?}", twitter_metadata);
binary_encode_proto(twitter_metadata)
}?;
payload_proto.ingestion_source_data = Some(binary_twitter_metadata);
payload_proto.ingestion_payload_type =
Some(protos::pubsub_event_payload_v1::IngestionPayloadType::TwitterMention as i32);
let binary_twitter_mention = {
let mut twitter_mention = protos::IngestionTwitterMention::default();
twitter_mention.text = Some(tweet_details.tweet_text.clone());
debug!("Twitter Mention Proto: {:?}", twitter_mention);
binary_encode_proto(twitter_mention)
}?;
payload_proto.ingestion_payload_data = Some(binary_twitter_mention);
Ok(payload_proto)
}
pub fn retweet_to_proto<'a>(tweet_details: TweetDetails) -> AnyhowResult<protos::PubsubEventPayloadV1>
{
let mut payload_proto = protos::PubsubEventPayloadV1::default();
payload_proto.ingestion_source_type =
Some(protos::pubsub_event_payload_v1::IngestionSourceType::IstTwitter as i32);
let binary_twitter_metadata = {
let mut twitter_metadata = protos::IngestionTwitterMetadata::default();
// TODO: Don't want to overflow. Represent as bytes in the meantime
// twitter_metadata.user_id = Some(tweet_details.user_id as i64);
twitter_metadata.username = tweet_details.username.clone();
twitter_metadata.display_name = tweet_details.display_name.clone();
twitter_metadata.avatar_url = tweet_details.profile_image_url.clone();
debug!("Twitter Metadata Proto: {:?}", twitter_metadata);
binary_encode_proto(twitter_metadata)
}?;
payload_proto.ingestion_source_data = Some(binary_twitter_metadata);
payload_proto.ingestion_payload_type =
Some(protos::pubsub_event_payload_v1::IngestionPayloadType::TwitterMention as i32);
let binary_twitter_retweet = {
let mut twitter_retweet = protos::IngestionTwitterRetweet::default();
twitter_retweet.original_text = tweet_details.retweeted_text.clone();
debug!("Twitter Retweet Proto: {:?}", twitter_retweet);
binary_encode_proto(twitter_retweet)
}?;
payload_proto.ingestion_payload_data = Some(binary_twitter_retweet);
Ok(payload_proto)
}
+155
View File
@@ -0,0 +1,155 @@
use crate::AnyhowResult;
use std::io::Read;
use log::{info, warn, debug};
use anyhow::anyhow;
#[derive(Deserialize)]
pub struct Secrets {
pub redis: RedisSecrets,
pub twitter: TwitterSecrets,
}
#[derive(Deserialize, Clone)]
pub struct RedisSecrets {
pub username: String,
pub password: String,
pub host: String,
pub port: u32,
pub uses_tls: bool,
}
#[derive(Deserialize, Clone)]
pub struct TwitterSecrets {
pub api_key: String,
pub api_secret_key: String,
// These have to be obtained over an OAuth-like flow
pub access_key: Option<String>,
pub access_secret: Option<String>,
}
impl Secrets {
pub fn from_file(filename: &str) -> AnyhowResult<Self> {
let mut file = std::fs::File::open(filename)?;
let mut buffer = String::new();
file.read_to_string(&mut buffer)?;
let secrets = toml::from_str(&buffer)?;
Ok(secrets)
}
}
impl RedisSecrets {
pub fn new(username: &str, password: &str, host: &str, port: u32, uses_tls: bool) -> Self {
Self {
username: username.to_string(),
password: password.to_string(),
host: host.to_string(),
port,
uses_tls,
}
}
pub fn connection_url(&self) -> String {
let protocol = if self.uses_tls { "rediss" } else { "redis" };
let mut auth = "".to_string();
if !self.username.is_empty() {
if !self.password.is_empty() {
auth = format!("{}:{}@", self.username, self.password);
} else {
auth = format!("{}@", self.username);
}
} else if !self.password.is_empty() {
auth = format!("default:{}@", self.password);
}
format!("{}://{}{}:{}", protocol, auth, self.host, self.port)
}
}
impl TwitterSecrets {
pub fn new(api_key: &str,
api_secret_key: &str,
access_key: &Option<String>,
access_secret: &Option<String>) -> Self {
Self {
api_key: api_key.to_string(),
api_secret_key: api_secret_key.to_string(),
access_key: access_key.clone(),
access_secret: access_secret.clone(),
}
}
/// This assumes we have a valid pair of access (key, secret).
pub async fn verify_access_token(&self) -> AnyhowResult<egg_mode::Token> {
let consumer_keypair = self.make_consumer_keypair();
let access_keypair = self.make_access_keypair()?;
let access_token = egg_mode::Token::Access {
consumer: consumer_keypair,
access: access_keypair,
};
egg_mode::auth::verify_tokens(&access_token).await?;
Ok(access_token)
}
/// Call this to get an access token.
/// This is an interactive flow that requests input from stdin.
pub async fn request_access_token(&self) -> AnyhowResult<()> {
let consumer_keypair = self.make_consumer_keypair();
let request_token = egg_mode::auth::request_token(&consumer_keypair, "oob")
.await?;
warn!("Go to the following URL, sign in, and paste the PIN that comes back:");
warn!("{}", egg_mode::auth::authorize_url(&request_token));
let mut pin = String::new();
std::io::stdin().read_line(&mut pin)?;
let token_result = egg_mode::auth::access_token(consumer_keypair, &request_token, pin)
.await?;
let access_token = token_result.0;
let user_id = token_result.1;
let username = token_result.2;
warn!("Ordinarily these should not be logged, but there is no log aggregator installed.");
warn!("In any event, it is best to do this locally before creating k8s secrets.");
warn!("user_id = {:?}", user_id);
warn!("username = {:?}", username);
match access_token {
egg_mode::Token::Access {
access: ref access,
..
} => {
warn!("access_key = {:?}", access.key);
warn!("access_secret = {:?}", access.secret);
}
_ => unreachable!(),
}
Ok(())
}
pub fn make_consumer_keypair(&self) -> egg_mode::KeyPair {
egg_mode::KeyPair::new(self.api_key.to_string(), self.api_secret_key.to_string())
}
pub fn make_access_keypair(&self) -> AnyhowResult<egg_mode::KeyPair> {
let access_key = self.access_key
.as_ref()
.ok_or(anyhow!("No access key!"))?
.clone();
let access_secret = self.access_secret
.as_ref()
.ok_or(anyhow!("No access secret!"))?
.clone();
Ok(egg_mode::KeyPair::new(access_key, access_secret))
}
}
+184
View File
@@ -0,0 +1,184 @@
pub struct Config {
pub token: egg_mode::Token,
pub user_id: u64,
pub screen_name: String,
}
impl Config {
pub async fn load() -> Self {
let a1 = Config::load_inner().await;
if let Some(conf) = a1 {
return conf;
}
Config::load_inner().await.unwrap()
}
/// This needs to be a separate function so we can retry after creating the
/// twitter_settings file. Idealy we would recurse, but that requires boxing
/// the output which doesn't seem worthwhile
async fn load_inner() -> Option<Self> {
//IMPORTANT: make an app for yourself at apps.twitter.com and get your
//key/secret into these files; these examples won't work without them
let consumer_key = include_str!("consumer_key").trim();
let consumer_secret = include_str!("consumer_secret").trim();
let con_token = egg_mode::KeyPair::new(consumer_key, consumer_secret);
let mut config = String::new();
let user_id: u64;
let username: String;
let token: egg_mode::Token;
//look at all this unwrapping! who told you it was my birthday?
if let Ok(mut f) = std::fs::File::open("twitter_settings") {
f.read_to_string(&mut config).unwrap();
let mut iter = config.split('\n');
username = iter.next().unwrap().to_string();
user_id = u64::from_str_radix(&iter.next().unwrap(), 10).unwrap();
let access_token = egg_mode::KeyPair::new(
iter.next().unwrap().to_string(),
iter.next().unwrap().to_string(),
);
token = egg_mode::Token::Access {
consumer: con_token,
access: access_token,
};
if let Err(err) = egg_mode::auth::verify_tokens(&token).await {
println!("We've hit an error using your old tokens: {:?}", err);
println!("We'll have to reauthenticate before continuing.");
std::fs::remove_file("twitter_settings").unwrap();
} else {
println!("Welcome back, {}!\n", username);
}
} else {
let request_token = egg_mode::auth::request_token(&con_token, "oob").await.unwrap();
println!("Go to the following URL, sign in, and give me the PIN that comes back:");
println!("{}", egg_mode::auth::authorize_url(&request_token));
let mut pin = String::new();
std::io::stdin().read_line(&mut pin).unwrap();
println!("");
let tok_result = egg_mode::auth::access_token(con_token, &request_token, pin)
.await
.unwrap();
token = tok_result.0;
user_id = tok_result.1;
username = tok_result.2;
match token {
egg_mode::Token::Access {
access: ref access_token,
..
} => {
config.push_str(&username);
config.push('\n');
config.push_str(&format!("{}", user_id));
config.push('\n');
config.push_str(&access_token.key);
config.push('\n');
config.push_str(&access_token.secret);
}
_ => unreachable!(),
}
let mut f = std::fs::File::create("twitter_settings").unwrap();
f.write_all(config.as_bytes()).unwrap();
println!("Welcome, {}, let's get this show on the road!", username);
}
//TODO: Is there a better way to query whether a file exists?
if std::fs::metadata("twitter_settings").is_ok() {
Some(Config {
token: token,
user_id: user_id,
screen_name: username,
})
} else {
None
}
}
}
pub fn print_tweet(tweet: &egg_mode::tweet::Tweet) {
if let Some(ref user) = tweet.user {
println!(
"{} (@{}) posted at {}",
Paint::blue(&user.name),
Paint::bold(Paint::blue(&user.screen_name)),
tweet.created_at.with_timezone(&chrono::Local)
);
}
if let Some(ref screen_name) = tweet.in_reply_to_screen_name {
println!("➜ in reply to @{}", Paint::blue(screen_name));
}
if let Some(ref status) = tweet.retweeted_status {
println!("{}", Paint::red("Retweet ➜"));
print_tweet(status);
return;
} else {
println!("{}", Paint::green(&tweet.text));
}
if let Some(source) = &tweet.source {
println!("➜ via {} ({})", source.name, source.url);
}
if let Some(ref place) = tweet.place {
println!("➜ from: {}", place.full_name);
}
if let Some(ref status) = tweet.quoted_status {
println!("{}", Paint::red("➜ Quoting the following status:"));
print_tweet(status);
}
if !tweet.entities.hashtags.is_empty() {
println!("➜ Hashtags contained in the tweet:");
for tag in &tweet.entities.hashtags {
println!(" {}", tag.text);
}
}
if !tweet.entities.symbols.is_empty() {
println!("➜ Symbols contained in the tweet:");
for tag in &tweet.entities.symbols {
println!(" {}", tag.text);
}
}
if !tweet.entities.urls.is_empty() {
println!("➜ URLs contained in the tweet:");
for url in &tweet.entities.urls {
if let Some(expanded_url) = &url.expanded_url {
println!(" {}", expanded_url);
}
}
}
if !tweet.entities.user_mentions.is_empty() {
println!("➜ Users mentioned in the tweet:");
for user in &tweet.entities.user_mentions {
println!(" {}", Paint::bold(Paint::blue(&user.screen_name)));
}
}
if let Some(ref media) = tweet.extended_entities {
println!("➜ Media attached to the tweet:");
for info in &media.media {
println!(" A {:?}", info.media_type);
}
}
}