- 1001
- 1002
#[async_trait] - 1003
impl ChannelAdapter for SlackAdapter { - 1004
fn scheme(&self) -> &'static str { - 1005
"slack" - 1006
} - 1007
- 1008
fn profile(&self) -> DeliveryProfile { - 1009
built_in_surface_profile("slack") - 1010
} - 1011
- 1012
async fn send(&self, _core: &Core, packet: &DeliveryPacket) -> Result<(), String> { - 1013
let (_, channel_id) = packet - 1014
.target - 1015
.split_once(':') - 1016
.ok_or_else(|| "slack target has no channel id".to_string())?; - 1017
post_chunks( - 1018
packet, - 1019
|chunk, last| { - 1020
let mut text = chunk.to_string(); - 1021
if last && let Some(prompt) = typed_verdict_prompt(&packet.actions) { - 1022
text.push_str(&prompt); - 1023
} - 1024
( - 1025
format!("{}/chat.postMessage", self.api_base), - 1026
serde_json::json!({ "channel": channel_id, "text": text }), - 1027
) - 1028
}, - 1029
|request| request.bearer_auth(&self.bot_token), - 1030
"slack chat.postMessage", - 1031
) - 1032
.await - 1033
} - 1034
} - 1035
- 1036
/// Shared chunk-and-POST loop for the two Phase 3 adapters: `body` builds - 1037
/// the (url, json) for one chunk and is told whether it is the last one, - 1038
/// `auth` applies the surface's auth header. - 1039
async fn post_chunks( - 1040
packet: &DeliveryPacket, - 1041
body: impl Fn(&str, bool) -> (String, serde_json::Value), - 1042
auth: impl Fn(reqwest::RequestBuilder) -> reqwest::RequestBuilder, - 1043
operation: &str, - 1044
) -> Result<(), String> { - 1045
let chunks: Vec<&str> = if packet.chunks.is_empty() { - 1046
vec![packet.fallback_markdown.as_str()] - 1047
} else { - 1048
packet.chunks.iter().map(String::as_str).collect() - 1049
}; - 1050
let client = reqwest::Client::new(); - 1051
let last = chunks.len().saturating_sub(1); - 1052
for (i, chunk) in chunks.iter().enumerate() { - 1053
let (url, json) = body(chunk, i == last); - 1054
let resp = auth(client.post(url)) - 1055
.json(&json) - 1056
.send() - 1057
.await - 1058
.map_err(|error| format!("{operation}: {error}"))?; - 1059
if !resp.status().is_success() { - 1060
return Err(format!("{operation} returned {}", resp.status())); - 1061
} - 1062
} - 1063
Ok(()) - 1064
} - 1065
- 1066
#[cfg(test)] - 1067
#[allow(clippy::unwrap_used, clippy::expect_used)] - 1068
mod tests { - 1069
use super::*; - 1070
- 1071
fn approval_actions() -> Vec<DeliveryAction> { - 1072
vec![ - 1073
DeliveryAction { - 1074
id: "approve".into(), - 1075
label: "Approve".into(), - 1076
verb: "approve".into(), - 1077
data: [("request_id".into(), "abc123".into())] - 1078
.into_iter() - 1079
.collect(), - 1080
}, - 1081
DeliveryAction { - 1082
id: "deny".into(), - 1083
label: "Deny".into(), - 1084
verb: "deny".into(), - 1085
data: [("request_id".into(), "abc123".into())] - 1086
.into_iter() - 1087
.collect(), - 1088
}, - 1089
] - 1090
} - 1091
- 1092
#[test] - 1093
fn inline_keyboard_carries_verb_and_request_id_in_callback_data() { - 1094
let markup = inline_keyboard_markup(&approval_actions()); - 1095
let row = markup["inline_keyboard"][0].as_array().unwrap(); - 1096
assert_eq!(row.len(), 2); - 1097
assert_eq!(row[0]["text"], "Approve"); - 1098
assert_eq!(row[0]["callback_data"], "approve:abc123"); - 1099
assert_eq!(row[1]["text"], "Deny"); - 1100
assert_eq!(row[1]["callback_data"], "deny:abc123"); - 1101
} - 1102
- 1103
#[test] - 1104
fn typed_verdict_prompt_asks_for_the_text_parse_verdict_accepts() { - 1105
let prompt = typed_verdict_prompt(&approval_actions()).unwrap(); - 1106
assert!(prompt.contains("yes abc123")); - 1107
assert!(prompt.contains("no abc123")); - 1108
} - 1109
- 1110
#[test] - 1111
fn typed_verdict_prompt_is_absent_without_actions() { - 1112
assert!(typed_verdict_prompt(&[]).is_none()); - 1113
} - 1114
- 1115
#[test] - 1116
fn generic_answer_delivery_carries_agent_conversation_provenance() { - 1117
let dir = tempfile::tempdir().unwrap(); - 1118
let core = Core::new(dir.path().to_path_buf()) - 1119
.unwrap() - 1120
.with_agent_identity(Some(vak_session::types::AgentIdentity { - 1121
id: "support".into(), - 1122
revision: 2, - 1123
name: "Support".into(), - 1124
character: "vak".into(), - 1125
personality: String::new(), - 1126
animation: "subtle".into(), - 1127
voice: "default".into(), - 1128
behaviour: String::new(), - 1129
responsibilities: String::new(), - 1130
instructions: String::new(), - 1131
})) - 1132
.with_conversation_context(Some(vak_session::ConversationContext { - 1133
conversation_id: "conv-1".into(), - 1134
audience_id: "aud-1".into(), - 1135
origin: Some(vak_session::ConversationOrigin { - 1136
surface: "telegram".into(), - 1137
address: "chat-1".into(), - 1138
bot_id: Some("support-bot".into()), - 1139
}), - 1140
})); - 1141
let content = enrich_provenance( - 1142
&core, - 1143
DeliveryContent::Answer(AnswerDraft::from_markdown("result")), - 1144
); - 1145
let answer = match content { - 1146
DeliveryContent::Answer(answer) => answer, - 1147
other => { - 1148
assert!( - 1149
matches!(other, DeliveryContent::Answer(_)), - 1150
"answer content expected" - 1151
); - 1152
return; - 1153
} - 1154
}; - 1155
assert_eq!( - 1156
answer.metadata.get("agent_id").map(String::as_str), - 1157
Some("support") - 1158
); - 1159
assert_eq!( - 1160
answer.metadata.get("agent_name").map(String::as_str), - 1161
Some("Support") - 1162
); - 1163
assert_eq!( - 1164
answer.metadata.get("agent_character").map(String::as_str), - 1165
Some("vak") - 1166
); - 1167
assert_eq!( - 1168
answer.metadata.get("agent_revision").map(String::as_str), - 1169
Some("2") - 1170
); - 1171
assert_eq!( - 1172
answer - 1173
.document - 1174
.metadata - 1175
.get("agent_voice") - 1176
.map(String::as_str), - 1177
Some("default") - 1178
); - 1179
assert_eq!( - 1180
answer.metadata.get("audience_id").map(String::as_str), - 1181
Some("aud-1") - 1182
); - 1183
assert_eq!( - 1184
answer.metadata.get("conversation_id").map(String::as_str), - 1185
Some("conv-1") - 1186
); - 1187
assert_eq!( - 1188
answer.metadata.get("bot_id").map(String::as_str), - 1189
Some("support-bot") - 1190
); - 1191
assert_eq!( - 1192
answer - 1193
.document - 1194
.metadata - 1195
.get("origin_surface") - 1196
.map(String::as_str), - 1197
Some("telegram") - 1198
); - 1199
} - 1200
- 1201
#[test] - 1202
fn phase_three_surfaces_declare_no_interactive_components_yet() { - 1203
// Approvals ship as typed yes/no on these surfaces; the profile - 1204
// must say so or the renderer would emit buttons nothing draws. - 1205
let discord = DiscordAdapter { - 1206
bot_token: "t".into(), - 1207
api_base: "http://localhost".into(), - 1208
}; - 1209
let slack = SlackAdapter { - 1210
bot_token: "t".into(), - 1211
api_base: "http://localhost".into(), - 1212
}; - 1213
assert_eq!(discord.scheme(), "discord"); - 1214
assert_eq!(slack.scheme(), "slack"); - 1215
assert!(!discord.profile().supports_actions); - 1216
assert!(!slack.profile().supports_actions); - 1217
} - 1218
- 1219
#[test] - 1220
fn inline_keyboard_is_empty_row_when_no_actions() { - 1221
let markup = inline_keyboard_markup(&[]); - 1222
let row = markup["inline_keyboard"][0].as_array().unwrap(); - 1223
assert!(row.is_empty()); - 1224
} - 1225
- 1226
fn telegram_adapter(token: &str) -> Arc<dyn ChannelAdapter> { - 1227
Arc::new(TelegramAdapter { - 1228
bot_token: token.into(), - 1229
api_base: "http://localhost".into(), - 1230
}) - 1231
} - 1232
- 1233
/// The whole point of a bot-scoped delivery target: two bots on the - 1234
/// same surface must resolve to two distinct adapters (and therefore - 1235
/// two distinct tokens), not whichever one happens to be registered - 1236
/// first — the exact "known limitation" docs/design/34 Phase 5 called - 1237
/// out and this change fixes. Compared by `Arc::ptr_eq` rather than by - 1238
/// field, since `resolve` hands back a trait object. - 1239
#[test] - 1240
fn bot_scoped_target_resolves_to_that_bots_own_adapter() { - 1241
let vakbot = telegram_adapter("vakbot-token"); - 1242
let vakyartha = telegram_adapter("vakyartha-token"); - 1243
let registry = AdapterRegistry { - 1244
adapters: HashMap::new(), - 1245
bot_adapters: HashMap::from([ - 1246
( - 1247
("telegram".to_string(), "VakBot".to_string()), - 1248
vakbot.clone(), - 1249
), - 1250
( - 1251
("telegram".to_string(), "Vakyartha".to_string()), - 1252
vakyartha.clone(), - 1253
), - 1254
]), - 1255
}; - 1256
- 1257
let (adapter_a, address_a) = registry.resolve("telegram:8846301562:VakBot").unwrap(); - 1258
assert_eq!(address_a, "8846301562"); - 1259
assert!( - 1260
Arc::ptr_eq(&adapter_a, &vakbot), - 1261
"must pick VakBot's own adapter" - 1262
); - 1263
assert!(!Arc::ptr_eq(&adapter_a, &vakyartha)); - 1264
- 1265
let (adapter_b, address_b) = registry.resolve("telegram:8846301562:Vakyartha").unwrap(); - 1266
assert_eq!(address_b, "8846301562"); - 1267
assert!( - 1268
Arc::ptr_eq(&adapter_b, &vakyartha), - 1269
"must pick Vakyartha's own adapter, not VakBot's" - 1270
); - 1271
} - 1272
- 1273
/// Naming a bot that has no token configured must fail loudly, never - 1274
/// silently fall back to a different bot's token — that would reply - 1275
/// under the wrong identity without anyone noticing. - 1276
#[test] - 1277
fn bot_scoped_target_naming_an_unknown_bot_is_a_clear_error() { - 1278
let registry = AdapterRegistry { - 1279
adapters: HashMap::new(), - 1280
bot_adapters: HashMap::from([( - 1281
("telegram".to_string(), "VakBot".to_string()), - 1282
telegram_adapter("vakbot-token"), - 1283
)]), - 1284
}; - 1285
let err = registry - 1286
.resolve("telegram:8846301562:SomeOtherBot") - 1287
.map(|_| ()) - 1288
.unwrap_err(); - 1289
assert!(err.contains("SomeOtherBot"), "{err}"); - 1290
assert!(err.contains("no token configured"), "{err}"); - 1291
} - 1292
- 1293
/// A chat target with no bot id is refused, not resolved. Picking - 1294
/// "some bot on this surface" replies under an identity the chat was - 1295
/// never bound to (AGENTS.md invariant 24). - 1296
#[test] - 1297
fn a_chat_target_with_no_bot_id_is_refused() { - 1298
let registry = AdapterRegistry { - 1299
adapters: HashMap::new(), - 1300
bot_adapters: HashMap::from([( - 1301
("telegram".to_string(), "VakBot".to_string()), - 1302
telegram_adapter("vakbot-token"), - 1303
)]), - 1304
}; - 1305
let err = registry - 1306
.resolve("telegram:8846301562") - 1307
.map(|_| ()) - 1308
.unwrap_err(); - 1309
assert!(err.contains("names no bot"), "{err}"); - 1310
assert!(err.contains("<bot_id>"), "{err}"); - 1311
} - 1312
- 1313
/// `log` and `webhook` are not bots, so they keep the two-part shape. - 1314
#[test] - 1315
fn non_chat_surfaces_still_resolve_without_a_bot_id() { - 1316
let mut registry = AdapterRegistry { - 1317
adapters: HashMap::new(), - 1318
bot_adapters: HashMap::new(), - 1319
}; - 1320
registry.register(LogAdapter); - 1321
let (_, address) = registry.resolve("log:anywhere").unwrap(); - 1322
assert_eq!(address, "anywhere"); - 1323
} - 1324
- 1325
#[test] - 1326
fn resolve_rejects_targets_with_no_address_or_empty_address() { - 1327
let registry = AdapterRegistry { - 1328
adapters: HashMap::from([("telegram", telegram_adapter("t"))]), - 1329
bot_adapters: HashMap::new(), - 1330
}; - 1331
assert!(registry.resolve("telegram").is_err()); - 1332
assert!(registry.resolve("telegram:").is_err()); - 1333
} - 1334
} - 1335
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.