Working bridge for two test channels
parent
7cbbf919d5
commit
bb245687de
|
@ -1 +1,3 @@
|
|||
/target
|
||||
/credentials.txt
|
||||
/matrix_state
|
||||
|
|
|
@ -112,6 +112,17 @@ dependencies = [
|
|||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atty"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.0.1"
|
||||
|
@ -285,17 +296,6 @@ version = "1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "command_attr"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a6c3666f685cb1efc0628b8c984dbad9c372d080450736c7732089c385ed81d"
|
||||
dependencies = [
|
||||
"proc-macro2 1.0.28",
|
||||
"quote 1.0.9",
|
||||
"syn 1.0.74",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.6.0"
|
||||
|
@ -518,6 +518,19 @@ dependencies = [
|
|||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b2cf0344971ee6c64c31be0d530793fba457d322dfec2810c453d0ef228f9c3"
|
||||
dependencies = [
|
||||
"atty",
|
||||
"humantime",
|
||||
"log",
|
||||
"regex",
|
||||
"termcolor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.0.20"
|
||||
|
@ -869,6 +882,12 @@ version = "1.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6456b8a6c8f33fee7d958fcd1b60d55b11940a79e63ae87013e6d22e26034440"
|
||||
|
||||
[[package]]
|
||||
name = "humantime"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "0.14.11"
|
||||
|
@ -1467,12 +1486,13 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"bincode",
|
||||
"discord_message_format",
|
||||
"env_logger",
|
||||
"log",
|
||||
"matrix-sdk",
|
||||
"serde",
|
||||
"serenity",
|
||||
"sled",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
|
@ -2224,19 +2244,16 @@ dependencies = [
|
|||
"bitflags",
|
||||
"bytes 1.0.1",
|
||||
"chrono",
|
||||
"command_attr",
|
||||
"flate2",
|
||||
"futures",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"static_assertions",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"typemap_rev",
|
||||
"url",
|
||||
"uwl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -2348,12 +2365,6 @@ dependencies = [
|
|||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "stdweb"
|
||||
version = "0.4.20"
|
||||
|
@ -2457,6 +2468,15 @@ dependencies = [
|
|||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.26"
|
||||
|
@ -2801,12 +2821,6 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uwl"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4bf03e0ca70d626ecc4ba6b0763b934b6f2976e8c744088bb3c1d646fbb1ad0"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
|
@ -2975,6 +2989,15 @@ version = "0.4.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
|
|
|
@ -8,11 +8,16 @@ bincode = "1.3.3"
|
|||
discord_message_format = { git = "https://git.lavender.software/charlotte/discord-message-format.git" }
|
||||
matrix-sdk = { git = "https://github.com/matrix-org/matrix-rust-sdk.git" }
|
||||
serde = { version = "1.0.129", features = ["derive"] }
|
||||
serenity = "0.10.9"
|
||||
sled = "0.34.6"
|
||||
tokio = { version = "1.10.1", features = ["full"] }
|
||||
tracing = "0.1.26"
|
||||
url = "2.2.2"
|
||||
log = "0.4.14"
|
||||
env_logger = "0.9.0"
|
||||
|
||||
[dependencies.serenity]
|
||||
version = "0.10.9"
|
||||
default-features = false
|
||||
features = ["builder", "cache", "client", "gateway", "model", "http", "utils", "rustls_backend"]
|
||||
|
||||
[patch.crates-io]
|
||||
olm-sys = { path = "./target/patch/olm-sys-1.1.2" }
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
use std::{cell::RefCell, str::FromStr, sync::Mutex};
|
||||
|
||||
use matrix_sdk::ruma::{
|
||||
events::{room::message::MessageEventContent, AnyMessageEventContent},
|
||||
RoomId,
|
||||
};
|
||||
use serenity::model::id::ChannelId;
|
||||
|
||||
use crate::{
|
||||
discord, matrix,
|
||||
message_ast::{self, format_discord, MessageContent},
|
||||
messages::MessageReference,
|
||||
};
|
||||
|
||||
pub struct Bridgers {
|
||||
pub discord: Mutex<RefCell<Option<discord::Context>>>,
|
||||
pub matrix: Mutex<RefCell<Option<matrix::Client>>>,
|
||||
}
|
||||
|
||||
impl Bridgers {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
discord: Mutex::new(RefCell::new(None)),
|
||||
matrix: Mutex::new(RefCell::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_message(
|
||||
&self,
|
||||
source: MessageReference,
|
||||
content: MessageContent,
|
||||
) -> Vec<MessageReference> {
|
||||
let mut created_messages = Vec::new();
|
||||
|
||||
if let Some(discord) = self.discord.lock().unwrap().borrow().as_ref() {
|
||||
// We probably want a function that returns an Option<ChannelId> taking the source
|
||||
match &source {
|
||||
MessageReference::Matrix(_room_id, _event_id) => {
|
||||
let channel_id = ChannelId(885690775193661463); // TODO: Look up linked channel
|
||||
let discord_message = channel_id
|
||||
.send_message(&discord.http, |m| m.content(format_discord(&content)))
|
||||
.await
|
||||
.expect("Failed to send discord message");
|
||||
|
||||
created_messages.push(MessageReference::from(&discord_message));
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(matrix) = self.matrix.lock().unwrap().borrow().as_ref() {
|
||||
match &source {
|
||||
MessageReference::Discord(_, _) => {
|
||||
let room_id = RoomId::from_str("!SjQatGOikRshcWNcln:matrix.org").unwrap(); // TODO: Get a room id
|
||||
if let Some(room) = matrix.get_joined_room(&room_id) {
|
||||
let event = room
|
||||
.send(
|
||||
AnyMessageEventContent::RoomMessage(
|
||||
MessageEventContent::text_plain(message_ast::format_discord(
|
||||
&content, // TODO: Format as HTML
|
||||
)),
|
||||
),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
created_messages.push(MessageReference::from((&room_id, &event.event_id)));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
created_messages
|
||||
}
|
||||
}
|
|
@ -1,8 +1,11 @@
|
|||
use log::info;
|
||||
use serenity::{async_trait, model::prelude::*, prelude::*};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::info;
|
||||
|
||||
use crate::{message_ast, MessageReference, SentMessage};
|
||||
use crate::{
|
||||
message_ast,
|
||||
messages::{MessageAuthor, MessageReference, SentMessage},
|
||||
};
|
||||
|
||||
pub use serenity::client::Context;
|
||||
|
||||
|
@ -22,13 +25,17 @@ struct DiscordHandler {
|
|||
#[async_trait]
|
||||
impl EventHandler for DiscordHandler {
|
||||
async fn ready(&self, ctx: Context, _ready: Ready) {
|
||||
info!("Discord side: Ready");
|
||||
|
||||
let _ = self.ctx_tx.send(ctx);
|
||||
info!("Discord ready!");
|
||||
|
||||
// TODO: Scan for channels to link
|
||||
}
|
||||
|
||||
async fn message(&self, _ctx: Context, message: Message) {
|
||||
async fn message(&self, ctx: Context, message: Message) {
|
||||
if message.author.id == ctx.cache.current_user_id().await {
|
||||
return;
|
||||
}
|
||||
|
||||
let message_ref = MessageReference::from(&message);
|
||||
// TODO: Store this message ref & associations in the DB
|
||||
|
||||
|
@ -38,6 +45,12 @@ impl EventHandler for DiscordHandler {
|
|||
let _ = self.message_tx.send(SentMessage {
|
||||
source: message_ref,
|
||||
content,
|
||||
author: MessageAuthor {
|
||||
display_name: message
|
||||
.author_nick(&ctx.http)
|
||||
.await
|
||||
.unwrap_or(message.author.name),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -49,8 +62,12 @@ pub async fn create_discord_client(
|
|||
) -> Client {
|
||||
let handler = DiscordHandler { ctx_tx, message_tx };
|
||||
|
||||
Client::builder(token)
|
||||
info!("Discord logging in…");
|
||||
let client = Client::builder(token)
|
||||
.event_handler(handler)
|
||||
.await
|
||||
.expect("Failed to create discord client")
|
||||
.expect("Failed to create discord client");
|
||||
info!("Discord starting…");
|
||||
|
||||
client
|
||||
}
|
||||
|
|
128
src/main.rs
128
src/main.rs
|
@ -1,104 +1,20 @@
|
|||
use std::{
|
||||
cell::RefCell,
|
||||
str::FromStr,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use discord::create_discord_client;
|
||||
use matrix::create_matrix_client;
|
||||
use matrix_sdk::{
|
||||
ruma::{
|
||||
events::{room::message::MessageEventContent, AnyMessageEventContent},
|
||||
RoomId,
|
||||
},
|
||||
SyncSettings,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod discord;
|
||||
mod matrix;
|
||||
mod bridgers;
|
||||
mod message_ast;
|
||||
mod messages;
|
||||
|
||||
use message_ast::MessageContent;
|
||||
use serenity::model::id::ChannelId;
|
||||
pub mod discord;
|
||||
pub mod matrix;
|
||||
|
||||
use bridgers::Bridgers;
|
||||
use messages::SentMessage;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub enum MessageReference {
|
||||
Discord(u64, u64),
|
||||
Matrix(String, String),
|
||||
}
|
||||
|
||||
pub struct SentMessage {
|
||||
pub source: MessageReference,
|
||||
pub content: MessageContent,
|
||||
}
|
||||
|
||||
struct Bridgers {
|
||||
discord: Mutex<RefCell<Option<discord::Context>>>,
|
||||
matrix: Mutex<RefCell<Option<matrix::Client>>>,
|
||||
}
|
||||
|
||||
impl Bridgers {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
discord: Mutex::new(RefCell::new(None)),
|
||||
matrix: Mutex::new(RefCell::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_message(
|
||||
&self,
|
||||
source: MessageReference,
|
||||
content: MessageContent,
|
||||
) -> Vec<MessageReference> {
|
||||
let mut created_messages = Vec::new();
|
||||
|
||||
if let Some(discord) = self.discord.lock().unwrap().borrow().as_ref() {
|
||||
// We probably want a function that returns an Option<ChannelId> taking the source
|
||||
match &source {
|
||||
MessageReference::Matrix(_room_id, _event_id) => {
|
||||
let channel_id = ChannelId(885690775193661463); // TODO: Look up linked channel
|
||||
let discord_message = channel_id
|
||||
.send_message(&discord.http, |m| {
|
||||
m.content(message_ast::format_discord(&content))
|
||||
})
|
||||
.await
|
||||
.expect("Failed to send discord message");
|
||||
|
||||
created_messages.push(MessageReference::from(&discord_message));
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(matrix) = self.matrix.lock().unwrap().borrow().as_ref() {
|
||||
match &source {
|
||||
MessageReference::Discord(_, _) => {
|
||||
let room_id = RoomId::from_str("asdfghj").unwrap(); // TODO: Get a room id
|
||||
if let Some(room) = matrix.get_joined_room(&room_id) {
|
||||
let event = room
|
||||
.send(
|
||||
AnyMessageEventContent::RoomMessage(
|
||||
MessageEventContent::text_plain(message_ast::format_discord(
|
||||
&content, // TODO: Format as HTML
|
||||
)),
|
||||
),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
created_messages.push(MessageReference::from((&room_id, &event.event_id)));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
created_messages
|
||||
}
|
||||
}
|
||||
use crate::message_ast::Styled;
|
||||
|
||||
async fn setup_discord(
|
||||
token: String,
|
||||
|
@ -127,7 +43,6 @@ async fn setup_matrix(
|
|||
message_tx: mpsc::UnboundedSender<SentMessage>,
|
||||
) {
|
||||
let client = create_matrix_client(homeserver_url, username, password, message_tx).await;
|
||||
let settings = SyncSettings::default().token(client.sync_token().await.unwrap());
|
||||
|
||||
bridgers
|
||||
.matrix
|
||||
|
@ -135,6 +50,7 @@ async fn setup_matrix(
|
|||
.unwrap()
|
||||
.replace(Some(client.clone()));
|
||||
|
||||
let settings = matrix_sdk::SyncSettings::default().token(client.sync_token().await.unwrap());
|
||||
tokio::spawn(async move {
|
||||
client.sync(settings).await;
|
||||
});
|
||||
|
@ -142,27 +58,41 @@ async fn setup_matrix(
|
|||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::init();
|
||||
|
||||
let bridgers = Arc::new(Bridgers::new());
|
||||
|
||||
let (message_tx, mut message_rx) = tokio::sync::mpsc::unbounded_channel::<SentMessage>();
|
||||
|
||||
#[inline]
|
||||
fn get_env_var(key: &str) -> String {
|
||||
std::env::var(key).expect("DISCORD_TOKEN not set in environment")
|
||||
}
|
||||
|
||||
setup_discord(
|
||||
"token".to_string(),
|
||||
get_env_var("DISCORD_TOKEN"),
|
||||
Arc::clone(&bridgers),
|
||||
message_tx.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
setup_matrix(
|
||||
"https://matrix.org".to_string(),
|
||||
"username".to_string(),
|
||||
"password".to_string(),
|
||||
get_env_var("MATRIX_HOMESERVER"),
|
||||
get_env_var("MATRIX_USERNAME"),
|
||||
get_env_var("MATRIX_PASSWORD"),
|
||||
Arc::clone(&bridgers),
|
||||
message_tx.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
while let Some(message) = message_rx.recv().await {
|
||||
let _ = bridgers.send_message(message.source, message.content).await;
|
||||
let mut content = message.content;
|
||||
content.insert(0, Styled::Plain(": ".to_string()));
|
||||
content.insert(
|
||||
0,
|
||||
Styled::Bold(vec![Styled::Plain(message.author.display_name.to_string())]),
|
||||
);
|
||||
|
||||
let _ = bridgers.send_message(message.source, content).await;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,16 +9,20 @@ use matrix_sdk::{
|
|||
},
|
||||
AnyMessageEventContent, AnySyncRoomEvent, SyncMessageEvent,
|
||||
},
|
||||
EventId, RoomId,
|
||||
EventId, RoomId, UserId,
|
||||
},
|
||||
ClientConfig, EventHandler, SyncSettings,
|
||||
};
|
||||
|
||||
use log::info;
|
||||
pub use matrix_sdk::Client;
|
||||
use tokio::sync::mpsc;
|
||||
use url::Url;
|
||||
|
||||
use crate::{message_ast::convert_plain, MessageReference, SentMessage};
|
||||
use crate::{
|
||||
message_ast::convert_plain,
|
||||
messages::{MessageAuthor, MessageReference, SentMessage},
|
||||
};
|
||||
|
||||
impl From<(&RoomId, &EventId)> for MessageReference {
|
||||
fn from((room_id, event_id): (&RoomId, &EventId)) -> Self {
|
||||
|
@ -61,11 +65,17 @@ fn _find_content(event: &AnySyncRoomEvent) -> Option<AnyMessageEventContent> {
|
|||
|
||||
struct MatrixHandler {
|
||||
message_tx: mpsc::UnboundedSender<SentMessage>,
|
||||
current_user_id: UserId,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for MatrixHandler {
|
||||
async fn on_room_message(&self, room: Room, event: &SyncMessageEvent<MessageEventContent>) {
|
||||
if event.sender == self.current_user_id {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Room::Joined(room) = room {
|
||||
let message_ref = MessageReference::from((room.room_id(), &event.event_id));
|
||||
|
||||
let message_type =
|
||||
|
@ -83,16 +93,25 @@ impl EventHandler for MatrixHandler {
|
|||
.filter(|f| f.format == MessageFormat::Html)
|
||||
.map(|f| &f.body)
|
||||
{
|
||||
todo!("Parse html_body into MessageContent AST")
|
||||
// TODO: Parse html_body into MessageContent AST
|
||||
convert_plain(&text.body)
|
||||
} else {
|
||||
convert_plain(&text.body)
|
||||
};
|
||||
|
||||
if let Ok(Some(sender)) = room.get_member(&event.sender).await {
|
||||
let _ = self.message_tx.send(SentMessage {
|
||||
source: message_ref,
|
||||
content,
|
||||
author: MessageAuthor {
|
||||
display_name: sender
|
||||
.display_name()
|
||||
.unwrap_or(sender.name())
|
||||
.to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
MessageType::Emote(_emote) => {
|
||||
// TODO
|
||||
|
@ -102,6 +121,7 @@ impl EventHandler for MatrixHandler {
|
|||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_matrix_client(
|
||||
homeserver_url: String,
|
||||
|
@ -109,20 +129,27 @@ pub async fn create_matrix_client(
|
|||
password: String,
|
||||
message_tx: mpsc::UnboundedSender<SentMessage>,
|
||||
) -> Client {
|
||||
let client_config = ClientConfig::new().store_path("./matrix");
|
||||
let client_config = ClientConfig::new().store_path("./matrix_state");
|
||||
|
||||
let homeserver_url =
|
||||
Url::parse(&homeserver_url).expect("Failed to parse the matrix homeserver URL");
|
||||
let client = Client::new_with_config(homeserver_url, client_config).unwrap();
|
||||
|
||||
info!("Matrix logging in…");
|
||||
client
|
||||
.login(&username, &password, None, Some("phoebe"))
|
||||
.await
|
||||
.expect("Failed to log in");
|
||||
|
||||
info!("Matrix starting…");
|
||||
client.sync_once(SyncSettings::default()).await.unwrap();
|
||||
|
||||
let event_handler = MatrixHandler { message_tx };
|
||||
let current_user_id = client.user_id().await.unwrap();
|
||||
|
||||
let event_handler = MatrixHandler {
|
||||
message_tx,
|
||||
current_user_id,
|
||||
};
|
||||
client.set_event_handler(Box::new(event_handler)).await;
|
||||
|
||||
client
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::message_ast::MessageContent;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub enum MessageReference {
|
||||
Discord(u64, u64),
|
||||
Matrix(String, String),
|
||||
}
|
||||
|
||||
pub struct MessageAuthor {
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
pub struct SentMessage {
|
||||
pub source: MessageReference,
|
||||
pub content: MessageContent,
|
||||
pub author: MessageAuthor,
|
||||
}
|
Loading…
Reference in New Issue