use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
pub mod types {
pub const MESSAGE: &str = "message";
pub const MESSAGE_UPDATE: &str = "messageUpdate";
pub const MESSAGE_DELETE: &str = "messageDelete";
pub const CONVERSATION_UPDATE: &str = "conversationUpdate";
pub const MESSAGE_REACTION: &str = "messageReaction";
pub const INSTALLATION_UPDATE: &str = "installationUpdate";
pub const EVENT: &str = "event";
pub const INVOKE: &str = "invoke";
pub const TYPING: &str = "typing";
}
pub const ADAPTIVE_CARD_ACTION: &str = "adaptiveCard/action";
pub const ADAPTIVE_CARD_CONTENT_TYPE: &str = "application/vnd.microsoft.card.adaptive";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ChannelAccount {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aad_object_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
impl ChannelAccount {
#[must_use]
pub fn new(id: impl Into<String>, name: Option<String>) -> Self {
Self {
id: id.into(),
name,
..Self::default()
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ConversationAccount {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversation_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_group: Option<bool>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
impl ConversationAccount {
#[must_use]
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
..Self::default()
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Attachment {
pub content_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<Box<Self>>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
impl Attachment {
#[must_use]
pub fn adaptive_card(card: Value) -> Self {
Self {
content_type: ADAPTIVE_CARD_CONTENT_TYPE.to_owned(),
content: Some(card),
..Self::default()
}
}
#[must_use]
pub fn with_preview(mut self, preview: Self) -> Self {
self.preview = Some(Box::new(preview));
self
}
#[must_use]
pub fn new(content_type: impl Into<String>, content: Value) -> Self {
Self {
content_type: content_type.into(),
content: Some(content),
..Self::default()
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct MessageReaction {
#[serde(rename = "type")]
pub kind: String,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Activity {
#[serde(rename = "type")]
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<ChannelAccount>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversation: Option<ConversationAccount>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recipient: Option<ChannelAccount>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_format: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attachment_layout: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<Attachment>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entities: Vec<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel_data: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reply_to_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub members_added: Vec<ChannelAccount>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub members_removed: Vec<ChannelAccount>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reactions_added: Vec<MessageReaction>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reactions_removed: Vec<MessageReaction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub locale: Option<String>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
impl Activity {
#[must_use]
pub fn message(text: impl Into<String>) -> Self {
Self {
kind: types::MESSAGE.to_owned(),
text: Some(text.into()),
..Self::default()
}
}
#[must_use]
pub fn typing() -> Self {
Self {
kind: types::TYPING.to_owned(),
..Self::default()
}
}
#[must_use]
pub fn with_attachment(mut self, attachment: Attachment) -> Self {
self.attachments.push(attachment);
self
}
#[must_use]
pub fn with_text_format(mut self, format: impl Into<String>) -> Self {
self.text_format = Some(format.into());
self
}
#[must_use]
pub fn with_mention(mut self, account: &ChannelAccount) -> Self {
let name = account.name.as_deref().unwrap_or(&account.id);
let tag = format!("<at>{}</at>", xml_escape(name));
self.text = Some(
self.text
.take()
.filter(|t| !t.is_empty())
.map_or_else(|| tag.clone(), |t| format!("{tag} {t}")),
);
self.entities.push(json!({
"type": "mention",
"text": tag,
"mentioned": { "id": account.id, "name": name },
}));
self
}
#[must_use]
pub fn is_invoke(&self) -> bool {
self.kind == types::INVOKE
}
#[must_use]
pub fn text_without_mentions(&self) -> String {
let mut rest = self.text.as_deref().unwrap_or_default();
let mut out = String::with_capacity(rest.len());
while let Some(open) = rest.find("<at>") {
let Some(close) = rest[open..].find("</at>") else {
break;
};
out.push_str(&rest[..open]);
rest = &rest[open + close + "</at>".len()..];
}
out.push_str(rest);
out.trim().to_owned()
}
#[must_use]
pub fn text_without_bot_mention(&self) -> String {
let text = self.text.as_deref().unwrap_or_default();
let bot = self.recipient.as_ref().map(|r| r.id.as_str());
let mentions: Vec<&Value> = self
.entities
.iter()
.filter(|e| e.get("type").and_then(Value::as_str) == Some("mention"))
.collect();
if !mentions.is_empty() {
let mut out = text.to_owned();
let bot_tags = mentions
.iter()
.filter(|e| {
bot.is_some() && e.pointer("/mentioned/id").and_then(Value::as_str) == bot
})
.filter_map(|e| e.get("text").and_then(Value::as_str))
.filter(|t| !t.is_empty());
for tag in bot_tags {
out = out.replacen(tag, "", 1);
}
return out.trim().to_owned();
}
let trimmed = text.trim_start();
let rest = trimmed
.strip_prefix("<at>")
.and_then(|r| r.find("</at>").map(|i| &r[i + "</at>".len()..]))
.unwrap_or(trimmed);
rest.trim().to_owned()
}
#[must_use]
pub fn is_card_submit(&self) -> bool {
self.kind == types::MESSAGE
&& self.value.is_some()
&& self.text.as_deref().is_none_or(|t| t.trim().is_empty())
}
#[must_use]
pub fn command(&self) -> Option<(String, String)> {
let text = self.text_without_bot_mention();
let (word, rest) = text.split_once(char::is_whitespace).unwrap_or((&text, ""));
let word = word.trim_start_matches('/').to_lowercase();
(!word.is_empty()).then(|| (word, rest.trim().to_owned()))
}
#[must_use]
pub fn tenant_id(&self) -> Option<&str> {
self.channel_data_str(&["tenant", "id"]).or_else(|| {
self.conversation
.as_ref()
.and_then(|c| c.tenant_id.as_deref())
})
}
#[must_use]
pub fn team_id(&self) -> Option<&str> {
self.channel_data_str(&["team", "id"])
}
#[must_use]
pub fn event_type(&self) -> Option<&str> {
self.channel_data_str(&["eventType"])
}
fn channel_data_str(&self, path: &[&str]) -> Option<&str> {
path.iter()
.try_fold(self.channel_data.as_ref()?, |v, k| v.get(k))?
.as_str()
}
#[must_use]
pub fn members_added_except_bot(&self) -> Vec<&ChannelAccount> {
let bot = self.recipient.as_ref().map(|r| r.id.as_str());
self.members_added
.iter()
.filter(|m| Some(m.id.as_str()) != bot)
.collect()
}
#[must_use]
pub fn card_action(&self) -> Option<CardAction> {
if self.name.as_deref() != Some(ADAPTIVE_CARD_ACTION) {
return None;
}
let action = self.value.as_ref()?.get("action")?;
CardAction::deserialize(action).ok()
}
#[must_use]
pub fn conversation_reference(&self) -> Option<ConversationReference> {
Some(ConversationReference {
activity_id: self.id.clone(),
user: self.from.clone(),
bot: self.recipient.clone(),
conversation: self.conversation.clone()?,
channel_id: self.channel_id.clone().unwrap_or_default(),
service_url: self.service_url.clone()?,
locale: self.locale.clone(),
})
}
}
fn error_code(status: u16) -> String {
match status {
400 => "BadRequest".to_owned(),
401 => "Unauthorized".to_owned(),
403 => "Forbidden".to_owned(),
404 => "NotFound".to_owned(),
500 => "InternalServerError".to_owned(),
s => s.to_string(),
}
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct CardAction {
pub verb: String,
#[serde(default)]
pub data: Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ConversationReference {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activity_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<ChannelAccount>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bot: Option<ChannelAccount>,
pub conversation: ConversationAccount,
pub channel_id: String,
pub service_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub locale: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ConversationParameters {
#[serde(default)]
pub is_group: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bot: Option<ChannelAccount>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub members: Vec<ChannelAccount>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub topic_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activity: Option<Activity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel_data: Option<Value>,
}
impl ConversationParameters {
#[must_use]
pub fn channel(channel_id: &str, tenant_id: &str, activity: Activity) -> Self {
Self {
is_group: true,
tenant_id: Some(tenant_id.to_owned()),
activity: Some(activity),
channel_data: Some(json!({
"channel": { "id": channel_id },
"tenant": { "id": tenant_id },
})),
..Self::default()
}
}
#[must_use]
pub fn personal(bot: ChannelAccount, user: ChannelAccount, tenant_id: &str) -> Self {
Self {
is_group: false,
bot: Some(bot),
members: vec![user],
tenant_id: Some(tenant_id.to_owned()),
channel_data: Some(json!({ "tenant": { "id": tenant_id } })),
..Self::default()
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct ResourceResponse {
#[serde(default)]
pub id: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ConversationResourceResponse {
#[serde(default)]
pub id: String,
#[serde(default)]
pub activity_id: Option<String>,
#[serde(default)]
pub service_url: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct TeamsChannelAccount {
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub aad_object_id: Option<String>,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub user_principal_name: Option<String>,
#[serde(default)]
pub given_name: Option<String>,
#[serde(default)]
pub surname: Option<String>,
#[serde(default)]
pub tenant_id: Option<String>,
#[serde(default)]
pub user_role: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct PagedMembers {
#[serde(default)]
pub members: Vec<TeamsChannelAccount>,
#[serde(default)]
pub continuation_token: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct TeamDetails {
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub aad_group_id: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct ChannelInfo {
pub id: String,
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct InvokeResponse {
pub status: u16,
pub body: Option<Value>,
}
impl InvokeResponse {
#[must_use]
pub const fn ok() -> Self {
Self {
status: 200,
body: None,
}
}
#[must_use]
pub const fn json(status: u16, body: Value) -> Self {
Self {
status,
body: Some(body),
}
}
#[must_use]
pub fn card_message(text: impl Into<String>) -> Self {
Self::json(
200,
json!({
"statusCode": 200,
"type": "application/vnd.microsoft.activity.message",
"value": text.into(),
}),
)
}
#[must_use]
pub fn card(card: Value) -> Self {
let mut body = json!({ "statusCode": 200, "type": ADAPTIVE_CARD_CONTENT_TYPE });
body["value"] = card;
Self::json(200, body)
}
#[must_use]
pub fn task_message(text: impl Into<String>) -> Self {
Self::json(
200,
json!({ "task": { "type": "message", "value": text.into() } }),
)
}
#[must_use]
pub fn task_card(title: impl Into<String>, card: Value) -> Self {
let mut body = json!({ "task": { "type": "continue", "value": {
"title": title.into(),
"card": { "contentType": ADAPTIVE_CARD_CONTENT_TYPE },
} } });
body["task"]["value"]["card"]["content"] = card;
Self::json(200, body)
}
#[must_use]
pub fn card_error(status: u16, message: impl Into<String>) -> Self {
Self::json(
200,
json!({
"statusCode": status,
"type": "application/vnd.microsoft.error",
"value": { "code": error_code(status), "message": message.into() },
}),
)
}
#[must_use]
pub fn compose_message(text: impl Into<String>) -> Self {
Self::json(
200,
json!({ "composeExtension": { "type": "message", "text": text.into() } }),
)
}
#[must_use]
pub fn with_task_size(mut self, height: &str, width: &str) -> Self {
if let Some(value) = self
.body
.as_mut()
.and_then(|b| b.pointer_mut("/task/value"))
.and_then(Value::as_object_mut)
{
value.insert("height".to_owned(), json!(height));
value.insert("width".to_owned(), json!(width));
}
self
}
#[must_use]
pub fn compose_results(attachments: &[Attachment]) -> Self {
Self::json(
200,
json!({ "composeExtension": {
"type": "result",
"attachmentLayout": "list",
"attachments": attachments,
} }),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn msg(text: &str) -> Activity {
let mut a = Activity::message(text);
a.service_url = Some("https://smba.trafficmanager.net/teams/".into());
a.channel_id = Some("msteams".into());
a.id = Some("a1".into());
a.from = Some(ChannelAccount::new("29:u", Some("Ada".into())));
a.recipient = Some(ChannelAccount::new("28:bot", Some("Bot".into())));
a.conversation = Some(ConversationAccount::new("19:c"));
a
}
#[test]
fn mentions_and_commands() {
let a = msg("<at>Bot</at> Deploy prod <at>Ada</at> now ");
assert_eq!(a.text_without_mentions(), "Deploy prod now");
assert_eq!(
a.command(),
Some(("deploy".to_owned(), "prod <at>Ada</at> now".to_owned()))
);
assert_eq!(msg("/Help").command(), Some(("help".into(), String::new())));
assert_eq!(msg("<at>Bot</at>").command(), None);
assert_eq!(msg(" ").command(), None);
assert_eq!(msg("a <at>b").text_without_mentions(), "a <at>b");
let mut none = msg("");
none.text = None;
assert_eq!(none.text_without_mentions(), "");
}
#[test]
fn with_mention_adds_text_and_entity() {
let ada = ChannelAccount::new("29:u", Some("Ada".into()));
let a = Activity::message("hello").with_mention(&ada);
assert_eq!(a.text.as_deref(), Some("<at>Ada</at> hello"));
assert_eq!(a.entities[0]["type"], "mention");
assert_eq!(a.entities[0]["text"], "<at>Ada</at>");
assert_eq!(a.entities[0]["mentioned"]["id"], "29:u");
let anon = ChannelAccount::new("29:x", None);
let a = Activity::typing().with_mention(&anon);
assert_eq!(a.text.as_deref(), Some("<at>29:x</at>"));
}
#[test]
fn channel_data_accessors() {
let mut a = msg("x");
assert_eq!(a.tenant_id(), None);
a.conversation.as_mut().unwrap().tenant_id = Some("t-conv".into());
assert_eq!(a.tenant_id(), Some("t-conv"));
a.channel_data = Some(json!({
"tenant": {"id": "t-cd"},
"team": {"id": "19:team"},
"eventType": "channelCreated",
}));
assert_eq!(a.tenant_id(), Some("t-cd"));
assert_eq!(a.team_id(), Some("19:team"));
assert_eq!(a.event_type(), Some("channelCreated"));
a.channel_data = Some(json!({"tenant": {"id": 5}}));
assert_eq!(a.tenant_id(), Some("t-conv"));
}
#[test]
fn members_added_except_bot() {
let mut a = msg("x");
a.members_added = vec![
ChannelAccount::new("28:bot", None),
ChannelAccount::new("29:new", None),
];
let ids: Vec<_> = a
.members_added_except_bot()
.iter()
.map(|m| m.id.as_str())
.collect();
assert_eq!(ids, ["29:new"]);
}
#[test]
fn card_action_parse() {
let mut a = msg("x");
a.kind = types::INVOKE.into();
a.name = Some(ADAPTIVE_CARD_ACTION.into());
a.value =
Some(json!({"action": {"type": "Action.Execute", "verb": "ok", "data": {"n": 1}}}));
let c = a.card_action().unwrap();
assert_eq!(c.verb, "ok");
assert_eq!(c.data["n"], 1);
a.name = Some("task/fetch".into());
assert!(a.card_action().is_none());
a.name = Some(ADAPTIVE_CARD_ACTION.into());
a.value = Some(json!({"action": {"type": "Action.Execute"}}));
assert!(a.card_action().is_none());
}
#[test]
fn conversation_reference_and_round_trip() {
let a = msg("x");
let r = a.conversation_reference().unwrap();
assert_eq!(r.service_url, "https://smba.trafficmanager.net/teams/");
assert_eq!(r.conversation.id, "19:c");
assert_eq!(r.bot.as_ref().unwrap().id, "28:bot");
assert_eq!(r.user.as_ref().unwrap().id, "29:u");
assert_eq!(r.activity_id.as_deref(), Some("a1"));
assert_eq!(r.channel_id, "msteams");
let mut no_url = a;
no_url.service_url = None;
assert!(no_url.conversation_reference().is_none());
let raw = json!({"type": "message", "text": "t", "newField": {"x": 1}, "attachments": []});
let a: Activity = serde_json::from_value(raw).unwrap();
assert_eq!(a.extra["newField"]["x"], 1);
let back = serde_json::to_value(&a).unwrap();
assert_eq!(back["newField"]["x"], 1);
assert!(back.get("attachments").is_none());
assert!(back.get("id").is_none());
assert!(serde_json::from_value::<Activity>(json!({"text": "no type"})).is_err());
}
#[test]
fn unknown_fields_survive_in_nested_types() {
let raw = json!({
"type": "message",
"from": {"id": "29:u", "userRole": "guest"},
"conversation": {"id": "19:c", "tenantId": "t", "isFuture": 1},
"attachments": [{
"contentType": ADAPTIVE_CARD_CONTENT_TYPE,
"content": {},
"preview": {"contentType": "application/vnd.microsoft.card.thumbnail", "content": {"title": "t"}},
"thumbnailUrl": "https://x.example/t.png",
}],
});
let a: Activity = serde_json::from_value(raw.clone()).unwrap();
assert!(a.attachments[0].preview.is_some());
assert_eq!(serde_json::to_value(&a).unwrap(), raw);
}
#[test]
fn mention_names_are_escaped() {
let odd = ChannelAccount::new("29:x", Some("A&B <C>".into()));
let a = Activity::message("hi").with_mention(&odd);
assert_eq!(a.text.as_deref(), Some("<at>A&B <C></at> hi"));
assert_eq!(a.entities[0]["text"], "<at>A&B <C></at>");
assert_eq!(a.entities[0]["mentioned"]["name"], "A&B <C>");
}
#[test]
fn bot_mention_only_is_removed_for_commands() {
assert_eq!(
msg("<at>Bot</at> give <at>Ada</at> a hug").command(),
Some(("give".into(), "<at>Ada</at> a hug".into()))
);
let mut a = msg("please <at>Bot</at> deploy");
a.entities =
vec![json!({"type": "mention", "text": "<at>Bot</at>", "mentioned": {"id": "28:bot"}})];
assert_eq!(a.text_without_bot_mention(), "please deploy");
assert_eq!(a.command(), Some(("please".into(), "deploy".into())));
}
#[test]
fn bot_mention_edge_cases() {
let mention = |text: &str, id: &str| json!({"type": "mention", "text": text, "mentioned": {"id": id}});
let mut a = msg("<at>Ada</at> deploy prod");
a.entities = vec![mention("<at>Ada</at>", "29:ada")];
assert_eq!(a.text_without_bot_mention(), "<at>Ada</at> deploy prod");
let mut a = msg("<at>Bot</at> greet <at>Bot</at>");
a.entities = vec![
mention("<at>Bot</at>", "28:bot"),
mention("<at>Bot</at>", "29:other-bot-name"),
];
assert_eq!(a.text_without_bot_mention(), "greet <at>Bot</at>");
}
#[test]
fn card_submit_and_more_reply_shapes() {
let mut a = msg("");
a.text = None;
a.value = Some(json!({"x": 1}));
assert!(a.is_card_submit());
assert!(!msg("hi").is_card_submit());
let m = InvokeResponse::compose_message("pick one").body.unwrap();
assert_eq!(
m,
json!({"composeExtension": {"type": "message", "text": "pick one"}})
);
let e = InvokeResponse::card_error(500, "Try later.");
assert_eq!(e.status, 200);
let b = e.body.unwrap();
assert_eq!(b["statusCode"], 500);
assert_eq!(b["type"], "application/vnd.microsoft.error");
assert_eq!(b["value"]["code"], "InternalServerError");
assert_eq!(b["value"]["message"], "Try later.");
let b = InvokeResponse::card_error(400, "x").body.unwrap();
assert_eq!(b["value"]["code"], "BadRequest");
}
#[test]
fn channel_thread_parameters_and_sized_task() {
let p =
ConversationParameters::channel("19:ch@thread.tacv2", "t1", Activity::message("hi"));
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v["isGroup"], true);
assert_eq!(v["channelData"]["channel"]["id"], "19:ch@thread.tacv2");
assert_eq!(v["channelData"]["tenant"]["id"], "t1");
assert_eq!(v["activity"]["text"], "hi");
let t = InvokeResponse::task_card("Form", json!({}))
.with_task_size("large", "medium")
.body
.unwrap();
assert_eq!(t["task"]["value"]["height"], "large");
assert_eq!(t["task"]["value"]["width"], "medium");
assert_eq!(
InvokeResponse::ok().with_task_size("small", "small").body,
None
);
}
#[test]
fn invoke_reply_shapes() {
assert_eq!(InvokeResponse::ok().status, 200);
assert!(InvokeResponse::ok().body.is_none());
let c = InvokeResponse::card(json!({"type": "AdaptiveCard"}));
assert_eq!(c.body.unwrap()["type"], ADAPTIVE_CARD_CONTENT_TYPE);
let t = InvokeResponse::task_message("done").body.unwrap();
assert_eq!(t["task"]["value"], "done");
let r = InvokeResponse::compose_results(&[Attachment::new("text/plain", json!("x"))])
.body
.unwrap();
assert_eq!(
r["composeExtension"]["attachments"][0]["contentType"],
"text/plain"
);
let p = ConversationParameters::personal(
ChannelAccount::new("28:b", None),
ChannelAccount::new("29:u", None),
"t1",
);
assert_eq!(serde_json::to_value(&p).unwrap()["tenantId"], "t1");
}
}