use std::{path::Path, process::ExitCode, sync::Arc, time::Duration};
use shep_client::{ConnectError, LinkState, ReconnectingClient};
use crate::{
bot, config,
error::Error,
session,
shepherd::Live,
stop::{Interrupted, Stop, wait},
wiring::Wiring,
};
const DEFAULT_NAME: &str = "discord";
fn unadopted_message(section: &str) -> String {
format!(
"shep-discord: $SHEP_DOG_NAME is not set, so nothing adopted this process. It will \
connect without naming itself, which the shepherd does not count as a handshake, \
and read [{section}] in dogs.toml once there is a socket to read it from."
)
}
fn config_failed_message(section: &str, err: &Error) -> String {
format!("shep-discord: [{section}] in dogs.toml: {err}")
}
const RESAY: u32 = 120;
struct Complaint {
line: String,
muted: u32,
}
fn worth_saying(message: Option<&str>, last: &mut Option<Complaint>) -> bool {
let Some(message) = message else {
*last = None;
return false;
};
if let Some(seen) = last.as_mut()
&& seen.line == message
{
seen.muted += 1;
if seen.muted < RESAY {
return false;
}
}
*last = Some(Complaint {
line: message.to_owned(),
muted: 0,
});
true
}
fn connect_failure_line(err: &Error, last: &mut Option<Complaint>) -> Option<String> {
let line = format!("shep-discord: {err}");
worth_saying(Some(&line), last).then_some(line)
}
fn gateway_ended_message(outcome: &Result<(), tokio::task::JoinError>) -> String {
match outcome {
Ok(()) => "shep-discord: the gateway task ended on its own; starting a fresh one on the \
next cycle."
.to_owned(),
Err(err) if err.is_panic() => format!(
"shep-discord: the gateway task panicked: {err}. Starting a fresh one on the next \
cycle rather than leaving Discord silently unanswered for the rest of this process."
),
Err(err) => format!(
"shep-discord: the gateway task was cancelled: {err}. Starting a fresh one on the \
next cycle."
),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Identity {
handshake: Option<String>,
section: String,
}
impl Identity {
pub fn from_env(env: impl Fn(&str) -> Option<String>) -> Self {
let handshake = env("SHEP_DOG_NAME").filter(|name| !name.is_empty());
let section = handshake.clone().unwrap_or_else(|| DEFAULT_NAME.to_owned());
Self { handshake, section }
}
}
async fn connect(socket: &Path, identity: &Identity) -> Result<Live, Error> {
let client = match &identity.handshake {
Some(name) => ReconnectingClient::connect_as_dog(socket, name).await?,
None => ReconnectingClient::connect(socket).await?,
};
Ok(Live::new(client))
}
const PROTOCOL_MISMATCH: u8 = 6;
fn refused_message(daemon_version: Option<&str>, message: &str) -> String {
format!(
"shep-discord: the shepherd refused this dog's handshake, and no amount of \
reconnecting fixes a protocol-version skew. The shepherd reports {}, and said: \
{message}. Exiting so it can restart this dog from disk.",
daemon_version.unwrap_or("no version")
)
}
fn refused(daemon_version: Option<&str>, message: &str) -> ExitCode {
eprintln!("{}", refused_message(daemon_version, message));
ExitCode::from(PROTOCOL_MISMATCH)
}
const INVALID_CONFIG: u8 = 4;
fn misconfigured_message(section: &str, err: &Error) -> String {
format!(
"shep-discord: [{section}] in dogs.toml: {err}. Nothing about that clears on \
its own, so every retry would be the same failure. Exiting rather than \
staying up: a dog that kept answering the shepherd's handshake while never \
answering Discord would be reported online on every column a listing has, \
and the only evidence would be this line. Fix the value and run \
shep restart {section}."
)
}
fn on_config_failure(section: &str, err: &Error, last: &mut Option<Complaint>) -> Option<ExitCode> {
if matches!(err, Error::Config(_)) {
eprintln!("{}", misconfigured_message(section, err));
return Some(ExitCode::from(INVALID_CONFIG));
}
let message = config_failed_message(section, err);
if worth_saying(Some(&message), last) {
eprintln!("{message}");
}
None
}
pub const RECHECK_INTERVAL: Duration = Duration::from_secs(30);
pub async fn run(socket: &Path, identity: &Identity) -> ExitCode {
let mut stop = Stop::on_ctrl_c();
let mut session: Option<Arc<Live>> = None;
let mut unresolved_warned = false;
let monitor = Arc::new(bot::monitor::Monitor::new());
let mut last_config_complaint: Option<Complaint> = None;
let mut last_connect_complaint: Option<Complaint> = None;
let mut monitor_started = false;
let mut gateway: Option<tokio::task::JoinHandle<()>> = None;
let mut wiring: Option<Wiring> = None;
if identity.handshake.is_none() {
eprintln!("{}", unadopted_message(&identity.section));
}
loop {
if let Some(handle) = gateway.take_if(|handle| handle.is_finished()) {
eprintln!("{}", gateway_ended_message(&handle.await));
}
if session.is_none() {
match connect(socket, identity).await {
Ok(live) => {
worth_saying(None, &mut last_connect_complaint);
session = Some(Arc::new(live));
}
Err(Error::Connect(ConnectError::ProtocolMismatch {
daemon_version,
message,
..
})) => return refused(daemon_version.as_deref(), &message),
Err(err) => {
if let Some(line) = connect_failure_line(&err, &mut last_connect_complaint) {
eprintln!("{line}");
}
}
}
}
if let Some(live) = &session {
if let LinkState::Refused {
daemon_version,
message,
} = live.link()
{
return refused(daemon_version.as_deref(), &message);
}
match live
.section(&identity.section)
.await
.and_then(|toml| config::Config::from_toml(&toml))
{
Ok(config) => {
worth_saying(None, &mut last_config_complaint);
let config = Arc::new(config);
let wiring = wiring.get_or_insert_with(|| Wiring::new(&config, &monitor));
if gateway.is_none() {
let state = bot::command::State {
live: Arc::clone(live),
config: Arc::clone(&config),
monitor: Arc::clone(&monitor),
board: wiring.board.clone(),
};
gateway = Some(tokio::spawn(bot::run(
Arc::clone(&config),
state,
stop.clone(),
)));
}
if !monitor_started {
monitor_started = true;
bot::monitor::refresh::start_from_config(
&monitor,
&config,
wiring.board.as_ref(),
live,
);
}
if config.log_channel.is_some()
|| config.err_channel.is_some()
|| config.monitor_channel.is_some()
{
let handshake = identity.handshake.as_deref();
if let Err(err) = session::stream_once(
live,
handshake,
&config,
&wiring.sink,
wiring.wired.as_ref(),
&mut unresolved_warned,
&mut stop,
)
.await
{
eprintln!("shep-discord: {err}");
}
}
}
Err(err) => {
if let Some(code) =
on_config_failure(&identity.section, &err, &mut last_config_complaint)
{
return code;
}
}
}
}
if wait(RECHECK_INTERVAL, &mut stop).await == Interrupted::Yes {
return ExitCode::SUCCESS;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::assert_no_dashes;
#[test]
fn nothing_printed_for_a_person_carries_a_dash() {
assert_no_dashes(&unadopted_message(DEFAULT_NAME));
assert_no_dashes(&refused_message(Some("9"), "protocol too old"));
assert_no_dashes(&gateway_ended_message(&Ok(())));
assert_no_dashes(&config_failed_message(
"chatter",
&Error::Unconfigured("token is required".to_owned()),
));
assert_no_dashes(&misconfigured_message(
"chatter",
&Error::Config("buffer_lines must be at least 1".to_owned()),
));
}
#[test]
fn nothing_printed_for_a_person_carries_a_double_space() {
for message in [
unadopted_message(DEFAULT_NAME),
refused_message(Some("9"), "protocol too old"),
gateway_ended_message(&Ok(())),
config_failed_message(
"chatter",
&Error::Unconfigured("token is required".to_owned()),
),
misconfigured_message(
"chatter",
&Error::Config("buffer_lines must be at least 1".to_owned()),
),
] {
assert!(!message.contains(" "), "two spaces in a row: {message:?}");
}
}
#[test]
fn a_config_complaint_names_the_section_it_read() {
let message =
config_failed_message("chatter", &Error::Config("token is required".to_owned()));
assert_eq!(
message,
"shep-discord: [chatter] in dogs.toml: token is required"
);
}
#[test]
fn a_config_complaint_repeats_only_when_it_changes() {
let mut last = None;
assert!(worth_saying(Some("token is required"), &mut last));
assert!(
!worth_saying(Some("token is required"), &mut last),
"the same unfixed problem says nothing on the next cycle"
);
assert!(
worth_saying(Some("guild_id is required"), &mut last),
"a different problem is a different sentence and is printed"
);
assert!(
!worth_saying(None, &mut last),
"a good read prints nothing of its own"
);
assert!(
worth_saying(Some("guild_id is required"), &mut last),
"and a problem coming back after a good read is announced again"
);
}
#[test]
fn an_unconfigured_dog_says_so_again_rather_than_only_once() {
let mut last = None;
let complaint = "shep-discord: [discord] in dogs.toml: token is required";
assert!(worth_saying(Some(complaint), &mut last), "the first cycle");
for cycle in 1..RESAY {
assert!(
!worth_saying(Some(complaint), &mut last),
"cycle {cycle} of {RESAY} is inside the muted window"
);
}
assert!(
worth_saying(Some(complaint), &mut last),
"and the cycle after the window says it again"
);
assert!(
!worth_saying(Some(complaint), &mut last),
"then goes quiet for another window rather than repeating forever"
);
}
#[test]
fn a_wrong_section_stops_this_dog_and_an_empty_one_leaves_it_running() {
let mut last = None;
for wrong in [
"this is not TOML at all",
"token = \"t\"\nguild_id = 1\nbuffer_line = 10\n",
"token = \"t\"\nguild_id = 0\n",
"token = \"t\"\nguild_id = 1\nlog_channel = 0\n",
"token = \"t\"\nguild_id = 1\nflush = \"whenever\"\n",
"token = \"t\"\nguild_id = 1\nbuffer_lines = 0\n",
] {
let err = config::Config::from_toml(wrong).expect_err(wrong);
assert!(
on_config_failure("discord", &err, &mut last).is_some(),
"a value this dog refuses stops it: {wrong}"
);
}
for both in ["guild_id = 0\n", "token = \"t\"\nbuffer_lines = 0\n"] {
let err = config::Config::from_toml(both).expect_err(both);
assert!(
on_config_failure("discord", &err, &mut last).is_some(),
"a refused value stops it even when a key is also missing: {both}"
);
}
for unfilled in ["", "guild_id = 1\n", "token = \"t\"\n"] {
let err = config::Config::from_toml(unfilled).expect_err(unfilled);
assert!(
on_config_failure("discord", &err, &mut last).is_none(),
"a section nobody filled in yet does not: {unfilled:?}"
);
}
}
#[test]
fn a_shepherd_that_could_not_answer_does_not_stop_this_dog() {
let mut last = None;
for err in [
Error::Request(shep_client::RequestError::Closed),
Error::Connect(ConnectError::HandshakeClosed),
Error::Unexpected {
asked: "a DogConfig".to_owned(),
got: "a Pong".to_owned(),
},
] {
assert!(
on_config_failure("discord", &err, &mut last).is_none(),
"{err}"
);
}
}
#[test]
fn a_shepherd_that_stays_down_is_complained_about_once_not_every_cycle() {
let mut last = None;
let down = Error::Connect(ConnectError::HandshakeClosed);
let line = connect_failure_line(&down, &mut last).expect("the first failure is said");
assert_eq!(line, format!("shep-discord: {down}"));
assert_no_dashes(&line);
for cycle in 1..RESAY {
assert!(
connect_failure_line(&down, &mut last).is_none(),
"cycle {cycle} of {RESAY} repeats the same failure"
);
}
assert!(
connect_failure_line(&down, &mut last).is_some(),
"and the cycle after the window says it again"
);
let other = Error::Request(shep_client::RequestError::Closed);
assert!(
connect_failure_line(&other, &mut last).is_some(),
"a different failure is a different sentence and is said"
);
worth_saying(None, &mut last);
assert!(
connect_failure_line(&other, &mut last).is_some(),
"a failure after a good connect is announced again"
);
}
fn only(key: &str, value: &str) -> impl Fn(&str) -> Option<String> {
let (key, value) = (key.to_owned(), value.to_owned());
move |asked| (asked == key).then(|| value.clone())
}
#[test]
fn the_dog_takes_both_names_from_shep_dog_name() {
let identity = Identity::from_env(only("SHEP_DOG_NAME", "chatter"));
assert_eq!(identity.handshake.as_deref(), Some("chatter"));
assert_eq!(identity.section, "chatter");
}
#[test]
fn without_shep_dog_name_the_dog_names_itself_to_nobody() {
let identity = Identity::from_env(|_| None);
assert_eq!(identity.handshake, None);
assert_eq!(identity.section, DEFAULT_NAME);
}
#[tokio::test]
async fn a_panicked_gateway_is_told_apart_from_an_ordinary_return() {
let panicked = tokio::spawn(async { panic!("a gateway task panicking") }).await;
assert!(panicked.is_err());
let message = gateway_ended_message(&panicked);
assert!(message.contains("panicked"), "{message}");
let ended: Result<(), tokio::task::JoinError> = tokio::spawn(async {}).await;
let message = gateway_ended_message(&ended);
assert!(!message.contains("panicked"), "{message}");
}
}